المعاملات (Transactions) — SQL الأساسيات
المعاملات (Transactions)
تحويل 100 دينار من سارة إلى عمر = عمليتان: خصم من سارة، وإضافة لعمر. ماذا لو انقطع الخادم بينهما؟ يضيع المال. المعاملة تجعل مجموعة عمليات وحدة واحدة: تنجح كلها أو لا يحدث منها شيء.
الصيغة
CREATE TABLE accounts (id INTEGER PRIMARY KEY, owner TEXT NOT NULL, balance REAL NOT NULL CHECK (balance >= 0));
INSERT INTO accounts VALUES (1, 'سارة', 500), (2, 'عمر', 100);
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- تثبيت كل شيء
SELECT owner, balance FROM accounts;ROLLBACK بدل COMMIT يلغي كل ما حدث منذ BEGIN.
ACID
| الحرف | الخاصية | المعنى |
|---|---|---|
| A | Atomicity (الذرية) | كل شيء أو لا شيء |
| C | Consistency (الاتساق) | القيود محترمة دائماً (مثل balance >= 0) |
| I | Isolation (العزل) | المعاملات المتزامنة لا ترى أنصاف بعضها |
| D | Durability (الديمومة) | بعد COMMIT تبقى البيانات حتى لو انقطع التيار |
القيود تحمي حتى داخل المعاملة
في المثال الكامل يحاول تحويل مبلغ أكبر من الرصيد: قيد CHECK (balance >= 0) يرفض التحديث، والتطبيق ينفذ ROLLBACK فلا يُضاف المال للمستلم.
من كود التطبيق
// مع node-postgres (pg) — نفس الفكرة في أي مكتبة
async function transfer(pool, fromId, toId, amount) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const r = await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1", [amount, fromId]);
if (r.rowCount === 0) throw new Error("رصيد غير كافٍ");
await client.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [amount, toId]);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release(); // أعد الاتصال للمجمع دائماً
}
}لاحظ WHERE ... AND balance >= $1: الفحص والتعديل في عبارة واحدة يمنع حالة سباق بين "اقرأ الرصيد" و "اخصم".
التزامن والأقفال
طلبان متزامنان يقرآن رصيد 500 ثم يخصم كل منهما 400 = رصيد سالب أو أموال من العدم. الحلول:
- تحديث ذري كما أعلاه (balance = balance - x WHERE balance >= x).
- SELECT ... FOR UPDATE (PostgreSQL/MySQL) يقفل الصف حتى نهاية المعاملة.
- مستويات العزل الأعلى (SERIALIZABLE) مع إعادة المحاولة عند التعارض.
نصائح
- معاملات قصيرة: لا تنتظر طلب HTTP خارجياً أو إدخال مستخدم داخل معاملة مفتوحة.
- أغلب المكتبات و ORMs توفر غلافاً: prisma.$transaction، knex.transaction، session.withTransaction في MongoDB.
تمرين
أضف إلى المثال جدول transfers يسجل كل تحويل، واجعل إدراج السجل جزءاً من نفس المعاملة. ماذا يحدث للسجل إذا فشل التحويل؟
CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
owner TEXT NOT NULL,
balance REAL NOT NULL CHECK (balance >= 0)
);
CREATE TABLE transfers (
id INTEGER PRIMARY KEY,
from_id INTEGER NOT NULL REFERENCES accounts(id),
to_id INTEGER NOT NULL REFERENCES accounts(id),
amount REAL NOT NULL CHECK (amount > 0),
at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO accounts VALUES (1, 'سارة', 500), (2, 'عمر', 100);
-- تحويل ناجح: ثلاث عمليات كوحدة واحدة
BEGIN;
UPDATE accounts SET balance = balance - 150 WHERE id = 1;
UPDATE accounts SET balance = balance + 150 WHERE id = 2;
INSERT INTO transfers (from_id, to_id, amount) VALUES (1, 2, 150);
COMMIT;
-- تحويل يُلغى: نغيّر رأينا قبل التثبيت
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
ROLLBACK;
-- خصم ذري لا يسمح بالسالب: يعدّل 0 صفوف إن كان الرصيد غير كافٍ
UPDATE accounts SET balance = balance - 10000 WHERE id = 1 AND balance >= 10000;
SELECT owner, balance FROM accounts ORDER BY id;
SELECT COUNT(*) AS transfers_logged FROM transfers;اختبر فهمك