تطوير البرمجيات 09 يوليو 2026 · 9 دقائق قراءة

مستويات عزل المعاملات: ما لا يخبرك به الـ ORM خلف transaction()

تكتب transaction() فتظنّها تحلّ مشاكل التزامن، لكنها تخفي قرارًا اسمه مستوى العزل. شرح للشذوذات الثلاثة، المستويات الأربعة، والفروق الجوهرية بين MySQL وPostgreSQL التي يخفيها الـ ORM.

مستويات عزل المعاملات: ما لا يخبرك به الـ ORM خلف transaction()

مستخدمان يضغطان زرّ «سحب» في اللحظة نفسها. رصيد الحساب 100 ريال، وكلٌّ يسحب 80. قرأ الطلبان الرصيد (100) قبل أن يكتب أيّهما، فسمح كلاهما بالسحب، وانتهى الحساب بـ«ناقص 60». لم يخطئ الكود ظاهريًّا؛ كل طلب فحص الرصيد ووجده كافيًا. المشكلة في مكان لا يراه أغلب المطوّرين: مستوى عزل المعاملات. وحين تلفّ الكود بـ `transaction()` في الـ ORM، فأنت تختار — دون أن تدري غالبًا — مستوى عزل يحدّد بالضبط هذا السلوك. لنكشف ما يخفيه الـ ORM.

ماذا يعني «العزل» في ACID؟

الحرف I في ACID يعني العزل (Isolation): إلى أيّ درجة تُحجَب تغييرات معاملة عن معاملة أخرى تعمل بالتوازي. المفارقة أن العزل الكامل (كأنّ المعاملات تعمل واحدةً تلو الأخرى) مكلفٌ في الأداء، والعزل المنخفض سريعٌ لكنه يفتح الباب لأخطاء بيانات خفية. لذلك يعرّف معيار SQL أربعة مستويات، كلٌّ منها مقايضةٌ بين الاتساق والتزامن. وفهم هذه المقايضة هو ما يفصل بين مطوّر يكتب `transaction()` بالحدس، وآخر يعرف ما يفعله فعلًا.

الشذوذات الثلاثة: ما الذي نحاول منعه؟

قبل المستويات، يجب فهم «الظواهر الشاذّة» (Read Phenomena) التي يحمي منها العزل، متدرّجةً من الأخطر:

القراءة القذرة (Dirty Read): تقرأ معاملتك بياناتٍ عدّلتها معاملة أخرى لم تُثبِّت (Commit) بعد. إن تراجعت تلك المعاملة، تكون قد قرأت بياناتٍ لم توجد قطّ. هذا أخطر الشذوذات.

القراءة غير المتكرّرة (Non-repeatable Read): تقرأ صفًّا، ثم تقرأه ثانيةً في المعاملة نفسها فتجد قيمةً مختلفة، لأن معاملة أخرى عدّلته وثبّتت بينهما.

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

المستويات الأربعة، من الأضعف للأقوى

كل مستوى يمنع مزيدًا من الشذوذات مقابل تزامنٍ أقلّ:

1. القراءة غير المثبَّتة (Read Uncommitted): الأضعف. يسمح بكل الشذوذات بما فيها القراءة القذرة. نادرًا ما يُستخدَم. (ملاحظة: PostgreSQL لا ينفّذه فعلًا، بل يعامله كـ Read Committed.)

2. القراءة المثبَّتة (Read Committed): يمنع القراءة القذرة فقط. ترى دائمًا بياناتٍ مثبَّتة، لكن قد تختلف بين قراءتين. هو الافتراضيّ في PostgreSQL، ومناسب لأغلب تطبيقات الويب.

3. القراءة القابلة للتكرار (Repeatable Read): يمنع القراءة القذرة وغير المتكرّرة. داخل المعاملة، كل القراءات ترى «لقطة» (Snapshot) ثابتة من أوّل قراءة. هو الافتراضيّ في MySQL InnoDB.

4. القابل للتسلسل (Serializable): الأقوى. يمنع كل الشذوذات، وكأنّ المعاملات تُنفَّذ واحدةً تلو الأخرى. الأغلى أداءً والأكثر عرضةً لأخطاء التسلسل.

هنا يبدأ ما لا يخبرك به الـ ORM: الافتراضات تختلف

النقطة الأخطر التي يخفيها التجريد: المستوى الافتراضيّ يختلف بين قواعد البيانات. فـ PostgreSQL يفترض Read Committed، بينما MySQL InnoDB يفترض Repeatable Read. هذا يعني أن الكود نفسه، بالـ ORM نفسه، قد يسلك سلوكًا مختلفًا على قاعدتَي بيانات مختلفتين. تطبيقٌ طُوّر واختُبر على MySQL ثم هُوجِر إلى PostgreSQL قد يبدأ فجأةً برؤية «قراءات غير متكرّرة» لم تظهر من قبل، ببساطة لأن الافتراض تغيّر. الـ ORM لا يحذّرك من هذا؛ يمرّر طلبك بصمت.

ومفاجأة أكبر: Repeatable Read ليس واحدًا

حتى ضمن المستوى نفسه، يختلف التنفيذ جوهريًّا. المعيار يقول إن Repeatable Read «يسمح» بالقراءات الشبحية. لكن:

في MySQL InnoDB، يمنع القراءات الشبحية عبر «أقفال الفجوات» (Gap Locks) التي تقفل النطاقات بين مدخلات الفهرس. الأثر الجانبيّ الخفيّ: قد تسبّب انتظارات أقفال غير متوقّعة على عبارات INSERT.

في PostgreSQL، يمنع القراءات الشبحية أيضًا لكن عبر آلية MVCC (تعدّد نسخ التزامن)، وهو سلوك أقوى ممّا يتطلّبه المعيار. والأهمّ: في PostgreSQL، محاولة تحديث صفّ عدّلته معاملة أخرى ضمن Repeatable Read تُنتج خطأً صريحًا: «could not serialize access». كثيرون يعدّون هذا أفضل من السماح الصامت بالتعديل كما في MySQL، لأنه يمنع حالةً مربكة بدل أن يخفيها.

-- في MySQL (الافتراضيّ: Repeatable Read)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- يعيد 500
-- معاملة أخرى تثبّت balance = 800
SELECT balance FROM accounts WHERE id = 1; -- ما زال يعيد 500 (لقطة ثابتة)
COMMIT;

القابل للتسلسل: تنفيذان مختلفان جذريًّا

حتى أقوى المستويات يُنفَّذ بطريقتين مختلفتين لهما آثار عملية متباينة. PostgreSQL يستخدم «عزل اللقطة القابل للتسلسل» (SSI): يراقب رسم تبعيّات القراءة/الكتابة بين المعاملات، وحين يرصد نمطًا خطيرًا قد يؤدّي لشذوذ، يُجهض الحدّ الأدنى من المعاملات بخطأ تسلسل. أمّا MySQL InnoDB فيعتمد على الأقفال (Gap Locks وNext-key Locks). الفرق العمليّ: مع PostgreSQL يجب أن يكون تطبيقك مستعدًّا لإعادة محاولة المعاملات التي تفشل بخطأ التسلسل، بينما مع MySQL تواجه غالبًا انتظار أقفال أو جمودًا (Deadlock).

الدرس العمليّ: لا تترك القرار للصدفة

الخطأ الأكبر أن تكتب `transaction()` وتفترض أنها «تحلّ مشاكل التزامن». هي تحدّد نطاق الذرّية فقط، لكنها تعمل عند مستوى العزل الافتراضيّ لقاعدتك — الذي قد لا يناسب حالتك. القاعدة العملية: لا ترفع مستوى العزل إلى Serializable بدافع «الأمان» دون داعٍ، فثمنه أداءً وأخطاء تسلسل مرتفع. بدلًا من ذلك، للعمليات الحرجة على الحالة المشتركة (كخصم رصيد)، استخدم القفل الصريح المتفائل أو المتشائم عند مستوى العزل المناسب. مثال بقفل متشائم يمنع مشكلة السحب المزدوج التي بدأنا بها:

-- القفل الصريح للصفّ يمنع السحب المزدوج
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- يقفل الصفّ
-- الطلب الآخر ينتظر هنا حتى ننتهي
UPDATE accounts SET balance = balance - 80 WHERE id = 1;
COMMIT;

في الـ ORM، هذا يقابله عادةً استدعاءٌ صريح مثل lockForUpdate في Laravel، لا مجرّد `transaction()`.

الخلاصة العملية: `transaction()` في الـ ORM ليست زرًّا سحريًّا للتزامن، بل غلافٌ يخفي قرارًا مهمًّا اسمه مستوى العزل. اعرف افتراض قاعدتك (Read Committed في PostgreSQL، Repeatable Read في MySQL)، وافهم أن التنفيذ يختلف جوهريًّا بينهما حتى عند المستوى نفسه، واختر العزل والأقفال بوعي لا بالحدس. فالفرق بين تطبيق يصمد تحت التزامن وآخر يسرّب بيانات صامتة ليس في الكود الظاهر، بل في هذه الطبقة التي يخفيها التجريد — ومن يفهمها، يكتب أنظمةً لا تنهار حين يضغط مستخدمان الزرّ في اللحظة نفسها.

هل وجدت هذا المقال مفيدًا؟

شارك هذا المقال

1 مشاركة

الوسوم: #قواعد البيانات#MySQL#المعاملات#مستويات العزل#PostgreSQL#التزامن

مقالات أخرى