چطور با مشکل بروزرسانی میلیون‌ها رکورد در Rails کنار نیامدیم

نقاشی حرکت بر روی ریل‌ها
آیا متد find_each را می‌شناسید؟ اگر با Ruby on Rails کار کرده باشید حتماً از این متد استفاده کرده‌اید. find_each و متد مشابه آن each، برای گرفتن رکوردها به صورت تک‌تک استفاده می‌شوند. به عنوان مثال:

People.find_each(&:party_all_night!)
People.each(&:party_all_night!)

تفاوت بین find_each و each در نحوه‌ی پیاده‌سازی آنهاست. متد each تمام رکوردها را در یک آرایه ذخیره می‌کند و یک Enumerator برای این آرایه خروجی می‌دهد. حالا فرض کنید تعداد رکوردها چند میلیون باشد. تمام این چند میلیون ActiveRecord باید در حافظه ذخیره شوند. میزان مصرف حافظه سرسام‌آور خواهد شد. این می‌تواند مشکل‌ساز باشد، مخصوصا برای پروسس‌هایی که عمر طولانی دارند چون که Ruby حافظه‌ی گرفته شده را به سیستم پس نمی‌دهد (و در عوض در آینده از آن دوباره استفاده می‌کند).

در مقایسه با each، متد find_each هوشمندانه‌تر عمل می‌کند. این متد رکوردها را در چند مرحله و در دسته‌های کوچک‌تر از دیتابیس دریافت می‌کند. مثلاً دسته‌های هزارتایی. بنابراین حافظه مصرفی از میزان مورد نیاز برای نگهداری هزار رکورد فراتر نخواهد رفت. به عنوان مثال اگر ده هزار رکورد داشته باشیم، در مجموع ده درخواست به دیتابیس فرستاده می‌شود و هر کدام فقط هزار رکورد خروجی می‌دهد.


یکی از پروژه‌های Rails در سارینا احتیاج به بروزرسانی روزانه میلیون‌ها رکورد دیتابیس دارد. روش‌های مختلفی برای انجام این کار هست. واضح‌ترین روش استفاده از update_all است.

Message.update_all(status: 0)

اما این روش برای ما مناسب نیست. چون منابع سیستمی زیادی را برای مدت طولانی به خود اختصاص می‌دهد و دیسک IO زیاد می‌شود، به ویژه به این دلیل که table ما یک trigger دارد که هنگام بروزرسانی هر رکورد یک procedure را صدا می‌زند. هنگام اجرای این عملیات دیتابیس تقریباً غیر قابل استفاده می‌شود.

یک راه حل مناسب برای این مشکل این است که مانند find_in_batches عمل کنیم. یعنی به جای بروزرسانی تمام رکوردها در یک مرحله، این کار را در چند مرحله‌ی کوچک‌تر انجام بدهیم. Rails متدی برای انجام این کار ندارد. این امکان را پیاده‌سازی کردم و برایش تست نوشتم. روی دیتابیس امتحان کردم. این روش مشکل ما را حل کرد. دیگر دیتابیس می‌تواند بین این بروزرسانی‌های کوچک به کارهای دیگر هم رسیدگی کند. حتی می‌توانیم این بروز رسانی را در ساعات پر ترافیک روز انجام دهیم.

ما در سارینا از پروژه‌های متن‌باز زیادی استفاده می‌کنیم و تا جایی که بتوانیم به این پروژه‌ها کمک می‌کنیم. می‌دانستم برنامه‌نویسان دیگری هم هستند که به چنین امکانی نیاز دارند، به همین دلیل آن را برای تیم Rails ارسال کردم و پس از بررسی‌ها و پیشنهادهای زیاد با Rails core ادغام شد. به پیشنهاد DHH آغازکننده‌ی Rails، یک API جدید به نام in_batches اضافه کردیم که جایگزین find_each و find_in_batches خواهد شد. به عنوان نمونه، اگر قبلا با find_each می‌نوشتیم:

People.find_each(&:party_all_night!)

حالا می‌نویسیم:

People.in_batches.each_record(&:party_all_night!)

و برای انجام بروزرسانی به صورت دسته‌ای می‌نویسیم:

Message.in_batches.update_all(status: 0)

در نهایت استفاده از این روش باعث شد تا یک عملیات بزرگ بروزرسانی به چند عملیات کوچک‌تر با مصرف منابع ناچیز تبدیل شود.

برای اینکه شما هم این امکان را امتحان کنید Rails خود را به نسخه‌ی پنجم ارتقاع بدهید. اگر از نسخه‌ی چهارم استفاده می‌کنید می‌توانید از این بک‌پورت برای Rails 4 استفاده کنید.

برنامه‌نویس گو و روبی در شرکت سارینا.