Kodax

المعاملات (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;
اختبر فهمك