طراحی و پیاده سازی

Transaction و Concurrency در پایگاه داده چگونه از خراب‌شدن اطلاعات جلوگیری می‌کنند؟

ساعت ۲۳ و ۴۵ دقیقه یک شب تعطیلات است و سامانه فروش بلیت اتوبوس تنها یک صندلی خالی برای مسیر فردا دارد. دو مسافر، در دو گوشه شهر، تقریباً در همان لحظه دکمه «ثبت خرید» را می‌زنند. صفحه هر دو پس از چند ثانیه تأیید نشان می‌دهد: بلیت صادر شد. صبح روز بعد، راننده در صندلی شماره ۱۴ با دو بلیت چاپ‌شده برای یک جای نشستن روبه‌رو می‌شود.

این صحنه یکی از کلاسیک‌ترین چالش‌های مهندسی نرم‌افزار است: چند عملیات همزمان که می‌خواهند روی یک داده مشترک بنویسند. ابزار پاسخ‌گوی این چالش دو مفهوم بنیادی است: تراکنش (Transaction) و همزمانی (Concurrency). در این مقاله با بازگشت به همین صحنه می‌بینیم پایگاه داده چگونه از خراب‌شدن اطلاعات جلوگیری می‌کند و اگر درست پیکربندی نشود، دقیقاً کجاها هنوز آسیب‌پذیر است.

سناریو: مرگ یک صندلی در چند میلی‌ثانیه

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

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

تراکنش چیست و چه تضمینی می‌دهد؟

تراکنش یعنی دیده‌شدن مجموعه‌ای از عملیات به‌عنوان یک کار واحد و تجزیه‌ناپذیر. در مثال ما، «خواندن، بررسی، صدور بلیت» باید یک تراکنش شود: یا همه گام‌ها با هم کامل می‌شوند یا اثر هیچ‌کدام باقی نمی‌ماند. پایگاه داده برای این کارِ واحد چهار تضمین کلاسیک می‌دهد که با نام خاصیت‌های ACID شناخته می‌شوند.

  • اتمیک‌بودن (Atomicity): کار یا تمام می‌شود یا اصلاً شروع‌نشده تلقی می‌شود؛ حالت نیمه‌کاره وجود ندارد و هر شکست، همه تغییرات را پس می‌گیرد.
  • سازگاری (Consistency): پس از پایان تراکنش، قواعد کسب‌وکار — مثل اینکه تعداد صندلی خالی هرگز منفی نمی‌شود — حفظ می‌مانند.
  • جداسازی (Isolation): تراکنش‌های همزمان طوری اجرا می‌شوند که انگار پشت‌سرهم اجرا شده‌اند و هیچ‌یک اثر میانه کار دیگری را نمی‌بیند.
  • پایایی (Durability): وقتی تراکنش به تأیید رسید، حتی با قطع برق یا سقوط سرور، نتیجه از بین نمی‌رود.

برگشت به عقب: کلید بازگشت به وضعیت سالم

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

وقتی چند نفر همزمان می‌رسند: خطاهای کلاسیک همزمانی

فروش دوباره صندلی نام اختصاصی خودش را دارد: به‌روزرسانی گم‌شده (Lost Update). دو خواندن یک عدد، دو تصمیم بر پایه همان عدد، و در نهایت گم‌شدن اثر یکی از دو به‌روزرسانی. اما این تنها الگوی آسیب نیست و خانواده بزرگی از خطاها در همین مرز شکل می‌گیرند.

  • خواندن داده ناپاک (Dirty Read): شما داده‌ای را می‌خوانید که تراکنش نویسنده‌اش هنوز به تأیید نرسیده است؛ اگر آن تراکنش برگردد، شما بر پایه داده‌ای تصمیم گرفته‌اید که هرگز رسماً وجود نداشت.
  • خواندن ناتکرارپذیر (Non-Repeatable Read): در طول یک تراکنش، یک مقدار را دو بار می‌خوانید و دو عدد متفاوت می‌گیرید، چون تراکنش دیگری میان راه مقدار را تغییر داده است.
  • خواندن شبح (Phantom Read): مجموعه رکوردهای برگشتی از یک پرس‌وجو میان دو اجرا تغییر می‌کند و رکورد تازه‌ای «ظاهر می‌شود»؛ عدد گزارش شما با گزارش لحظه‌ای قبل نمی‌خواند.

نکته خطرناک این خطاها این است که در آزمون‌های معمولی خودشان را نشان نمی‌دهند؛ چون محیط توسعه به‌ندرت صد کاربر را در همان میلی‌ثانیه جمع می‌کند. خرابی همزمانی دقیقاً در محیط عملیاتی و زیر بار واقعی سراغ شما می‌آید، جایی که بازتولید صحنه سخت‌ترین بخش کار است.

قفل‌ها و سطح‌های جداسازی: تنظیم عایق میان تراکنش‌ها

قفل (Lock) ساده‌ترین ابزار حفاظت است. قفل اشتراکی (Shared Lock) مثل کتابخانه‌ای است که چند نفر می‌توانند همزمان یک کتاب را بخوانند، اما هیچ‌کس حق ندارد هنگام خواندن، متنش را دستکاری کند. قفل انحصاری (Exclusive Lock) مثل برداشتن کلید اتاق از میزبان است: وقتی کلید نزد یک نفر است، بقیه بیرون می‌مانند تا کارش تمام شود.

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

سطح جداسازیچه خطاهایی هنوز ممکن است؟رفتار کلی
خواندن بدون تأیید (Read Uncommitted)تقریباً همه، حتی Dirty Readسریع اما تقریباً بی‌محافظ؛ کاربرد بسیار محدود
خواندن تأییدشده (Read Committed)Non-Repeatable Read و Phantom Readپیش‌فرض رایج بسیاری از سیستم‌ها؛ هر خواندن فقط داده تأییدشده می‌بیند
خواندن تکرارپذیر (Repeatable Read)در برخی موتورها، Phantom Readمقادیر خوانده‌شده در طول تراکنش ثابت می‌مانند
ترتیب‌پذیر (Serializable)هیچ‌کدامسخت‌گیرانه‌ترین سطح؛ رفتار معادل اجرای پشت‌سرهم تراکنش‌ها

رویکرد مکمل قفل‌ها، کنترل همزمانی خوش‌بینانه (Optimistic Concurrency) است: به‌جای قفل‌کردن از ابتدا، اجازه دهید همه بخوانند؛ اما هنگام نوشتن بررسی کن که نسخه‌ای که می‌خواهی بازنویسی کنی همان نسخه‌ای است که خوانده‌ای. اگر در این فاصله کسی جلوتر رفته باشد، عملیات رد می‌شود و درخواست‌کننده دوباره تلاش می‌کند. در سناریوهایی که تعارض کم است، این روش از صف‌های طولانی قفل جلوگیری می‌کند و توان عملیاتی بالاتری می‌دهد.

مثال کاربردی: بازنویسی صحنه فروش صندلی

حالا همان شب ۲۳ و ۴۵ دقیقه را با منطق اصلاح‌شده بازسازی کنیم. درخواست هر دو مسافر به تراکنشی می‌رسد که با قفل انحصاری روی رکورد خودِ صندلی شروع می‌شود یا با سطح جداسازی سخت‌گیرانه اجرا می‌شود. مسافر اول زودتر می‌رسد، قفل را می‌گیرد، صندلی را فروخته‌شده علامت می‌زند و تراکنشش به تأیید می‌رسد. مسافر دوم که چند میلی‌ثانیه دیرتر رسیده، پشت قفل می‌ماند؛ وقتی نوبتش می‌شود و صندلی خالی را می‌خواند، عدد را صفر می‌بیند و به‌جای بلیت، پیام روشن «صندلی تکمیل شد» می‌گیرد.

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

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

درس‌آموخته: هزینه‌ها و مرزهای حفاظت

قفل و جداسازی به‌موقع هم هزینه دارند. قفل‌های بدترکیب می‌توانند به بن‌بست (Deadlock) برسند: تراکنش اول قفل رکورد A را دارد و منتظر B است، در حالی که تراکنش دوم قفل B را دارد و منتظر A. پایگاه داده معمولاً یکی از دو تراکنش را قربانی می‌کند و برمی‌گرداند؛ پس برنامه باید برای تلاش دوباره آماده باشد. نگه‌داشتن قفل هم باید کوتاه باشد: اگر تراکنشی میان راه برای تماس با سرویس خارجی مکث کند، قفلش صفی از همه بقیه می‌سازد و کل سامانه کند می‌شود.

پس قاعده عملی چنین است: تراکنش را کوتاه نگه دارید، قفل را روی دقیق‌ترین دانه لازم بگذارید — رکورد صندلی، نه کل جدول صندلی‌ها — و عملیات‌های کند مثل ایمیل و فراخوانی سرویس پرداخت را بیرون از مرز تراکنش انجام دهید. برای الگوهای خاص مثل رزرو موقت، طراحی «نگه‌داشتن با مهلت انقضا» می‌تواند به‌جای قفل طولانی، همان تضمین را با هزینه کمتر بدهد.

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

کاربرد عملی

  • هر جا منطق کسب‌وکار روی عددی مشترک تصمیم می‌گیرد — موجودی، ظرفیت، سقف مصرف — آن منطق را درون یک تراکنش با سطح جداسازی متناسب ببرید و برای رد شدن و تلاش دوباره، مسیر روشنی تعریف کنید.
  • قیدهای یکتایی و محدودیت‌های ستونی را در خود پایگاه داده هم تعریف کنید، نه فقط در کد؛ اگر لایه برنامه جا بزند، این قیدها آخرین سنگر می‌مانند.
  • در تست‌ها صحنه تعارض را شبیه‌سازی کنید: دو تراکنش موازی روی یک رکورد؛ ابزارهای آزمون همزمانی برای همین کار ساخته شده‌اند.
  • خطای بن‌بست و تلاش‌های دوباره را ثبت کنید تا الگوی تعارض‌ها دیده شود؛ گاهی راه‌حل واقعی، بازطراحی ترتیب دست‌یابی به رکوردهاست، نه قفل سخت‌گیرانه‌تر.

نکات کلیدی این مقاله

  • تراکنش یعنی «همه یا هیچ»؛ کسری از تراکنش خطرناک‌تر از عدم اجرای آن است.
  • Lost Update و Dirty Read در محیط توسعه دیده نمی‌شوند؛ دقیقاً زیر بار واقعی ظاهر می‌شوند.
  • سطح جداسازی اهرم تنظیم میان ایمنی داده و توان عملیاتی است، نه یک تنظیم فرعی.
  • قفل را روی کوچک‌ترین دانه لازم و برای کوتاه‌ترین زمان ممکن بگذارید و برای بن‌بست، مسیر تلاش دوباره داشته باشید.
  • در سناریوهای کم‌تعارض، کنترل خوش‌بینانه با بررسی نسخه اغلب ساده‌تر و سریع‌تر از قفل طولانی است.

سوالات متداول

آیا همه عملیات‌های برنامه باید درون تراکنش باشند؟

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

سطح جداسازی پیش‌فرض کدام است و اگر تغییرش ندهیم چه می‌شود؟

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

وقتی دو سرویس جدا روی یک داده می‌نویسند، قفل کافی است؟

قفل پایگاه داده فقط مرز میان تراکنش‌های همان پایگاه را اداره می‌کند. اگر دو سرویس مستقل منطق‌های جدا دارند، اول باید مالکیت داده روشن شود؛ معمولاً یک سرویس مالک می‌شود و دیگری از طریق فراخوانی یا صف، درخواستش را می‌فرستد. برای سناریوهای توزیع‌شده، قفل توزیع‌شده یا طراحی عملیات به‌صورت تکرارپذیر (Idempotent) به کار می‌آید.

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا