پست

مدیریت Contention در مصاحبه‌های طراحی سیستم

بررسی Race Condition و راه‌حل‌هایی مثل Conditional Write، قفل خوش‌بینانه و بدبینانه، سطح Isolation و قفل توزیع‌شده

مدیریت Contention در مصاحبه‌های طراحی سیستم

Contention وقتی رخ می‌دهد که چند پروسس هم‌زمان برای یک منبع واحد رقابت کنند، مثل رزرو آخرین بلیت کنسرت یا پیشنهاد قیمت روی یک کالای حراجی. بدون مدیریت درست، به Race Condition، رزرو دوباره‌ی یک چیز و State ناسازگار می‌رسید.

Race Condition

خرید بلیت کنسرت را در نظر بگیرید. یک صندلی برای The Weeknd باقی مانده و Alice و Bob هر دو آن را می‌خواهند. هر دو در یک لحظه روی «Buy Now» می‌زنند. روش بدیهی برای مدیریت خرید این است که تعداد صندلی‌های فعلی را بخوانید، چک کنید بالای صفر است، بعد آن را کم کنید و بلیت را بفروشید.

1
2
3
4
5
6
7
-- Read the current count
SELECT available_seats FROM concerts WHERE concert_id = 'weeknd_tour';

-- The app checks available_seats > 0, then writes the new value back
UPDATE concerts
SET available_seats = available_seats - 1
WHERE concert_id = 'weeknd_tour';

برای یک خریدار دقیقاً درست است. مشکل وقتی شروع می‌شود که Alice و Bob هم‌زمان آن را اجرا کنند. درخواست Alice یک صندلی موجود می‌خواند. کسری از میلی‌ثانیه بعد، پیش از اینکه Alice چیزی بنویسد، درخواست Bob همان تعداد را می‌خواند و او هم یک صندلی می‌بیند. هر دو عددی را که خوانده‌اند بررسی می‌کنند، هر دو نتیجه می‌گیرند صندلی برای فروش هست و هر دو برای شارژ کارت جلو می‌روند. Update مربوط به Alice اول Commit می‌شود و تعداد به صفر می‌رسد. Update مربوط به Bob درست بعد از آن Commit می‌شود، دوباره کم می‌کند و تعداد به ۱- می‌لغزد.

وقتی غبار می‌نشیند، از هر دو کارت ۵۰۰ دلار کسر شده و هر دو خریدار برای یک صندلی ایمیل تأیید دارند. Alice و Bob هر دو به کنسرت می‌آیند و فکر می‌کنند Row 5 و Seat 12 مال خودشان است. یکی از آن‌ها بیرون انداخته می‌شود و سالن مجبور است با دو مشتری بسیار عصبانی Refund صادر کند.

مشکل از این است که خواندن و نوشتن Atomic نیستند. خواندن تعداد و نوشتن مقدار جدید دو مرحله‌ی جدا هستند و در فاصله‌ی بین آن‌ها دنیا می‌تواند عوض شود. Bob در این شکاف می‌لغزد، تعدادی را می‌خواند که Alice در شُرف باطل کردنش است و بر اساس عددی عمل می‌کند که از قبل کهنه شده. پنجره‌ی زمانی کوچک است (میکروثانیه در حافظه و میلی‌ثانیه روی شبکه) ولی برای اینکه هر دو درخواست فکر کنند صندلی را برده‌اند کافی است.

مشکل با مقیاس بدتر می‌شود. با ۱۰ هزار کاربر هم‌زمان روی همان منبع، حتی پنجره‌های کوچک Race Condition تعارض‌های عظیم می‌سازند. با رشد بیشتر، احتمالاً باید بین چند Node هماهنگی کنید که پیچیدگی را بیشتر می‌کند.

برای درست انجام دادن این کار به نوعی همگام‌سازی (Synchronization) نیاز داریم.

این الگو در تحلیل مسائل Ticketmaster، Rate Limiter، Online Auction و Online Chess به کار می‌رود.

راه‌حل

این Race Condition یک Lost Update است، شکست کلاسیک چرخه‌ی Read-modify-write. دو درخواست یک مقدار را می‌خوانند، هر دو بر اساس آن عمل می‌کنند و هر دو دوباره می‌نویسند، پس یکی بی‌صدا دیگری را بازنویسی می‌کند.

چند راه برای ایمن کردن Read-modify-write وجود دارد. هر کدام به همان تکنیک Compare-and-Set تکیه دارد، یعنی Read-modify-write‌ای که نوشتن را مشروط می‌کنید به اینکه مقدار تغییر نکرده باشد. مثال‌ها را با SQL می‌نویسیم، ولی همین حرکت‌ها در هر Data Store جدی وجود دارد و فقط وقتی هر رویکرد به سقفش می‌رسد، هماهنگی اضافه می‌کنیم. با Conditional Write شروع کنید.

Conditional Write

با ساده‌ترین حالت شروع می‌کنیم، چون بسیاری از قوانین ایمنی به یک if ساده می‌رسند. اگر صندلی باقی مانده باشد، شمارنده را کم می‌کنید، یا اگر سفارش لغو نشده باشد، آن را ارسال‌شده علامت می‌زنید. وقتی قانون شما یک if درباره‌ی داده‌ی فعلی است، پایگاه داده می‌تواند آن را بررسی کند و تغییر را در یک دستور واحد اعمال کند، بدون قفل یا شماره‌ی نسخه.

پایگاه داده می‌تواند «فقط اگر صندلی باقی مانده بود کم کن» را در یک دستور اعمال کند:

1
2
3
4
UPDATE concerts
SET available_seats = available_seats - 1
WHERE concert_id = 'weeknd_tour'
  AND available_seats > 0;

خوشبختانه این از قبل زیر هم‌زمانی ایمن است. مهم نیست چند نفر هم‌زمان آخرین بلیت را بگیرند، فقط یک نفر موفق می‌شود. دلیلش این است که پایگاه داده اجازه نمی‌دهد دو Update هم‌زمان یک ردیف را تغییر دهند. آن‌ها را وادار می‌کند نوبتی کار کنند و آن که باید صبر کند، روی مقداری که اول خوانده بود عمل نمی‌کند. فرض کنید Alice و Bob همین را هم‌زمان اجرا کنند. Update مربوط به Alice اول می‌رود و شمارنده را از ۱ به ۰ می‌برد. Update مربوط به Bob منتظر پایان کار او می‌ماند، شرط available_seats > 0 را روی ردیفی که Alice به‌جا گذاشته دوباره بررسی می‌کند، آن را نادرست می‌یابد و صفر ردیف را تغییر می‌دهد. یک صندلی، یک فروش. بررسی و کاهش یک گام Atomic واحدند، پس شکافی برای لغزیدن Bob نیست.

رفتار دقیق شکست به سطح Isolation پایگاه داده‌ی شما بستگی دارد که بعداً به آن می‌رسیم. در پیش‌فرض‌های رایج، شرط را دوباره بررسی می‌کند و صفر ردیف را تطبیق می‌دهد، دقیقاً مثل بالا. در سطوح سخت‌گیرانه‌تر، پایگاه داده به‌جای آن بازنده را با خطای Serialization کنار می‌گذارد که اپلیکیشن شما Retry می‌کند. در هر دو حالت، یک صندلی، یک فروش. فقط شکل شکست فرق می‌کند.

با این حال، خرید واقعی فقط شمارنده نیست. باید رکورد بلیت را هم بسازید، یعنی دو نوشتن که باید با هم بمانند. صندلی را کم کنید ولی Insert شکست بخورد و صندلی را از تابلو برداشته‌اید برای بلیتی که وجود ندارد. تراکنش‌ها برای همین هستند. عملیات را داخل BEGIN و COMMIT می‌گذارید و پایگاه داده آن‌ها را یک واحد می‌داند که یا کامل اتفاق می‌افتد یا کامل Rollback می‌شود.

اما ترکیب دو نوشتن مشکل دیگری می‌آورد. وقتی UPDATE صفر ردیف را تطبیق می‌دهد، یعنی WHERE چیزی برای تغییر پیدا نکرده، خطا نیست. دستور موفق می‌شود، فقط کاری نکرده است، پس تراکنش را شکست نمی‌دهد یا Rollback نمی‌کند. اگر INSERT را بی‌قید اجرا کنید، کاهش ناموفق Bob باز هم بلیتی برای صندلی‌ای می‌سازد که هرگز نگرفته. راه‌حل این است که Insert را وابسته کنید به اینکه Update واقعاً چیزی را تغییر داده یا نه.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BEGIN TRANSACTION;

WITH reservation AS (
  UPDATE concerts
  SET available_seats = available_seats - 1
  WHERE concert_id = 'weeknd_tour'
    AND available_seats > 0
  RETURNING concert_id
)
INSERT INTO tickets (user_id, concert_id, seat_number, purchase_time)
SELECT 'user123', concert_id, 'A15', NOW()
FROM reservation;

COMMIT;

UPDATE فقط وقتی ردیف برمی‌گرداند که واقعاً ردیفی را عوض کرده باشد و INSERT ... SELECT ردیف‌هایش را از همان نتیجه می‌گیرد. وقتی Update هیچ‌چیز را تطبیق ندهد، Insert هیچ‌چیز نمی‌نویسد. اگر ترجیح می‌دهید منطق را در اپلیکیشن نگه دارید، تعداد ردیف‌های تحت‌تأثیر را بعد از Update بررسی کنید و اگر صفر بود Rollback کنید. در هر دو حالت، هیچ بلیتی بدون صندلی پشتش نمی‌ماند.

این محافظ فقط تعداد صندلی را محافظت می‌کند، نه صندلی مشخص را. WHERE وجود صندلی را تضمین می‌کند ولی چیزی درباره‌ی اینکه کدام صندلی نمی‌گوید. همان Race را با ۲۰ صندلی باقی‌مانده به‌جای ۱ تصور کنید. Alice و Bob هر دو تراکنش را اجرا می‌کنند، هر دو کاهش موفق می‌شود چون ۲۰ و ۱۹ هر دو بزرگ‌تر از صفرند، و هر دو Insert بلیتی برای صندلی A15 می‌نویسند. شمارنده کاملاً دقیق است و باز دو نفر مالک یک صندلی‌اند. پایگاه داده کار اشتباهی نکرد. ما هرگز به آن نگفتیم A15 چیزی است که ممکن است سر آن رقابت شود.

راه‌حل محافظت از منبعی است که مردم واقعاً سرش دعوا می‌کنند. به هر بلیت ردیف خودش را بدهید و دقیقاً همان Conditional Write را به‌جای شمارنده روی بلیت بزنید.

1
2
3
4
5
UPDATE tickets
SET status = 'sold', user_id = 'user123'
WHERE concert_id = 'weeknd_tour'
  AND seat_number = 'A15'
  AND status = 'available';

حالا وقتی Alice و Bob هر دو سراغ A15 می‌روند، یکی ردیف را اول به sold تبدیل می‌کند و Update دیگری صفر ردیف را تطبیق می‌دهد، چون status = 'available' دیگر درست نیست. یک بلیت، یک برنده. شمارنده‌ی صندلی به داده‌ی مشتق تبدیل می‌شود که می‌توانید از ردیف‌های بلیت محاسبه کنید، یا روی ردیف کنسرت به‌عنوان Cache نگه دارید و در همان تراکنش کم کنید.

محافظ باید از منبعی که سرش رقابت است محافظت کند. شمارنده می‌تواند جواب بدهد «آیا بلیتی باقی مانده؟». فقط ردیف بلیت می‌تواند جواب بدهد «آیا این بلیت باقی مانده؟».

این بخش بزرگی از Contention واقعی را مدیریت می‌کند، مثل کاهش شمارنده، تغییر وضعیت از available به sold، یا ادعای ردیف تخصیص‌نیافته. تا وقتی پایگاه داده بتواند محافظ شما را به‌عنوان بخشی از نوشتن ارزیابی کند، تمام است و هرگز سراغ قفل صریح نرفتید. WHERE همان نحوه‌ی نوشتن Compare-and-Set در SQL است؛ همان حرکتی که DynamoDB با ConditionExpression، Redis با SET NX، Cassandra با Lightweight Transaction و HTTP API با هدر If-Match انجام می‌دهد. اما این سقف هم هست. پایگاه داده فقط قوانینی را اعمال می‌کند که بتواند در WHERE بخواند و بسیاری از تصمیم‌های واقعی را نمی‌توان این‌طور نوشت.

قفل بدبینانه (Pessimistic Locking)

فرض کنید گروهی چهارنفره می‌خواهند کنار هم بنشینند. حالا انتخاب صندلی تصمیم واحدی نیست که پایگاه داده برایتان بگیرد. اپلیکیشن باید نقشه‌ی صندلی را بخواند، چهار صندلی خالی پشت سر هم پیدا کند و فقط بعد آن‌ها را ادعا کند. چون انتخاب صندلی در کد اپلیکیشن، بین خواندن و نوشتن رخ می‌دهد، تراکنش دیگری می‌تواند در این فاصله آن صندلی‌ها را تغییر دهد. پیدا کردن بلوک مجاور، شرطی نیست که بتوانید در WHERE بگذارید، پس خواندن و نوشتن در یک دستور جمع نمی‌شوند. دو گروه هر دو همان نقشه را می‌خوانند، هر دو به A15 تا A18 می‌رسند و هر دو برای ادعای آن جلو می‌روند، و هر کدام دومی بنویسد صندلی‌هایی را می‌گیرد که دیگری قبلاً انتخاب کرده بود. دوباره به Lost Update برگشته‌ایم.

اینجاست که به قفل صریح روی ردیف نیاز دارید. قفل بدبینانه با گرفتن قفل از ابتدا از تعارض جلوگیری می‌کند. اسمش از «بدبین» بودن نسبت به تعارض می‌آید؛ فرض می‌کند رخ خواهد داد و پیش از وقوع مسدودش می‌کند.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
BEGIN TRANSACTION;

-- Lock the open seats in this section while we pick a block
SELECT seat_number FROM seats
WHERE concert_id = 'weeknd_tour'
  AND section = 'floor'
  AND status = 'available'
FOR UPDATE;

-- App scans the result, finds A15-A18 open and adjacent, then claims them
UPDATE seats
SET status = 'sold', user_id = 'user123'
WHERE concert_id = 'weeknd_tour'
  AND seat_number IN ('A15', 'A16', 'A17', 'A18');

COMMIT;

عبارت FOR UPDATE هر ردیفی را که SELECT برمی‌گرداند قفل می‌کند. هنگامی که شما بلوک را انتخاب می‌کنید، هیچ تراکنش دیگری نمی‌تواند آن صندلی‌ها را ادعا کند، پس گروه دومی که همان Query را اجرا می‌کند تا Commit شما منتظر می‌ماند. تا آن موقع A15 تا A18 فروخته شده‌اند، پس از نتایجش بیرون می‌افتند و از بین باقی‌مانده انتخاب می‌کند. هر کدام یک بلوک، هیچ صندلی دوبار فروخته نمی‌شود. مشکل این است که برای ادعای چهار صندلی، همه‌ی صندلی‌های خالی آن بخش را قفل کرده‌اید، پس ترافیک سنگین رزرو اینجا سریالی می‌شود، که بخشی از دلیلی است که شما را به رویکرد خوش‌بینانه‌ی بعدی می‌کشاند.

UPDATE شرطی اینجا جواب نمی‌دهد. با شمارنده، بررسی شرطی بود که پایگاه داده هنگام نوشتن می‌توانست ارزیابی کند، پس یک دستور کل کار را انجام می‌داد. با بلوک صندلی، تصمیم در اپلیکیشن بین خواندن و نوشتن اجرا می‌شود و پایگاه داده نمی‌تواند آن را داخل WHERE دوباره انجام دهد. پس ردیف‌هایی را که می‌خوانید قفل می‌کنید، تا وقتی ثابت نگه داشته شده‌اند تصمیم می‌گیرید و پیش از رها کردن می‌نویسید. همین شکل هر Read-decide-write‌ای را پوشش می‌دهد که تصمیمش Predicate نیست. بررسی مانده‌ی حساب در برابر سقف روزانه پیش از انتقال پول، یا خواندن موجودی چند کالا پیش از ادعای یک بسته، هر دو به همین دلیل به قفل نیاز دارند. و اگر منطق شما دوباره به چیزی ساده شد که یک WHERE بتواند بیان کند، قفل را کنار بگذارید و به UPDATE شرطی برگردید. برای قفلی که لازم ندارید هزینه ندهید.

قفل در این زمینه مکانیزمی است که تا آزاد شدن قفل مانع دسترسی سایر اتصال‌های پایگاه داده به همان داده می‌شود. پایگاه‌های داده‌ای مثل PostgreSQL و MySQL می‌توانند هزاران اتصال هم‌زمان را مدیریت کنند، اما قفل‌ها تضمین می‌کنند فقط یک اتصال در هر لحظه بتواند ردیف مشخص (یا مجموعه‌ای از ردیف‌ها) را تغییر دهد.

حالت‌های شکست رایج: قفل کارش را در محافظت از منابع در برابر Contention انجام می‌دهد، ولی گران است. دو اشتباه رایج را باید مراقب باشید.

  • قفل کردن بیش از حد و برای مدت طولانی. قفل فقط برای ردیف‌هایی که پوشش می‌دهد و مدتی که نگه داشته می‌شود ایمنی می‌خرد، پس هر دو را تا حد ممکن کوچک نگه دارید. کل جدول را قفل کنید و همه‌ی Writerها پشت هم صف می‌کشند و هم‌زمانی‌ای را که می‌خواستید محافظت کنید می‌کشند. به‌جای میلی‌ثانیه چند ثانیه نگهش دارید و خریدارهای پشت آن در گلوگاه جمع می‌شوند. بدترین مقصر I/O کند است. فراخوانی API پرداخت داخل تراکنش یعنی هر خریدار دیگر همان کنسرت منتظر Latency شخص ثالث می‌ماند، پس آن کار را پیش از گرفتن قفل یا بعد از آزاد کردنش انجام دهید. در مثال ما، قفل را به صندلی‌های خالی یک بخش محدود می‌کنیم و به محض ادعای بلوک رهایش می‌کنیم، پس به بقیه‌ی سالن سرایت نمی‌کند.
  • Deadlock ناشی از ترتیب ناسازگار. دو تراکنشی که ردیف‌های یکسان را با ترتیب مخالف می‌گیرند Deadlock می‌شوند، هرکدام یک قفل را نگه می‌دارد و برای قفل دیگری تا ابد منتظر است. همیشه قفل‌ها را با ترتیب ثابتی بگیرید، مثلاً مرتب بر اساس ID، تا چرخه شکل نگیرد. پایگاه داده Deadlock را تشخیص می‌دهد و یکی از تراکنش‌ها را می‌کشد، پس خطا را قابل Retry بدانید. توضیح کامل در Deep Dive های پایین هست.

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

کنترل هم‌زمانی خوش‌بینانه (OCC)

Optimistic Concurrency Control یا OCC شرط‌بندی مخالف می‌کند. فرض می‌کند تعارض‌ها نادرند و آن‌ها را بعد از وقوع تشخیص می‌دهد، به‌جای اینکه برای پیشگیری مسدود کند. همان شکاف بین خواندن و نوشتن را محافظت می‌کند، فقط بدون ثابت نگه داشتن چیزی. اجازه می‌دهید همه ادامه دهند و تصادم را هنگام نوشتن می‌گیرید. در Contention کم، سربار قفل را کاملاً حذف می‌کند.

OCC به مقداری در ردیف نیاز دارد که با هر نوشتن عوض شود. ردیف را بخوانید و آن مقدار را یادداشت کنید. وقتی می‌خواهید بنویسید، به پایگاه داده بگویید تغییر را فقط اگر مقدار هنوز همان است که لحظه‌ای پیش خواندید اعمال کند. اگر کسی پیش از شما رسیده باشد، آن را عوض کرده است، پس WHERE شما چیزی را تطبیق نمی‌دهد، Update صفر ردیف را لمس می‌کند و این نشانه‌ی شما برای Retry است. به این مقدار شماره‌ی نسخه (Version) می‌گویند و رایج‌ترین شکل آن یک عدد صحیح اختصاصی است که با هر نوشتن یکی زیاد می‌کنید. تنها نیاز واقعی این است که مقدار هر وقت ردیف عوض شد، عوض شود. اگر تا حالا از ETag در HTTP استفاده کرده‌اید، همین ایده را به کار برده‌اید. هدر If-Match یک بررسی نسخه روی شبکه است و سرور نوشتن شما را با 412 رد می‌کند اگر منبع از زمان خواندن شما جابه‌جا شده باشد. etcd همین کار را با شماره‌ی Revision و DynamoDB با ویژگی Version انجام می‌دهد.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- Both Alice and Bob read: 1 seat, version 42

-- Alice writes first:
BEGIN TRANSACTION;
UPDATE concerts
SET available_seats = available_seats - 1, version = version + 1
WHERE concert_id = 'weeknd_tour'
  AND version = 42;  -- the version Alice read

INSERT INTO tickets (user_id, concert_id, seat_number, price, purchase_time)
VALUES ('alice', 'weeknd_tour', 'A15', 750.00, NOW());
COMMIT;
-- Succeeds. seats = 0, version = 43

-- Bob writes against the version he read:
BEGIN TRANSACTION;
UPDATE concerts
SET available_seats = available_seats - 1, version = version + 1
WHERE concert_id = 'weeknd_tour'
  AND version = 42;  -- stale, the row is on 43 now

-- Bob's UPDATE matches 0 rows. Check the count, roll back, skip the insert.
ROLLBACK;

همان تله‌ی Conditional Write اینجا هم صدق می‌کند. نسخه‌ی کهنه خطا نمی‌دهد و فقط صفر ردیف را به‌روز می‌کند، پس اپلیکیشن شما باید تعداد ردیف‌های تحت‌تأثیر را بررسی کند و اگر صفر بود Rollback کند. وگرنه INSERT اجرا می‌شود و دوباره به بلیتی بدون صندلی می‌رسید. وقتی Bob صفر ردیف را می‌بیند، می‌داند کسی پیش از او رسیده. می‌تواند وضعیت فعلی را دوباره بخواند و با نسخه‌ی تازه Retry کند، یا به کاربر بگوید نمایش پر شد، به‌جای اینکه به شکلی مرموز شکست بخورد.

می‌توانید از Timestamp یا مقدار کسب‌وکاری هم به‌عنوان نسخه استفاده کنید. Timestamp آخرین به‌روزرسانی جواب می‌دهد به شرط اینکه هر نوشتن واقعاً آن را لمس کند و دقت ساعت شما آن‌قدر ریز باشد که دو Update سریع هرگز روی یک مقدار نیفتند. مقدار کسب‌وکاری‌ای که از قبل ردیابی می‌کنید هم می‌تواند کار کند، مثل تعداد صندلی در این مثال یا بالاترین پیشنهاد در حراجی. مشکل مقادیر کسب‌وکاری مسئله‌ی ABA است، که در آن مقدار از A به B و دوباره به A برمی‌گردد و بررسی شما نمی‌تواند تشخیص دهد چیزی رخ داده. اگر مانده‌ی حساب بانکی بین خواندن و نوشتن شما از ۱۰۰ دلار به ۵۰ و دوباره به ۱۰۰ برود، بررسی خوش‌بینانه رد می‌شود حتی اگر حساب زیر پایتان به شکل معناداری تغییر کرده باشد. پس مقدار کسب‌وکاری را فقط وقتی دوباره استفاده کنید که فقط در یک جهت حرکت کند، مثل پیشنهاد قیمت که همیشه بالا می‌رود. وقتی شک دارید، شمارنده‌ی اختصاصی که فقط افزایش می‌یابد پیش‌فرض امن است.

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

بین Conditional Write، قفل و نسخه حالا می‌توانیم هر تصمیمی درباره‌ی یک ردیف را محافظت کنیم. اما هر سه ابزار یک نقطه‌ی کور مشترک دارند: ردیف‌هایی را محافظت می‌کنند که هر دو تراکنش لمس می‌کنند. دسته‌ای از تعارض‌ها هست که تراکنش‌ها هرگز به یک ردیف دست نمی‌زنند.

سطوح Isolation

گاهی دو تراکنش هرکدام مجموعه‌ای هم‌پوشان از ردیف‌ها را می‌خوانند، هرکدام تصمیمی می‌گیرد که به‌تنهایی کاملاً معتبر است، و فقط با هم قانونی را می‌شکنند. چون هیچ ردیف واحدی تصادم نمی‌کند، UPDATE شرطی، قفل ردیف و بررسی نسخه همه به‌راحتی عبور می‌کنند. به این Write Skew می‌گویند.

برنامه‌ی On-call را تصور کنید با یک قانون: همیشه دست‌کم یک مهندس باید On-call باشد. الان دو نفر هستند، Alice و Bob. هر دو بیمار می‌شوند و هم‌زمان می‌خواهند کنار بروند. تراکنش Alice برنامه را می‌خواند، می‌بیند Bob هنوز On-call است و تصمیم می‌گیرد کنار رفتن خودش مشکلی ندارد. تراکنش Bob همان برنامه را می‌خواند، می‌بیند Alice هنوز On-call است و دقیقاً همان تصمیم را می‌گیرد. هر دو Commit می‌شوند. حالا هیچ‌کس On-call نیست، حتی با اینکه هیچ‌کدام از تراکنش‌ها به‌تنهایی کار اشتباهی نکرد.

توجه کنید چرا ابزارهای قبلی کمک نمی‌کنند. Alice و Bob در ردیف‌های متفاوت، یعنی ردیف خودشان، می‌نویسند، پس ردیف مشترکی برای قفل، نسخه‌دار کردن یا محافظت با WHERE نیست. تعارض در چیزی است که برای تصمیم‌گیری خوانده‌اند. نیاز دارید پایگاه داده متوجه شود هر دو تصمیم به داده‌ای تکیه کرده بودند که دیگری عوض کرده است.

سطوح Isolation برای همین است. کنترل می‌کنند یک تراکنش چقدر از کار در جریان تراکنش دیگر را ببیند و در انتهای سخت‌گیرانه، پایگاه داده را وادار می‌کنند این وابستگی‌های خواندن-نوشتن را از طرف شما ردیابی کند. بیشتر پایگاه‌های داده چهار سطح استاندارد دارند (این‌ها گزینه‌اند، نه نردبانی که باید بالا بروید):

  • READ UNCOMMITTED: می‌تواند تغییرات Commit‌نشده‌ی تراکنش‌های دیگر را ببیند (به‌ندرت استفاده می‌شود).
  • READ COMMITTED: فقط تغییرات Commit‌شده را می‌بیند (پیش‌فرض در PostgreSQL).
  • REPEATABLE READ: داده‌ای که چند بار داخل یک تراکنش خوانده می‌شود ثابت می‌ماند (پیش‌فرض در MySQL).
  • SERIALIZABLE: قوی‌ترین Isolation؛ تراکنش‌ها طوری به نظر می‌رسند که یکی پس از دیگری اجرا شده‌اند.

هیچ‌کدام از سطوح ضعیف‌تر Write Skew را نمی‌گیرند، حتی REPEATABLE READ. SERIALIZABLE همان است که می‌گیرد. تضمین می‌کند نتیجه مثل این باشد که تراکنش‌ها یکی‌یکی اجرا شده‌اند و استعفای دوگانه‌ی On-call با هیچ چنین ترتیبی جور نیست. پس پایگاه داده اجازه نمی‌دهد هر دو Commit شوند. یکی را با خطا متوقف می‌کند و اپلیکیشن Retry می‌کند. در Retry، آن مهندس برنامه‌ی به‌روز را می‌خواند، می‌بیند آخرین نفری است که بیرون می‌رود و عقب می‌کشد.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- Is anyone else on call right now?
SELECT count(*) FROM on_call
WHERE team_id = 'payments'
  AND is_active = true;

-- App sees 2, decides it's safe to step down, then writes:
UPDATE on_call
SET is_active = false
WHERE engineer_id = 'alice';

COMMIT;
-- If Bob's transaction made the same decision concurrently,
-- one of the two commits aborts with a serialization error.

مشکل این است که SERIALIZABLE رایگان نیست. پایگاه داده را وادار می‌کند همه‌ی خواندن‌ها و نوشتن‌های لازم برای یافتن این تعارض‌ها را ردیابی کند و هر Abort کاری را که باید دوباره انجام شود دور می‌ریزد، پس گران‌ترین گزینه‌ی منو است. می‌تواند شکاف‌های قبلی را هم مدیریت کند، ولی قفل بدبینانه یا OCC ارزان‌تر به همان‌جا می‌رسند، پس SERIALIZABLE را برای وقتی نگه دارید که تعارض چند ردیف را در بر می‌گیرد. سطوح Isolation همچنین قابلیت پایگاه داده‌ی رابطه‌ای است. بیشتر Storeهای NoSQL و توزیع‌شده SERIALIZABLE واقعی نمی‌دهند، پس در آن‌ها تنها گزینه‌ی واقعی همان ارزان‌تر است: تا می‌توانید قید (Invariant) را روی یک سلول واحد جمع کنید که مستقیم قابل محافظت باشد.

و وقتی می‌توانید، اغلب ارزان‌تر هم هست که تعارض را روی یک ردیف واحد که بتواند در آن فرود بیاید Materialize کنید. شمارنده روی ردیف تیم، یا قفل FOR UPDATE روی آن ردیف پیش از اینکه کسی کنار برود، قید چندردیفی را به Contention همان‌ردیف تبدیل می‌کند که ابزارهای قبلی خوب مدیریتش می‌کنند. همان درسی است که مثال صندلی به ما داد، که در آن دادن ردیف جدا به هر صندلی تنها راهی بود که پایگاه داده بتواند دو خریدار A15 را از هم تشخیص دهد. پایگاه داده فقط تعارضی را که می‌بیند می‌تواند محافظت کند و کار شما در همه‌ی این‌ها این است که مطمئن شوید چیز مورد Contention به‌صورت یک سلول واحد قابل‌آدرس‌دهی وجود دارد، یعنی ردیف، کلید یا آیتمی که Store هنگام نوشتن بتواند بررسی کند.

قفل‌های توزیع‌شده (Distributed Locks)

توجه کنید هر ابزار تا اینجا داخل یک تراکنش پایگاه داده زندگی می‌کند و قفل پایگاه داده فقط تا وقتی که تراکنش نگه‌دارنده‌اش هست دوام دارد. وقتی کار محافظت‌شده یک Read-decide-write سریع است، این خوب است. اما گاهی لازم است دسترسی انحصاری را در طول چیزی نگه دارید که یک تراکنش نمی‌تواند پوشش دهد.

Ticketmaster نمونه‌ی کلاسیک است. صندلی A15 را انتخاب می‌کنید و اپ ده دقیقه آن را برای شما نگه می‌دارد تا جزئیات پرداخت را پر کنید. قفل FOR UPDATE نمی‌تواند این را انجام دهد. یک اتصال را برای ده دقیقه‌ی کامل قفل می‌کرد و هر تراکنش دیگری را که به آن ردیف نیاز دارد معطل می‌کرد، و فقط داخل تراکنشی که گرفته زندگی می‌کند، پس سرورهای وب Stateless دیگرتان نمی‌توانند آن را ببینند. راه‌حل این است که قفل را به‌عنوان تراکنش نگه ندارید و به‌عنوان داده نگه دارید، رکوردی از اینکه چه کسی صندلی را نگه داشته و چه زمانی مهلتش تمام می‌شود، که هر سرور بتواند بخواند و از هر تراکنش منفردی بیشتر دوام بیاورد.

این یک قفل توزیع‌شده است. همان ایده‌ی قفل از قبل است، فقط به‌صورت اجاره (Lease) با طول عمر مخصوص خودش نگه داشته می‌شود، به‌جای قفل محدود به تراکنش، و هر وقت دسترسی انحصاری باید چند مرحله، فراخوانی سرویس دیگر یا انتظار را در بر بگیرد استفاده می‌کنید. چند گزینه برای محل زندگی این اجاره دارید و هرکدام بین سرعت، زیرساخت و ظرافت مدیریت شکست Trade-off دارند:

  • Redis با TTL: Redis عملیات Atomic با انقضای خودکار می‌دهد که آن را برای قفل رزرو عالی می‌کند. دستور SET با NX (فقط اگر وجود ندارد تنظیم کن) و TTL به‌صورت Atomic قفلی می‌سازد که Redis وقتی TTL تمام شد خودش پاک می‌کند (پرچم NX حیاتی است؛ بدون آن پروسس دوم می‌تواند قفل موجود را بازنویسی کند). بدون Job پاکسازی و هر سرور وب می‌تواند همان کلید را بررسی کند. مزیت سرعت و سادگی است. نکته این است که قفل TTL تضمین انحصار محکمی نیست. اگر نگه‌دارنده از TTL فراتر برود، مثلاً با GC Pause طولانی یا شبکه‌ی کند، Redis قفل را به تماس‌گیرنده‌ی بعدی می‌دهد و برای لحظه‌ای دو کلاینت فکر می‌کنند آن را دارند. برای رزرو نرم مثل نگه داشتن صندلی، این اتفاق نادر و بی‌ضرر است، ولی به همین دلیل وقتی Grant دوگانه داده را خراب می‌کند، به قفل TTL ساده تکیه نمی‌کنید. Redis هم Single Point of Failure است که باید برایش برنامه بریزید.
  • ستون‌های پایگاه داده: اگر از قبل روی پایگاه داده هستید، قفل فقط دو ستون روی ردیف است، یکی برای اینکه چه کسی نگه داشته و یکی برای زمان انقضای آن. گرفتن قفل همان Conditional Write قبلی است: ردیف را فقط اگر آزاد است یا نگه‌داری قبلی منقضی شده ادعا کنید.
1
2
3
4
UPDATE seats
SET reserved_by = 'user123', reserved_until = NOW() + INTERVAL '10 minutes'
WHERE seat_id = 'A15'
  AND (reserved_until IS NULL OR reserved_until < NOW());

یک ردیف به‌روز شد یعنی نگه‌داری را گرفتید، صفر یعنی کس دیگری دارد. چون انقضا مستقیم در WHERE است، نگه‌داری منقضی‌شده آزاد حساب می‌شود و برای درست بودن نیازی به Job پاکسازی نیست. مزیت زیرساخت جدید نیست و Consistency مشابه بقیه‌ی داده‌هاست. عیب این است که نوشتن در پایگاه داده از Cache کندتر است و خود ردیف قفل زیر بار سنگین نقطه‌ی Contention می‌شود.

  • ZooKeeper و etcd: سرویس‌های هماهنگی ساخته‌شده برای سیستم‌های توزیع‌شده‌اند. حتی هنگام Partition شبکه و خرابی Leader تضمین‌های Consistency قوی می‌دهند. ZooKeeper از Ephemeral Node استفاده می‌کند که وقتی Session کلاینت تمام شود خودکار ناپدید می‌شود و پاکسازی طبیعی برای پروسس‌های Crash‌شده می‌دهد. هر دو سیستم از الگوریتم‌های Consensus (Raft برای etcd و ZAB برای ZooKeeper) برای حفظ Consistency بین چند Node استفاده می‌کنند. مزیت استحکام است؛ برای مدیریت سناریوهای پیچیده‌ی شکستی طراحی شده‌اند که Redis و رویکردهای پایگاه داده با آن‌ها مشکل دارند. عیب پیچیدگی عملیاتی است، چون باید خوشه‌ی هماهنگی جداگانه اجرا و نگهداری کنید.

نگه داشتن صندلی در وضعیت رزرو مثل این، به همان اندازه که برای درستی است، برای تجربه‌ی کاربر هم برد است. پنجره‌ای را که دو نفر می‌توانند سر صندلی دعوا کنند از کل Checkout به لحظه‌ی انتخاب کاهش می‌دهد، پس هیچ‌کس پرداخت را پر نمی‌کند و بعد می‌شنود صندلی رفته است.

معادل این تکنیک‌ها خارج از SQL

تکنیکدر SQLهمان حرکت در جاهای دیگر
Conditional Writeشرط WHERE روی نوشتنDynamoDB با ConditionExpression، Redis با SET NX، Cassandra با Lightweight Transaction، HTTP با If-Match
Optimistic Concurrencyستون version با WHERE version = ...ETag یا If-Match در HTTP، Revision در etcd، ویژگی Version در DynamoDB
Pessimistic LockingSELECT ... FOR UPDATEMutex یا Distributed Lock که هنگام تصمیم‌گیری نگه داشته می‌شود
Serializable IsolationISOLATION LEVEL SERIALIZABLEبیشتر مخصوص رابطه‌ای؛ در جاهای دیگر قید را روی یک سلول جمع می‌کنید
Distributed Lockردیف رزرو با TTLRedis با SET NX EX، Lease در ZooKeeper یا etcd

هر وقت ممکن است، منبع مورد Contention را در یک Store معتبر واحد نگه دارید. هر تکنیک اینجا به این وابسته است. اگر زیر Contention بالا به Strong Consistency نیاز دارید، اول هر کاری می‌توانید بکنید تا داده‌ی مرتبط در یک پایگاه داده بماند. نُه بار از ده بار شدنی است. دو موقعیت فرض «یک خانه» را می‌شکنند و هر دو خارج از این الگو هستند. اگر یک عملیات باید Atomic چند سرویس یا Shard را در بر بگیرد (مثل انتقال بین دو حساب Shard‌شده)، آن یک Distributed Transaction است که در الگوی Multi-step Processes پوشش داده می‌شود. و اگر همان رکورد هم‌زمان در چند جا قابل نوشتن است (Replication چندرهبره یا بدون رهبر)، «خانه‌ی واحدی» برای دفاع از آن نیست و وارد حل تعارض مثل Last-write-wins، Vector Clock یا CRDT می‌شوید که موضوع Replication است و فراتر از دامنه‌ی ماست.

انتخاب رویکرد درست

مثل بسیاری از جاها در طراحی سیستم، همیشه جواب قطعی وجود ندارد. باید Trade-offهای هر رویکرد را بر اساس Use Case خود بسنجید و انتخابتان را توجیه کنید.

ابزار درست به شکل نوشتنی که محافظت می‌کنید بستگی دارد. فهرست را از بالا بروید و اولین موردی را که جور است بگیرید:

  • بررسی شما یک Predicate روی ردیفی است که می‌نویسید: UPDATE شرطی استفاده کنید و هر کار بعدی را به تعداد ردیف‌های تحت‌تأثیر وابسته کنید، چه کاهش شمارنده باشد، چه تغییر وضعیت یا ادعای ردیف. به قفل صریح نیاز نیست چون نوشتن به‌تنهایی Atomic است. ساده‌ترین چیزی است که کار می‌کند، پس اول سراغ آن بروید.
  • باید بخوانید، در کد اپلیکیشن تصمیم بگیرید، بعد بنویسید: بررسی چیزی نیست که WHERE بتواند بیان کند، مثل پیدا کردن بلوک صندلی خالی یا بررسی مقدار ردیف دیگر. قفل بدبینانه با FOR UPDATE ردیف‌هایی را که خوانده‌اید در طول شکاف نگه می‌دارد و قابل‌پیش‌بینی و فکر کردن درباره‌اش آسان است. پیش‌فرض خوب وقتی Contention بالاست.
  • همان Read-decide-write ولی تصادم‌ها نادرند: OCC. ردیف را با مقداری که با هر نوشتن عوض می‌شود برچسب بزنید، یعنی شمارنده‌ی نسخه، Timestamp یا مقدار کسب‌وکاری که فقط یک جهت حرکت می‌کند، و وقتی نوشتن‌تان می‌بیند قبلاً جابه‌جا شده Retry کنید. عملکرد بهتر و بدون مسدودسازی وقتی تعارض‌ها کم‌اند.
  • قید شما چند ردیفی است که هرگز تصادم نمی‌کنند: این Write Skew است و ابزارهای ساده‌تر از آن می‌گذرند چون ردیف مشترکی برای قفل یا محافظت نیست. به Isolation از نوع SERIALIZABLE برسید تا پایگاه داده تعارض را بگیرد، یا قید را روی یک ردیف واحد که می‌توانید قفل کنید Materialize کنید.
  • نگه‌داری باید از یک تراکنش بیشتر دوام بیاورد: قفل توزیع‌شده، برای وقتی که انحصار باید یک انتظار، فراخوانی سرویس دیگر یا چند مرحله را در بر بگیرد، مثل رزرو صندلی در طول Checkout. همچنین حرکتی برای جریان‌های رقابتی رو به کاربر است که رزرو کوتاه همان‌قدر درباره‌ی UX است که درباره‌ی درستی.
رویکردکی استفاده کنیمکی پرهیز کنیمLatency معمولپیچیدگی
Conditional Writeبررسی شما Predicate روی ردیف نوشتنی است (شمارنده، وضعیت، ادعا)تصمیم به منطق اپلیکیشن نیاز دارد یا ردیف‌های دیگر را در بر می‌گیردکم (یک دستور Atomic)کم
Pessimistic LockingRead-decide-write که بررسی Predicate در WHERE نیست؛ Contention بالاContention کم، یا Conditional Write از قبل کافی استکم برای هر عملیات، ولی قفلی نگه می‌دارد که دیگران منتظرش می‌مانندکم
Optimistic Concurrencyهمان Read-decide-write ولی تصادم‌ها نادر؛ نسبت خواندن به نوشتن بالاContention بالا که Retryها روی هم جمع می‌شوندکم وقتی تعارضی نیست؛ هزینه‌ی Retry هنگام تعارضمتوسط
SERIALIZABLE IsolationWrite Skew یا قید چندردیفی بدون ردیف واحد برای قفلمسیرهای داغ با Contention بالا (هزینه‌ی Abort و Retry)متوسط (ردیابی تعارض)متوسط
Distributed Locksانحصار باید انتظار، فراخوانی خارجی یا چند مرحله را در بر بگیرد (رزرو)محافظ تک‌ردیفی داخل یک تراکنش از قبل کافی استکم (نوشتن‌های ساده‌ی وضعیت)متوسط

وقتی شک دارید، آن را در یک پایگاه داده نگه دارید و ساده‌ترین ابزار مناسب را بگیرید. UPDATE شرطی اگر بررسی شما Predicate است، قفل بدبینانه وقتی منطق Read-decide-write واقعی دارید. همیشه می‌توانید بعداً سخت‌گیرانه‌ترش کنید.

کی در مصاحبه استفاده کنیم؟

منتظر نمانید مصاحبه‌کننده درباره Contention بپرسد. وقتی سناریوهایی را دیدید که چند پروسس برای یک منبع رقابت می‌کنند، آن را اعلام کنید و مکانیزم‌های هماهنگی پیشنهاد دهید. این معمولاً وقتی است که در نیازمندی‌های غیرعملکردی تشخیص می‌دهید سیستم شما Strong Consistency لازم دارد.

نشانه‌های تشخیص

نمونه‌هایی روشن که ممکن است به الگوهای Contention نیاز داشته باشید:

  • چند کاربر که برای منابع محدود رقابت می‌کنند: مثل بلیت کنسرت، پیشنهاد در حراجی، موجودی فروش ناگهانی یا تطبیق راننده‌ها با مسافران.
  • جلوگیری از رزرو یا شارژ دوباره: در سناریوهایی مثل پردازش پرداخت، رزرو صندلی یا برنامه‌ریزی اتاق جلسه.
  • تضمین Consistency داده زیر هم‌زمانی بالا: برای عملیاتی مثل به‌روزرسانی مانده‌ی حساب، مدیریت موجودی یا ویرایش مشارکتی.
  • مدیریت Race Condition در سیستم‌های توزیع‌شده: هر سناریویی که همان عملیات ممکن است هم‌زمان روی چند سرور رخ دهد و نتیجه به ترتیب عملیات حساس باشد.

سناریوهای رایج مصاحبه

این در سؤال‌های رایج مصاحبه خیلی ظاهر می‌شود. یکی از محبوب‌ترین الگوهاست و مصاحبه‌کننده‌ها دوست دارند درباره‌اش بپرسند.

  • سیستم‌های حراجی آنلاین: برای نشان دادن OCC عالی است چون چند پیشنهاددهنده برای یک کالا رقابت می‌کنند. می‌توانید بالاترین پیشنهاد فعلی را به‌عنوان «نسخه» استفاده کنید (چون پیشنهادها فقط بالا می‌روند، خطر ABA نیست) و پیشنهاد جدید را فقط اگر از پیشنهاد فعلی مورد انتظار بالاتر است بپذیرید.
  • Ticketmaster و رزرو رویداد: اگرچه به نظر سناریوی کلاسیک قفل بدبینانه برای انتخاب صندلی می‌آید، رزرو موقت در واقع برد بزرگ‌تری است. وقتی کاربران صندلی انتخاب می‌کنند، فوراً با قفل توزیع‌شده و TTL ۱۰ دقیقه‌ای آن را رزرو می‌کنید، که از تجربه‌ی وحشتناک پر کردن اطلاعات پرداخت و بعد فهمیدن اینکه کس دیگری صندلی را گرفته جلوگیری می‌کند.
  • سیستم‌های بانکی و پرداخت: وقتی جابه‌جایی پول داخل یک پایگاه داده می‌ماند، مسئله‌ی قفل بدبینانه یا OCC است و همین‌جا زندگی می‌کند. وقتی انتقال باید چند سرویس یا Shard را در بر بگیرد، دیگر مسئله‌ی Contention نیست و تبدیل به مسئله‌ی Distributed Transaction می‌شود که در Multi-step Processes پوشش داده می‌شود.
  • Dispatch سرویس سواری مشترک: رزرو موقت وضعیت اینجا می‌درخشد. می‌توانید هنگام ارسال درخواست سفر، وضعیت راننده را روی pending_request بگذارید که از چند درخواست هم‌زمان به همان راننده جلوگیری می‌کند. یا از Cache با TTL برای پاکسازی خودکار وقتی راننده در ۱۰ ثانیه پاسخ نمی‌دهد استفاده کنید، یا ستون وضعیت با Timestamp انقضا که درخواست کهنه‌شده بدون Job پاکسازی آزاد خوانده می‌شود.
  • سیستم‌های فروش ناگهانی و موجودی: نمونه‌ی عالی برای نشان دادن ترکیبی از رویکردها. می‌توانید OCC را با ستون نسخه‌ی اختصاصی برای به‌روزرسانی موجودی استفاده کنید، همراه با «نگه‌داری» موقت سبد خرید (با قفل توزیع‌شده و TTL) تا تجربه‌ی کاربر بهتر شود و Contention هنگام Checkout کم شود.
  • Yelp و سیستم‌های نقد: مثال خوب OCC. وقتی کاربران نقد ثبت می‌کنند، باید میانگین امتیاز کسب‌وکار را به‌روز کنید. چند نقد هم‌زمان برای یک رستوران Contention می‌سازد، پس می‌توانید ستون نسخه‌ی اختصاصی بگذارید و فقط اگر نسخه با آنچه خواندید مطابق است به‌روز کنید. این از خراب شدن محاسبه‌ی امتیاز هنگام رسیدن هم‌زمان نقدها جلوگیری می‌کند.

بهترین نامزدها مشکلات Contention را پیش از پرسیده شدن شناسایی می‌کنند. هنگام طراحی هر سیستمی که منابع مشترک دارد، فوراً به هماهنگی بپردازید:

«این سیستم حراجی چند پیشنهاددهنده دارد که برای کالاها رقابت می‌کنند، پس از OCC با بالاترین پیشنهاد فعلی به‌عنوان بررسی نسخه استفاده می‌کنم.»

«برای سیستم بلیت‌فروشی می‌خواهم کاربران بعد از پر کردن اطلاعات پرداخت صندلی را از دست ندهند، پس رزرو صندلی با Timeout ده‌دقیقه‌ای پیاده می‌کنم.»

کی پیچیده‌اش نکنیم

وقتی راه‌حل‌های ساده‌تر کار می‌کنند، سراغ مکانیزم‌های هماهنگی پیچیده نروید.

اشتباه رایجی که می‌بینم این است که نامزدها وقتی تراکنش پایگاه داده ساده با قفل ردیف یا OCC کافی است، سراغ قفل‌های توزیع‌شده (Redis و مانند آن) می‌روند. افزودن اجزای جدید پیچیدگی سیستم را بالا می‌برد و حالت‌های شکست تازه می‌آورد، پس تا می‌توانید از آن‌ها پرهیز کنید.

  • سناریوهای Contention کم که تعارض نادر است (مثل به‌روزرسانی توضیحات محصول که فقط ادمین‌ها می‌توانند ویرایش کنند) می‌توانند از OCC پایه با منطق Retry استفاده کنند. وقتی منطق Retry ساده تعارض گاه‌به‌گاه را مدیریت می‌کند، طرح‌های قفل‌گذاری پیچیده پیاده نکنید.
  • عملیات تک‌کاربره مثل لیست کارهای شخصی، اسناد خصوصی یا تنظیمات کاربر Contention ندارند، پس هماهنگی لازم نیست.
  • بارهای سنگین خواندنی که بیشتر عملیات خواندن است و نوشتن گاه‌به‌گاه دارند، می‌توانند از OCC ساده برای تعارض‌های نوشتن نادر بدون اثر روی عملکرد خواندن استفاده کنند.

Deep Dive های رایج

مصاحبه‌کننده‌ها دوست دارند Edge Caseها و سناریوهای شکست را بکاوند.

«چطور از Deadlock در قفل بدبینانه جلوگیری می‌کنید؟»

انتقال بانکی بین دو حساب را در نظر بگیرید. Alice می‌خواهد ۱۰۰ دلار به Bob بدهد، در حالی که Bob هم‌زمان می‌خواهد ۵۰ دلار به Alice بدهد. تراکنش A باید حساب Alice را بدهکار و حساب Bob را بستانکار کند. تراکنش B باید حساب Bob را بدهکار و حساب Alice را بستانکار کند. تراکنش A اول حساب Alice را قفل می‌کند، بعد سعی می‌کند حساب Bob را قفل کند. تراکنش B اول حساب Bob را قفل می‌کند، بعد سعی می‌کند حساب Alice را قفل کند. هر دو تراکنش برای آزاد شدن قفل دیگری تا ابد منتظر می‌مانند.

این Deadlock رخ می‌دهد چون تراکنش‌ها قفل‌ها را با ترتیب متفاوت می‌گیرند. منطق کسب‌وکار به ترتیب اهمیت نمی‌دهد و فقط می‌خواهد وقتی کاربران تعامل دارند هر دو را به‌روز کند. اما پایگاه داده نمی‌تواند ذهن شما را بخواند که گرفتن کدام قفل‌ها هم‌زمان ایمن است.

راه‌حل استاندارد Ordered Locking است، یعنی همیشه قفل‌ها را با ترتیب ثابتی بگیرید، فارغ از جریان منطق کسب‌وکارتان. همه‌ی منابعی را که باید قفل کنید پیش از گرفتن هر قفل بر اساس کلید قطعی (مثل ID کاربر یا Primary Key) مرتب کنید. اگر باید کاربران 123 و 456 را قفل کنید، همیشه 123 را اول قفل کنید حتی اگر منطق کسب‌وکارتان اول 456 را پردازش کند. این از انتظار دایره‌ای جلوگیری می‌کند چون همه‌ی تراکنش‌ها از یک ترتیب گرفتن پیروی می‌کنند.

برای انتقال بین کاربران 456 و 123، همیشه کاربر 123 را اول قفل کنید، فارغ از اینکه چه کسی انتقال را شروع کرده. این حیاتی است. باید همه‌ی شرکت‌کنندگان را با همان کلید مرتب کنید، نه «اول شروع‌کننده را قفل کن». اگر Alice (ID برابر 456) به Bob (ID برابر 123) انتقال بدهد، چون 123 < 456، Bob را اول قفل کنید. اگر Bob به Alice انتقال بدهد، باز هم Bob را اول قفل کنید. طرح دقیق ترتیب مهم نیست، تا وقتی در کل سیستم بین همه‌ی تراکنش‌ها یکسان باشد.

حتی با Ordered Locking، Deadlock را چیزی بدانید که مدیریت می‌کنید، نه چیزی که کنار گذاشته‌اید. هر پایگاه داده‌ی اصلی تشخیص خودکار Deadlock دارد: وقتی چرخه‌ای ببیند، یکی از تراکنش‌ها را با خطای Deadlock Abort می‌کند و بقیه جلو می‌روند. به‌صورت پیش‌فرض روشن است، پس کار شما گرفتن آن خطا و Retry کردن بازنده با گرفتن قفل‌ها به ترتیب است. Timeout انتظار قفل پشتیبان درشت‌تری برای حالت متفاوت است، تراکنشی که پشت قفلی گیر کرده که صرفاً بیش از حد نگه داشته شده، نه چرخه‌ی واقعی. پس برای جلوگیری از Deadlock به Ordered Locking تکیه کنید و برای پاک کردن آن‌هایی که می‌لغزند به تشخیص‌دهنده‌ی پایگاه داده.

«مسئله‌ی ABA را در OCC چطور مدیریت می‌کنید؟»

مسئله‌ی ABA وقتی رخ می‌دهد که مقدار بین خواندن و نوشتن شما از A به B و دوباره به A برگردد. بررسی خوش‌بینانه‌ی شما همان مقدار را می‌بیند و فرض می‌کند چیزی عوض نشده، در حالی که انتقال‌های وضعیت مهمی رخ داده است.

فرض کنید هر رستوران review_count نگه می‌دارد و شما آن را به‌عنوان نسخه استفاده می‌کنید. آن را روی 100 می‌خوانید. بین خواندن و نوشتن شما، یک نقد حذف می‌شود (به 99 می‌رسد) و نقد جدیدی می‌آید (دوباره به 100 می‌رساند). نوشتن شما بررسی می‌کند «آیا نسخه هنوز 100 است؟»، بله می‌بیند و Commit می‌کند، و به‌روزرسانی میانی را بی‌صدا خراب می‌کند. شمارش A → B → A رفت و برگشت و بررسی برابری نمی‌تواند رفت‌وبرگشت را ببیند.

امن‌ترین راه‌حل ستون نسخه‌ی اختصاصی است که با هر به‌روزرسانی افزایش می‌یابد، فارغ از اینکه داده‌ی کسب‌وکار عوض شده یا نه. Update شما می‌شود «میانگین جدید را تنظیم کن و نسخه را افزایش بده، ولی فقط اگر نسخه با چیزی که خواندم مطابق است».

1
2
3
4
5
-- Use a dedicated version column for safety
UPDATE restaurants
SET avg_rating = 4.1, review_count = review_count + 1, version = version + 1
WHERE restaurant_id = 'pizza_palace'
  AND version = 42;  -- Expected current version

اگر نمی‌توانید ستون نسخه اضافه کنید، راه‌حل جایگزین این است که هر فیلدی را که خوانده‌اید در WHERE بگذارید تا نوشتن فقط وقتی اعمال شود که کل ردیف هنوز با آنچه دیدید مطابق است. سنگین‌تر و دست‌وپاگیرتر است ولی هر تغییری را می‌گیرد، از جمله رفت‌وبرگشتی که مقدار کسب‌وکاری تنها پنهان می‌کند. بعضی پایگاه‌های داده نسخه‌ی ردیف داخلی هم عرضه می‌کنند، مثل ستون سیستمی xmin در Postgres، که می‌توانید به‌جای نگهداری نسخه‌ی خودتان استفاده کنید.

«وقتی همه یک منبع را می‌خواهند عملکرد چه می‌شود؟»

این مشکل Hot Partition یا «سلبریتی» است، جایی که سیستم توزیع‌شده‌ی با طراحی دقیق شما ناگهان همه‌اش روی یک منبع واحد می‌کوبد. تصور کنید سلبریتی به Twitter می‌پیوندد و میلیون‌ها کاربر هم‌زمان می‌خواهند او را Follow کنند، یا کلکسیونی نادر در eBay عرضه می‌شود و هزاران نفر روی یک کالا پیشنهاد می‌دهند، یا Taylor Swift کنسرت سورپرایز اعلام می‌کند و همه همزمان بلیت می‌خواهند.

مسئله‌ی اساسی این است که استراتژی‌های مقیاس‌دهی معمول وقتی Contention روی یک آیتم مشخص فرود می‌آید می‌شکنند. Sharding بار را بین ردیف‌ها تقسیم می‌کند ولی همه اینجا همان ردیف را می‌خواهند، پس چیزی برای تقسیم نیست. Load Balancing فقط درخواست‌ها را بین سرورهایی پخش می‌کند که بعد روی همان ردیف صف می‌کشند. Read Replica بار خواندن را از Primary برمی‌دارد، ولی دعوا اینجا بر سر نوشتن است، پس به آن دست نمی‌زند.

استراتژی اول شما باید این باشد که بپرسید آیا می‌توانید خود مسئله را عوض کنید، به‌جای اینکه زیرساخت بیشتری بریزید. شاید به‌جای یک کالای حراجی، در واقع ۱۰ کالای یکسان دارید و می‌توانید برای هر کدام حراجی جدا برگزار کنید. شاید به‌جای نیاز به Consistency فوری برای تعامل‌های شبکه‌ی اجتماعی، لایک‌ها و فالوها را Eventually Consistent کنید؛ کاربران متوجه نمی‌شوند اگر Follow‌شان چند ثانیه طول بکشد تا در تعداد فالوورهای سلبریتی ظاهر شود.

Queue-Based Serialization: برای موارد که روی منبع داغ به Strong Consistency نیاز دارید، Serialization مبتنی بر صف پیاده کنید. همه‌ی درخواست‌های آن منبع مشخص را در صف اختصاصی بگذارید که توسط یک Worker Thread پردازش می‌شود. این Contention را با متوالی کردن عملیات به‌جای هم‌زمان کردنشان کاملاً حذف می‌کند. صف مثل بافر عمل می‌کند که می‌تواند جهش ترافیک را جذب کند، در حالی که Worker درخواست‌ها را با نرخ پایدار پردازش می‌کند. (نکته: نوشتن‌هایی که ترتیب می‌خواهند باید در همان Partition باشند.)

Trade-off آن Throughput است، نه فقط Latency. یک Worker فقط تا حدی سریع می‌تواند پردازش کند، و Single Point of Failure است که باید پشتش Standby بگذارید. مسئله‌ی Contention را به مسئله‌ی سریالی تبدیل کرده‌اید، که اغلب معامله‌ی بهتری است وقتی جایگزین، گیر کردن کل سیستم است، ولی سرعت آن منبع را برای همیشه سقف‌دار کرده‌اید.

نتیجه‌گیری

مدیریت Contention برای سیستم‌های قابل‌اعتماد مهم است، ولی ایده‌ی اصلی ساده‌تر از چیزی است که بیشتر مهندس‌ها انتظار دارند. هر منبع مورد Contention یک منبع حقیقت واحد دارد، یعنی تنها جایی که مالک مقدار واقعی است، و درستی همان‌جا اعمال می‌شود. Conditional Write، قفل بدبینانه، سطوح Isolation و OCC فقط راه‌های متفاوتی برای هماهنگی دسترسی در همان منبع حقیقت‌اند که با بزرگ‌تر شدن شکافی که باید محافظت کنید، تشدید می‌شوند. و وقتی آن شکاف سرانجام از یک تراکنش بزرگ‌تر شود و یک انتظار یا فراخوانی سیستم دیگر را در بر بگیرد، قفل توزیع‌شده انحصار را نگه می‌دارد. آن‌ها از دسترسی به منبع حقیقت محافظت می‌کنند، نه اینکه جایگزینش شوند.

قفل بدبینانه Contention بالا را قابل‌پیش‌بینی مدیریت می‌کند، OCC وقتی تعارض‌ها نادرند عملکرد عالی می‌دهد، و پایگاه داده‌ی مدرن مثل PostgreSQL می‌تواند Contention بسیار بیشتری از آنچه مردم فکر می‌کنند در منبع حقیقت واحد جذب کند. وقتی ترافیک یا تجربه‌ی کاربر ایجاب می‌کند سراغ قفل‌ها و رزروهای خارجی بروید، نه به‌صورت پیش‌فرض. و حرکت اول همیشه این است که مطمئن شوید چیز مورد Contention واقعاً به‌صورت سلول واحدی وجود دارد که Store بتواند محافظتش کند، چه ردیف باشد، چه کلید، چه آیتم، بسته به جایی که زندگی می‌کند.

مرز را هم روشن نگه دارید. به محض اینکه عملیات باید چند منبع حقیقت را در بر بگیرد، Contention را پشت سر گذاشته‌اید و وارد قلمرو Distributed Transaction شده‌اید. ساده‌ترین راه‌حلی که در منبع حقیقت کار می‌کند تقریباً همیشه راه درست است.