۵.۱ میلیون دلار فروش در هر دقیقه؛ این رکورد خیرهکنندهای بود که مهندسان شاپیفای (Shopify) در جریان جمعه سیاه ۲۰۲۵ ثبت کردند. اما پیروزی واقعی این نبرد در لایههای زیرین پایگاهداده رخ داد. هدف اصلی این بود که کابوس «بیشفروشی» (Overselling) — یعنی زمانی که یک کالای واحد به دو خریدار مختلف فروخته شود — برای همیشه پایان یابد. برای دستیابی به این هدف، شاپیفای سیستم حیاتی رزرو موجودی خود را از ردیس (Redis) به مایاسکیوال (MySQL) منتقل کرد.
جلوگیری از بیشفروشی، بازی خطرناکی است که سرعت و دقت فوقالعادهای را همزمان میطلبد. در این مدل، اگر سیستم بیش از حد کند باشد، خریداران با محدودیتهای دسترسی (Throttling) مواجه میشوند و تجربه کاربری تخریب میشود. اما اگر سیستم اشتباه کند، فروشنده دچار ضرر مالی شده یا اعتبار خود را با ارسال ایمیلهای عذرخواهی برای لغو سفارش از دست میدهد. شاپیفای سالها برای این وظیفه به ردیس تکیه میکرد، زیرا این ابزار در عملیاتهای سادهی افزایش و کاهش اعداد (Increments/Decrements) بسیار سریع عمل میکند.
چالشهای بیشفروشی و مکانیسمهای عملیاتی
محافظت در برابر بیشفروشی شامل دو عملیات اصلی است که باید در مقیاس بسیار بالا بهطور کامل و بدون خطا اجرا شوند:
- رزرو (Reserve): زمانی که خریدار روی دکمه «تکمیل خرید» کلیک میکند و فرآیند پرداخت آغاز میشود، سیستم باید آیتمها را با وضعیت «رزرو شده» علامتگذاری کند. این یک نگهدارندهی موقتی است که معمولاً تنها چند دقیقه طول میکشد.
- مالکیت (Claim): زمانی که پرداخت با موفقیت انجام شد، سیستم باید مقدار کالا را بهطور دائمی از دفتر کل موجودی (Inventory Ledger) کسر کند. این دفتر کل، «منبع حقیقت» (Source of Truth) در کل سازمان است.
اگر سیستم در یک جهت شکست بخورد، ممکن است دو خریدار همزمان آخرین واحد موجود از یک کالا را بخرند؛ این اتفاق فروشنده را مجبور میکند سفارش را لغو کرده و هزینههای پشتیبانی مشتری را تحمل کند. در جهت مخالف، ممکن است به خریدار گفته شود کالایی تمام شده است در حالی که هنوز موجود است و این منجر به از دست رفتن درآمد میشود. در مقیاس شاپیفای، هر دو نوع شکست بهسرعت تشدید میشوند. شاپیفای بیش از ۱۴٪ از تجارت الکترونیک ایالات متحده را پشتیبانی میکند و در اوج جمعه سیاه ۲۰۲۵، شاهد افزایش ۱۱ درصدی در میزان فروش در هر دقیقه نسبت به سال گذشته بود.
مدل ردیس و محدودیتهای ساختاری
در سیستم قدیمی، رزروها در ردیس ذخیره میشدند. هر آیتم یک کلید تعداد داشت؛ رزرو کردن یک آیتم به معنای استفاده از دستور DECR و آزادسازی آن به معنای استفاده از INCR بود. اگرچه ردیس همزمانی (Concurrency) را بهخوبی مدیریت میکرد، اما یک شکاف خطرناک در سازگاری دادهها (Consistency Gap) ایجاد میکرد، زیرا رزروها و دفتر کل موجودی در دو سیستم کاملاً مجزا زندگی میکردند.
گام «مالکیت» نیازمند بهروزرسانی MySQL و همزمان پاکسازی ردیس بود. این دو عملیات را نمیشد در یک گام اتمیک (Atomic) واحد بستهبندی کرد. بسته به ترتیب اجرا، این موضوع منجر به موارد زیر میشد:
- بیشفروشی: کالایی فروخته میشد، اما کسر مقدار هرگز به دفتر کل (Ledger) نمیرسید.
- کمفروشی: کالایی از دفتر کل کسر میشد، اما همچنان در ردیس به عنوان «رزرو شده» علامت میخورد.
علاوه بر این، مدل ردیس فاقد آگاهی از «چندین موقعیت انبار» (Multi-location awareness) بود و نیاز به سربار عملیاتی برای نگهداری یک کلاستر مجزا داشت. انتقال رزروها به همان پایگاهداده MySQL که دفتر کل در آن بود، به شاپیفای اجازه داد تا از تراکنشهای ACID استفاده کند و این حالتهای شکست را بهطور کامل حذف نماید. این تغییر تضمین کرد که رزروها تنها از مکانهایی انجام شوند که قادر به تامین سفارش هستند و در عین حال تضمینهای سختگیرانه ACID بین رزروها و دفتر کل موجودی برقرار بماند.
گذار به مدل «یک ردیف برای هر واحد»
برای حل این معضل، شاپیفای رزروها را به MySQL منتقل کرد. نقطه عطف این تغییر، عبور از مدل «یک ردیف با یک ستون مقدار» بود، زیرا آن مدل باعث تداخل شدید (Contention) میشد. در عوض، آنها طراحی جدیدی را پذیرفتند — الهام گرفته از رویکرد 37signals در توزیع بار پشتیبانی شده توسط دیتابیس — که در آن هر واحد قابل فروش، یک ردیف مجزا در دیتابیس است.
اگر یک کالا ۱۰ واحد موجودی داشته باشد، MySQL ۱۰ ردیف ذخیره میکند. برای رزرو سه واحد، سیستم بهسادگی سه ردیف خاص را در یک تراکنش انتخاب و جابهجا میکند. این روش تضمینهای ACID را در کل جریان «رزرو-و-مالکیت» برقرار میکند و باگهایی را که در آن پرداخت موفق میشد اما موجودی Claim نمیشد (یا برعکس)، برطرف میکند.
برای اینکه این مدل در مقیاس بالا کارآمد باشد، تیم از قابلیت SKIP LOCKED در MySQL 8 استفاده کرد. وقتی یک تراکنش سعی میکند ردیفهایی را قفل کند، MySQL به سادگی از ردیفهایی که قبلاً توسط پردازش دیگری قفل شدهاند میگذرد و ردیفهای در دسترس بعدی را برمیگرداند. این کار صف تراکنشهایی که بر سر یک ردیف واحد میجنگند را حذف کرده و تداخل را بهطرز چشمگیری کاهش میدهد.

مدیریت استخر ردیفها (Row Pool)
ایجاد یک ردیف برای هر واحد برای تمام موجودیها در نهایت منجر به شکست میشد. برای مثال، کالایی با ۵۰,۰۰۰ واحد در ۱۰ مکان مختلف، باعث ایجاد ۵۰۰,۰۰۰ ردیف میشد که باعث کند شدن کوئری رزرو در هنگام اسکن ردیفها میگشت. شاپیفای این مشکل را با حفظ یک «استخر محدود» (Bounded Pool) از ردیفهای در دسترس، با سقف ۱,۰۰۰ ردیف برای هر ترکیب کالا/موقعیت حل کرد.
- تامین مجدد (Replenishment): یک پردازش پسزمینه این استخر را از دفتر کل اصلی موجودی پر میکند.
- سقف ۱,۰۰۰ تایی: بر اساس نرخهای مشاهده شده در اوج رزروها طی فروشهای لحظهای (Flash Sales)، عدد ۱,۰۰۰ به عنوان مقداری تعیین شد که هم برای جذب جهشهای ترافیکی کافی باشد و هم برای کوچک نگه داشتن جدول و سریع نگه داشتن اسکن
SKIP LOCKEDمناسب باشد. - بازیابی درونخطی (Inline Recovery): در طول یک فروش لحظهای شدید، ممکن است استخر بهطور موقت تخلیه شود. در این حالت، مسیر رزرو، عملیات تامین مجدد را بهصورت درونخطی (Inline) تحریک میکند. یک قفل تضمین میکند که در هر لحظه تنها یک تراکنش عملیات پر کردن را انجام دهد؛ سایر رزروهای همزمان برای همان کالا منتظر پایان این عملیات میمانند تا از اثر «گله تابان» (Thundering Herd) و رقابت برای درج ردیفها جلوگیری شود.
این فرآیند باعث افزودن تأخير (Latency) به یک رزرو خاص میشود اما صحت دادهها را حفظ میکند و تضمین میکند خریدار در صورت وجود موجودی، هرگز رد نشود.
حل معمای قفلگذاری (Locking Puzzle)
عملکرد بالا بهطور تصادفی به دست نیامد. تیم مجبور شد استراتژیهای ایندکسگذاری و قفلگذاری خود را بازنگری کند تا به اهداف نرخ تراکنش (Throughput) برسند.
کلیدهای اصلی ترکیبی (Composite Primary Keys):
اولین پروتوتایپ آنها از یک ID با افزایش خودکار (Auto-increment) به عنوان کلید اصلی استفاده میکرد. تیم با بررسی SHOW ENGINE INNODB STATUS مشاهده کرد که به جای یک قفل، دو قفل ردیفی در هر رزرو ایجاد میشود. دلیل این بود که InnoDB هم ایندکس ثانویه (استفاده شده در عبارت WHERE) و هم ایندکس خوشهای (Clustered Index) را قفل میکرد. آنها به کلید اصلی ترکیبی (shop_id, inventory_item_id, inventory_group_id, id) تغییر مسیر دادند. از آنجایی که ستونهای مورد استفاده برای فیلتر کردن اکنون بخشی از کلید اصلی بودند، تعداد قفلها به یک مورد برای هر ردیف کاهش یافت. در این مقیاس، طراحی ایندکس و کلید اصلی مستقیماً بر تعداد قفلها و نرخ تراکنش اثر میگذارد.
READ COMMITTED و قفلهای شکاف (Gap Locks):
هنگام اجرای SELECT ... FOR UPDATE SKIP LOCKED روی یک جدول خالی که نیاز به تامین مجدد داشت، تیم با «قفلهای شکاف» (از جمله روی رکورد مجازی supremum) مواجه شد. این قفلها مانع از درج ردیفهای جدید توسط تراکنش تامین مجدد میشدند و منجر به بنبست (Deadlock) میگشتند.
با تغییر سطح جداسازی تراکنش (Transaction Isolation Level) از حالت پیشفرض MySQL یعنی REPEATABLE READ به READ COMMITTED موفق شدند جلوی ایجاد قفلهای شکاف توسط InnoDB را بگیرند. این کشف حیاتی با کمک راهنمای Jahfer Husain درباره قفلهای InnoDB به دست آمد. این اولین باری بود که از یک سطح جداسازی غیرپیشفرض در این کدبیس استفاده میشد و نیاز به ایجاد یک پشتیبانی کوچک در فریمورک برای تنظیم سطوح جداسازی بهازای هر تراکنش داشت.
ترتیب سازگار قفلها (Consistent Lock Ordering):
بنبستها زمانی رخ میدادند که مسیرهای reserve و claim دو جدول را با ترتیبهای متفاوت لمس میکردند. مسیر reserve یک INSERT در reserved_quantities و سپس یک DELETE از reservation_units انجام میداد، در حالی که مسیر claim یک DELETE از reserved_quantities اجرا میکرد.
برای رفع این مشکل، آنها ترتیب را استاندارد کردند: مسیر reserve همیشه ابتدا از جدول واحدها (units) حذف میکند و سپس در reserved_quantities درج میکند. این کار انتظارات دایرهای (Circular Waits) را حذف کرد، زیرا اکنون هر دو مسیر قفلها را با یک ترتیب یکسان کسب میکنند و هیچکدام قفلی را نگه نمیدارند که دیگری منتظر آن باشد.
دستهبندی با UNION ALL:
برای کاهش هزینه رفتوبرگشتهای دیتابیس (Round Trips) در سبدهایی که چندین آیتم داشتند، شاپیفای شروع به دستهبندی کوئریهای رزرو با استفاده از UNION ALL کرد. این کار به آنها اجازه داد تا تمام واحدهای مورد نیاز را در یک رفتوبرگشت واحد دریافت کنند و تأخیر را در بارهای سنگین بهشدت کاهش دهند.
گلوگاه نامرئی: اتصالات (Connections)
علیرغم تمام این بهینهسازیها، سیستم در ابتدا به سقفی از نرخ تراکنش رسید که بسیار پایینتر از هدف آنها بود. تأخیر رزرو (P90) قابل قبول بود و CPU به حداکثر نرسیده بود، اما آنها شاهد صف شدن تردها (Threads) در MySQL و اتمام اتصالات در لایه ProxySQL بودند.
برای تشخیص علت، آنها «تخصیص بر اساس فراخوانکننده» (Per-caller attribution) را پیاده کردند. دانستن اینکه اتصالات تمام شدهاند، به شما نمیگوید چه کسی آنها را نگه داشته است. آنها هر دستور SQL را با یک تگ کامنت علامتگذاری کردند، مانند /* conn_tag:checkout_completion */. در لایه ProxySQL، آنها ردیابی را اضافه کردند تا این تگها را تجزیه کرده و مدت زمان نگه داشتن اتصال توسط هر فراخوان را اندازهگیری کنند.
این تحلیل فاش کرد که رزروها تنها کاربر سنگین نبودند؛ بخشهای دیگر مسیر پرداخت (Checkout) اتصالات را بیشتر از حد نیاز نگه داشته بودند. رزروها «کاهی بودند که کمر شتر را شکست» نه به دلیل کند بودن، بلکه چون استخر اتصالات پیش از آن تقریباً ته کشیده بود. پاکسازی مسیر پرداخت باعث کاهش ۵۰ درصدی خواندنیها و ۳۳ درصدی تراکنشها در دیتابیس اصلی شد.
علاوه بر این، آنها پیکربندی MySQL را بازنگری کردند. متوجه شدند که همزمانی تردهای InnoDB سالها پیش بهصورت محافظهکارانه تنظیم شده بود و هرگز مجدداً ارزیابی نشده بود. پس از افزایش همزمانی تردها و پاکسازی کد، سقف نرخ تراکنش برداشته شد. در طول فروشهای لحظهای با حجم بالا، CPU نویسنده (Writer) زیر ۵۰٪ و CPU خواننده (Reader) زیر ۱۶٪ باقی ماند و فضای خالی زیادی برای رشد فراهم شد.
انتقال بدون ریسک (Zero-Risk Cutover)
شاپیفای ریسک انتقال یکباره (Big Bang) را نپذیرفت. آنها از «حالت سایه» (Shadow Mode) استفاده کردند که در آن هر رزرو بهطور همزمان در هر دو سیستم ردیس و MySQL نوشته میشد. ردیس منبع حقیقت باقی ماند در حالی که تیم تأیید میکرد MySQL نتایج تجاری یکسانی تولید میکند و با استفاده از ترافیک واقعی تولید، الزامات عملکرد را برآورده میکند.
از آنجایی که هر دو سیستم فعال بودند، هیچ رزرو در حال پردازشی برای انتقال وجود نداشت. رزروهای ردیس همچنان پذیرفته میشدند در حالی که MySQL وضعیت خود را میساخت. پس از تأیید، منبع حقیقت از طریق یک استقرار تدریجی پاد-به-پاد (Pod-by-pod)، شروع شده از پادهای کمترافیک و رسیدن به تجار با حجم بالا، به MySQL منتقل شد. به دلیل فعال بودن مسیر نوشتن دوگانه (Dual-write)، آنها یک «کلید قطع» (Kill-switch) داشتند تا فوراً به ردیس بازگردند.
درسهای آموخته شده
این مهاجرت ثابت میکند که پایگاههای داده رابطهای مدرن میتوانند بارهایی را مدیریت کنند که پیش از این مختص زیرساختهای تخصصی NoSQL بود. دو نکته اصلی استخراج شد:
۱. بازنگری در تصمیمات قدیمی: قابلیتهایی مانند SKIP LOCKED چیزهایی را امروز ممکن میکنند که پنج سال پیش نبودند. تنظیمات قدیمی «قاعده سرانگشتی» برای محدودیت تردها باید با تکامل سختافزار و بارهای کاری مجدداً بررسی شوند. اگر اعداد با هم نمیخوانند — مانند CPU پایین اما صفهای طولانی — باید عمیقتر جستجو کنید.
۲. شروع کوچک و مشاهده: یک پروتوتایپ حداقلی — یک اسکریپت ساده Ruby و MySQL بدون فریمورک کامل Rails — یک حلقه بازخورد سریع فراهم کرد. مشاهده رفتار قفلها در یک ترمینال مجزا، بیش از هر تئوری محض به تیم آموخت.
اگر در حال حاضر برای ایجاد انحصار متقابل (Mutual Exclusion) با نرخ تراکنش بالا به سراغ Redis، Kafka یا یک لایه هماهنگی سفارشی میروید، ممکن است دیتابیس فعلی شما همین حالا هم کافی باشد. درس اینجا این است که گلوگاه اغلب در «لولهکشی» — یعنی نحوه نگه داشتن اتصالات — است، نه در خود موتور دیتابیس.
بهطور حیاتی، این پروژه تنها درباره سریع کردن رزروها نبود، بلکه درباره تبدیل آنها به «همسایگان امن» در دیتابیس بود. رزروها دیتابیسی را با بهروزرسانیهای سبد خرید، پردازش پرداخت و ایجاد سفارش به اشتراک میگذارند. سیستمی که اتصالات را اشباع کند یا قفلها را بیش از حد نگه دارد، همه آنها را به خطر میاندازد. برای شاپیفای، نتیجه ملموس است: رزروهای قابلاعتمادتر به معنای عدم بیشفروشی و نرخ موفقیت بالاتر برای تجار آنهاست.




گفتگو