لرفض حفظ مستند قديم، أدرج المراجعة التي حمّلها المحرر أصلًا في شرط التحديث، ثم زدها ذريًا. لا يمكن لـ SERIALIZABLE وحده التعبير عن هذا الشرط عندما يكون المستند قد حُمّل في معاملة سابقة.

هذا الدليل موجّه إلى مطوّر يعمل في Terminal على جهاز Mac ويحتاج إلى محرر مقالات PostgreSQL يرفض النصوص البديلة القديمة. يستخدم صفوف مقالات اصطناعية وعرضًا توضيحيًا كاملًا باستخدام node-postgres. المهمة محددة: احفظ التغييرات فقط عندما تظل المراجعة المخزنة مطابقة للمراجعة التي استندت إليها المسودة المقدمة.

لماذا قد يسمح SERIALIZABLE باستبدال قديم؟

يعرّف PostgreSQL التنفيذ القابل للتسلسل بأنه تنفيذ ينتج أثرًا مكافئًا لتشغيل المعاملات المثبّتة واحدة تلو الأخرى بترتيب ما. يتعلق هذا الضمان بمعاملات قاعدة البيانات وعملياتها. PostgreSQL: Transaction Isolation.

تأمل التسلسل التوضيحي التالي:

  1. يحمّل محرر المقالة 1 عند المراجعة 1، وتنتهي معاملة القراءة.
  2. يستبدل حفظ آخر نص المقالة ويثبّت المراجعة 2.
  3. يرسل المحرر الأصلي نصًا بديلًا يستند إلى المراجعة 1.
  4. تحدّث معاملة جديدة قابلة للتسلسل المقالة 1 باستخدام معرّفها فقط.

يمكن أن تنجح المعاملة الأخيرة: فقد نُفّذت المعاملات بالتتابع بالفعل. لا يوجد في تحديث مشروط بـ id = 1 فقط ما يفيد بأن النص المرسل يتطلب المراجعة 1. هذا تطبيق لتعريف قابلية التسلسل الموثّق، وليس استثناءً منه.

تختلف المعاملات المتداخلة. يمكن لـ PostgreSQL رفض معاملة قابلة للتسلسل عندما يتعارض تحديث متزامن مع لقطة المعاملة أو عندما تحول التبعيات دون التنفيذ التسلسلي. تُنشأ اللقطة عند أول تعليمة ليست للتحكم في المعاملة؛ ومجرد إصدار BEGIN قبل ذلك لا يحافظ على قراءة المحرر السابقة. PostgreSQL: Transaction Isolation.

لذا فالسؤال التشخيصي هو: أين يشترط الحفظ صراحةً المراجعة التي حمّلها المحرر؟ إذا لم يكن الجواب في أي موضع، فلن يضيف تغيير مستوى العزل هذا الشرط المفقود.

ما الضمان الذي ينبغي أن يوفّره التحقق من المراجعة؟

تعامل مع كل مسودة محمّلة على أنها زوج يتكون من نصها القابل للتحرير ومراجعتها الأساسية. يتضمن الحفظ معرّف المقالة والنص البديل والمراجعة الأساسية دون تغيير. يجب أن يطابق تحديث قاعدة البيانات كلًا من المعرّف وتلك المراجعة، وأن يستبدل النص ويزيد المراجعة المخزنة ضمن التعليمة نفسها.

على سبيل المثال، يعبّر الشرط WHERE id = $1 AND revision = $2 عن المتطلب السابق، بينما ينفّذ SET body = $3, revision = revision + 1 الانتقال. يحدّث PostgreSQL الصفوف المطابقة للشرط، ويوفّر RETURNING قيم الصفوف التي تم تحديثها فعلًا. ويُعد تحديث صفر من الصفوف أمر SQL ناجحًا، لذا يجب على التطبيق تفسيره صراحةً. PostgreSQL: UPDATE.

يُطلق على هذا النمط غالبًا اسم التحكم في التزامن التفاؤلي أو المقارنة والاستبدال. وفق تصميم هذه المقالة، يجب أن تقدّم كل عملية تغيّر محتوى المقالة القابل للتحرير المراجعة. اجعل المراجعات غير قابلة لأن تكون NULL، ولا تعِد ضبطها، ولا تُعِد استخدام هوية مقالة مع رمز مراجعة قديم. وإلا فقد توحي المساواة خطأً بأن المسودة القديمة ما زالت مطابقة.

إن إجراء قراءة منفصلة يتبعها تحديث يستخدم المعرّف وحده يُبقي هذا الشرط خارج عملية الكتابة نفسها. وبالمثل، فإن جلب أحدث مراجعة قبيل الحفظ واستبدال المراجعة المرسلة بها يغيّر معنى الطلب: إذ يسمح باستبدال إصدار لم يستخدمه المحرر أساسًا لمسودته.

شغّل مثالًا اصطناعيًا كاملًا على جهاز Mac

جهّز نسخة PostgreSQL قيد التشغيل، وقاعدة بيانات مؤقتة موجودة، وتثبيت Node.js حالي أو إصدار LTS مدعوم مع npm. افتح Terminal، وأنشئ مجلد عمل، وثبّت برنامج التشغيل باستخدام الأمر الموثق npm install pg. node-postgres: Introduction and Install.

mkdir revision-write-demo
cd revision-write-demo
npm install pg

احفظ البرنامج الكامل التالي باسم revision-demo.mjs في ذلك المجلد. يستخدم الاستيراد الافتراضي المتوافق مع ESM والموثّق. يمكن لكل من Client وPool قراءة إعدادات اتصال PostgreSQL من متغيرات البيئة؛ اضبط هذه الإعدادات لقاعدة البيانات المؤقتة الموجودة قبل تشغيله. node-postgres: ESM، node-postgres: Connecting.

يقارن البرنامج بين مقالتين اصطناعيتين. تُحفظ للمقالة 1 تغييران متتاليان بلا شروط، ضمن معاملتين قابلتين للتسلسل. أما المقالة 2 فتُجرى لها عمليتا حفظ مع التحقق من المراجعة، وتستخدم كلتاهما المراجعة الأصلية نفسها. ويعزل جدولها المؤقت العرض التوضيحي عن جداول المقالات الدائمة. جرى التحقق من صياغة JavaScript باستخدام Node.js؛ لكن العرض التوضيحي لقاعدة البيانات لم يُنفّذ في بيئة البحث.

import pg from 'pg';
import assert from 'node:assert/strict';
const { Client } = pg;
const client = new Client();

async function saveArticle(id, expectedRevision, body) {
  for (let attempt = 1; attempt <= 3; attempt += 1) {
    let active = false;
    try {
      await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
      active = true;
      const result = await client.query(
        `UPDATE revision_demo_articles
         SET body = $3, revision = revision + 1
         WHERE id = $1 AND revision = $2
         RETURNING id, body, revision`,
        [id, expectedRevision, body]
      );
      if (result.rowCount === 0) {
        await client.query('ROLLBACK');
        active = false;
        return { status: 'stale-or-missing' };
      }
      await client.query('COMMIT');
      active = false;
      return { status: 'saved', article: result.rows[0] };
    } catch (error) {
      if (active) await client.query('ROLLBACK');
      if (error.code !== '40001' || attempt === 3) throw error;
    }
  }
}

await client.connect();
try {
  await client.query(`
    CREATE TEMP TABLE revision_demo_articles (
      id integer PRIMARY KEY,
      body text NOT NULL,
      revision integer NOT NULL CHECK (revision > 0)
    )
  `);
  await client.query(
    `INSERT INTO revision_demo_articles (id, body, revision)
     VALUES (1, 'Opening draft', 1), (2, 'Opening draft', 1)`
  );
  const loaded = await client.query(
    'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
  );
  const expectedRevision = loaded.rows[1].revision;

  // Illustrative blind saves: each is a separate transaction.
  for (const body of ['First edit', 'Stale replacement']) {
    await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
    try {
      await client.query(
        `UPDATE revision_demo_articles
         SET body = $1, revision = revision + 1 WHERE id = 1`,
        [body]
      );
      await client.query('COMMIT');
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    }
  }

  const first = await saveArticle(2, expectedRevision, 'First edit');
  const stale = await saveArticle(2, expectedRevision, 'Stale replacement');
  assert.equal(first.status, 'saved');
  assert.equal(first.article.revision, 2);
  assert.equal(stale.status, 'stale-or-missing');

  const final = await client.query(
    'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
  );
  assert.deepEqual(final.rows, [
    { id: 1, body: 'Stale replacement', revision: 3 },
    { id: 2, body: 'First edit', revision: 2 }
  ]);
  console.table(final.rows);
  console.log(stale.status);
} finally {
  await client.end();
}

إذا كان اسم قاعدة البيانات المؤقتة الموجودة هو revision_demo، فنفّذ الأمر أدناه. استبدل اسمها الفعلي واحتفظ بأي إعدادات اتصال في البيئة تحتاج إليها نسختك المحلية. node-postgres: Connecting.

PGDATABASE=revision_demo node revision-demo.mjs

القيم النهائية المتوقعة هي:

المقالة المتن المراجعة المعنى
1 Stale replacement 3 ثُبّتت عمليتا الحفظ بلا شروط
2 First edit 2 رُفضت عملية الحفظ الثانية التي تحققت من المراجعة

ينبغي أن تكون الحالة النهائية المطبوعة stale-or-missing.

يتجنب التسلسل عمدًا الاعتماد على تزامن يتأثر بالتوقيت. فهو يوضّح الحد الفاصل الأساسي مباشرةً: يمكن أن ينجح استبدال قديم حتى عندما تكون كل معاملة حفظ قابلة للتسلسل، لأن عمليات الحفظ تُنفّذ بترتيب تسلسلي صحيح.

كيف ينبغي لـ node-postgres التعامل مع كل نتيجة؟

يمرر البرنامج القيم منفصلة عن نص SQL باستخدام واجهة برنامج التشغيل الموثقة للاستعلامات ذات المعاملات. كما يتحقق من rowCount، التي تشير إلى عدد الصفوف التي عالجها التحديث؛ ولا تكفي مصفوفة rows الفارغة وحدها بديلًا عامًا للتحقق من عدد الصفوف المحدّثة. node-postgres: Queries، node-postgres: pg.Result.

استخدم جدول القرارات التالي عند تكييف الدالة:

النتيجة إجراء التطبيق
تحديث صف واحد وتثبيت ناجح اقبل الحفظ واحتفظ بالمراجعة المُعادة
تحديث صفر من الصفوف ارفض الحفظ باعتباره قديمًا أو متعلقًا بمقالة غير موجودة؛ واحتفظ بالمسودة المرسلة
رمز SQLSTATE 40001 ألغِ المعاملة وأعِد المحاولة للمعاملة كاملة، ضمن سياسة محدودة للمحاولات
خطأ آخر أو بلوغ حد إعادة المحاولة أظهر الإخفاق؛ ولا تُبلغ عن نجاح الحفظ

لا تثبت نتيجة تحديث صفر من الصفوف ما إذا كانت المقالة قد حُذفت أو تغيّرت مراجعتها. يجمع العرض التوضيحي هاتين النتيجتين عمدًا. إذا احتاجت الواجهة إلى مزيد من التفاصيل، فحمّل المقالة الحالية بعد ذلك واعرض تلك النتيجة بوصفها ملاحظة لاحقة. لا تصفها بأنها دليل على الحالة الدقيقة وقت التحديث الفاشل.

تستخدم إخفاقات التسلسل رمز SQLSTATE 40001. ويتطلب PostgreSQL إعادة محاولة المعاملة كاملة، بما يشمل المنطق الذي يحدد التعليمات والقيم. قد تتطلب إعادة المحاولة عدة محاولات ولا تضمن الإكمال في نهاية المطاف. يستخدم المثال ثلاث محاولات بوصفها سياسة تطبيق توضيحية. PostgreSQL: Serialization Failure Handling.

أبقِ expectedRevision دون تغيير خلال تلك المحاولات. فهو يمثّل المتطلب السابق لمسودة المستخدم. إذا زادت عملية حفظ أخرى المراجعة، فيمكن حينئذ لمعاملة جديدة رفض الرمز القديم بسبب تحديث صفر من الصفوف. سيؤدي استبدال الرمز تلقائيًا بأحدث مراجعة إلى إزالة الحماية من الكتابة القديمة.

لا تعيد الدالة النجاح إلا بعد اكتمال COMMIT. ويجب أن تستخدم كل تعليمات المعاملة كائن Client نفسه، وفق متطلبات node-postgres. عند تكييف ذلك مع مجموعة اتصالات، احصل على عميل باستخدام pool.connect()، واستخدمه طوال المعاملة، ثم أطلقه بعد ذلك. node-postgres: Transactions، node-postgres: pg.Pool.

هل ما زلت بحاجة إلى SERIALIZABLE أو SELECT FOR UPDATE؟

بالنسبة إلى هذا التحديث المشروط الضيق لصف واحد، يمكن لمستوى Read Committed فرض شرط المراجعة أيضًا: بعد انتظار تحديث متزامن يجري تثبيته، يعيد PostgreSQL تقييم شرط التحديث مقابل الصف المحدّث. ولا يوسّع ذلك مستوى Read Committed ليضمن قواعد معقدة متعددة الصفوف. PostgreSQL: Transaction Isolation.

أبقِ SERIALIZABLE عندما تعتمد المعاملة المحيطة على قراءات وكتابات أوسع تتطلب ضماناته. يعبّر شرط المراجعة عن المتطلب السابق للمحرر؛ أما مستوى العزل فيحكم التفاعلات بين عمليات قاعدة البيانات ضمن المعاملة. اختر كليهما وفق المهمة الفعلية.

يمكن لـ SELECT FOR UPDATE دعم معاملة تحتاج إلى فحص صف وإجراء عدة عمليات مترابطة. ويمنع قفل الصف الكتّاب المتعارضين حتى تنتهي المعاملة. وفي مستوى Repeatable Read أو Serializable، قد يؤدي قفل صف تغيّر منذ لقطة المعاملة إلى خطأ. PostgreSQL: Explicit Locking.

إن الحصول على هذا القفل عند وصول الحفظ فقط يترك على التطبيق مسؤولية مقارنة المراجعة الأساسية المرسلة. فقفل الصف الحالي لا يخبر قاعدة البيانات بأي مراجعة سابقة استندت إليها المسودة. وبالنسبة إلى استبدال الصف الواحد المعروض هنا، يجمع التحديث المشروط المقارنة والتغيير بالفعل.

قبل دمج هذا النمط، تحقّق من كل المسارات التي تكتب المحتوى، واحتفظ بمسودة المحرر عند الرفض، ولا تحدّث المراجعة الأساسية للمحرر إلا بعد تأكيد الحفظ. عند رفض حفظ قديم، اعرض المقالة الحالية إلى جانب المسودة المحفوظة حتى يتمكن القارئ من التوفيق بين النصين وإرسال حفظ جديد يستند إلى المراجعة التي راجعها فعلًا.

المصادر