لمنع حالات الجمود عند تداخل تحديثات PostgreSQL المتزامنة، اقفل مجموعة الصفوف المستهدفة كاملةً لكل معاملة بالترتيب نفسه وفق مفتاح غير قابل للتغيير قبل التحديث. أبقِ المعاملة على عميل واحد من node-postgres، وأعِد المحاولة من البداية عند الإخفاقات المؤهلة.
هذا الدليل موجّه إلى مطوّر يعمل في Terminal على macOS ويحتاج إلى تحديث دفعتين متزامنتين لصفوف مقالات اصطناعية بالكامل. يستخدم المثال توثيق PostgreSQL الحالي، الذي يحدّد الإصدار PostgreSQL 18، وتوثيق node-postgres الحالي. يقتصر نطاقه على قائمة معروفة من معرّفات المقالات الموجودة، مع بقاء المعرّفات دون تغيير أثناء العمل المتزامن. PostgreSQL: Explicit Locking
لماذا قد تؤدي التحديثات المتداخلة إلى الجمود
لنتأمل معاملتين: تحدّث A المقالة 1 ثم المقالة 2؛ وتحدّث B المقالة 2 ثم المقالة 1. إذا حصلت كل معاملة على قفل صفها الأول قبل طلب قفل الصف الثاني، فستنتظر كل منهما قفلاً تحوزه الأخرى. يكتشف PostgreSQL الحلقة ويلغي إحدى المعاملتين؛ ولا يستطيع التطبيق اختيار أيهما. وقد تؤدي التحديثات العادية إلى هذه الحالة من دون عبارات قفل صريحة. PostgreSQL: Explicit Locking
التمييز المفيد هنا هو بين التداخل والحلقة. قد يستلزم التداخل الانتظار، أما الحلقة فتمنع كلتا المعاملتين من التقدم. يوصي PostgreSQL باتباع ترتيب موحّد لاكتساب الأقفال عبر التطبيقات، مع اكتساب أقوى نمط قفل مطلوب أولاً. PostgreSQL: Explicit Locking
في عبء العمل الذي يتناوله هذا المقال، اتبع قاعدة واحدة: احصل على أقفال كل صفوف المقالات المطلوبة بترتيب تصاعدي وفق id قبل تغيير أي مقالة. طبّق هذه القاعدة على المعاملة بأكملها، بما في ذلك أي عبارات سابقة تتعامل مع المقالات.
اقفل مجموعة الصفوف المستهدفة كاملةً قبل التحديث
إن فرز مصفوفة JavaScript لا يوجّه تحديث SQL جماعي إلى زيارة الصفوف بهذا الترتيب. لا تتضمن صياغة PostgreSQL UPDATE بند ORDER BY مباشراً. يعرض توثيقه طريقة القفل المرتب عبر استعلام تحديد، ويوضح أن توحيد الترتيب قد يمنع حالات الجمود بين عمليات التحديث. PostgreSQL: UPDATE
استخدم عبارة قفل منفصلة، وانتظر اكتمالها داخل المعاملة. تستهدف معاملة SQL التوضيحية هذه المقالات 1 و2 و3؛ ويظهر جدول الإعداد في القسم التالي.
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT id
FROM deadlock_demo_articles
WHERE id = ANY(ARRAY[3, 1, 2]::integer[])
ORDER BY id
FOR UPDATE;
UPDATE deadlock_demo_articles
SET revision = revision + 1
WHERE id = ANY(ARRAY[3, 1, 2]::integer[]);
COMMIT;
تحدّد العبارة الأولى ترتيب اكتساب الأقفال. ثم يعمل التحديث على الصفوف التي أقفلتها المعاملة مسبقاً. تبقى أقفال الصفوف عادةً حتى نهاية المعاملة. اجعل هذه المدة قصيرة: جهّز المدخلات قبل البدء، وتجنّب انتظار إدخال المستخدم عبر سطح المكتب أثناء الاحتفاظ بالأقفال. PostgreSQL: Explicit Locking
تحدّد الشروط التالية عقد الترتيب لهذا المثال:
- تستخدم كل دفعة منافسة الترتيب التصاعدي لمعرّفات المقالات
id. - تبقى المعرّفات دون تغيير أثناء العمل المتزامن.
- تحدد كل دفعة مجموعة أهدافها كاملةً قبل القفل.
- لا تكون أي عبارة سابقة قد أقفلت صفوف المقالات بترتيب مختلف.
- لا يتضمن جدول المثال مشغّلات أو عمليات على جداول ذات صلة تُنشئ تبعيات أقفال إضافية.
هذه شروط مسبقة لتطبيق هذا الاستدلال على عبء العمل. تكون المعاملة التي تقفل المقالة 9 أولاً ثم تطلب المقالات من 1 إلى 3 قد خالفت العقد بالفعل، حتى لو تضمّن استعلامها الأخير ORDER BY id.
شغّل مثالاً كاملاً لـ node-postgres على جهاز Mac
تتمثل المتطلبات المسبقة في خادم PostgreSQL محلي قيد التشغيل، وقاعدة بيانات مؤقتة موجودة، وNode.js مع npm، وإتاحة psql في Terminal. اضبط اتصال قاعدة البيانات الحالية عبر متغيرات بيئة PostgreSQL. تستخدم مجموعات node-postgres وpsql متغيرات الاتصال نفسها؛ واضبط PGHOST صراحةً عند الحاجة كي يصل كلا الأمرين إلى الخادم نفسه. node-postgres: Connecting
في مجلد عمل جديد، ثبّت الاعتمادية:
npm init -y
npm install pg
احفظ إعداد قاعدة البيانات الكامل التالي باسم setup.sql. ينشئ جدولاً توضيحياً جديداً وثلاث مقالات اصطناعية. شغّله مرة واحدة في قاعدة البيانات المؤقتة. يتعمد الإخفاق إذا كان الجدول موجوداً بالفعل بدلاً من استبدال محتوياته الموجودة.
BEGIN;
CREATE TABLE deadlock_demo_articles (
id integer PRIMARY KEY,
title text NOT NULL,
revision integer NOT NULL DEFAULT 0
);
INSERT INTO deadlock_demo_articles (id, title)
VALUES
(1, 'Paper Shapes'),
(2, 'Color Notes'),
(3, 'Desk Sketches');
COMMIT;
psql -v ON_ERROR_STOP=1 -f setup.sql
احفظ البرنامج الكامل التالي باسم update-articles.mjs. تستخدم كل العبارات التابعة لمحاولة ما العميل الذي تعيده pool.connect(). يشترط node-postgres صراحةً استخدام عميل واحد طوال المعاملة؛ ولا يحافظ تمرير عبارات المعاملة عبر pool.query() على هذا الشرط. node-postgres: Transactions
import pg from 'pg';
import { setTimeout as sleep } from 'node:timers/promises';
const { Pool } = pg;
const pool = new Pool({ max: 2, connectionTimeoutMillis: 5000 });
pool.on('error', error => {
console.error('Idle database client error:', error.message);
process.exitCode = 1;
});
function normalizeIds(input) {
if (!Array.isArray(input) || input.length === 0 ||
input.some(id => !Number.isInteger(id) ||
id < 1 || id > 2147483647)) {
throw new Error('Use a nonempty array of positive PostgreSQL integer IDs');
}
return [...new Set(input)].sort((a, b) => a - b);
}
async function incrementRevisions(input) {
const ids = normalizeIds(input);
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const client = await pool.connect();
let discard = false;
try {
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
const locked = await client.query(
`SELECT id
FROM deadlock_demo_articles
WHERE id = ANY($1::integer[])
ORDER BY id
FOR UPDATE`,
[ids]
);
if (locked.rows.length !== ids.length) {
throw new Error('A requested article is missing; batch cancelled');
}
const updated = await client.query(
`UPDATE deadlock_demo_articles
SET revision = revision + 1
WHERE id = ANY($1::integer[])
RETURNING id, revision`,
[ids]
);
if (updated.rowCount !== ids.length) {
throw new Error('Unexpected update count; batch cancelled');
}
await client.query('COMMIT');
return updated.rows.sort((a, b) => a.id - b.id);
} catch (error) {
try {
await client.query('ROLLBACK');
} catch (rollbackError) {
discard = true;
throw new Error('Rollback failed; connection discarded', {
cause: rollbackError
});
}
const retryable = error.code === '40P01' || error.code === '40001';
if (!retryable || attempt === maxAttempts) throw error;
} finally {
client.release(discard);
}
// Illustrative bounded backoff, after releasing the client.
await sleep(50 * 2 ** (attempt - 1) + Math.floor(Math.random() * 50));
}
}
try {
const outcomes = await Promise.allSettled([
incrementRevisions([3, 1, 2]),
incrementRevisions([3, 2])
]);
for (const outcome of outcomes) {
if (outcome.status === 'rejected') throw outcome.reason;
}
const result = await pool.query(
'SELECT id, title, revision FROM deadlock_demo_articles ORDER BY id'
);
console.table(result.rows);
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
await pool.end();
}
node update-articles.mjs
تُمرّر المصفوفة كمعامل واحد وتُحوّل صراحةً إلى النوع integer[]؛ ويوثّق node-postgres تحويل مصفوفات JavaScript إلى قيم مصفوفات PostgreSQL. يضمن إزالة التكرارات أن يعني العدد المطلوب عدد المقالات المتميزة. ويحدّد ORDER BY في قاعدة البيانات ترتيب اكتساب الأقفال، لا ترتيب مصفوفة الإدخال. node-postgres: Queries
عند استخدام جدول أُعدّ حديثاً، ومن دون كتّاب آخرين، ومع نجاح الدفعتين، تكون المراجعات المتوقعة 1 للمقالة 1 و2 للمقالتين 2 و3. تنتج هذه القيم عن عضوية الدفعتين، بغض النظر عن أيهما ينتهي أولاً. تزيد مرات تشغيل البرنامج اللاحقة القيم الموجودة مجدداً. يؤدي غياب أي معرّف مطلوب إلى إلغاء تلك الدفعة بدلاً من تحديث الصفوف التي عُثر عليها فقط بصمت.
كل دفعة معاملة منفصلة. إذا نجحت إحداهما وأخفقت الأخرى نهائياً، تظل الدفعة الناجحة ملتزماً بها. يتيح Promise.allSettled() اكتمال كلتا المحاولتين قبل أن يغلق البرنامج المجموعة. وتُنفّذ عبارة pool.query() الأخيرة خارج أيٍّ من المعاملتين. توثّق واجهة المجموعة كيفية تحرير العملاء المسحوبين منها، وإتلاف عميل عند الحاجة، وإنهاء المجموعة بعد اكتمال عمل البرنامج النصي. node-postgres: Pool
نُفّذت فحوصات التطبيع وحسابات المراجعات التوضيحية باستخدام Node.js. لم يكن PostgreSQL متاحاً في بيئة البحث، لذا جرت مراجعة SQL وسلوك التزامن في قاعدة البيانات بالرجوع إلى التوثيق بدلاً من تنفيذهما.
حافظ على مستوى العزل الذي تتطلبه مهمتك
يستخدم البرنامج READ COMMITTED صراحةً لتحديثات المعرّفات الثابتة. في هذا المستوى، قد تنتظر عبارة القفل مُحدِّثاً ثم تقفل إصدار الصف المحدّث، شريطة أن يظل مطابقاً للشرط. يأخذ كل استعلام لقطة جديدة. لذا قد لا يكون الصف الذي حُذف قبل اكتساب القفل موجوداً؛ ويتعامل البرنامج مع ذلك عبر فحص العدد. PostgreSQL: Transaction Isolation
لا تخفّض مستوى عزل معاملة موجودة لمجرد استخدام هذا المثال. في ظل REPEATABLE READ، قد تؤدي محاولة قفل صف تغيّر منذ لقطة المعاملة أو تحديثه إلى إخفاق تسلسلي. يتطلب SERIALIZABLE أيضاً معالجة إعادة المحاولة، ويمنع حالات الشذوذ التسلسلية عند الالتزام؛ لكنه لا يغني عن توحيد ترتيب أقفال الصفوف العادية. PostgreSQL: Transaction Isolation
لا يضمن قفل الصفوف المعروفة بقاء عضوية شرط ما دون تغيير. على سبيل المثال، لا ينفذ هذا المثال مهمة «تحديث كل مقالة يطابق عنوانها شرطاً معيناً، بما في ذلك الإضافات المتزامنة». فهذه مهمة اتساق مختلفة. حدّد متطلبات العزل والتنسيق الخاصة بها قبل تكييف الوصفة ذات المعرّفات الثابتة.
كذلك، أبقِ عمود الترتيب غير قابل للتغيير. يوثّق PostgreSQL أنه في ظل READ COMMITTED، يحدث الفرز قبل القفل؛ وقد تجعل القيم التي تتغير أثناء انتظار العبارة الصفوف المُعادة تبدو غير مرتبة. يجنب استخدام المعرّفات التي لا تتغير هذه المضاعفة المحددة. PostgreSQL: SELECT
أعِد محاولة المعاملات كاملةً وتقصَّ الإخفاقات المتكررة
يحدّد PostgreSQL إخفاقات الجمود بالرمز SQLSTATE 40P01، وإخفاقات التسلسل بالرمز 40001. أعد محاولة المعاملة كاملةً، بما يشمل أي قرارات تعتمد على قاعدة البيانات وتحدد عبارات المعاملة أو قيمها. إعادة محاولة التحديث الأخير وحده لا تحقق هذا الشرط. PostgreSQL: Serialization Failure Handling
يسمح المثال بثلاث محاولات إجمالاً. اختير التأخير الأسي القصير مع تفاوت عشوائي كخيارات توضيحية للتطبيق، وليسا من الإعدادات الافتراضية لـ PostgreSQL. يحرر المثال العميل قبل التأخير، ويتوقف فوراً عند الأخطاء الأخرى، ويبلّغ عن استنفاد المحاولات. ويحذّر PostgreSQL من أن إعادة المحاولة لا تضمن النجاح في نهاية المطاف. PostgreSQL: Serialization Failure Handling
استخدم دليل القرار هذا عند تكييف التنفيذ:
| الحالة | الإجراء |
|---|---|
| دفعة أخرى تحوز بالفعل أحد الصفوف المطلوبة | اسمح باكتساب الأقفال بالترتيب أن ينتظر إذا كانت المهمة تتطلب كل الصفوف المطلوبة. |
| أحد المعرّفات المطلوبة غير موجود | ألغِ المعاملة وأبلغ عن حالة الهدف المفقود. |
رمز SQLSTATE هو 40P01 أو 40001 |
ألغِ المعاملة، ثم أعد محاولة المعاملة المؤهلة كاملةً ضمن حدّ أقصى. |
| تستمر الإخفاقات بعد القفل المرتب | افحص العبارات السابقة والكتّاب الآخرين والمشغّلات والجداول الإضافية بحثاً عن مخالفات لعقد الترتيب. |
| تتعامل العملية مع عدة جداول | حدّد ترتيباً موحّداً للجداول، إلى جانب ترتيب الصفوف، لكل المسارات المتنافسة. |
| يجب أن تُعتمد الدفعة كوحدة ذرية | أبقِها في معاملة واحدة؛ فالدفعات التي تُعتمد كل منها على حدة ستغيّر طبيعة المهمة. |
تجنّب إضافة SKIP LOCKED إلى هذه الدفعة التي تستهدف جميع الصفوف، لأنه يستبعد الصفوف التي لا يمكن قفلها فوراً. يصفه PostgreSQL بأنه مفيد للمستهلكين الذين يعملون بأسلوب الطوابير، وغير مناسب لعرض عام متسق. يُبلغ NOWAIT عن خطأ بدلاً من انتظار قفل صف متعارض؛ وهو يغيّر سلوك الانتظار لكنه لا يُكمل الدفعة. PostgreSQL: SELECT
يكون التنفيذ جاهزاً للتكييف عندما تستطيع تسمية مجموعة الأهداف كاملةً، والإبقاء على مفاتيح ترتيبها دون تغيير، وتطبيق قاعدة الاكتساب نفسها عبر المعاملات المتنافسة. أبقِ على فحص الصفوف المفقودة وإعادة المحاولة المحدودة للمعاملة كاملةً، كي يؤدي التنافس أو نقص المدخلات إلى نتيجة صريحة.
المصادر
PostgreSQL 18: Explicit Locking — مجموعة تطوير PostgreSQL.
PostgreSQL 18: UPDATE — مجموعة تطوير PostgreSQL.
PostgreSQL 18: SELECT — مجموعة تطوير PostgreSQL.
PostgreSQL 18: Transaction Isolation — مجموعة تطوير PostgreSQL.
PostgreSQL 18: Serialization Failure Handling — مجموعة تطوير PostgreSQL.
Transactions — node-postgres.
Connecting — node-postgres.
Queries — node-postgres.
Pool — node-postgres.