دو سال پیش، وسط یک پروژه‌ی فروشگاهی، با یک باگ پنهان روبرو شدم که دو هفته طول کشید تا ریشه‌اش را پیدا کنم. مشتری گزارش می‌داد که بعضی سفارش‌ها در سامانه ثبت می‌شوند، ولی موجودی محصول از انبار کم نمی‌شود. با بررسی لاگ‌ها معلوم شد که بین ثبت سفارش و کسر موجودی، یک سرویس خارجی فراخوانی می‌شود که گاهی کند و گاهی خطا می‌دهد — و در آن حالت، نیمی از عملیات انجام شده بود و نیمی نه. آن روز برایم روشن شد که تراکنش‌ها در MySQL فقط یک مفهوم دانشگاهی نیستند؛ سازوکاری هستند که در همان لحظه‌ی بحران، تفاوت بین داده‌ی سالم و داده‌ی نیمه‌کاره را تعیین می‌کنند. از آن پروژه به بعد، در هر عملیاتی که بیش از یک جدول را تغییر می‌دهد، تراکنش را جدی می‌گیرم. در این مقاله، همان مسیری را می‌روم که امروز با تیم‌ها و مشتریانم طی می‌کنم: از مفهوم ACID و سینتکس پایه تا سطوح Isolation، قفل‌ها، Deadlock، SAVEPOINT و الگوهای واقعی در پروژه‌های PHP و پایتون.

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

اگر تازه با MySQL آشنا می‌شوید، اول آموزش MySQL از صفر را بخوانید. اما فرض کنیم با مفاهیم پایه‌ی INSERT، UPDATE و DELETE راحت هستید. حالا سؤال اصلی این است: تراکنش، چه چیزی را در دیتابیس اضافه می‌کند که دستورات معمولی ندارند؟

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

سه سناریوی رایج که در پروژه‌های واقعی به آن‌ها برخورده‌ام و همه با تراکنش حل می‌شوند:

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

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

تراکنش، قولِ صادقانه‌ی دیتابیس است که یا همه‌چیز اعمال می‌شود یا هیچ؛ اگر این قول را جدی نگیرید، داده‌ی شما در روز بحران، نیمه‌کاره می‌ماند.

ACID: چهار ستون تراکنش

هر تراکنش در MySQL، بر اساس چهار خصیصه‌ی معروف به ACID سنجیده می‌شود. دانستن دقیق هر کدام، در پروژه‌های واقعی به‌کار می‌آید چون بعضی از این خصیصه‌ها، قابل تنظیم هستند و تصمیم درست درباره‌شان، تفاوت بین سرعت و صحت را می‌سازد.

Atomicity (اتمی بودن)

تراکنش، یک واحد تقسیم‌ناپذیر است. اگر یکی از دستورات آن شکست خورد، کل تراکنش لغو می‌شود. این خصیصه، از طریق ROLLBACK یا خطای خودکار پیاده می‌شود.

Consistency (انسجام)

تراکنش، دیتابیس را از یک وضعیت معتبر به وضعیت معتبر دیگری می‌برد. اگر قیدی (مثل UNIQUE یا FOREIGN KEY) در طول تراکنش نقض شود، آن تراکنش اعمال نمی‌شود. اصول قیدها در طراحی دیتابیس در MySQL آمده است.

Isolation (ایزوله بودن)

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

Durability (دوام)

بعد از COMMIT، تغییرات به‌طور دائمی ذخیره می‌شوند — حتی اگر سرور ری‌استارت شود یا برق قطع شود. InnoDB این خصیصه را از طریق Redo Log پیاده می‌کند.

نکته‌ی مهمی که در پروژه‌های واقعی به آن رسیده‌ام: در MySQL، فقط موتور InnoDB از ACID پشتیبانی می‌کند. اگر جدول شما روی MyISAM باشد، تراکنش‌ها هیچ اثری ندارند — دستورات در همان لحظه اعمال می‌شوند. این تفاوت، در تفاوت InnoDB و MyISAM با جزئیات بیشتری توضیح داده شده است.

سینتکس پایه: START، COMMIT و ROLLBACK

تراکنش در MySQL با سه دستور پایه پیاده می‌شود:

-- شروع تراکنش
START TRANSACTION;
-- یا
BEGIN;

-- عملیات
UPDATE accounts SET balance = balance - 1000000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000000 WHERE id = 2;

-- اگر همه‌چیز درست بود
COMMIT;

-- اگر خطایی رخ داد
ROLLBACK;

سه نکته‌ی مهم در همین چند خط که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

  • START TRANSACTION و BEGIN: این دو در MySQL مترادف‌اند و از نظر عملکرد یکسان. ولی START TRANSACTION خواناتر است و در مستندات رسمی توصیه شده.
  • نبود COMMIT به‌معنی از دست رفتن داده است: اگر تراکنش را با COMMIT تمام نکنید و مثلاً نشست کلاینت قطع شود، MySQL تغییرات را به‌طور خودکار ROLLBACK می‌کند. این رفتار، در پروژه‌های واقعی، منبع باگ‌های پنهان است — چون کد شما فرض می‌کند داده ذخیره شده، ولی در دیتابیس خبری نیست.
  • هر تراکنش، یک بازه‌ی زمانی است: بین START و COMMIT، منابع دیتابیس ممکن است قفل شوند. هرچه این بازه طولانی‌تر باشد، ریسک تعارض با تراکنش‌های دیگر بیشتر است.

COMMIT و ROLLBACK در سناریوهای واقعی

در کد برنامه، تراکنش معمولاً در یک بلوک try-catch پیاده می‌شود تا در صورت خطا، ROLLBACK بزند:

START TRANSACTION;

try {
    INSERT INTO orders (user_id, total, status) VALUES (5, 1500000, "pending");
    INSERT INTO order_items (order_id, product_id, quantity) VALUES (LAST_INSERT_ID(), 12, 2);
    UPDATE products SET stock = stock - 2 WHERE id = 12 AND stock >= 2;

    -- اگر عملیات موفق بود، تغییرات را ثبت کن
    COMMIT;
} catch (Exception $e) {
    ROLLBACK;
    throw $e;
}

توجه به شرط AND stock >= 2 در دستور UPDATE: این شرط تضمین می‌کند که اگر موجودی کافی نبود، عملیات به‌روزرسانی صفر ردیف را تغییر می‌دهد — و کد می‌تواند آن را تشخیص دهد و تراکنش را لغو کند. اصول کار با این شرط‌ها در دستورات پرکاربرد MySQL آمده است.

سناریویی که در پروژه‌ی واقعی نجات‌دهنده بود

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

راه‌حل ما: انتقال فراخوانی درگاه به بیرون از تراکنش، و استفاده از یک وضعیت میانی pending در سفارش. این الگو، به الگوی محبوب «Saga» نزدیک است که در پروژه‌های مدرن، برای تراکنش‌های توزیع‌شده به‌کار می‌رود. همین اصل را در بهینه‌سازی کوئری‌های MySQL نیز به‌عنوان کاهش زمان تراکنش توصیه کرده‌ام — هم برای سرعت، هم برای کاهش ریسک تعارض.

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

Autocommit و رفتار پیش‌فرض MySQL

یکی از نکات ظریفی که در پروژه‌های واقعی زیاد به آن برخورده‌ام، رفتار autocommit است:

-- بررسی وضعیت فعلی
SELECT @@autocommit;

-- غیرفعال‌سازی
SET autocommit = 0;

-- فعال‌سازی
SET autocommit = 1;

وقتی autocommit فعال است (پیش‌فرض)، هر دستور SQL به‌طور خودکار یک تراکنش جداگانه است و بلافاصله commit می‌شود. وقتی غیرفعال باشد، دستورات تا زمانی که COMMIT نزنید، در تراکنش جاری باقی می‌مانند.

سه نکته‌ی مهم در autocommit که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

  • در بیشتر کتابخانه‌ها، autocommit به‌طور پیش‌فرض فعال است: ولی وقتی شما یک تراکنش صریح با START TRANSACTION شروع می‌کنید، autocommit به‌طور موقت برای آن تراکنش غیرفعال می‌شود.
  • در mysqldump و ابزارهای بکاپ: رفتار autocommit در بکاپ منطقی، روی یکپارچگی داده اثر دارد. نکات مربوط به این موضوع در پشتیبان‌گیری از MySQL آمده است.
  • در کتابخانه‌های برنامه‌نویسی: بعضی کتابخانه‌ها autocommit را به‌طور پیش‌فرض غیرفعال می‌کنند و انتظار دارند خودتان commit بزنید. این تفاوت، در اتصال پایتون به MySQL و اتصال PHP به MySQL با جزئیات بررسی شده است.

سطوح Isolation و مشکل‌هایی که حل می‌کنند

یک تراکنش، وقتی در حال اجرا است، ممکن است با تراکنش‌های دیگر تعارض داشته باشد. سطوح Isolation، تعیین می‌کنند که تا چه حد این تعارض‌ها دیده شوند. MySQL چهار سطح دارد:

سطحDirty ReadNon-Repeatable ReadPhantom Read
READ UNCOMMITTEDممکنممکنممکن
READ COMMITTEDغیرممکنممکنممکن
REPEATABLE READ (پیش‌فرض)غیرممکنغیرممکنممکن
SERIALIZABLEغیرممکنغیرممکنغیرممکن

سه مشکلی که هر سطح حل می‌کند

  • Dirty Read: خواندن داده‌ای که در تراکنش دیگری نوشته شده ولی هنوز commit نشده است. اگر آن تراکنش ROLLBACK بزند، داده‌ی خوانده‌شده، وجود خارجی ندارد.
  • Non-Repeatable Read: خواندن یک رکورد در دو زمان مختلف، دو مقدار متفاوت برمی‌گرداند — چون تراکنش دیگری بین دو خواندن، آن رکورد را به‌روزرسانی کرده است.
  • Phantom Read: در دو خواندن با شرط یکسان، تعداد ردیف‌های برگشتی متفاوت است — چون تراکنش دیگری بین دو خواندن، رکورد جدیدی درج کرده است.

تنظیم سطح Isolation

-- بررسی سطح فعلی
SELECT @@transaction_isolation;

-- تغییر سطح برای نشست جاری
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- تنظیم سطح به‌طور کلی برای سرور
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

پیش‌فرض MySQL، REPEATABLE READ است — سطحی که در بیشتر پروژه‌های واقعی، تعادل خوبی بین کارایی و دقت فراهم می‌کند. تغییر به READ COMMITTED در بعضی سناریوها (مثل گزارش‌گیری‌های طولانی) می‌تواند کارایی را بالا ببرد، ولی احتمال Non-Repeatable Read را افزایش می‌دهد. تصمیم بین این سطوح، در پروژه‌های واقعی، بیشتر از جنس کسب‌وکار است تا فنی — بسته به این‌که کدام نوع خطا برای شما پذیرفتنی‌تر است.

قفل‌ها در تراکنش: Shared و Exclusive

تراکنش‌ها با قفل‌ها کار می‌کنند. MySQL دو نوع اصلی قفل دارد:

  • Shared Lock (S): قفل اشتراکی — چند تراکنش همزمان می‌توانند داده را بخوانند، ولی هیچ‌کدام نمی‌توانند تغییرش دهند. با SELECT ... LOCK IN SHARE MODE درخواست می‌شود.
  • Exclusive Lock (X): قفل انحصاری — وقتی تراکنشی این قفل را گرفته، تراکنش دیگر نه می‌تواند بخواند، نه بنویسد. با SELECT ... FOR UPDATE یا هر دستور UPDATE/DELETE درخواست می‌شود.
-- قفل اشتراکی
START TRANSACTION;
SELECT * FROM products WHERE id = 12 LOCK IN SHARE MODE;
-- تراکنش‌های دیگر می‌توانند همین رکورد را بخوانند ولی نه به‌روزرسانی کنند
COMMIT;

-- قفل انحصاری
START TRANSACTION;
SELECT * FROM products WHERE id = 12 FOR UPDATE;
-- تراکنش‌های دیگر نه می‌توانند بخوانند نه بنویسند
COMMIT;

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

START TRANSACTION;

SELECT stock FROM products WHERE id = 12 FOR UPDATE;
-- اینجا فرض کنید stock = 5 است

-- اگر stock کمتر از مقدار موردنیاز بود، تراکنش را لغو کن
UPDATE products SET stock = stock - 2 WHERE id = 12;

COMMIT;

بدون FOR UPDATE، ممکن است دو تراکنش همزمان موجودی را بخوانند (هر دو ۵ ببینند)، و هر دو تصمیم بگیرند که ۳ عدد بفروشند — نتیجه: فروش ۶ عدد از محصولی که فقط ۵ عدد داشته است. این خطا، در پروژه‌های فروشگاهی، «بیش‌فروشی» نام دارد و تنها با قفل درست، پیشگیری می‌شود. اگر با قفل‌های طولانی و اثرشان روی کارایی درگیر هستید، رفع خطای Lock wait timeout در MySQL راه‌های تشخیص و کاهش این نوع قفل‌ها را نشان می‌دهد.

Deadlock: دو تراکنش، دو قفل، یک بن‌بست

Deadlock وقتی رخ می‌دهد که دو یا چند تراکنش، هر کدام منتظر قفلی باشند که در اختیار دیگری است. مثال ساده:

  • تراکنش A، قفل رکورد ۱ را گرفته و می‌خواهد رکورد ۲ را بگیرد.
  • تراکنش B، قفل رکورد ۲ را گرفته و می‌خواهد رکورد ۱ را بگیرد.

هیچ‌کدام نمی‌توانند ادامه دهند و MySQL مجبور می‌شود یکی از دو تراکنش را لغو کند:

-- بررسی آخرین Deadlock
SHOW ENGINE INNODB STATUS\G

این دستور، جزئیات آخرین Deadlock را نشان می‌دهد — چه تراکنش‌هایی و چه قفل‌هایی درگیر بوده‌اند. راه‌های پیشگیری از Deadlock که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

  • ترتیب دسترسی یکسان: اگر همه‌ی تراکنش‌ها، جداول و رکوردها را به یک ترتیب مشخص قفل کنند، Deadlock کمتر رخ می‌دهد.
  • تراکنش‌های کوتاه: هرچه تراکنش کوتاه‌تر باشد، پنجره‌ی زمانی برای تعارض کوچک‌تر است.
  • ایندکس درست: روی ستون‌های WHERE و JOIN، ایندکس مناسب بگذارید تا قفل‌ها در سطح کمتری اعمال شوند. اصول این نکته در ایندکس‌گذاری در MySQL آمده است.
  • مدیریت Retry در کد: حتی با بهترین طراحی، گاهی Deadlock رخ می‌دهد. کد برنامه باید آماده باشد که در آن حالت، تراکنش را دوباره اجرا کند. تجربه‌ی من: در یک پروژه‌ی پرفروش، با اضافه‌کردن یک Retry Pattern ساده، نرخ خطاهای Deadlock از ده در روز به صفر رسید. اصول این الگو در رفع خطای Deadlock در MySQL آمده است.

نکته‌ی ظریف: Deadlock در MySQL یک خطای بحرانی نیست — یک رفتار عادی در سیستم‌های همزمان است. اگر کد شما خطای Deadlock را مدیریت می‌کند، داده‌ی شما امن است. مشکل وقتی است که کد، این خطا را خفه کند و بدون retry، به کاربر خطای ناخوشایند نشان دهد.

SAVEPOINT: بازگشت نقطه‌ای

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

START TRANSACTION;

INSERT INTO orders (user_id, total) VALUES (5, 500000);
SET @order_id = LAST_INSERT_ID();

SAVEPOINT after_order;

INSERT INTO order_items (order_id, product_id) VALUES (@order_id, 12);
INSERT INTO order_items (order_id, product_id) VALUES (@order_id, 15);

-- فرض کنید خطایی رخ داد
ROLLBACK TO SAVEPOINT after_order;
-- حالا سفارش باقی است، ولی اقلام پاک شده‌اند

-- دوباره امتحان کن
INSERT INTO order_items (order_id, product_id) VALUES (@order_id, 12);

COMMIT;

کاربرد اصلی SAVEPOINT در پروژه‌های واقعی، در عملیات‌های پیچیده‌ای است که بعضی بخش‌ها می‌توانند مستقل شکست بخورند و شما می‌خواهید فقط آن بخش را لغو کنید. مثال: در یک عملیات Import، اگر یک رکورد مشکل داشت، می‌خواهید بقیه وارد شوند. SAVEPOINT این امکان را می‌دهد. تجربه‌ی من: در پروژه‌ای که داده‌ی CRM را از سه منبع مختلف ترکیب می‌کردیم، SAVEPOINT ابزار کلیدی برای مدیریت خطاهای جزئی بود — به‌جای لغو کل Import، فقط رکورد مشکل‌دار را رد می‌کردیم.

InnoDB و MyISAM: تفاوت در تراکنش

یکی از مهم‌ترین تفاوت‌های عملی بین InnoDB و MyISAM، رفتارشان در برابر تراکنش است. بدون درک این تفاوت، کوئری‌های تراکنشی روی جدول‌های MyISAM، نتایج غیرمنتظره می‌دهند:

  • InnoDB: پشتیبانی کامل از تراکنش. START TRANSACTION، COMMIT و ROLLBACK اثر واقعی دارند.
  • MyISAM: از تراکنش پشتیبانی نمی‌کند. حتی اگر START TRANSACTION بزنید، هر دستور بلافاصله اعمال می‌شود و ROLLBACK هیچ اثری ندارد.
-- بررسی موتور ذخیره‌سازی جدول
SHOW TABLE STATUS LIKE "orders"\G
-- ستون Engine، موتور واقعی را نشان می‌دهد

نکته‌ی مهم در پروژه‌های واقعی: اگر پروژه‌ای با جدول‌های MyISAM دارید و می‌خواهید از تراکنش استفاده کنید، اول باید موتور را عوض کنید. این کار در MySQL ساده است (ALTER TABLE ... ENGINE=InnoDB) ولی باید با احتیاط انجام شود. اصول کامل مهاجرت در تفاوت InnoDB و MyISAM آمده است.

تراکنش در PHP و پایتون

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

PHP با PDO

try {
    $pdo->beginTransaction();

    $stmt = $pdo->prepare("INSERT INTO orders (user_id, total) VALUES (?, ?)");
    $stmt->execute([5, 500000]);
    $order_id = $pdo->lastInsertId();

    $stmt = $pdo->prepare("INSERT INTO order_items (order_id, product_id) VALUES (?, ?)");
    $stmt->execute([$order_id, 12]);

    $pdo->commit();
} catch (Exception $e) {
    $pdo->rollBack();
    throw $e;
}

در PDO، متد beginTransaction با START TRANSACTION هم‌ارز است. اگر از commit یا rollBack استفاده نکنید و اسکریپت تمام شود، PDO به‌طور خودکار rollBack می‌کند. اصول کامل کار با PDO در آموزش PDO در PHP و اتصال PHP به MySQL آمده است.

پایتون با PyMySQL

import pymysql

connection = pymysql.connect(
    host="localhost",
    user="db_user",
    password="db_password",
    database="mydb",
    charset="utf8mb4",
    autocommit=False,  # تراکنش دستی
)

try:
    with connection.cursor() as cursor:
        cursor.execute(
            "INSERT INTO orders (user_id, total) VALUES (%s, %s)",
            (5, 500000),
        )
        order_id = cursor.lastrowid

        cursor.execute(
            "INSERT INTO order_items (order_id, product_id) VALUES (%s, %s)",
            (order_id, 12),
        )

    connection.commit()
except Exception as e:
    connection.rollback()
    raise
finally:
    connection.close()

نکته‌ی مهم در PyMySQL: پیش‌فرض autocommit=False است، ولی اگر صریحاً autocommit=False نگذارید، ممکن است رفتار متفاوتی ببینید. همیشه این پارامتر را صریح تنظیم کنید. اصول کامل کار با تراکنش در پایتون در اتصال پایتون به MySQL آمده است.

در هر دو زبان، الگوی مشترک این است: شروع تراکنش، انجام عملیات در try، و در catch/except، لغو با rollback. همین الگو، در کتابخانه‌های دیگر (SQLAlchemy، Django ORM، Laravel) هم مشابه است — فقط سینتکس متفاوت است.

الگوهای واقعی در پروژه‌ها

چند الگوی تراکنشی که در پروژه‌های واقعی به ذهنیت ثابت من تبدیل شده‌اند:

الگوی اول: تراکنش با Retry

برای مدیریت Deadlock و خطاهای گذرا:

def execute_with_retry(connection, function, retries=3):
    for attempt in range(retries):
        try:
            connection.begin()
            result = function()
            connection.commit()
            return result
        except pymysql.err.OperationalError as e:
            connection.rollback()
            if "Deadlock" in str(e) and attempt < retries - 1:
                time.sleep(0.1 * (2 ** attempt))  # تأخیر نمایی
                continue
            raise

این الگو در پروژه‌های پربازدید، تفاوت بین «سایت با خطاهای گاه‌به‌گاه» و «سایت پایدار» را می‌سازد.

الگوی دوم: تراکنش کوتاه، عملیات بیرونی خارج

هر عملیات شبکه‌ای (API، ایمیل، فایل) باید بیرون از تراکنش باشد:

# خوب: عملیات شبکه بیرون از تراکنش
payment_result = call_payment_gateway(...)  # بیرون از تراکنش

if payment_result.success:
    with connection.cursor() as cursor:
        cursor.execute("START TRANSACTION")
        cursor.execute("UPDATE orders SET status = %s WHERE id = %s", ("paid", order_id))
        cursor.execute("UPDATE products SET stock = stock - 1 WHERE id = %s", (product_id,))
        cursor.execute("COMMIT")

# بد: عملیات شبکه داخل تراکنش
with connection.cursor() as cursor:
    cursor.execute("START TRANSACTION")
    cursor.execute("UPDATE orders SET status = %s WHERE id = %s", ("pending", order_id))
    payment_result = call_payment_gateway(...)  # تراکنش ممکن است timeout شود
    cursor.execute("COMMIT")

تجربه‌ی من: در پروژه‌ای که فراخوانی درگاه پرداخت داخل تراکنش بود، در ساعات شلوغی، تعداد خطاهای «Timeout تراکنش» به‌شدت بالا می‌رفت. انتقال درگاه به بیرون تراکنش، این خطاها را تقریباً صفر کرد.

الگوی سوم: تأیید مالی با قفل انحصاری

START TRANSACTION;

SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- تراکنش‌های دیگر نمی‌توانند این رکورد را تغییر دهند

SELECT balance FROM accounts WHERE id = 2 FOR UPDATE;

UPDATE accounts SET balance = balance - 1000000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000000 WHERE id = 2;

COMMIT;

این الگو، در سیستم‌های مالی، استاندارد طلایی است. بدون FOR UPDATE، ممکن است دو تراکنش همزمان، موجودی را بخوانند و نتیجه‌ی محاسبه‌شده اشتباه شود.

اشتباهاتی که در پروژه‌های واقعی دیده‌ام

در بازبینی کد و ساختار تراکنش پروژه‌های مختلف، این اشتباهات را زیاد دیده‌ام:

  • فراموش کردن COMMIT: تغییرات در دیتابیس ذخیره نمی‌شوند و هیچ‌کس متوجه نمی‌شود تا وقتی داده‌ی ناپدید، گزارش شود. اولین سؤال در چنین حادثه‌ای: «آیا در تابع مربوطه، commit زده شده است؟»
  • نبود ROLLBACK در catch: اگر خطایی رخ دهد و شما rollback نزنید، تراکنش باز می‌ماند و قفل‌ها تا پایان نشست باقی می‌مانند. نتیجه: کاربران بعدی روی همان رکورد قفل می‌خورند.
  • تراکنش‌های طولانی: عملیات شبکه‌ای، پردازش فایل، یا خواندن از سیستم خارجی داخل تراکنش. نتیجه: قفل‌های طولانی، Deadlock زیاد، و خطاهای Lock wait timeout.
  • جدول‌های MyISAM در کد تراکنشی: کد شما تراکنش صریح می‌زند ولی جدول روی MyISAM است. نتیجه: ROLLBACK هیچ اثری ندارد. بررسی موتور جدول، بخشی از بازبینی اولیه‌ی هر پروژه است.
  • نبود Retry برای Deadlock: در پروژه‌های پربازدید، Deadlock یک واقعیت است. کد باید آن را مدیریت کند، نه اینکه خطای ناخوشایند به کاربر نشان دهد.
  • استفاده از LOCK TABLES به‌جای تراکنش: بعضی تیم‌ها به‌جای تراکنش، قفل کل جدول می‌گذارند. نتیجه: بار سنگین روی دیتابیس، کاهش همزمانی، و کندی کلی. تراکنش، راه استاندارد و سبک‌تری است.
  • عدم بررسی نتیجه‌ی UPDATE: اگر یک UPDATE صفر ردیف را تغییر دهد و شما این را چک نکنید، تراکنش ادامه می‌یابد و با داده‌ی اشتباه commit می‌شود. استفاده از cursor.rowcount برای بررسی ضروری است.
  • ترتیب ناسازگار در قفل‌گیری: دو کد مختلف، رکوردها را به ترتیب‌های متفاوت قفل می‌کنند. نتیجه: Deadlock بیشتر. قاعده‌ی طلایی: همیشه به یک ترتیب مشخص قفل بگیرید.
  • نادیده‌گرفتن سطح Isolation: تفاوت بین REPEATABLE READ و READ COMMITTED در پروژه‌های گزارش‌گیری زیاد است. بعضی تیم‌ها بدون بررسی، پیش‌فرض را استفاده می‌کنند و بعد از مشاهده‌ی نتایج غیرمنتظره، علت را در کد می‌جویند.
  • نبود مستندسازی تراکنش‌ها: یک تابع پیچیده که چند جدول را در یک تراکنش تغییر می‌دهد، دو ماه بعد حتی توسط نویسنده‌اش هم سخت فهم می‌شود. یادداشت کوتاه با توضیح «چرا این تراکنش لازم است»، ارزش زیادی در نگهداری دارد.
  • نبود منطق Idempotent در Retry: اگر تراکنشی را دوباره اجرا کنید، باید مطمئن شوید که نتیجه‌ی نهایی، همان نتیجه‌ی اجرای یک‌بار است. مثلاً با یک UNIQUE KEY یا INSERT IGNORE روی داده‌ی تکراری، از ایجاد داده‌ی مکرر جلوگیری کنید.

یک توصیه‌ی عملی از تجربه: در پروژه‌های جدید، یک تابع یا Decorator مرکزی برای مدیریت تراکنش بسازید که شامل begin، commit، rollback و retry باشد. تمام عملیات‌هایی که بیش از یک جدول را تغییر می‌دهند، از همین تابع استفاده کنند. این کار، دو مزیت دارد: یکدستی رفتار در کل پروژه، و کاهش خطاهای انسانی مثل فراموش‌کردن commit. اصول مکمل کار با قفل و تراکنش در سطح سرور، در بهترین روش‌های امنیت MySQL و پشتیبان‌گیری از MySQL آمده است — چون بکاپ‌گیری از یک دیتابیس با تراکنش‌های طولانی، نیاز به رویکرد متفاوتی دارد. اگر با JOIN در تراکنش‌ها سر و کار دارید، آموزش JOIN در MySQL نشان می‌دهد که ترتیب JOIN چطور روی قفل‌های اعمال‌شده اثر می‌گذارد؛ و اگر با دستورات روزمره‌ی MySQL درگیرید، دستورات پرکاربرد MySQL فهرستی از ابزارهای بررسی قفل و تراکنش در اختیارتان می‌گذارد.

سخن آخر

تراکنش‌ها در MySQL، از یک START TRANSACTION ساده شروع می‌شوند ولی در پروژه‌های واقعی، به یک تصمیم معماری تبدیل می‌شوند که صحت داده و پایداری سیستم را تعیین می‌کند. سه نکته‌ی اصلی که در این مقاله به آن‌ها رسیدیم: اول، هر عملیاتی که بیش از یک جدول را تغییر می‌دهد، کاندیدای تراکنش است — نه فقط برای سیستم‌های مالی، بلکه برای هر منطق کسب‌وکاری که داده‌ی ناسازگار نمی‌پذیرد؛ دوم، تراکنش‌های کوتاه و بدون عملیات شبکه‌ای بسازید — هر ثانیه اضافه در تراکنش، ریسک قفل و Deadlock را بیشتر می‌کند؛ سوم، Retry برای Deadlock را جدی بگیرید — در پروژه‌های پربازدید، این خطا اجتناب‌ناپذیر است و مدیریت درست آن، تفاوت بین سایت پایدار و سایت لرزان را می‌سازد.

اگر امروز می‌خواهید شروع کنید، سه کار کوچک پیشنهاد می‌کنم: در یک دیتابیس تستی، دو عملیات مرتبط را در یک تراکنش انجام دهید و بعد از اجرا، ROLLBACK بزنید و ببینید که هیچ‌کدام از تغییرات باقی نمی‌مانند؛ در پروژه‌ی فعلی، یک تابع که چند جدول را تغییر می‌دهد پیدا کنید و مطمئن شوید که در یک تراکنش پیچیده شده است؛ و برای یکی از عملیات‌های پرتکرار، یک الگوی Retry برای Deadlock پیاده کنید. این سه کار، در بیشتر پروژه‌ها، سطح پایداری داده را به‌طور محسوس بالا می‌برد. اگر تجربه‌ای از تراکنش‌ها در پروژه‌های خودتان دارید — مخصوصاً اگر با یک Deadlock سرسخت یا خطای نیمه‌کاره روبرو شده‌اید — در دیدگاه‌ها بنویسید؛ همین نکته‌های میدانی، برای خواننده‌ی بعدی از هر مستند رسمی ارزشمندتر است. 🔄