مستويات العزل في قواعد البيانات: متى ترفعها ومتى تقفل

بقلم فريق تقني · (آخر تحديث: )· برمجة
مستويات العزل في قواعد البيانات: متى ترفعها ومتى تقفل

قبل أن ترفع مستوى العزل، اعرف أين تقف: الافتراضي يختلف بين المحرّكات. PostgreSQL يبدأ من Read Committed، وMySQL/InnoDB من REPEATABLE READ، وOracle من Read Committed مع منع القراءة القذرة إطلاقاً. وSQL Server محلياً من READ COMMITTED مع READ_COMMITTED_SNAPSHOT مطفأً، بينما Azure SQL Database من READ COMMITTED SNAPSHOT: الاسم نفسه والسلوك مختلف.

عملياً، ترحيل تطبيق من MySQL إلى PostgreSQL ينزل بك من Repeatable Read إلى Read Committed دون أن يعترض المحرّك أو يسجّل تحذيراً: قراءتان متتاليتان داخل المعاملة الواحدة قد تريان بيانات مختلفة. وربما بدّل إطار عملك الافتراضي أصلاً دون علمك، كما يفعل Django مع MySQL وسنراه بعد قليل.

ما يسمح به كل مستوى فعلاً

المستوىقراءة قذرةقراءة غير قابلة للتكرارقراءة شبحيةانحراف كتابة
Read Uncommittedمسموح بالمعيار، ممنوع في PostgreSQLمسموحمسموحمسموح
Read Committedممنوعمسموحمسموحمسموح
Repeatable Readممنوعممنوعمسموح بالمعيار وفي SQL Server، وممنوع في PostgreSQL وفي InnoDB بأقفال المفتاح التاليمسموح
Serializableممنوعممنوعممنوعممنوع

يقبل PostgreSQL المستويات الأربعة لكنه ينفّذ ثلاثة: Read Uncommitted فيه يسلك سلوك Read Committed تماماً، فلا قراءة غير مثبَّتة أصلاً.

انحراف الكتابة: تجربة تعيدها بنفسك في دقيقتين

معاملة A تحسب SUM لصفوف class = 1 فتحصل على 30 وتدرجه في صف جديد بـclass = 2، وB تفعل العكس: تحسب SUM لصفوف class = 2 فتحصل على 300 وتدرجه في صف جديد بـclass = 1. عند Repeatable Read تُثبَّت المعاملتان معاً، والنتيجة لا تطابق أي ترتيب تسلسلي ممكن. وهذا هو شكل «آخر مقعد» و«حد أقصى للحجوزات» و«رصيد كافٍ»: تقرأ مجموعاً ثم تكتب بناءً عليه. ومنه مثال الطبيبين الشهير في الفصل 7 من كتاب Designing Data-Intensive Applications لمؤلفه Martin Kleppmann: طبيبان ينسحبان معاً من مناوبة يُشترط بقاء واحد فيها، وكلٌّ منهما رأى في لقطته أن زميله ما زال موجوداً.

ولرؤية العطب بعينيك، جرّب مثال «الأبيض والأسود» من ويكي PostgreSQL:

CREATE TABLE dots (id int PRIMARY KEY, color text NOT NULL);
INSERT INTO dots
  SELECT id, CASE WHEN id % 2 = 1 THEN 'black' ELSE 'white' END
  FROM generate_series(1, 10) AS id;

افتح جلستين على Repeatable Read، ونفّذ في الأولى UPDATE dots SET color = 'black' WHERE color = 'white'; وفي الثانية بالتوازي UPDATE dots SET color = 'white' WHERE color = 'black';. تُثبَّت الاثنتان وتنقلب الألوان بالكامل — نتيجة مستحيلة في أي تنفيذ متسلسل. أعد التجربة على Serializable فتنجح واحدة وتُرفض الأخرى:

ERROR:  could not serialize access due to read/write dependencies among transactions
HINT:  The transaction might succeed if retried.

رسالتا فشل في PostgreSQL لا تخلط بينهما

توثيق PostgreSQL يقولها بلا مواربة: Repeatable Read عنده هو عزل اللقطة نفسه. وقاعدته «الكاتب الأول يفوز»: إن حاولت تعديل صف سبقتك إليه معاملة متزامنة مثبَّتة، تلقيت خطأً مختلفاً حرفياً هو ERROR: could not serialize access due to concurrent update. إذن concurrent update تعني Repeatable Read، وread/write dependencies among transactions تعني Serializable، وكلتاهما SQLSTATE 40001 ومعناهما واحد: أعد المحاولة.

أما Serializable المبني على عزل اللقطة القابل للتسلسل فيضيف أقفال مسند تظهر في pg_locks باسم SIReadLock. هذه الأقفال لا تحجب أحداً ولا تسبب جموداً، لكنها تستهلك ذاكرة مشتركة، وتحت الضغط تُرقّى من صف إلى صفحة إلى العلاقة كلها — يضبطها max_pred_locks_per_transaction وافتراضيه 64 — والترقية ترفع نسبة الإجهاض بإنذارات كاذبة. لذا تنصح الوثائق: أعلن المعاملات READ ONLY كلما أمكن، وحدّد سقف الاتصالات بمجمّع، ولا تترك جلسات معلّقة داخل معاملة، واحذف SELECT FOR UPDATE التي صارت زائدة تحت Serializable. ورقة VLDB 2012 لواضعَي التنفيذ Ports وGrittner قاست أداءً أدنى بقليل فقط من عزل اللقطة وأفضل بوضوح من القفل ثنائي الطور في الأحمال كثيفة القراءة — بشرط ألا يتزاحم الجميع على صف ساخن واحد، فعندها تأكل دورة الإجهاض والإعادة أي مكسب.

ثمن الرفع: إعادة المعاملة كاملة وبشروطها

الرفع ينقل العبء إلى تطبيقك. رمز SQLSTATE 40001 اسمه serialization_failure في PostgreSQL، والجمود 40P01. وحين يصلك أحدهما فأجهض المعاملة وأعدها من بدايتها كاملة، لا العبارة الفاشلة وحدها. والحلقة الصحيحة محدودة المحاولات، لا تُطلق إلا على هذين الرمزين تحديداً، وبتراجع أُسّي مع عشوائية بين المحاولات. وبلا هذا المنطق لا تصلح فساد البيانات بل تحوّله إلى أخطاء 500.

والأخطر أن كل ما داخل المعاملة يُنفَّذ من جديد عند الإعادة، فأبقِ الآثار الخارجية — بريد، استدعاء HTTP، بوابة دفع — بعد نجاح COMMIT حصراً. وإن تعذّر ذلك فأنت تحتاج حارساً على مستوى الطلب، وهنا يفيد مفتاح idempotency لمنع تكرار الطلب في واجهات الدفع. ولا تعامل الإعادة كحدث نادر: CockroachDB يعمل على Serializable افتراضياً ويُرجع SQLSTATE 40001 نفسه برسائل تبدأ بـrestart transaction، والإعادة هناك روتين لا استثناء.

في MySQL التمييز أدق: الجمود ER_LOCK_DEADLOCK رقم 1213 بـSQLSTATE 40001 يُرجع المعاملة كاملة، أما مهلة انتظار القفل ER_LOCK_WAIT_TIMEOUT رقم 1205 بـSQLSTATE HY000 فتُرجع العبارة الفاشلة وحدها، ومعالجتهما بكود واحد خطأ. ولإرجاع المعاملة كاملة عند المهلة شغّل الخادم بـinnodb-rollback-on-timeout.

والصياغة سطر واحد: BEGIN ISOLATION LEVEL SERIALIZABLE; في PostgreSQL، وSET TRANSACTION ISOLATION LEVEL READ COMMITTED; في MySQL — وهذه الأخيرة بدون GLOBAL أو SESSION تنطبق على المعاملة التالية وحدها، ولا يجوز تنفيذها داخل معاملة.

فخّ MySQL: تحذف صفوفاً لا تراها

عند REPEATABLE READ تُلتقط اللقطة من أول قراءة، وكل SELECT لاحق يقرأ اللقطة نفسها. لكنها لا تنطبق بالضرورة على عبارات التعديل: UPDATE وDELETE يقرآن أحدث نسخة مثبَّتة. والنتيجة أن SELECT COUNT(c1) FROM t1 WHERE c1 = 'xyz' يعيد 0، ثم DELETE FROM t1 WHERE c1 = 'xyz' يحذف عدة صفوف. فإن أردت أحدث حالة فانزل إلى READ COMMITTED أو اقرأ بقفل مثل SELECT ... FOR SHARE.

في SQL Server: اسمان متقاربان وآليتان متباعدتان

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

Msg 3960: Snapshot isolation transaction aborted due to update conflict.

وعلاجه المعتاد تلميح WITH (UPDLOCK) على SELECT التمهيدية. وهناك خياران منفصلان على مستوى قاعدة البيانات يشيع الخلط بينهما: ALTER DATABASE ... SET ALLOW_SNAPSHOT_ISOLATION ON يتيح للجلسات طلب SNAPSHOT صراحة، بينما ALTER DATABASE ... SET READ_COMMITTED_SNAPSHOT ON يحوّل READ COMMITTED الافتراضي نفسه إلى قراءة بالإصدارات؛ تفعيل أحدهما لا يؤدي عمل الآخر. وللإصدارات ثمن: كل تحديث يكتب النسخة السابقة من الصف في tempdb افتراضياً. وفخّ أخير: مستوى العزل يلتصق بالاتصال، فالاتصال العائد من تجمّع الاتصالات يحمل آخر SET TRANSACTION ISOLATION LEVEL نُفِّذ عليه ولو من طلب سابق مختلف تماماً.

قفل صريح أو عمود إصدار قد يغنيك عن الرفع

ما دمت لا تستخدم Serializable، فلا سبيل إلى ضمان صحة صف وحمايته من تحديث متزامن إلا عبر SELECT FOR UPDATE أو SELECT FOR SHARE أو قفل جدول مناسب. وانتبه لحدود هذا القفل: SELECT FOR UPDATE في PostgreSQL يقفل الصف فعلاً ويمنع أي UPDATE أو DELETE أو SELECT FOR UPDATE آخر عليه حتى تنتهي معاملتك، لكنه لا يحمي من صف جديد يُدرَج لاحقاً فيخالف الشرط نفسه — ذاك انحراف كتابة يحتاج Serializable لا قفل صف واحد. وإن اعتمدت القفل الصريح فاستخدم Read Committed، أو خذ الأقفال قبل الاستعلامات عند Repeatable Read، وإلا سبقت اللقطة تغييرات مثبَّتة.

ورفع العزل لا يلغي قيود الفرادة على مستوى المخطط، وهي أرخص دفاع ضد التكرار؛ واختيار المفتاح الأساسي الذي تُبنى عليه هذه القيود قرار مستقل تجد تفصيله في UUID أم bigint: كيف تختار المفتاح الأساسي لجدولك.

وهناك بديل ثالث على مستوى التطبيق: القفل التفاؤلي بعمود إصدار.

UPDATE accounts
SET balance = 950, version = version + 1
WHERE id = 42 AND version = 7;

افحص عدد الصفوف المتأثرة: 0 يعني أن غيرك سبقك، فأعد القراءة وكرّر. Rails يفعّل هذا تلقائياً بمجرد وجود عمود lock_version ويرمي StaleObjectError عند التعارض. ويتفوق هذا الأسلوب على القفل الصريح وعلى Serializable معاً حين تكون التعارضات نادرة، أو حين تمتد دورة «اقرأ ففكّر فاكتب» عبر طلب ويب كامل لا تستطيع خلاله إبقاء معاملة مفتوحة. لكنه لا يحمي من انحراف كتابة يمس عدة صفوف؛ ذاك ما يزال يحتاج Serializable أو قيداً في المخطط.

ما يفسده PgBouncer وDjango والمعاملات الطويلة

الأول PgBouncer في وضع تجميع المعاملات: جدول الميزات الرسمي يضع SET/RESET على «Never»، فأمر SET TRANSACTION ISOLATION LEVEL على مستوى الجلسة يتسرب إلى عملاء آخرين يتشاركون الاتصال نفسه. الصحيح SET LOCAL داخل المعاملة أو BEGIN TRANSACTION ISOLATION LEVEL ... فينتهي الأثر بانتهائها. وتذكّر أن المعاملة التي تنتظر قفلاً تحتجز اتصالاً من المجمّع طوال انتظارها، وضبط حجم المجمّع ومهلاته تجده في تجمّع الاتصالات في قاعدة البيانات: اضبط الحجم والمهلات.

الثاني إطارك نفسه: Django يضبط MySQL على READ COMMITTED افتراضياً متجاوزاً افتراضي InnoDB، ووثائقه تقول حرفياً «Data loss is possible with repeatable read»، بمثال get_or_create الذي يرمي IntegrityError ثم لا يجد الكائن. فتحقق مما يفعله إطارك قبل أن تفترض افتراضي المحرّك.

الثالث المعاملات الطويلة: VACUUM في PostgreSQL لا يستطيع إزالة نسخ الصفوف التي ما زالت مرئية لمعاملة قديمة، وقائمة التاريخ في InnoDB تتضخم لأن سجلات التراجع تبقى محفوظة. حتى معاملة قراءة «بريئة» طويلة تعطّل التنظيف في المحرّكين.

طابِق سيناريوك مع مستوى العزل

الحالةماذا تختار
تحويل مالي مع فحص رصيدSerializable مع إعادة محاولة تلقائية، أو Read Committed مع SELECT … FOR UPDATE على صف الحساب
حجز أو مخزون بشرط تجميعيانحراف كتابة صريح: Serializable، أو قفل صريح على صف الأب، أو قيد فرادة يمنع الحجز المكرر
تقرير طويل يمسح ملايين الصفوفSERIALIZABLE READ ONLY DEFERRABLE في PostgreSQL: بلا عبء Serializable المعتاد ولا خطر إلغائه بفشل تسلسل، لكنه قد يتوقف عند بدايته حتى يأخذ لقطة آمنة
تعديل «اقرأ ففكّر فاكتب» عبر طلب ويبقفل تفاؤلي بعمود إصدار مع إعادة قراءة عند فشل التحديث
لوحة تحكم قراءة غالبةابقَ على الافتراضي
عمّال خلفيون على جدول طابورRead Committed مع SELECT … FOR UPDATE SKIP LOCKED؛ يعطي عرضاً غير متسق فلا يصلح لأغراض عامة

إذا كنت على Aurora MySQL

نسخ القراءة تعمل افتراضياً على REPEATABLE READ وتتجاهل أي SET TRANSACTION ISOLATION LEVEL ترسله إليها. ويمكن تفعيل READ COMMITTED بإعداد الجلسة aurora_read_replica_read_committed، لكن العزل حينها أقل صرامة منه على النسخة الرئيسية ويسمح بقراءات غير قابلة للتكرار وقراءات شبحية. فهو للتقارير التحليلية الضخمة، لا للاستعلامات القصيرة التي تحتاج دقة.

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