امنع التكرارات الناتجة عن فحص ثم إدراج متزامنين باستخدام قيد فريد على المفتاح المنطقي للمقالة وINSERT ... ON CONFLICT. وعندما تحتاج أيضًا إلى الصف الموجود، استخدم استراتيجية استرجاع تتوافق مع مستوى عزل المعاملة.

هذا البرنامج التعليمي موجّه إلى مطوّر يعمل في Terminal على macOS ويحتاج إلى دالة في Node.js تنشئ مقالة اصطناعية مرة واحدة وتعيد صفها إلى المستدعين المتزامنين. تحافظ الأمثلة على عنوان المقالة الأولى. وهي تستند إلى وثائق PostgreSQL الحالية ووثائق node-postgres الرسمية.

لماذا لا تمنع المعاملة وحدها التكرارات

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

يوضح هذا الجدول الزمني التوضيحي حالة التنافس على مستوى التطبيق:

الخطوة الاستدعاء A الاستدعاء B
1 يتحقق من paper-shapes: غير موجود
2 يتحقق من paper-shapes: غير موجود
3 يُدرج المقالة
4 يحاول إدراج الاسم اللطيف نفسه
5 يعتمد المعاملة يحاول اعتماد المعاملة

من دون قيد فريد على الاسم اللطيف، يمكن لعمليتي الإدراج إنشاء صفوف منفصلة. ولا يعبّر المفتاح الأساسي على معرّف يُنشأ تلقائيًا عن قاعدة وجوب تفرد الأسماء اللطيفة. يدعم PostgreSQL القيود الفريدة على عمود واحد أو مجموعة من الأعمدة. PostgreSQL: Constraints

إن إحاطة التحقق والإدراج بـBEGIN وCOMMIT لا تحجز الاسم اللطيف غير الموجود. وإضافة FOR UPDATE إلى عملية بحث لا تعيد أي صف لا تحل المشكلة أيضًا: فهي تقفل الصفوف المسترجعة، وبالتالي لا توجد مقالة غير موجودة لقفلها. وهذا يتوافق مع سلوك قفل الصفوف الموثق في PostgreSQL. PostgreSQL: Explicit Locking

حدّد بدقة ما يُعدّ تكرارًا

في هذا المثال، يحدد الاسم اللطيف المطابق تمامًا مقالة واحدة. العنوان محتوى، لذا ينبغي أن يعيد الطلب الثاني بالاسم اللطيف نفسه المقالة المخزنة بدلًا من استبدال عنوانها.

ابدأ بتثبيت محلي موجود لـPostgreSQL وأدوات سطر الأوامر الخاصة به وNode.js مع npm. في Terminal على macOS، أنشئ قاعدة بيانات جديدة للشرح وافتح موجه SQL الخاص بها:

createdb article_concurrency_demo
psql -d article_concurrency_demo

نفّذ هذا المخطط مرة واحدة في قاعدة البيانات الجديدة:

CREATE TABLE articles (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    slug text NOT NULL,
    title text NOT NULL,
    CONSTRAINT articles_slug_key UNIQUE (slug)
);

يفرض القيد المنفصل UNIQUE (slug) قاعدة المقالة. كما أن NOT NULL مهم: فالقيود الفريدة العادية تسمح افتراضيًا بقيم فارغة متعددة. وينشئ PostgreSQL تلقائيًا فهرسًا فريدًا لهذا القيد. PostgreSQL: Constraints

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

SELECT slug, count(*) AS row_count
FROM articles
GROUP BY slug
HAVING count(*) > 1;

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

اختر سلوك التعارض المطلوب

التعارض فرع طبيعي من هذه المهمة. حدّد معناه قبل اختيار SQL. يدعم PostgreSQL تخطي الإدراج المتعارض أو تحديث الصف الموجود؛ ويُرجع RETURNING الصفوف التي أُدرجت أو حُدّثت بالفعل. PostgreSQL: INSERT

النتيجة المطلوبة النهج النتيجة المُعادة
أنشئ إذا لم تكن موجودة؛ وإلا فتخطَّ ON CONFLICT (slug) DO NOTHING مع RETURNING، لا يعيد الإدراج المتخطى أي صف
أنشئ إذا لم تكن موجودة؛ وإلا فاستبدل العنوان ON CONFLICT (slug) DO UPDATE مع RETURNING، الصف المُدرج أو المُحدّث
أنشئ إذا لم تكن موجودة؛ وإلا فاحتفظ بها واسترجعها DO NOTHING، ثم عملية بحث منفصلة يُنفّذ في القسم التالي

لعملية إدراج فقط، يعمل بيان SQL الكامل هذا مع مخطط الشرح:

INSERT INTO articles (slug, title)
VALUES ('paper-shapes', 'Paper Shapes')
ON CONFLICT (slug) DO NOTHING
RETURNING id, slug, title;

إذا كان السلوك المقصود هو استبدال العنوان، فاستخدم هذا البيان البديل:

INSERT INTO articles (slug, title)
VALUES ('paper-shapes', 'Paper Shapes — Revised')
ON CONFLICT (slug) DO UPDATE
SET title = EXCLUDED.title
RETURNING id, slug, title;

هنا، تشير EXCLUDED.title إلى العنوان المقترح. يوفّر DO UPDATE غير المشروط نتيجة ذرية للإدراج أو التحديث وفقًا للتوثيق، مع مراعاة الأخطاء المستقلة ومعالجة حالات الفشل حسب مستوى العزل. أما التحديث المشروط الذي يرفض شرط WHERE الخاص به التحديث، فقد لا يعيد أي شيء. اختر التحديث عندما يكون تغيير المقالة المخزنة مناسبًا. PostgreSQL: INSERT

نفّذ الإنشاء أو إعادة الموجود عند مستوى Read Committed

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

اخرج من موجه SQL باستخدام \q. وفي مجلد عمل جديد، ثبّت اعتماد Node.js:

mkdir article-concurrency-demo
cd article-concurrency-demo
npm init -y
npm install pg

احفظ البرنامج الكامل التالي باسم demo.mjs. يفترض وجود قاعدة البيانات والجدول اللذين أنشأتهما أعلاه. يستخدم مجمّع الاتصالات اسم قاعدة البيانات وإعدادات الاتصال القياسية المتبقية؛ ويدعم node-postgres متغيرات بيئة اتصال PostgreSQL عندما تختلف الإعدادات المحلية. node-postgres: Connecting

import pg from 'pg';
import assert from 'node:assert/strict';

const { Pool } = pg;
const pool = new Pool({
  database: 'article_concurrency_demo',
  max: 8
});

async function getOrCreateArticle(slug, title) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');

    for (let attempt = 0; attempt < 5; attempt += 1) {
      const inserted = await client.query(`
        INSERT INTO articles (slug, title)
        VALUES ($1, $2)
        ON CONFLICT (slug) DO NOTHING
        RETURNING id, slug, title
      `, [slug, title]);

      if (inserted.rows.length === 1) {
        await client.query('COMMIT');
        return inserted.rows[0];
      }

      const existing = await client.query(`
        SELECT id, slug, title
        FROM articles
        WHERE slug = $1
        FOR UPDATE
      `, [slug]);

      if (existing.rows.length === 1) {
        await client.query('COMMIT');
        return existing.rows[0];
      }
    }

    throw new Error('Article kept disappearing; retry limit reached');
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

try {
  const results = await Promise.allSettled(
    Array.from({ length: 8 }, () =>
      getOrCreateArticle('paper-shapes', 'Paper Shapes'))
  );

  const failures = results.filter(result => result.status === 'rejected');
  if (failures.length) {
    throw new AggregateError(failures.map(result => result.reason));
  }

  const rows = results.map(result => result.value);
  assert.equal(new Set(rows.map(row => row.id)).size, 1);

  const count = await pool.query(
    'SELECT count(*)::integer AS n FROM articles WHERE slug = $1',
    ['paper-shapes']
  );
  assert.equal(count.rows[0].n, 1);
  console.log({ calls: rows.length, matchingRows: count.rows[0].n });
} finally {
  await pool.end();
}

شغّله من ذلك المجلد:

node demo.mjs

مع الإعداد المذكور ومن دون أي عملية أخرى تغيّر صفوف الشرح، يكون الناتج النهائي المتوقع:

{ calls: 8, matchingRows: 1 }

جرى التحقق من صياغة JavaScript في البرنامج باستخدام Node.js. لم يُنفّذ شرح قاعدة البيانات هنا لعدم توفر أدوات PostgreSQL؛ والناتج أعلاه نتيجة متوقعة.

تستخدم كل معاملة عميلًا واحدًا مستعارًا لكل العبارات. ويشترط node-postgres ذلك صراحةً: إذ قد يؤدي إصدار عبارات المعاملة عبر pool.query إلى إرسالها إلى عملاء مختلفين. ويستخدم استعلام التحقق pool.query بعد انتهاء المعاملات فقط. node-postgres: Transactions

يُمرّر نص الاستعلام والقيم كلٌّ على حدة باستخدام $1 و$2، وفقًا لواجهة الاستعلامات ذات المعاملات في برنامج التشغيل. node-postgres: Queries

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

بعد استرجاع الصف، يمنع FOR UPDATE المعاملات الأخرى من تغييره أو حذفه إلى أن تنتهي هذه المعاملة. وهو يحمي نتيجة موجودة؛ أما القيد الفريد فيعالج عمليات الإدراج المتنافسة. وبعد الاعتماد، يمكن لمعاملة أخرى تغيير الصف مجددًا. إذا كان عمل قاعدة البيانات التابع يحتاج إلى استخدام المقالة المقفلة، فضع ذلك العمل قبل COMMIT. PostgreSQL: Explicit Locking

التزم بحدود مستويات العزل

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

في حالتي Repeatable Read أو Serializable، كن مستعدًا لإعادة المحاولة الكاملة للمعاملة بعد SQLSTATE 40001، بما في ذلك قرارات التطبيق التي أصدرت عباراتها. لا تكفي إعادة آخر استعلام وحده. ويحدد PostgreSQL أيضًا 40P01 على أنه خطأ تعارض أقفال قد يكون قابلًا لإعادة المحاولة. PostgreSQL: Serialization Failure Handling

لا تتعامل تلقائيًا مع كل انتهاك للقيد الفريد 23505 على أنه عابر. فقد يمثّل تعارضًا دائمًا. وينبغي لسياسة إعادة المحاولة أن تحدد الحالة المتوقعة، وتضع حدًا للمحاولات، وتبلّغ عن استنفادها؛ فـPostgreSQL لا يضمن نجاح المحاولات في نهاية المطاف. يعالج الشرح تعارض الاسم اللطيف المتوقع باستخدام ON CONFLICT، بينما تمر الأخطاء الأخرى كما هي. PostgreSQL: Serialization Failure Handling

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

المصادر