كيفية استخدام Transactions في MySQL لحماية بياناتك

كيفية استخدام Transactions في MySQL لحماية بياناتك

تُعد Transactions في MySQL واحدة من أهم المهارات التي تفرّق بين مطوّر يكتب استعلامات بسيطة ومطوّر قادر على بناء أنظمة موثوقة يعتمد عليها في بيئة إنتاج حقيقية. تخيّل عملية تحويل بنكي بين حسابين: خُصم المبلغ من الحساب الأول، ثم انقطع الاتصال بالخادم قبل إضافة المبلغ إلى الحساب الثاني. بدون معاملات لن تجد سوى بيانات مالية تالفة يستحيل إصلاحها بسهولة. هنا بالتحديد تظهر قيمة إدارة المعاملات التي تضمن تنفيذ مجموعة أوامر SQL كوحدة واحدة إما بالكامل أو لا شيء إطلاقاً.

في هذا الدليل الشامل سنتناول كل ما تحتاجه حول Transactions في MySQL: الأساسيات ومفهوم ACID، وأوامر START TRANSACTION وCOMMIT وROLLBACK وSAVEPOINT، ومستويات العزل Isolation Levels، والأقفال Locking، ومعالجة الجمود Deadlock، مع أمثلة عملية قابلة للتنفيذ في SQL وPHP وPython وNode.js.

أبرز النقاط التي ستتعلمها في هذا الدليل

  • المعنى الحقيقي للمعاملة Transaction والفرق بينها وبين تنفيذ الاستعلامات بشكل منفصل.
  • خصائص ACID الأربع وكيف يطبّقها محرك InnoDB فعلياً على القرص.
  • الاستخدام العملي لأوامر START TRANSACTION وCOMMIT وROLLBACK وSAVEPOINT.
  • مستويات العزل الأربعة في MySQL ومتى تختار كل مستوى بحسب طبيعة تطبيقك.
  • كيفية تجنّب الأخطاء الشائعة مثل Deadlocks والمعاملات الطويلة وفقدان التراجع.
  • أمثلة جاهزة للنسخ في PHP وPython وNode.js مع معالجة أخطاء احترافية.

ما معنى Transactions في MySQL ولماذا هي ضرورية؟

المعاملة Transaction هي مجموعة من أوامر SQL تُنفَّذ ككتلة منطقية واحدة غير قابلة للتجزئة. إما أن تنجح جميعها ويُثبَّت أثرها في قاعدة البيانات نهائياً، وإما أن تفشل جميعها فتعود البيانات إلى الحالة التي كانت عليها قبل البدء. هذا المبدأ يُعرف اختصاراً بـ All or Nothing.

لتفهم الفكرة بشكل ملموس، تخيّل أنك تدير متجراً إلكترونياً. عند إنشاء طلب جديد يجب تنفيذ أربع خطوات على الأقل: إدخال الطلب في جدول الطلبات، إدخال عناصره في جدول التفاصيل، خصم الكميات من المخزون، وتسجيل عملية دفع. لو نجحت الخطوة الأولى وفشلت الثانية، ستحصل على طلب فارغ بلغ المخزون خصماً وهمياً. المعاملات تمنع هذه الكارثة تماماً.

الفرق بين التنفيذ العادي والتنفيذ داخل معاملة

بدون معاملات، يعمل MySQL في وضع autocommit، أي أن كل أمر DML يُثبَّت تلقائياً فور تنفيذه. هذا مناسب للاستعلامات المستقلة، لكنه خطير على العمليات المركّبة.

في المثال التالي ننفّذ عمليتين متتاليتين في وضع autocommit، وهما عرضتان لخطر الفشل الجزئي:

-- وضع autocommit الافتراضي: كل أمر يُثبَّت فوراً
UPDATE accounts SET balance = balance - 500 WHERE id = 1;
-- إذا توقف السيرفر هنا، اختفى المبلغ ولا شيء يعيده
UPDATE accounts SET balance = balance + 500 WHERE id = 2;

لاحظ أن الأمر الأول ثُبّت قبل تنفيذ الثاني. أي خطأ بينهما يعني ضياع المال فعلياً. أما داخل معاملة، فلن يُثبَّت أي تغيير إلا عند تنفيذ COMMIT.

العمليات التي تحتاج معاملات فعلياً

  • التحويلات المالية بين الحسابات البنكية أو المحافظ الإلكترونية.
  • معالجة الطلبات وحجز المخزون في أنظمة التجارة الإلكترونية.
  • أنظمة الحجوزات (تذاكر، غرف فنادق، مواعيد طبية) لمنع الحجز المزدوج.
  • ترحيل البيانات بين الجداول بشكل متسق.
  • تحديث عدّادات ومجاميع إحصائية مرتبطة بجداول أخرى.
  • أي عملية تُعدّد فيها الاستعلامات وتترابط نتائجها منطقياً.

خصائص ACID الأربع وكيف يطبّقها InnoDB فعلياً

تُبنى المعاملات على أربع خصائص أساسية تعرف اختصاراً بـ ACID، وهي حجر الأساس في أي قاعدة بيانات علائقية جادة.

الذرية Atomicity

تعني أن المعاملة وحدة لا تتجزأ. إذا فشل أي جزء منها، يُلغى أثر كل ما نُفّذ داخلها. يطبّق InnoDB ذلك عبر Undo Log الذي يحتفظ بالنسخ القديمة من الصفوف لاستخدامها في التراجع Rollback عند الحاجة.

الاتساق Consistency

تعني انتقال قاعدة البيانات من حالة صحيحة إلى حالة صحيحة أخرى، مع احترام كل القيود Constraints مثل المفاتيح الأجنبية وUNIQUE وCHECK. لا يكفي أن تتراجع المعاملة عند الخطأ، بل يجب ألا تنتهك البيانات قواعد العمل المعرّفة مسبقاً.

العزل Isolation

تعني أن المعاملات المتزامنة لا تتداخل مع بعضها البعض. كل معاملة تعمل وكأنها وحدها في النظام. يطبّق InnoDB العزل عبر MVCC (التحكم في التزامن متعدد الإصدارات) والأقفال Locking، ويُضبط مستوى العزل حسب الحاجة بين الصرامة والأداء.

الديمومة Durability

تعني أنه بعد نجاح COMMIT تصبح البيانات محفوظة بشكل دائم حتى لو انهار الخادم في اللحظة التالية. يتحقق ذلك عبر Redo Log وكتابته على القرص قبل تأكيد العملية، وفق إعداد innodb_flush_log_at_trx_commit.

محركات التخزين: InnoDB مقابل MyISAM

ليس كل محرك تخزين في MySQL يدعم المعاملات. هذه نقطة أساسية يغفل عنها كثيرون، فيكتبون كوداً يعتمد على ROLLBACK ثم يكتشفون أن البيانات لم تتراجع أبداً.

InnoDB — المحرك الافتراضي والمدعوم للمعاملات

  • يدعم المعاملات بالكامل مع COMMIT وROLLBACK وSAVEPOINT.
  • يدعم خصائص ACID الأربع عبر Undo Log وRedo Log.
  • يوفر قفل على مستوى الصف Row-Level Locking مما يزيد التزامن.
  • يدعم المفاتيح الأجنبية Foreign Keys.
  • هو المحرك الافتراضي في MySQL 5.5 وما بعده.

MyISAM — لا معاملات إطلاقاً

  • لا يدعم المعاملات، وأوامر COMMIT وROLLBACK تُتجاهل بصمت دون أي رسالة خطأ.
  • يستخدم قفلاً على مستوى الجدول بالكامل Table-Level Locking.
  • لا يدعم المفاتيح الأجنبية.
  • أسرع قليلاً في عمليات القراءة البسيطة فقط على السنوات الماضية، لكنه غير مناسب اليوم لمعظم التطبيقات.

للتأكد من محرك جدول معيّن، نفّذ الأمر التالي في عميل mysql:

SHOW TABLE STATUS WHERE Name = 'accounts';

ابحث في عمود Engine عن القيمة InnoDB. إذا وجدت MyISAM فاعلم أن أي معاملة تكتبها على هذا الجدول بلا قيمة عملياً. للتحويل نفّذ:

ALTER TABLE accounts ENGINE = InnoDB;

هذا الأمر يحوّل الجدول مع الحفاظ على بياناته، ولمن الجدير بالذكر أنه من أوامر DDL التي تُسبّب التزاماً ضمنياً Implicit Commit لأي معاملة مفتوحة.

أوامر المعاملات الأساسية: START TRANSACTION و COMMIT و ROLLBACK

ثلاثة أوامر تكوّن جوهر أي معاملة في MySQL، وإتقانها يغطي 90% من احتياجاتك اليومية.

بدء المعاملة: START TRANSACTION أو BEGIN

يُستخدم هذا الأمر لفتح معاملة جديدة. الصيغتان START TRANSACTION وBEGIN متكافئتان وظيفياً، لكن START TRANSACTION أوضح دلالياً وأكثر شيوعاً في الكود الاحترافي. يمكنك أيضاً تحديد خصائص المعاملة مباشرة:

START TRANSACTION;
-- معاملة للقراءة فقط، أخف على الأداء
START TRANSACTION READ ONLY;
-- معاملة قراءة وكتابة (الافتراضي)
START TRANSACTION READ WRITE;
-- معاملة تأخذ لقطة متسقة فوراً
START TRANSACTION WITH CONSISTENT SNAPSHOT;

المعاملة READ ONLY مفيدة في التقارير الطويلة لأنها تسمح لمحرك InnoDB بتحسين الأداء وتقليل الأقفال المطلوبة.

تثبيت المعاملة: COMMIT

عند تنفيذ COMMIT تصبح كل التغييرات المتراكمة داخل المعاملة دائمة ومرئية لبقية الجلسات. هذا هو الأمر الوحيد الذي يضمن حفظ البيانات فعلياً.

START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

بعد تنفيذ COMMIT لا يمكن الرجوع عن أي من التغييرين. تأكد دائماً من صحة المنطق قبل هذه الخطوة.

التراجع عن المعاملة: ROLLBACK

يُلغي ROLLBACK كل التغييرات التي نُفّذت منذ بداية المعاملة ويعيد البيانات إلى حالتها السابقة، بشرط أن تكون المعاملة مفتوحة ولم تُثبَّت بعد.

START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- اكتشفنا خطأ في المنطق أو فشل الاستعلام التالي
ROLLBACK;
-- رصيد الحساب 1 عاد كما كان تماماً
SELECT balance FROM accounts WHERE id = 1;

التراجع لا يعيد البيانات فقط، بل يُحرّر أيضاً كل الأقفال التي حصلت عليها المعاملة، مما يسمح لبقية الجلسات بالمتابعة فوراً.

الفرق الجوهري بين الأوامر الثلاثة

  1. START TRANSACTION: يفتح نافذة مؤقتة من التغييرات المعزولة.
  2. COMMIT: يغلق النافذة ويثبّت المحتوى في قاعدة البيانات نهائياً.
  3. ROLLBACK: يغلق النافذة ويرمي كل التغييرات دون أثر.

مثال عملي كامل: تحويل الأموال بين حسابين

لنبنِ مثالاً واقعياً بأكمله من الصفر. سننشئ جدول حسابات، نضيف بيانات، ثم ننفّذ تحويلاً آمناً بين حسابين مع التحقق من كفاية الرصيد.

الخطوة الأولى: إنشاء الجدول والبيانات

CREATE TABLE accounts (
  id INT PRIMARY KEY AUTO_INCREMENT,
  owner VARCHAR(100) NOT NULL,
  balance DECIMAL(12,2) NOT NULL DEFAULT 0.00,
  CONSTRAINT chk_balance_non_negative CHECK (balance >= 0)
) ENGINE = InnoDB;

INSERT INTO accounts (owner, balance) VALUES
('أحمد', 1000.00),
('سارة', 250.00);

قيد CHECK (balance >= 0) مهم جداً: يمنع الخادم نفسه من قبول رصيد سالب حتى لو أخطأ التطبيق. هذا دفاع في العمق Defense in Depth.

الخطوة الثانية: تنفيذ التحويل داخل معاملة

START TRANSACTION;

UPDATE accounts
SET balance = balance - 300
WHERE id = 1 AND balance >= 300;

-- التحقق من أن الصف قد تأثر فعلاً
-- إذا كانت النتيجة 0 فهذا يعني أن الرصيد غير كافٍ

UPDATE accounts
SET balance = balance + 300
WHERE id = 2;

COMMIT;

لاحظ شرط balance >= 300 داخل جملة UPDATE. هذا أسلوب ذكي للتحقق من الرصيد دون قفل إضافي، لأن الصف يُقفل بالفعل أثناء التحديث.

الخطوة الثالثة: التعامل مع حالة الرصيد غير الكافي

في التطبيق الحقيقي، يجب فحص عدد الصفوف المتأثرة بعد التحديث الأول. إذا كان صفراً، وجب التراجع:

START TRANSACTION;

UPDATE accounts SET balance = balance - 300 WHERE id = 1 AND balance >= 300;

-- في التطبيق: إذا كانت affected_rows = 0
-- ROLLBACK;

UPDATE accounts SET balance = balance + 300 WHERE id = 2;

COMMIT;

هذه الحماية تمنع حالة خطيرة تُعرف بـ Lost Update حيث يتنافس طلبان على نفس الرصيد فيقرآن نفس القيمة قبل أن يكتبا.

نقاط الحفظ SAVEPOINT والتراجع الجزئي

لا يدعم MySQL المعاملات المتداخلة Nested Transactions بشكل مباشر. لكنه يوفر بديلاً عملياً ممتازاً هو SAVEPOINT، وهو علامة داخل المعاملة يمكن الرجوع إليها دون إلغاء العمل كله.

إنشاء نقطة حفظ والرجوع إليها

START TRANSACTION;

INSERT INTO orders (customer_id, total) VALUES (10, 500.00);

SAVEPOINT after_order;

UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 7;

-- حدث خطأ في الخطوة التالية فقط
-- نتراجع إلى نقطة الحفظ دون إلغاء الأمر INSERT
ROLLBACK TO SAVEPOINT after_order;

COMMIT;

النتيجة: بقي أمر INSERT الخاص بالطلب محفوظاً، بينما تُراجع تعديل المخزون فقط. هذا مفيد جداً في المعاملات الطويلة التي تحتوي خطوات اختيارية.

إدارة نقاط الحفظ المتعددة

START TRANSACTION;

INSERT INTO shipments (order_id, status) VALUES (55, 'pending');
SAVEPOINT sp1;

UPDATE orders SET status = 'shipped' WHERE id = 55;
SAVEPOINT sp2;

UPDATE customers SET last_order_at = NOW() WHERE id = 10;

-- حذف نقطة حفظ معيّنة لتوفير الذاكرة
RELEASE SAVEPOINT sp2;

COMMIT;

عند تنفيذ RELEASE SAVEPOINT تُحذف نقطة الحفظ من قائمة نقاط الحفظ النشطة، لكن التغييرات المنفّذة بعدها تبقى داخل المعاملة. النقاط تُدار بمبدأ LIFO أي آخر نقطة تُلغى أولاً.

نقاط استخدام ذكية لنقاط الحفظ

  • معالجة دفعات كبيرة Bulk Inserts مع إمكانية التراجع عن جزء فاشل فقط.
  • التحقق من الشروط الاختيارية دون إلغاء المعاملة الأساسية.
  • تسجيل عمليات تدقيق Audit Log لا يجب أن تُلغى حتى لو فشل المنطق التجاري.
  • إعادة المحاولة Retry لمنطق فرعي دون خسارة التعقيد الكامل للمعاملة.

إعداد autocommit والتحكم في سلوك المعاملات

متغيّر autocommit يحدد ما إذا كان كل أمر SQL يُثبَّت تلقائياً أم لا. فهمه ضروري لتجنّب سلوك غير متوقع.

فحص قيمة autocommit الحالية

SELECT @@autocommit;
-- النتيجة الافتراضية: 1

تعطيل autocommit على مستوى الجلسة

SET autocommit = 0;

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

-- لا شيء ثُبّت بعد
COMMIT;

SET autocommit = 1;

عند تعطيل autocommit تُعتبر كل جلسة معاملة مفتوحة، وتُثبَّت فقط عند COMMIT. هذا النمط مستخدم في بعض أطر العمل لكنه محفوف بالمخاطر لأن النسيان يعني بقاء الأقفال مفتوحة.

أوامر تُسبّب Commit ضمنياً

هذه نقطة يخطئ فيها كثير من المطورين. الأوامر التالية تُنهي المعاملة المفتوحة تلقائياً قبل تنفيذها:

  • كل أوامر DDL مثل CREATE وALTER وDROP وTRUNCATE.
  • أوامر التحكم بالصلاحيات GRANT وREVOKE.
  • أمر LOCK TABLES وUNLOCK TABLES.
  • أوامر إدارة مثل ANALYZE TABLE وOPTIMIZE TABLE.
  • تنفيذ START TRANSACTION جديدة أثناء وجود معاملة مفتوحة.

بمعنى عملي: إذا وضعت أمر ALTER TABLE في منتصف معاملة طويلة، فسيتم تثبيت كل ما سبقه بشكل صامت دون تحذير. لهذا يُفضّل فصل أوامر DDL تماماً عن المنطق المعاملي.

مستويات العزل Isolation Levels في MySQL بالتفصيل

مستوى العزل يحدد درجة رؤية المعاملة لتغييرات المعاملات الأخرى المتزامنة. كلما زاد العزل زادت السلامة وقلّ الأداء، والعكس صحيح.

المستويات الأربعة بالترتيب من الأقل صرامة

  1. READ UNCOMMITTED: يسمح بقراءة بيانات لم تُثبَّت بعد. أسرع لكن أخطر، ونادراً ما يُستخدم.
  2. READ COMMITTED: يرى فقط البيانات المُثبَّتة. هو المستوى الافتراضي في PostgreSQL وOracle.
  3. REPEATABLE READ: المستوى الافتراضي في InnoDB. يضمن أن كل قراءة لنفس الصف داخل المعاملة تُعيد نفس النتيجة.
  4. SERIALIZABLE: الأعلى صرامة، يحوّل القراءات إلى أقفال مشتركة ويمنع أي تداخل. أبطأ بشكل ملحوظ.

فحص مستوى العزل الحالي

SELECT @@transaction_isolation;
-- في MySQL 8.0 النتيجة الافتراضية: REPEATABLE-READ

تغيير مستوى العزل على مستوى الجلسة

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

START TRANSACTION;
SELECT * FROM inventory WHERE product_id = 7;
COMMIT;

هذا التغيير يؤثر على الجلسة الحالية فقط ولا يمس بقية الاتصالات. لتغييره للأمر الواحد فقط استخدم:

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT COUNT(*) FROM orders WHERE status = 'pending';
COMMIT;

أي مستوى عزل تختار؟

  • REPEATABLE READ: الخيار الافتراضي المناسب لمعظم تطبيقات الويب. توازن جيد بين السلامة والأداء.
  • READ COMMITTED: مناسب للأنظمة التي تفضّل رؤية أحدث البيانات المُثبَّتة وتقلّل احتمالية Deadlocks.
  • SERIALIZABLE: للحالات الحرجة جداً مثل الحجوزات المحدودة، مع القبول ببطء واضح.
  • READ UNCOMMITTED: للتقارير الإحصائية التقريبية فقط حيث لا تتأثر القرارات بالبيانات غير المثبّتة.

القراءات الشاذة: Dirty Read و Non-Repeatable Read و Phantom Read

فهم هذه الظواهر يوضّح لماذا نحتاج مستويات عزل أصلاً.

القراءة القذرة Dirty Read

تحدث عندما تقرأ معاملة بيانات كتبتها معاملة أخرى لم تُثبَّت بعد. لو تراجعت تلك المعاملة، تكون قراءتك وهمية تماماً. READ UNCOMMITTED هو المستوى الوحيد الذي يسمح بها.

-- الجلسة A
START TRANSACTION;
UPDATE products SET price = 99.99 WHERE id = 3;
-- لم يُنفّذ COMMIT بعد

-- الجلسة B (على مستوى READ UNCOMMITTED)
SELECT price FROM products WHERE id = 3;
-- يعرض 99.99 رغم أن المعاملة لم تُثبَّت

القراءة غير القابلة للتكرار Non-Repeatable Read

تحدث عندما تقرأ نفس الصف مرتين داخل معاملة واحدة فتحصل على قيمتين مختلفتين، لأن معاملة أخرى عدّلته وثبّتت التغيير بين القراءتين. يمنعها REPEATABLE READ باستخدام لقطة Snapshot ثابتة.

القراءة الوهمية Phantom Read

تحدث عندما تعيد نفس استعلام النطاق Range Query مجموعتين مختلفتين من الصفوف، لأن معاملة أخرى أضافت صفوفاً جديدة مطابقة للشرط. يمنعها مستوى SERIALIZABLE عبر أقفال الفجوات Gap Locks.

في InnoDB، مستوى REPEATABLE READ يمنع معظم حالات Phantom Read بفضل Next-Key Locks، وهو تحسين مهم على السلوك القياسي.

الأقفال في Transactions: FOR UPDATE و FOR SHARE

في بعض الحالات لا تكفي المعاملة وحدها، بل تحتاج قفلاً صريحاً يمنع الآخرين من التعديل أثناء قراءتك.

القفل الحصري SELECT ... FOR UPDATE

يقرأ الصفوف ويضع عليها قفلاً حصرياً يمنع أي معاملة أخرى من تعديلها أو قفلها حتى إتمام المعاملة.

START TRANSACTION;

SELECT balance FROM accounts
WHERE id = 1
FOR UPDATE;

-- الصف مقفل الآن، لا يمكن لأحد تعديله
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

COMMIT;

هذا النمط يُسمى Read-Modify-Write وهو ضروري لمنع التنافس على نفس الصف. يستخدم بكثافة في أنظمة الحجز والمخزون.

القفل المشترك SELECT ... FOR SHARE

يسمح لعدة معاملات بقراءة الصف وقفله بشكل مشترك، لكنه يمنع أي تعديل حتى تحرير الأقفال.

START TRANSACTION;

SELECT * FROM products WHERE id = 3 FOR SHARE;

-- يمكن لمعاملة أخرى القراءة بنفس الطريقة، لكن لا يمكنها التعديل
COMMIT;

هذه الصيغة حلّت محل الصيغة القديمة LOCK IN SHARE MODE التي تُعدّ مهجورة Deprecated في MySQL 8.0.

خيارات NOWAIT و SKIP LOCKED

في MySQL 8.0 أُضيفت خيارات عملية جداً للتعامل مع الأقفال المتنازع عليها:

-- فشل فوري بدل الانتظار إذا كان الصف مقفلاً
SELECT * FROM jobs WHERE status = 'new' FOR UPDATE NOWAIT;

-- تخطي الصفوف المقفلة والعمل على غيرها، مفيد لطوابير المهام
SELECT * FROM jobs WHERE status = 'new' LIMIT 5 FOR UPDATE SKIP LOCKED;

خيار SKIP LOCKED ثورة حقيقية لأنظمة طوابير المهام Queue Workers، لأنه يسمح لعدة عمّال بسحب مهام مختلفة في نفس اللحظة دون تصادم.

الجمود Deadlocks وكيفية معالجته برمجياً

الجمود Deadlock حالة ينتظر فيها كل من الطرفين الآخر إلى الأبد. مثال كلاسيكي:

  1. المعاملة A تقفل الصف 1 وتنتظر الصف 2.
  2. المعاملة B تقفل الصف 2 وتنتظر الصف 1.
  3. لا أحد يستطيع التقدم، فيتدخل النظام.

كيف يتعامل InnoDB مع الجمود

يكتشف InnoDB الجمود تلقائياً ويلغي المعاملة الأقل تكلفة إلغاءً كاملاً، ويرجع الخطأ برقم 1213 مع رمز SQLSTATE 40001. المعاملة الأخرى تكمل عملها بشكل طبيعي.

ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction

هذا الخطأ ليس كارثة، بل جزء طبيعي من العمل تحت التزامن العالي. الحل الصحيح هو إعادة المحاولة Retry وليس تجاهل الخطأ.

عرض آخر حالة جمود حدثت

SHOW ENGINE INNODB STATUS\G

ابحث في المخرجات عن قسم LATEST DETECTED DEADLOCK، ستجد فيه الاستعلامات المتنازعة والصفوف المقفلة والأقفال التي انتظرها كل طرف. هذه أهم أداة تشخيصية متاحة.

نمط إعادة المحاولة الاحترافي

SET SESSION innodb_lock_wait_timeout = 5;

تقليل مدة انتظار الأقفال من 50 ثانية الافتراضية إلى قيم أقل يجعل النظام يفشل بسرعة ويعيد المحاولة بدل تجميد الخيوط Threads.

قواعد عملية لتقليل الجمود

  • رتب دائماً عمليات التعديل على الجداول والصفوف بنفس الترتيب في كل الكود.
  • أبقِ المعاملة قصيرة جداً ولا تضع بداخلها استدعاءات شبكة أو معالجة ملفات.
  • استخدم فهارس مناسبة، لأن غياب الفهرس يحوّل الأقفال إلى قفل جدول شبه كامل.
  • قلّل عدد الصفوف التي تلمسها في كل معاملة.
  • استخدم SKIP LOCKED في طوابير المهام بدل القفل التقليدي.
  • فكّر في READ COMMITTED للأنظمة التي تكثر فيها Deadlocks بسبب قراءة النطاقات.

استخدام Transactions في MySQL من لغات البرمجة

تنفيذ المعاملات من SQL المباشر جيد للتعلم، لكن التطبيقات الحقيقية تحتاج التحكم بها من الكود.

المعاملات في PHP باستخدام PDO

استخدم دائماً وضع الاستثناءات Exceptions لضمان التقاط الأخطاء وإتمام التراجع:

<?php
$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', 'app_user', 'secret', [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

try {
    $pdo->beginTransaction();

    $stmt = $pdo->prepare('UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?');
    $stmt->execute([300, 1, 300]);

    if ($stmt->rowCount() === 0) {
        throw new RuntimeException('الرصيد غير كافٍ');
    }

    $pdo->prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')
        ->execute([300, 2]);

    $pdo->commit();
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    error_log('Transaction failed: ' . $e->getMessage());
    throw $e;
}

لاحظ الفحص inTransaction() قبل التراجع، فهو يمنع خطأ آخر إذا كانت المعاملة أُغلقت تلقائياً بسبب خطأ سابق. وبعد التراجع يُعاد رمي الاستثناء ليتمكن المستدعي من التعامل معه.

المعاملات في Python باستخدام mysql-connector

import mysql.connector
from mysql.connector import Error

conn = mysql.connector.connect(
    host='localhost',
    database='shop',
    user='app_user',
    password='secret'
)

cursor = conn.cursor()
try:
    cursor.execute('START TRANSACTION')

    cursor.execute(
        'UPDATE accounts SET balance = balance - %s WHERE id = %s AND balance >= %s',
        (300, 1, 300)
    )
    if cursor.rowcount == 0:
        raise ValueError('الرصيد غير كافٍ')

    cursor.execute(
        'UPDATE accounts SET balance = balance + %s WHERE id = %s',
        (300, 2)
    )

    conn.commit()
    print('تم التحويل بنجاح')

except (Error, ValueError) as err:
    conn.rollback()
    print('تم التراجع عن المعاملة:', err)

finally:
    cursor.close()
    conn.close()

كتلة finally تضمن إغلاق المؤشر والاتصال حتى في حالة الفشل، مما يمنع تسرّب الاتصالات Connection Leaks.

المعاملات في Node.js باستخدام mysql2

const mysql = require('mysql2/promise');

async function transfer(amount) {
  const conn = await mysql.createConnection({
    host: 'localhost',
    database: 'shop',
    user: 'app_user',
    password: 'secret',
  });

  try {
    await conn.beginTransaction();

    const [res] = await conn.execute(
      'UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?',
      [amount, 1, amount]
    );

    if (res.affectedRows === 0) {
      throw new Error('الرصيد غير كافٍ');
    }

    await conn.execute(
      'UPDATE accounts SET balance = balance + ? WHERE id = ?',
      [amount, 2]
    );

    await conn.commit();
    return { ok: true };
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    await conn.end();
  }
}

استخدام end() في كتلة finally ضروري، لأن كل اتصال غير مغلق يستهلك من حد max_connections ويؤدي لانهيار التطبيق تحت الضغط.

مثال واقعي: معالجة طلب في متجر إلكتروني

هذا سيناريو حقيقي يجمع كل ما تعلمناه: إنشاء طلب، حجز المخزون، وتسجيل الدفع، داخل معاملة واحدة.

الكود الكامل

START TRANSACTION;

-- 1) إنشاء الطلب
INSERT INTO orders (customer_id, total, status)
VALUES (42, 899.00, 'pending');

SET @order_id = LAST_INSERT_ID();

-- 2) قفل المخزون والتحقق من التوفّر
SELECT quantity FROM inventory
WHERE product_id = 7
FOR UPDATE;

-- في التطبيق نتأكد أن quantity >= 1 وإلا ننفّذ ROLLBACK

UPDATE inventory
SET quantity = quantity - 1
WHERE product_id = 7 AND quantity >= 1;

-- 3) إدخال تفاصيل الطلب
INSERT INTO order_items (order_id, product_id, qty, price)
VALUES (@order_id, 7, 1, 899.00);

-- 4) تسجيل الدفع
INSERT INTO payments (order_id, amount, status)
VALUES (@order_id, 899.00, 'captured');

-- 5) تحديث حالة الطلب
UPDATE orders SET status = 'confirmed' WHERE id = @order_id;

COMMIT;

لو فشلت أي خطوة، يتوقف التنفيذ ويُنفَّذ ROLLBACK فيعود المخزون ويُحذف الطلب والدفع معاً. لا وجود لحالة طلب بلا دفع أو دفع بلا طلب.

لماذا استخدام FOR UPDATE هنا ضروري؟

بدون قفل صريح، قد يقرأ طلبان الكمية نفسها (مثلاً 1) قبل أن يعدّلها أحدهما، فيبيع كلاهما نفس القطعة الأخيرة. هذا خطأ شائع جداً في أنظمة المخزون ويُعرف بـ Race Condition. القفل الحصري يضمن أن المعاملة الثانية تنتظر حتى تنتهي الأولى.

إدارة المهل الزمنية

إذا كانت المعاملة قد تنتظر طويلاً، حدّد مهلة مناسبة داخل التطبيق. في PDO يمكن استخدام PDO::ATTR_TIMEOUT، وفي الإعدادات يمكن ضبط innodb_lock_wait_timeout وwait_timeout. لا تترك معلّقاً بلا سقف زمني مطلقاً.

أخطاء شائعة عند استخدام Transactions في MySQL

معظم مشاكل المعاملات في الإنتاج تعود إلى هذه الأخطاء المتكررة، وتجنّبها يوفر عليك ساعات تصحيح طويلة.

  • استخدام محرك MyISAM ثم التوقع بأن ROLLBACK سيعمل. النتيجة: لا تراجع ولا رسالة خطأ.
  • نسيان ROLLBACK في حالة الاستثناء، فتبقى المعاملة معلّقة والأقفال محتجزة حتى انتهاء المهلة.
  • عدم فحص عدد الصفوف المتأثرة بعد UPDATE، فيمر التحديث الفاشل وكأنه نجح.
  • وضع استدعاء API خارجي أو إرسال بريد إلكتروني داخل المعاملة، فتبقى الأقفال محتجزة لثوانٍ طويلة.
  • تنفيذ DDL داخل المعاملة مثل ALTER TABLE، مما يُسبّب Commit ضمنياً صامتاً.
  • معاملات طويلة جداً تمسّ آلاف الصفوف، فتزيد فرص الجمود وتضخم Undo Log.
  • الاعتماد على autocommit = 0 في مكتبة مشتركة، فيصبح سلوك الكود غير متوقع لبقية الفريق.
  • تجاهل خطأ 1213 تماماً بدل إعادة المحاولة، مما يفقد عمليات صحيحة تماماً.
  • استخدام SELECT ... FOR UPDATE بدون فهرس، فيتحول القفل إلى قفل جدول ضخم يعطّل النظام.
  • قراءة البيانات قبل بدء المعاملة ثم الاعتماد عليها في القرار داخل المعاملة، مما يسمح بسباق زمني.

أفضل الممارسات لكتابة Transactions آمنة

  1. ابدأ المعاملة فقط عند الحاجة، ولا تغلّف كل استعلام بها.
  2. أبقِ المعاملة قصيرة قدر الإمكان، بحيث تُنفَّذ كل استعلاماتها في أجزاء من الثانية.
  3. نفّذ كل المنطق التجاري والتحقق قبل START TRANSACTION، ثم اكتب داخل المعاملة فقط.
  4. استخدم دائماً الاستعلامات المُجهّزة Prepared Statements لمنع حقن SQL.
  5. رتب عمليات الوصول للجداول والصفوف بترتيب ثابت لتقليل Deadlocks.
  6. تعامل مع أخطاء 1213 و1205 بنمط إعادة محاولة مع تأخير تصاعدي Exponential Backoff.
  7. استخدم SAVEPOINT للعمليات الجزئية القابلة للفشل داخل معاملة كبيرة.
  8. اضبط مستوى عزل مناسب لكل نوع عملية، ولا تعتمد الافتراضي دائماً.
  9. سجّل الأخطاء مع معرّف المعاملة ووقت التنفيذ لتسهيل التتبع.
  10. راقب عدد المعاملات المفتوحة عبر SHOW ENGINE INNODB STATUS ومتغيّر Innodb_row_lock_waits.

مثال على إعادة المحاولة مع تأخير تصاعدي

async function runWithRetry(fn, maxAttempts = 3) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const isDeadlock = err.code === 'ER_LOCK_DEADLOCK';
      const isLockTimeout = err.code === 'ER_LOCK_WAIT_TIMEOUT';
      if ((!isDeadlock && !isLockTimeout) || attempt === maxAttempts) {
        throw err;
      }
      await new Promise((r) => setTimeout(r, 50 * Math.pow(2, attempt)));
    }
  }
}

هذا النمط يرفع معدل نجاح العمليات في الأنظمة المزدحمة بشكل ملحوظ، لأن Deadlock غالباً يختفي عند إعادة التنفيذ بترتيب مختلف عن الآخرين.

اعتبارات الأداء عند استخدام المعاملات

المعاملات تُنفّذ أكثر من وظيفة سلامة، بل تؤثر مباشرة على أداء قاعدة البيانات.

حجم المعاملة

المعاملة الصغيرة تنفّذ أقفالاً أقل وتستهلك Undo Log أقل. أما المعاملة الضخمة التي تعدّل ملايين الصفوف فتُنتج Undo Log هائل وتؤخر Purge Threads وتُضخّم زمن الاسترداد بعد إعادة التشغيل.

إعدادات InnoDB المؤثرة

SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
SHOW VARIABLES LIKE 'innodb_log_file_size';
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

القيمة الافتراضية innodb_flush_log_at_trx_commit = 1 تعطي أقصى درجات الأمان لأنها تُفرّغ السجل على القرص عند كل COMMIT. القيمة 2 تزيد السرعة لكنها قد تفقد ثانية أو بعض الثواني عند تعطل النظام.

نصائح أداء عملية

  • قسّم العمليات الكبيرة إلى دفعات Batches بحجم معقول بين كل دفعة وأخرى.
  • تجنّب Commit فور كل صف عند إدخال آلاف السجلات، واستخدم مجموعات.
  • استخدم READ ONLY في استعلامات التقارير الطويلة لتقليل الأقفال.
  • تأكد من وجود فهارس تغطي شروط WHERE في الاستعلامات داخل المعاملات.
  • راقب Innodb_row_lock_current_waits وInnodb_row_lock_time_avg لاكتشاف التنازع مبكراً.
  • فوّض المهام الثقيلة غير الحرجة إلى طوابير خارج المعاملة.

اعتبارات الأمان في إدارة المعاملات

السلامة والأمان وجهان لعملة واحدة عند التعامل مع البيانات الحساسة.

منع حقن SQL داخل المعاملات

المعاملة لا تحمي من الحقن. استخدم دائماً الاستعلامات المُجهّزة، وشغّل التطبيق بمستخدم محدود الصلاحيات لا يملك DROP أو GRANT.

-- لا تفعل هذا أبداً
-- SET @sql = CONCAT('UPDATE accounts SET balance = ', @user_input);

-- افعل هذا دائماً من التطبيق
UPDATE accounts SET balance = balance - ? WHERE id = ?;

التحقق من الصلاحيات

استخدم مستخدم قاعدة بيانات مختلفاً لكل تطبيق، وامنحه فقط الصلاحيات التي يحتاجها فعلاً (SELECT وINSERT وUPDATE وDELETE على قواعد محددة). تجنّب استخدام حساب root في التطبيق نهائياً.

البيانات الحساسة في السجلات

عند التسجيل Logging، لا تكتب قيم المعاملات المالية أو بيانات العملاء في ملفات الخطأ. اكتفِ بمعرّف المعاملة واسم العملية وحالة النجاح أو الفشل.

التدقيق Audit

سجّل العمليات الحساسة في جدول تدقيق منفصل. إذا كنت بحاجة لضمان عدم تراجع السجل حتى عند فشل المنطق التجاري، فاحفظه داخل SAVEPOINT مستقل أو استخدم جدولاً على محرك مختلف أو اكتبه بعد COMMIT.

أسئلة شائعة حول Transactions في MySQL

هل يدعم MyISAM المعاملات في MySQL؟

لا، محرك MyISAM لا يدعم المعاملات إطلاقاً. عندما تنفّذ ROLLBACK على جدول MyISAM فلن يحدث شيء ولن تحصل على أي رسالة خطأ، وهذا ما يجعله خطيراً في العمليات المالية أو المركّبة. استخدم محرك InnoDB دائماً.

هل يمكنني استخدام المعاملات المتداخلة Nested Transactions؟

لا يدعم MySQL المعاملات المتداخلة الحقيقية. لكن يمكنك محاكاة التأثير باستخدام SAVEPOINT مع ROLLBACK TO SAVEPOINT، حيث تُتراجع جزئياً إلى نقطة محددة دون إلغاء المعاملة بأكملها.

ما الفرق بين COMMIT وROLLBACK؟

COMMIT يثبّت كل تغييرات المعاملة في قاعدة البيانات بشكل دائم ومرئي لبقية الجلسات. أما ROLLBACK فيلغي كل التغييرات منذ بداية المعاملة ويعيد البيانات لحالتها السابقة، مع تحرير كل الأقفال المكتسبة.

متى يُنفَّذ Commit ضمنياً دون كتابته؟

يُسبّب أي أمر DDL مثل CREATE أو ALTER أو DROP أو TRUNCATE تثبيتاً ضمنياً للمعاملة المفتوحة قبل تنفيذه. كذلك تفعل أوامر GRANT وREVOKE وLOCK TABLES وتنفيذ START TRANSACTION جديدة، لذا افصل هذه الأوامر عن منطق المعاملات تماماً.

لماذا أحصل على خطأ Deadlock بشكل متكرر؟

السبب غالباً هو تعارض ترتيب الأقفال بين المعاملات المتزامنة، أو معاملات طويلة تحتجز صفوفاً مدة كبيرة، أو استعلامات بدون فهارس تعمل على عدد ضخم من الصفوف. الحل هو توحيد ترتيب الوصول، تقصير المعاملات، إضافة الفهارس المناسبة، وإعادة المحاولة عند الخطأ 1213.

ما مستوى العزل الافتراضي في MySQL 8.0؟

المستوى الافتراضي هو REPEATABLE READ، ويمكن التحقق منه بتنفيذ SELECT @@transaction_isolation. هذا المستوى يوفر لقطة ثابتة للبيانات داخل المعاملة، ويمنع معظم حالات القراءة غير القابلة للتكرار باستخدام MVCC.

هل المعاملات تبطئ التطبيق؟

لا، إذا كانت قصيرة وموجّهة. ما يبطئ النظام فعلاً هو المعاملات الطويلة التي تحجز أقفالاً على صفوف كثيرة، أو غياب الفهارس، أو تنفيذ عمليات خارجية داخل المعاملة. المعاملة السليمة تكلفتها ضئيلة مقابل السلامة التي توفرها.

هل يمكن استخدام المعاملات مع أكثر من قاعدة بيانات؟

نعم، عبر معاملات XA التي توفر بروتوكولاً من مرحلتين Two-Phase Commit عبر عدة موارد. لكنها معقدة وتكلفتها عالية وتحتاج مديراً للتنسيق، لذا لا تُستخدم إلا عند الضرورة القصوى مثل أنظمة الدفع الموزعة.

الخطوات التالية: كيف تبدأ في تطبيق المعاملات اليوم

المعاملات في MySQL ليست ميزة متقدمة، بل أساس لا يستقيم نظام مالي أو تجاري بدونها. الفكرة الجوهرية بسيطة: اجمع العمليات المرتبطة في وحدة واحدة، وثبّتها بـ COMMIT عند النجاح فقط، وتراجع بـ ROLLBACK عند أي فشل.

إليك خطة عمل عملية تبدأ بها من الآن:

  1. افحص محرك كل جداولك الحساسة وتأكد أنها InnoDB، باتباع الأمر SHOW TABLE STATUS.
  2. حدّد في تطبيقك كل عملية تتطلب أكثر من استعلام مترابط، وغلّفها بمعاملة واحدة.
  3. أضف معالجة أخطاء شاملة تفحص inTransaction قبل التراجع وتعيد رمي الاستثناء.
  4. استبدل التحديثات المباشرة بنمط UPDATE شرطي WHERE balance >= ? للتحقق من الشروط ذرياً.
  5. استخدم SELECT ... FOR UPDATE في عمليات المخزون والحجوزات لمنع السباقات الزمنية.
  6. اضبط innodb_lock_wait_timeout على قيمة أصغر من الافتراضية لتفادي التعليق الطويل.
  7. طبّق نمط إعادة المحاولة Exponential Backoff عند الأخطاء 1213 و1205.
  8. راقب SHOW ENGINE INNODB STATUS دورياً، وخصوصاً قسم Deadlocks.
  9. اكتب اختبارات تحاكي التنفيذ المتزامن Concurrent Tests، فهي الوحيدة التي تكشف مشاكل الأقفال قبل الإنتاج.

ابدأ اليوم بمراجعة أهم عملية في تطبيقك، واسأل نفسك: ماذا يحدث إذا فشلت في المنتصف؟ إذا لم تجد جواباً مطمئناً، فأنت بحاجة إلى معاملة. أضفها، اختبرها تحت ضغط، وراقب الأداء. عندها فقط ستدرك أن إدارة المعاملات في MySQL ليست عبئاً إضافياً، بل شبكة أمان تحمي بياناتك وسمعتها وثقة مستخدميك.

#mysql #transactions #إدارة المعاملات #innodb #acid #sql #begin commit rollback #مستويات العزل #أقفال قاعدة البيانات #deadlock #حماية البيانات #تطوير الويب

اشترك في نشرتنا البريدية

12k+

المشتركون

أسبوعيًا

التكرار

مجاني

دائمًا