اختبار المعاملات وترحيلات قواعد البيانات
نوعان من عيوب قواعد البيانات يفلتان من الاختبار الوظيفي المعتاد. الأول لا يظهر إلا حين تفشل الأمور في منتصفها أو تحدث في اللحظة نفسها: طلب محفوظ دون أصنافه، أو آخر قطعة في المخزون تُباع مرتين. والثاني لا يظهر إلا حين يتغير المخطط تحت بيانات حقيقية: ترحيل (Migration) استغرق ثانيتين على قاعدة الاختبار يقفل جدول الإنتاج عشرين دقيقة، أو يحوّل بصمت قيماً كان ينبغي أن يحفظها.
تغطي هذه الصفحة النوعين. تشرح المعاملات (Transactions) ومستويات العزل من منظور المختبِر — بدقة، بما في ذلك مواضع اختلاف PostgreSQL وMySQL — وتعرض اختبارات تزامن يمكنك تشغيلها بجلستي SQL، وتقدّم منهجاً وقائمة تحقق لاختبار ترحيل المخطط والبيانات قبل وصوله إلى الإنتاج.
المعاملات وخصائص ACID من منظور المختبِر
تجمع المعاملة عدة عبارات بحيث تسري كلها معاً أو لا يسري منها شيء. تبدأ بـ BEGIN (وSTART TRANSACTION في MySQL مكافئة لها)، وتنتهي بـ COMMIT لتثبيت التغييرات أو ROLLBACK لإلغائها، ولا تظهر تغييراتها للجلسات الأخرى قبل التثبيت. تصف خصائص ACID الأربع ما تعد به قاعدة البيانات:
- الذرّية (Atomicity) — كل العبارات أو لا شيء: الطلب وأصنافه وحجز المخزون وسجل الدفع تُكتب معاً، أو لا يُكتب شيء.
- الاتساق (Consistency) — كل معاملة مثبّتة تنقل القاعدة من حالة صالحة إلى أخرى: القيود صامدة والإجماليات ما زالت متطابقة.
- العزل (Isolation) — المعاملات المتزامنة لا يرى بعضها عمل بعض غير المكتمل، بالقدر الذي يعد به مستوى العزل المختار.
- الديمومة (Durability) — ما إن يعود COMMIT حتى يصمد التغيير أمام الانهيار أو إعادة التشغيل.
اختبار الذرّية: التثبيت والتراجع وحقن الأعطال
لا تحتاج إلى إعادة اختبار ضمانات محرك قاعدة البيانات. ما تختبره هو هل يستخدم التطبيق المعاملات بشكل صحيح: هل العبارات المتلازمة مجمّعة في معاملة واحدة، وهل يطلق كل مسار خطأ تراجعاً، وهل يُرسَل شيء خارج المعاملة — بريد، أو رسالة لخدمة أخرى، أو ملف — قبل أن تُثبَّت البيانات فعلاً.
المنهج هو حقن الأعطال (Failure injection): اجعل خطوة تفشل بعد تنفيذ خطوات سابقة، ثم ابحث عن البقايا. وتختلف المحركات هنا اختلافاً مهماً. في PostgreSQL يُجهض الخطأ داخل المعاملة المعاملةَ كلها، وتفشل كل عبارة لاحقة حتى ROLLBACK. أما في MySQL InnoDB فمعظم الأخطاء تتراجع عن العبارة الفاشلة وحدها وتبقى المعاملة مفتوحة — فشيفرة التطبيق التي تلتقط الخطأ ثم تثبّت تُبقي الصفوف المكتوبة قبله. اختبر مسارات الخطأ على المحرك الذي تشحن عليه.
وتنتهي المعاملة كذلك عند حدود قاعدة البيانات. حين تثبّت خدمة الطلبات ثم تستدعي خدمة دفع تفشل، لا يستطيع أي تراجع في القاعدة إلغاء الطلب المثبَّت؛ يحتاج التصميم إلى إجراء تعويضي (مثل Saga تلغي الطلب) أو صندوق صادر (Outbox) لا ينشر الأحداث إلا بعد التثبيت. اختبر هذا التعويض كميزة قائمة بذاتها.
- بيانات غير صالحة في آخر صنف من عدة أصناف — يجب ألا يبقى الطلب ولا الأصناف السابقة.
- انتهاء مهلة مزوّد الدفع بعد كتابة الطلب — ينتهي الطلب في حالة محددة (معلّق أو ملغى)، ولا يكون «مدفوعاً» دون دفعة أبداً.
- إنهاء عملية التطبيق بين خطوتين (في بيئة اختبار) — عند إعادة التشغيل لا يوجد شيء مكتوب نصفه، ولا تُكرّر إعادة المحاولة شيئاً.
- انتهاك مفتاح فريد في منتصف المعاملة — يرى المستخدم خطأً واضحاً ولا تبقى صفوف جزئية.
-- PostgreSQL test database: the order must not survive a failed item
BEGIN;
INSERT INTO orders (order_id, customer_id, status, total_amount, created_at)
VALUES (9001, 317, 'PENDING', 150.00, '2026-09-28 10:00:00');
INSERT INTO order_items (order_item_id, order_id, product_sku, quantity, unit_price)
VALUES (90011, 9001, 'SKU-100', 0, 150.00); -- violates CHECK (quantity > 0)
ROLLBACK;
SELECT COUNT(*) AS leftover FROM orders WHERE order_id = 9001; -- expect 0ظواهر التزامن التي يمكنك اختبارها
العزل موازنة بين الصحة والإنتاجية، لذا تسمح قواعد البيانات للمعاملات برؤية بعض آثار غيرها. يسمّي معيار SQL ثلاث ظواهر؛ وتضيف وثائق PostgreSQL شذوذ التسلسل؛ أما الظاهرة التي يلتقيها المختبِرون عملياً أكثر من غيرها فليست في قائمة المعيار أصلاً، وهي التحديث المفقود.
- القراءة القذرة (Dirty read) — تقرأ معاملة بيانات كتبتها معاملة أخرى لم تُثبَّت بعد (وقد تتراجع).
- القراءة غير القابلة للتكرار (Non-repeatable read) — تقرأ معاملة الصف نفسه مرتين فتحصل على قيمتين مختلفتين، لأن معاملة أخرى ثبّتت تغييراً بينهما.
- القراءة الشبحية (Phantom read) — تنفّذ معاملة الاستعلام نفسه مرتين فتحصل على مجموعة صفوف مختلفة، لأن معاملة أخرى أدرجت صفوفاً مطابقة أو حذفتها وثبّتت.
- شذوذ التسلسل (Serialization anomaly) — نتيجة معاملات متزامنة مثبّتة لا تطابق أي ترتيب لتشغيلها واحدة تلو الأخرى (المثال الكلاسيكي Write skew: طبيبان يخرج كل منهما من المناوبة لأنه رأى الآخر ما زال مناوباً).
- التحديث المفقود (Lost update) — تقرأ معاملتان القيمة نفسها، وتحسب كل منهما قيمة جديدة منها، فتطمس الكتابة الثانية الأولى بصمت. يعيد السكربت أدناه إنتاجها بجلستي SQL.
-- Setup on a test database: one unit left
CREATE TABLE inventory (
sku VARCHAR(40) NOT NULL PRIMARY KEY,
stock INTEGER NOT NULL CHECK (stock >= 0)
);
INSERT INTO inventory (sku, stock) VALUES ('SKU-100', 1);
-- Step 1, session A
BEGIN;
SELECT stock FROM inventory WHERE sku = 'SKU-100'; -- reads 1
-- Step 2, session B
BEGIN;
SELECT stock FROM inventory WHERE sku = 'SKU-100'; -- reads 1
UPDATE inventory SET stock = 0 WHERE sku = 'SKU-100'; -- app computed 1 - 1
COMMIT;
-- Step 3, session A
UPDATE inventory SET stock = 0 WHERE sku = 'SKU-100'; -- app computed 1 - 1
COMMIT;
-- Two sales, one unit: B's update is lost.
-- PostgreSQL READ COMMITTED and MySQL InnoDB REPEATABLE READ: both commits succeed.
-- PostgreSQL REPEATABLE READ: step 3 fails with a serialization error; the app must retry.- الجلسة A
inventorySKU-100، المخزون يبدأ بـ 1- الجلسة B
- 1 من الجلسة A إلى
inventory:BEGINثم قراءة المخزونSELECT stock - 2 من
inventoryإلى الجلسة A: تقرأ 1 ردّ - 3 من الجلسة B إلى
inventory:BEGINثم قراءة المخزونSELECT stock - 4 من
inventoryإلى الجلسة B: تقرأ 1 ردّ - 5 من الجلسة B إلى
inventory: كتابة 0 ثمCOMMITحسب التطبيق 1 − 1؛ والمخزون الآن 0UPDATE … SET stock = 0 - 6 من الجلسة A إلى
inventory: كتابة 0 ثمCOMMITحسبها التطبيق أيضاً 1 − 1 من القيمة التي قرأتها A في الخطوة 2، لا من القيمة الحالية 0UPDATE … SET stock = 0
النتيجة: في PostgreSQL READ COMMITTED، وكذلك في MySQL InnoDB REPEATABLE READ: ينجح التثبيتان ويضيع تحديث B. أما في PostgreSQL REPEATABLE READ فتفشل الخطوة 6 بخطأ تسلسل، ويجب على التطبيق إعادة المحاولة.
أُعيد إنتاجه بجلستي SQL على قاعدة بيانات اختبار. الخطوات 1–2 و3–5 و6 في الشكل هي الخطوات 1 و2 و3 في السكربت.
مستويات العزل: ما يمنعه كل منها وكيف تختلف المحركات
يعرّف معيار SQL أربعة مستويات بحسب الظواهر الثلاث التي يجب أن يمنعها كل منها. هذه ضمانات دنيا، وكل محرك ينفّذها بطريقته، فراجع دائماً وثائق المحرك والإصدار الذي تشغّله.
يعتمد PostgreSQL افتراضياً READ COMMITTED ولا يسمح بالقراءة القذرة أبداً — فمستوى READ UNCOMMITTED فيه يتصرف مثل READ COMMITTED. ويمنح REPEATABLE READ فيه كل معاملة لقطة (Snapshot) تؤخذ عند بدء أول عبارة فيها غير عبارات التحكم في المعاملة مثل BEGIN، ولا يسمح بالقراءة الشبحية أيضاً، وهذا أشد مما يطلبه المعيار؛ وإن حاولت معاملة كهذه تحديث صف غيّرته معاملة أخرى وثبّتته بعد لقطتها، فإنها تفشل بخطأ تسلسل يجب على التطبيق التقاطه وإعادة المحاولة. أما SERIALIZABLE فيكشف فوق ذلك شذوذ التسلسل ويُجهض إحدى المعاملات المعنية.
يعتمد محرك InnoDB في MySQL افتراضياً REPEATABLE READ. عبارات SELECT العادية في المعاملة تقرأ لقطة متسقة تُنشأ عند أول قراءة؛ أما القراءات المُقفِلة (Locking reads) (SELECT … FOR UPDATE أو FOR SHARE) وUPDATE وDELETE فتعمل على أحدث الصفوف المثبّتة وتأخذ أقفالاً، منها أقفال Next-key (الفجوات) التي تمنع الإدراج في النطاق الممسوح. وعلى خلاف PostgreSQL، لا يفشل تحديث صف غيّرته معاملة أخرى مثبّتة — بل يُطبَّق على أحدث نسخة — فقد يفقد نمط «اقرأ ثم اكتب» في شيفرة التطبيق تحديثاً ما لم يستخدم قراءة مُقفِلة أو عبارة ذرّية. أما SQLite فيطبّق العزل بطريقته الخاصة، فراجع وثائقه بدلاً من افتراض سلوك PostgreSQL أو MySQL.
- READ UNCOMMITTED — يسمح المعيار عنده بالظواهر الثلاث: القراءة القذرة، والقراءة غير القابلة للتكرار (Non-repeatable read)، والقراءة الشبحية (Phantom read).
- READ COMMITTED — يمنع القراءة القذرة (Dirty read)، وتبقى القراءة غير القابلة للتكرار والقراءة الشبحية ممكنتين.
- REPEATABLE READ — يمنع القراءة القذرة والقراءة غير القابلة للتكرار، ويسمح المعيار عنده بالقراءة الشبحية.
- SERIALIZABLE — يمنع الظواهر الثلاث كلها.
- هذه الضمانات الدنيا في معيار SQL، وقد تمنع المحركات أكثر: لا يسمح PostgreSQL بالقراءة القذرة أبداً، ويمنع REPEATABLE READ فيه القراءة الشبحية أيضاً. والتحديث المفقود ليس في جدول المعيار؛ انظر ملاحظات المحركات أعلاه. ويلخّص الجدول أدناه المستويات الأربعة بمصطلحاتها الإنجليزية (possible = ممكنة، prevented = ممنوعة).
Isolation level Dirty read Non-repeatable read Phantom read
READ UNCOMMITTED possible possible possible
READ COMMITTED prevented possible possible
REPEATABLE READ prevented prevented possible
SERIALIZABLE prevented prevented preventedاختبارات تزامن تشغّلها يدوياً وتؤتمتها
أبسط اختبار تزامن يحتاج إلى جلستي SQL وجدول خطوات مكتوب، كما في السكربت أعلاه. فهو يعيد إنتاج السباق (Race condition) بشكل حتمي، مما يجعله مثالياً لتقرير الخطأ. ثم اختبر التطبيق الحقيقي: أرسل الطلبات المتنافسة بالتوازي عبر API، بسكربت صغير أو أداة حمل مثل k6 أو JMeter بعدد قليل من المستخدمين الافتراضيين، وكرر التشغيل مرات كافية لإصابة النافذة الزمنية.
النتيجة المتوقعة لاختبار التزامن ليست أبداً «ينجح الاثنان» حين لا يجوز إلا لواحد. ينجح طلب واحد بالضبط؛ ويتلقى الآخر استجابة محددة (تعارض، أو رسالة نفاد مخزون، أو إعادة محاولة شفافة)؛ وبعدها تتطابق البيانات — لا يصبح المخزون سالباً، ولا تُستخدم القسيمة فوق حدها، ويبقى مجموع الأرصدة في التحويل دون تغيير. تكتشف المحركات الجمود (Deadlock) وتُجهض إحدى المعاملات؛ وينبغي أن يعيد التطبيق محاولتها، وألا يرى المستخدم 500 أبداً.
- آخر قطعة في المخزون يشتريها عميلان في اللحظة نفسها.
- نقر مزدوج على زر الإرسال، أو إعادة محاولة من العميل بعد انتهاء مهلة — مع مفتاح عدم تكرار الأثر (Idempotency key) يعيد الطلب الثاني نتيجة الأول بدل إنشاء طلب ثانٍ.
- مسؤولان يحرران السجل نفسه — مع القفل التفاؤلي (Optimistic locking) يُرفض الحفظ الثاني بتعارض بدل أن يطمس الأول بصمت.
- قسيمة محدودة يستخدمها مستخدمون كثيرون في آن واحد.
- تحويلات في اتجاهين متعاكسين بين الحسابين نفسيهما — نمط جمود كلاسيكي.
-- Option 1: lock the row while deciding (PostgreSQL, MySQL; not SQLite)
BEGIN;
SELECT stock FROM inventory WHERE sku = 'SKU-100' FOR UPDATE;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'SKU-100';
COMMIT;
-- Option 2: one conditional, atomic statement; the app checks rows affected = 1
UPDATE inventory
SET stock = stock - 1
WHERE sku = 'SKU-100' AND stock >= 1;
-- Option 3: optimistic locking with a version column; 0 rows affected = conflict
UPDATE orders
SET status = 'SHIPPED', version = version + 1
WHERE order_id = 1042 AND version = 7;ترحيل المخطط والبيانات: ما الذي يسوء
ترحيل المخطط (Schema migration) يغيّر البنية — يضيف جدولاً أو عموداً أو فهرساً أو قيداً، أو يعيد تسمية شيء أو يحذفه. وترحيل البيانات (Data migration) يغيّر المحتوى — يملأ عموداً جديداً، أو يقسم حقل اسم، أو يحوّل عملات. أدوات الترحيل مثل Flyway وLiquibase أو ترحيلات إطار العمل نفسه تطبّق سكربتات مرقّمة بالترتيب وتسجّل ما نُفّذ. والمخاطر واحدة أياً كانت الأداة:
- الأقفال وإعادة كتابة الجداول — بعض العمليات، مثل تغيير نوع عمود أو بناء فهرس دون خيار المحرك المتاح أثناء التشغيل، تقفل الجدول أو تعيد كتابته. الثواني على قاعدة الاختبار قد تصبح دقائق أو ساعات بحجم الإنتاج. وسلوك كل عملية يعتمد على المحرك وإصداره.
- فقدان البيانات — حذف عمود أو تضييقه، أو تقليل عدد المنازل العشرية، أو تحويل النص إلى أرقام، أو تغيير مجموعة المحارف قد يدمّر القيم أو يفسدها دون خطأ.
- ملء ناقص — السكربت يفوّت قيم NULL أو القيم غير المعتادة أو الصفوف التي أُنشئت أثناء تشغيله.
- التوافق أثناء الطرح — في النشر المتدرّج تعمل نسختا التطبيق القديمة والجديدة على المخطط نفسه في الوقت نفسه، فإعادة تسمية عمود تكسر النسخة القديمة فوراً.
- الفشل الجزئي — يثبّت MySQL ضمنياً قبل عبارات DDL وبعدها، فالترحيل متعدد الخطوات الذي يفشل في منتصفه يترك المخطط نصف معدَّل. ويستطيع PostgreSQL تشغيل معظم DDL داخل معاملة، لكن بعض العبارات مثل CREATE INDEX CONCURRENTLY لا يمكن تشغيلها داخل كتلة معاملة.
- خطوات باتجاه واحد — سكربت التراجع يستطيع إعادة إنشاء عمود محذوف لكن لا يستطيع إعادة بياناته.
كيف تختبر الترحيل
تمرّن على كل ترحيل مهم على قاعدة بيانات بحجم الإنتاج وشكله — المحرك والإصدار نفسيهما، وأعداد صفوف متقاربة، وتوزيعات قيم متشابهة. ابنِها من بيانات اصطناعية مولّدة، أو من بيانات الإنتاج فقط عبر عملية إخفاء هوية (Anonymisation) اعتمدها مسؤول حماية البيانات؛ فنظام حماية البيانات الشخصية السعودي (PDPL) يسري على البيانات الشخصية في بيئات الاختبار أيضاً. قِس مدة الترحيل وراقب الاستعلامات المحجوبة أثناء تشغيله، ويُفضَّل أن تصل حركة اختبار إلى التطبيق في الوقت نفسه.
تحقق باستعلامات تشغّلها قبل الترحيل وبعده: أعداد الصفوف، ومجاميع أعمدة المبالغ، والتوزيعات حسب الحالة، وفحوص خاصة بالتغيير — لا NULL متبقية في عمود ممتلئ، وقيم مسموح بها فقط، وقيود صالحة. احفظ نتائج «قبل» لتكون المقارنة دقيقة.
خطط للتوافق بنمط التوسيع ثم التقليص (Expand and contract، ويسمى أيضاً Parallel change): أضف البنية الجديدة أولاً مع إبقاء القديمة، واكتب في الاثنتين، واملأ البيانات، وحوّل القراءة إلى البنية الجديدة، ولا تحذف القديمة إلا في إصدار لاحق. تصبح كل خطوة متوافقة مع السابق، ويمكنك اختبار النسخة القديمة من التطبيق على المخطط الجديد. وأخيراً اختبر طريق العودة: شغّل ترحيل التراجع أو إجراء الاستعادة على قاعدة التمرين، وقِس مدته، ودوّن الخطوات ذات الاتجاه الواحد.
-- Fingerprint: run before and after the migration and compare the results
SELECT 'orders' AS table_name, COUNT(*) AS row_count, SUM(total_amount) AS amount_sum FROM orders
UNION ALL
SELECT 'order_items', COUNT(*), SUM(quantity * unit_price) FROM order_items
UNION ALL
SELECT 'payments', COUNT(*), SUM(amount) FROM payments;
-- A distribution that must not change
SELECT status, COUNT(*) AS order_count
FROM orders
GROUP BY status
ORDER BY status;
-- Example: after adding orders.currency with a backfill to 'SAR'
SELECT COUNT(*) AS missing_currency FROM orders WHERE currency IS NULL;
SELECT currency, COUNT(*) AS order_count
FROM orders
GROUP BY currency
ORDER BY currency;جرّبها: مولّد مجموعات بيانات SQL جرّبها: مدقّق استعلامات SQL
قائمة التحقق لاختبار الترحيل
انسخ هذه القائمة إلى تذكرة كل ترحيل يغيّر البيانات أو يمس جدولاً كبيراً. هي قصيرة عمداً؛ وكل سطر فيها سؤال يجب أن يستطيع أحدهم الإجابة عنه بنعم قبل الإصدار.
قائمة التحقق لاختبار الترحيل: <معرّف الترحيل>
قبل
[ ] تصنيف التغيير: مخطط أو بيانات أو كلاهما
[ ] تحديد الخطوات المدمّرة (DROP، تضييق النوع أو الطول، إعادة كتابة البيانات)
[ ] قاعدة تمرين بحجم قريب من الإنتاج (بيانات اصطناعية أو مخفاة الهوية ومعتمدة)
[ ] تشغيل استعلامات البصمة وحفظ نتائجها (الأعداد والمجاميع والتوزيعات)
[ ] التأكد من النسخة الاحتياطية أو الاستعادة لنقطة زمنية ومعرفة مدة الاستعادة
أثناء التمرين
[ ] قياس المدة ومراقبة الأقفال والاستعلامات المحجوبة
[ ] حركة التطبيق تعمل أثناء تشغيل الترحيل
[ ] محاكاة فشل في المنتصف وفهم الحالة الناتجة
بعد
[ ] استعلامات البصمة متطابقة، أو مختلفة كما هو مقصود بالضبط
[ ] الأعمدة الجديدة ممتلئة، ولا NULL غير متوقعة، والقيود صالحة
[ ] النسخة القديمة من التطبيق تعمل على المخطط الجديد
[ ] النسخة الجديدة تجتاز فحوص الدخان والسلامة
[ ] اختبار ترحيل التراجع أو الاستعادة وتوثيق الخطوات ذات الاتجاه الواحد
[ ] خطط الاستعلامات الرئيسية لم تتغير أو تحسّنتأتمتة اختبارات المعاملات والترحيل في CI
شغّل اختبارات قاعدة البيانات على المحرك نفسه والإصدار الرئيسي نفسه المستخدمين في الإنتاج، مُشغَّلاً من جديد في خط الأنابيب — في حاوية عادةً. استبدال PostgreSQL أو MySQL بـ SQLite أو بقاعدة في الذاكرة يخفي بالضبط السلوك الذي تتناوله هذه الصفحة: العزل والأقفال وDDL داخل المعاملات وفرض القيود كلها تختلف.
ينبغي أن يطبّق خط الأنابيب كل الترحيلات على قاعدة فارغة، ويطبّق الترحيلات الجديدة على قاعدة مزروعة بالإصدار المنشور السابق، ويشغّل اختبارات التكامل بما فيها حالات التزامن، وينتهي باستعلامات السلامة للقراءة فقط. أما تمارين الأحجام الكبيرة فتستغرق وقتاً أطول وتُشغَّل عادةً على جدول زمني أو قبل الإصدار لا مع كل إيداع.
- الترحيلات من الصفر ومن الإصدار السابق تنجح كلتاهما.
- ترحيلات التراجع، حيث يدعمها الفريق، تعمل بنظافة على بيانات مزروعة.
- اختبارات التزامن تتحقق من البيانات النهائية، لا من رموز الاستجابة وحدها.
- استعلامات السلامة (الأيتام والتكرار والمطابقة) تعيد صفر صفوف بعد المجموعة.
- ترحيل يقفل جدولاً أطول من الحد المتفق عليه في التمرين يوقف الإصدار.
جرّبها: مولّد جمل SQL INSERT جرّبها: مولّد مجموعات الأرقام الحدّية
الأسئلة الشائعة
أي مستوى عزل ينبغي أن يستخدمه تطبيقنا؟
عادةً الافتراضي في المحرك، مع حماية صريحة حيث تعتمد قاعدة عمل على قراءة البيانات نفسها ثم كتابتها: قراءة مُقفِلة، أو تحديث شرطي ذرّي، أو قفل تفاؤلي. المستويات الأشد مثل SERIALIZABLE صحيحة لكنها تتطلب أن يعيد التطبيق محاولة المعاملات المُجهضة. إنه قرار تصميم؛ والاختبار يُظهر هل يصمد النهج المختار.
هل يسمح REPEATABLE READ في PostgreSQL بالقراءة الشبحية؟
لا. يسمح معيار SQL بالأشباح عند REPEATABLE READ، لكن تنفيذ PostgreSQL يستخدم لقطة تمنعها. ويبقى يسمح بشذوذ التسلسل الذي لا يمنعه إلا SERIALIZABLE.
هل يمكن التراجع عن كل ترحيل؟
لا. إضافة البنية قابلة للعكس عادةً؛ أما حذف الأعمدة وتضييق الأنواع وإعادة كتابة البيانات فلا، لأن القيم الأصلية ذهبت. خطة التراجع لهذه الخطوات هي نسخة احتياطية أو استعادة لنقطة زمنية، مختبَرة ومقيسة المدة في التمرين.
كم نحتاج من البيانات لتمرين الترحيل؟
ما يكفي لإعادة إنتاج سلوك الإنتاج: أعداد صفوف متقاربة للجداول التي يمسها الترحيل وتوزيعات قيم واقعية، بما فيها NULL والحالات الحدّية. عشرة صفوف لا تثبت إلا أن السكربت سليم الصياغة.
كيف نختبر التزامن دون أدوات خاصة؟
افتح جلستي SQL واتبع جدول خطوات مكتوباً لتعيد إنتاج السباق بدقة. وللتطبيق، أرسل طلبات متوازية بسكربت صغير أو أداة حمل بعدد مستخدمين قليل، وكرر التشغيل، وتحقق من البيانات النهائية.
هل يكفي SQLite لاختبارات قاعدة البيانات في CI؟
للمنطق الذي لا يعتمد على المحرك، قد يكفي. أما المعاملات والعزل والأقفال والترحيلات وفرض القيود فاختبرها على المحرك الذي تشحن عليه — فسلوك SQLite يختلف عن PostgreSQL وMySQL في كل منها.
المراجع
- Transactions — PostgreSQL Documentationما المعاملة: عدة خطوات تُطبَّق كلها أو لا شيء عبر BEGIN وCOMMIT وROLLBACK، ولا يراها الآخرون قبل التثبيت.
- Transaction Isolation — PostgreSQL Documentationمستويات العزل في SQL والظواهر التي تسمح بها — القراءة القذرة والقراءة غير القابلة للتكرار والأشباح وشذوذ التسلسل — كما ينفّذها PostgreSQL.
- Transaction Isolation Levels — MySQL 8.4 Reference Manualمستويات العزل الأربعة في SQL كما ينفّذها InnoDB، ومستوى REPEATABLE READ هو الافتراضي فيه.
- Transaction — SQLiteكيف يبدأ SQLite المعاملات ويثبّتها ويتراجع عنها، بما في ذلك أنماط التأجيل والفورية والحصرية.
- Constraints — PostgreSQL Documentationقيود التحقق وعدم القيمة الفارغة والتفرّد والمفتاح الأساسي والمفتاح الأجنبي والإقصاء — القواعد التي تفرضها قاعدة البيانات ويتحقق منها المختبِر.
- ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)الجزء الإطاري من معيار SQL الدولي، الذي تنفّذه لهجات PostgreSQL وMySQL وSQLite وغيرها بدرجات متفاوتة.
- Personal Data Protection Law (English translation)النظام السعودي لحماية البيانات الشخصية — ولماذا ينبغي أن تستخدم بيئات الاختبار بيانات اصطناعية بدلاً من نسخ سجلات العملاء الحقيقية.
تمرين عملي
راجع الدفع ببطاقة هدية والترحيل المرافق له
تختبر الإصدار 5.3 من تطبيق لتوصيل البقالة (مثال خيالي)، وهو يتيح للعملاء دفع ثمن الطلب ببطاقة هدية. يحمل طلب دمج (Pull request) واحد تغييرين تعرضهما المادة أدناه: (أ) معالج إتمام الطلب (Checkout handler) الجديد بشيفرة وصفية (Pseudo-code)، و(ب) الترحيل (Migration) V42 الذي يعدّل جدول gift_cards قبل تشغيل أول نسخة من الإصدار 5.3. يعمل الإنتاج على MySQL 8.0 بمحرك InnoDB ومستوى العزل الافتراضي، ويُطرح الإصدار بنشر متدرّج (Rolling deployment).
اعمل على الورق مستعيناً بالدليل: حقن الأعطال (Failure injection)، وجدول الخطوات بجلستين، ومخاطر الترحيل، واستعلامات البصمة، وقائمة التحقق لاختبار الترحيل. قاعدة البيانات اختيارية؛ فإن كانت لديك نسخة اختبار من MySQL 8.0 فيمكنك أن تشغّل عليها جدول الخطوة 2. اكتب كل ملاحظة كما تطرحها في المراجعة: السطر أو العبارة، وما الذي يسوء على هذا المحرك، والاختبار الذي يُظهره، والنتيجة المتوقعة. ويساعدك مولّد بيانات عشرية في بيانات التمرين الخاصة بتغيير الرصيد، وتقارن أداة مقارنة النصوص نتائج الاستعلامات المحفوظة قبل الترحيل وبعده. خصّص لذلك 45–60 دقيقة.
تطبيق لتوصيل البقالة (مثال خيالي) - الإصدار 5.3
بطاقات الهدايا موجودة من قبل: يشتريها العملاء ويطّلعون على رصيدها، ومنذ الإصدار 4.8
يحصلون فيها على استرداد نقدي (Cashback) بثلاث منازل عشرية (مثل 12.345).
يضيف الإصدار 5.3 دفع ثمن الطلب ببطاقة هدية. كل بطاقات الهدايا بالريال (SAR).
الإنتاج: MySQL 8.0 بمحرك InnoDB ومستوى العزل الافتراضي (REPEATABLE READ)،
ونحو مليوني بطاقة هدية. قاعدة الاختبار: MySQL 8.0 و300 بطاقة هدية.
نشر متدرّج: تُستبدل أربع نسخ من التطبيق واحدة تلو الأخرى، فيخدم الإصداران
5.2 و5.3 الطلبات جنباً إلى جنب نحو 15 دقيقة.
أ. معالج إتمام الطلب في الإصدار 5.3 (شيفرة وصفية؛ cart.total = مجموع كل أصناف السلة)
1 placeOrder(cart, cardCode):
2 BEGIN
3 card = SELECT balance FROM gift_cards WHERE card_code = cardCode
4 if card.balance < cart.total:
5 ROLLBACK; return 409 "Insufficient gift card balance"
6 orderId = INSERT INTO orders (customer_id, status, total_amount)
7 VALUES (cart.customerId, 'PAID', cart.total)
8 for each item in cart.items:
9 try:
10 INSERT INTO order_items (order_id, product_sku, quantity, unit_price)
11 VALUES (orderId, item.sku, item.qty, item.price) -- FK to products(sku)
12 catch DatabaseError:
13 log.warn("item skipped", item.sku)
14 newBalance = card.balance - cart.total
15 UPDATE gift_cards SET balance = newBalance WHERE card_code = cardCode
16 sendEmail(cart.customerEmail, "Order confirmed", orderId)
17 COMMIT
18 return 201 { orderId }
ب. الترحيل V42 - يُطبَّق مرة واحدة قبل تشغيل أول نسخة من الإصدار 5.3.
استغرق على قاعدة الاختبار 3 ثوانٍ.
ALTER TABLE gift_cards ADD COLUMN currency CHAR(3) NULL;
UPDATE gift_cards SET currency = 'SAR' WHERE currency = '';
ALTER TABLE gift_cards MODIFY balance DECIMAL(10,2) NOT NULL; -- was DECIMAL(12,3)
ALTER TABLE gift_cards RENAME COLUMN code TO card_code;- الجزء (أ)، الذرّية: جد الموضعين اللذين قد يترك فيهما المعالج بيانات أو آثاراً جانبية لا تطابق ما ثُبِّت. ولكل منهما اكتب اختبار حقن أعطال: ما الذي تجعله يفشل، وما الذي تفحصه بعد ذلك، والنتيجة المتوقعة.
- الجزء (أ)، التزامن: جد السباق (Race condition) في المعالج. اكتب جدول خطوات بجلستين يعيد إنتاجه على قاعدة الاختبار بالبطاقة
GC-T-001(رصيدها 100.00) وطلبين بقيمة 60.00 و70.00، مع النتيجة التي تنتجها الشيفرة الحالية والنتيجة الصحيحة. ثم صف اختبار API المتوازي وما الذي يتحقق منه. - الجزء (ب): راجع V42 عبارةً عبارة مقابل مخاطر الترحيل في الدليل: الأقفال وإعادة كتابة الجداول، وفقدان البيانات، والملء الناقص، والتوافق أثناء النشر المتدرّج، والفشل الجزئي، والخطوات ذات الاتجاه الواحد.
- اكتب خطة التمرين على V42: قاعدة التمرين، واستعلامات البصمة التي تشغّلها قبل الترحيل وبعده مع النتائج التي يجب أن يعطيها ترحيل صحيح، وما تراقبه أثناء التشغيل.
- قرّر هل يمكن إصدار V42 والمعالج كما كُتبا. اذكر ما يجب تغييره، والأسئلة التي تطرحها على المطورين وعلى مالك المنتج.
إظهار الإجابة النموذجية
الذرّية، الأسطر 8–13: خطأ الصنف المُبتلَع. في MySQL InnoDB يؤدي خطأ مثل انتهاك المفتاح الأجنبي (Foreign key) إلى التراجع عن عبارة INSERT الفاشلة وحدها، وتبقى المعاملة مفتوحة، فيُبقي COMMIT في السطر 17 الطلبَ والأصنافَ المكتوبة قبل الخطأ والرصيدَ المخصوم. فيدفع العميل cart.total كاملاً، بما فيه صنف لم يُحفظ قط. اختبار حقن الأعطال: ضع في السلة ثلاثة أصناف ثالثها منتج اختباري، واحذف هذا المنتج من products قبل إتمام الشراء كأنه سُحب من الكتالوج (أو أرسل الصنف الثالث برمز SKU غير موجود)، ثم أتمّ الشراء ببطاقة رصيدها كافٍ. النتيجة المتوقعة: رسالة خطأ واضحة من فئة 4xx مثل «أحد أصناف سلتك لم يعد متاحاً»، ولا صف جديد في orders أو order_items لعميل الاختبار، ورصيد البطاقة دون تغيير، ولا بريد. أما هذه الشيفرة فتعيد 201 وتترك طلباً بصنفين مع خصم الإجمالي كاملاً. الإصلاح الذي ستعيد اختباره: أي خطأ من قاعدة البيانات يتراجع عن المعاملة كلها ويعيد تلك الرسالة. وفي PostgreSQL تفشل الشيفرة نفسها بطريقة أخرى: يُجهض الخطأ المعاملة وتفشل كل عبارة لاحقة، فلا يُثبَّت شيء، لكن الطلب ينتهي بخطأ غير معالَج. ولهذا يقول الدليل: اختبر مسارات الخطأ على المحرك الذي تشحن عليه.
الذرّية، السطران 16 و17: البريد قبل COMMIT. تغادر رسالة التأكيد النظام قبل أن تُثبَّت البيانات، ولا يستطيع أي تراجع استعادتها؛ كما أنها تُبقي صف بطاقة الهدية مقفلاً ريثما يستجيب خادم البريد. الاختبار في بيئة الاختبار: أوقف التطبيق مؤقتاً بنقطة توقف (Breakpoint) بين السطرين 16 و17، أي بعد إرسال البريد وقبل COMMIT، ثم أنهِ العملية هناك، أو استخدم مفتاح حقن أعطال يضيفه المطورون للاختبار. النتيجة المتوقعة: لا شيء مكتوب نصفه، ولا تأكيد لطلب غير موجود في قاعدة البيانات. أما هذه الشيفرة فترسل «Order confirmed» برقم طلب غير موجود، لأن MySQL يتراجع عن المعاملة غير المثبّتة حين يُغلق اتصالها. الإصلاح الذي ستعيد اختباره: أرسل البريد بعد COMMIT، أو اكتبه في جدول صندوق صادر (Outbox) داخل المعاملة ولا ترسله إلا بعد التثبيت.
التزامن، الأسطر 3 و14 و15: قراءة ثم كتابة قيمة محسوبة في التطبيق. السطر 3 عبارة SELECT عادية، فيستطيع طلبا إتمام شراء بالبطاقة نفسها أن يقرأ كلاهما الرصيد القديم، ويجتاز كلاهما فحص السطر 4، ويكتب كل منهما رصيداً محسوباً من تلك القيمة القديمة: إنه التحديث المفقود (Lost update). وفي MySQL InnoDB عند REPEATABLE READ لا يفشل التحديث الثاني، بل يُطبَّق على أحدث نسخة من الصف، فينجح التثبيتان كلاهما وتدفع البطاقة أكثر مما تحمل. وكل حلول الدليل تصلح هنا: قراءة مُقفِلة (Locking read) (SELECT balance ... FOR UPDATE في السطر 3)؛ أو عبارة شرطية ذرّية واحدة UPDATE gift_cards SET balance = balance - ? WHERE card_code = ? AND balance >= ? تتحقق الشيفرة بعدها من أن صفاً واحداً بالضبط تأثّر، وإلا تتراجع وتعيد 409؛ أو القفل التفاؤلي (Optimistic locking) بعمود إصدار. الاختيار للمطورين، واختباراتك يجب أن تنجح مع أيٍّ منها يختارون.
جدول الخطوات بجلستين على قاعدة اختبار MySQL 8.0، والبطاقة GC-T-001 برصيد 100.00. الخطوة 1، الجلسة A: BEGIN; ثم SELECT balance FROM gift_cards WHERE card_code = 'GC-T-001'; فتقرأ 100.00. الخطوة 2، الجلسة B: BEGIN; والعبارة SELECT نفسها، فتقرأ 100.00 أيضاً. الخطوة 3، الجلسة B، طلب 60.00: UPDATE gift_cards SET balance = 40.00 WHERE card_code = 'GC-T-001'; ثم COMMIT;. الخطوة 4، الجلسة A، طلب 70.00: UPDATE gift_cards SET balance = 30.00 WHERE card_code = 'GC-T-001'; ثم COMMIT;. النتيجة الحالية: ينجح التثبيتان والرصيد 30.00، مع أن طلبات بقيمة 130.00 دُفعت من 100.00؛ فقد ضاع تحديث B. وفي PostgreSQL عند REPEATABLE READ تفشل الخطوة 4 بخطأ تسلسل بدلاً من ذلك، أما عند مستواه الافتراضي READ COMMITTED فيضيع التحديث هناك أيضاً. النتيجة الصحيحة: يُدفع طلب واحد فقط. ومع FOR UPDATE في السطر 3 تنتظر قراءة B في الخطوة 2، فتمضي A وتدفع 70.00 وتثبّت، ثم تقرأ B القيمة 30.00 ويجب أن ترفض طلب 60.00 بالرمز 409.
ثم اختبار التطبيق: أرسل طلبَي إتمام الشراء بالبطاقة GC-T-001 بالتوازي عبر API، بسكربت صغير أو بأداة حمل مثل k6 أو JMeter بمستخدمَين افتراضيين اثنين. أعد الرصيد إلى 100.00 وكرر التشغيل مرات كثيرة، لأن النافذة الزمنية للسباق قصيرة. وتحقق من البيانات النهائية لا من رموز الاستجابة وحدها: في كل تشغيل استجابة 201 واحدة بالضبط واستجابة 409 واحدة «Insufficient gift card balance»؛ والرصيد النهائي 40.00 أو 30.00 ويطابق الطلب الذي فاز؛ ولا يكون سالباً أبداً؛ ولا يتجاوز مجموع الطلبات المدفوعة 100.00 أبداً.
الترحيل V42، العبارتان 1 و2. إضافة عمود يقبل NULL تغيّر البنية فقط وقابلة للعكس. أما الملء (Backfill) فخاطئ: بعد العبارة 1 تحمل كل الصفوف الموجودة القيمة NULL في currency، والشرط NULL = '' لا يكون صحيحاً أبداً، فلا يحدّث WHERE currency = '' أي صف؛ وعلى قاعدة الاختبار تُبلغ عبارة UPDATE عن 0 صف متأثر بدلاً من 300. يجب أن يكون الشرط WHERE currency IS NULL؛ وعلى مليوني صف تقفل عبارة UPDATE هذه كل صف تمسحه حتى تُثبَّت، فقِس مدتها في التمرين وشغّلها على دفعات. وحتى عندها، تظل نسخ 5.2 العاملة أثناء الطرح تُنشئ بطاقات هدايا بلا عملة. فإما أن يحصل العمود على DEFAULT 'SAR' فتأخذ كل بطاقة جديدة القيمة SAR أياً كان الإصدار الذي كتبها، وإما أن يكتب 5.3 القيمة SAR ويُعاد تشغيل الملء بعد خروج آخر نسخة من 5.2. وبعد ذلك فقط يمكن لترحيل لاحق أن يجعل العمود NOT NULL.
العبارة 3، أي تغيير balance من DECIMAL(12,3) إلى DECIMAL(10,2)، ترحيل بيانات مختبئ في تغيير مخطط. فقدان البيانات: كل رصيد فيه منزلة عشرية ثالثة، مثل 12.345 من الاسترداد النقدي، يفقد تلك المنزلة دون خطأ، فتتغير أموال العملاء؛ وهي خطوة باتجاه واحد، لأن ترحيل التراجع لا يستطيع إعادة المنزلة المفقودة، فطريق العودة هو الاستعادة من نسخة احتياطية. وتنخفض الدقة (Precision) أيضاً من 12 إلى 10 أرقام، فتحقق من أن أكبر رصيد ما زال يتسع. الأقفال: تغيير نوع عمود يجعل MySQL 8.0 ينسخ الجدول كله، وتنتظر عمليات الكتابة على gift_cards حتى ينتهي النسخ؛ و3 ثوانٍ لـ 300 صف لا تقول شيئاً عن مليوني صف. العبارة 4، أي إعادة التسمية: يعمل V42 والنسخ الأربع كلها ما زالت على 5.2، فمن لحظة تنفيذها حتى استبدال آخر نسخة من 5.2 تفشل كل استعلامات 5.2 التي ما زالت تستخدم code، مثل عرض الرصيد أو إضافة الاسترداد النقدي. الفشل الجزئي: يثبّت MySQL ضمنياً قبل كل عبارة DDL وبعدها، فإن توقف V42 عند العبارة 3 أو 4 بقيت العبارات السابقة مطبَّقة، وفشلت إعادة تشغيل V42 فوراً عند العبارة 1 لأن العمود currency موجود مسبقاً.
خطة التمرين. القاعدة: MySQL 8.0 كما في الإنتاج، وفيها نحو مليوني بطاقة هدية من بيانات اصطناعية مولّدة تشبه الإنتاج في شكلها (أرصدة بمنزلتين وبثلاث منازل عشرية، وأرصدة صفرية، وأكبر القيم الواقعية)، أو من بيانات الإنتاج فقط عبر عملية إخفاء هوية (Anonymisation) اعتمدها مسؤول حماية البيانات، لأن بطاقات الهدايا تخص العملاء، ونظام حماية البيانات الشخصية (PDPL) يسري على البيانات الشخصية في بيئات الاختبار أيضاً. قبل الترحيل: تأكد من النسخة الاحتياطية أو الاستعادة لنقطة زمنية، ثم شغّل واحفظ نتائج SELECT COUNT(*) AS card_count, SUM(balance) AS balance_sum FROM gift_cards; وSELECT COUNT(*) AS three_decimals FROM gift_cards WHERE balance <> ROUND(balance, 2);. أثناء التمرين: أبقِ حركة اختبار على 5.2 تعمل، لأنه الإصدار العامل أثناء تنفيذ V42، وقِس مدة كل عبارة، وراقب الاستعلامات المحجوبة، وفي أحد التشغيلات أوقف الترحيل في منتصفه وسجّل الحالة التي يتركها. بعد الترحيل: card_count وbalance_sum دون تغيير (وإن كان three_decimals أكبر من 0 فإن V42 بصيغته الحالية يغيّر هذا العدد من الأرصدة، وغالباً ما يختلف balance_sum أيضاً: هذا هو فقدان البيانات بالأرقام)؛ وSELECT COUNT(*) AS missing_currency FROM gift_cards WHERE currency IS NULL; تعيد 0، والتجميع حسب currency لا يُظهر إلا SAR؛ والإصدار 5.2 ما زال يعمل على المخطط الجديد؛ و5.3 يجتاز فحوص الدخان والسلامة؛ والاستعادة مختبَرة ومقيسة المدة. قارن النتائج المحفوظة بأداة مقارنة النصوص لا بالعين.
القرار: لا يُصدَر أيٌّ من التغييرين بصيغته الحالية. الملاحظات المانعة: الطلب الجزئي والتحديث المفقود (يدفع العملاء ثمن سلع لا يحصلون عليها، أو تدفع البطاقة أكثر مما تحمل)، والبريد قبل COMMIT، والملء الذي لا يحدّث شيئاً، وإعادة التسمية التي تكسر 5.2 أثناء الطرح، وتغيير في أرصدة العملاء لم يقرّره أحد. اطلب خطة بنمط التوسيع ثم التقليص (Expand and contract): يضيف V42 العمود currency فقط؛ ويكتب 5.3 القيمة SAR للبطاقات الجديدة، أو يأخذها العمود قيمةً افتراضية؛ ويُشغَّل الملء WHERE currency IS NULL على دفعات بعد الطرح، ويأتي NOT NULL في إصدار لاحق؛ ويُبقي معالج 5.3 على الاسم code، أو يُضاف card_code ويُكتب مع code ويُملأ، ثم تُحوَّل القراءة إليه، ولا يُحذف code إلا في إصدار بعد 5.3؛ وينتظر تغيير الرصيد قرار مالك المنتج، ويأتي في ترحيل مستقل بتمرين خاص به؛ وتغيير DDL واحد في كل ترحيل، حتى يترك الفشل حالة معروفة. أسئلة لمالك المنتج: ماذا يحدث للمنزلة العشرية الثالثة في الأرصدة الحالية (أي قاعدة تقريب، وهل يُعَدّ تقرير بالفروق، وهل يُبلَّغ العملاء)؟ وللمطورين: أي حل للتزامن، وهل ينتهي الطلب الخاسر دائماً بالرمز 409، لا بالرمز 500 ولا بانتهاء مهلة انتظار القفل؟ وهل يوجد حد متفق عليه لمدة القفل يوقف الإصدار؟
إن اختلفت قائمتك فراجع كل ملاحظة مقابل أسئلة الدليل. هل يجمع المعالج كل ما يتلازم في معاملة واحدة، ويتراجع عند كل خطأ، ولا يفعل شيئاً خارج قاعدة البيانات قبل COMMIT؟ هل يتحقق اختبار التزامن من البيانات النهائية؟ هل تبقى كل خطوة ترحيل متوافقة مع النسخة التي ما زالت تعمل، وتحفظ البيانات، ولها طريق عودة مختبَر؟ والملاحظة التي لا يذكرها الدليل مقبولة إن استطعت أن تذكر الإخفاق الذي تسببه والاختبار الذي يُظهره.
تقدّمك في هذا الموضوع
سجّل الدخول لتتابع تقدّمك
علّم الموضوعات مكتملة، وتابع من حيث توقفت، وافتح امتحانات التخصص.
جزء من مسار تعلم ضمان الجودة
قبل هذا الموضوع
الموضوعات التالية
الأدوات المذكورة في هذا الدليل
ولّد سكربتات تهيئة SQL (جملة CREATE TABLE وجمل INSERT) بصفوف اختبار واقعية لأي لهجة.
فحص جمل SQL بحثاً عن مشكلات الصياغة والأقواس غير المتوازنة والسلاسل النصية غير المغلقة والأنماط الخطرة.
تنسيق استعلامات SQL بحالة أحرف متسقة للكلمات المفتاحية ومسافات بادئة وفواصل أسطر (MySQL وPostgreSQL وSQL Server وOracle وSQLite).
توليد جمل INSERT من تعريف جدول أو صفوف نموذجية أو CSV أو JSON.
ولّد القيم الحدّية حول الحدود: min-1 وmin وmin+1 وmax-1 وmax وmax+1 للأعداد الصحيحة بأي عرض.