يضيف المطور عموداً جديداً إلى جدول الطلبات في نشر عادي بعد الظهر، والأمر ALTER TABLE orders ADD COLUMN note text ينتهي على جهازه في ملي ثانيتين، ولكن في الإنتاج يتوقف الموقع كله فجأة، وكل صفحة تقرأ من جدول الطلبات تنتظر، ثم يعود كل شيء بعد عشر ثوان دون أن يفهم أحد السبب. والسبب ليس في العمود الجديد، وإنما في الأقفال Locks: الأمر احتاج قفلاً لا يتفق مع أي قفل آخر، فانتظر تقريراً طويلاً كان يقرأ الجدول، وكل استعلام جاء بعده وقف في الطابور خلفه.
وفي مقال التخزين من الداخل رأينا أن النسخ المتعددة MVCC تجعل القارئ لا ينتظر الكاتب، فلماذا توجد الأقفال أصلاً؟ لأن النسخ لا تحل كل شيء: كاتبان على الصف نفسه يجب أن ينتظر أحدهما الآخر، وتغيير شكل الجدول لا يمكن أن يحدث وأحد يقرأ منه. وقد شرحنا مستويات العزل Isolation Levels بتجارب على جلستين في الجزء الثالث من سلسلة تعلم SQL، والتحديث المفقود Lost Update في مقال المحفظة الرقمية، والقفل المتشائم FOR UPDATE في نظام حجز التذاكر، فلن نكررها هنا، وسوف نركز على ما لم نشرحه.
دليلتعلم SQL (3): CTE ودوال النوافذ والمعاملاتكيف تكتب استعلامات أوضح بجمل WITH ودوال النوافذ، وتحمي بياناتك بالمعاملات ومستويات العزل ومن حقن SQL.
دليلالمحفظة الرقمية من الداخل: دفتر القيد المزدوج Ledgerكيف تبني محفظة رقمية لا يضيع فيها ريال ولا يخصم مرتين، بالقيد المزدوج والمعاملات والمطابقة مع مزود الدفع.
دليلكيف تبني نظام حجز تذاكر يتحمل لحظة فتح البيعكيف تمنع بيع المقعد الواحد مرتين، وكيف يصمد نظام الحجز عندما يدخل الجميع في الدقيقة نفسها لشراء التذاكر.وسوف نناقش في هذا المقال ما يلي:
- أقفال الجداول وأقفال الصفوف، ومن يتعارض مع من.
- طابور الأقفال Lock Queue: كيف يوقف أمر ALTER TABLE ينتظر كل الاستعلامات خلفه، بتجربة تحت حمل حقيقي.
- الحل:
lock_timeoutوإعادة المحاولة، والتجربة نفسها بعد الحل. - قراءة
pg_locksوpg_blocking_pidsلتعرف من يقفل من. - أقفال الصفوف الأربعة، ولماذا لا يمنع إدخال طلب تعديل اسم العميل لكنه يمنع حذفه.
- الجمود Deadlock: نسببه عمداً، ونقرأ رسالته، ونعرف كيف يكتشفه PostgreSQL.
- ظواهر العزل Anomalies التي لم نشرحها سابقاً: انحراف القراءة Read Skew، وإعادة التقييم في Read Committed، ولماذا لا توجد قراءة قذرة Dirty Read في PostgreSQL.
- الأقفال الاستشارية Advisory Locks لمهمة يجب أن تعمل مرة واحدة.
تجهيز المختبر
سيرفر PostgreSQL 18.6 واحد، مع تفعيل تسجيل انتظار الأقفال log_lock_waits، وتقليل deadlock_timeout إلى ثانية، وهي القيمة الافتراضية أصلاً، وكتبناها صراحة لأننا سوف نعتمد عليها:
name: lab-lock
services:
pg:
image: postgres:18.6
container_name: lab-lock-pg
environment:
POSTGRES_PASSWORD: lab-only-password
POSTGRES_DB: shop
command: >
postgres -c log_lock_waits=on -c deadlock_timeout=1s -c log_line_prefix='%m [%p] '
ports:
- "127.0.0.1:15461:5432"
volumes:
- lab-lock-data:/var/lib/postgresql
volumes:
lab-lock-data:والجداول: عملاء وطلبات بمفتاح أجنبي Foreign Key، وجدول حسابين، وجدول صغير للزيارات سوف نحتاجه في قسم العزل:
CREATE EXTENSION IF NOT EXISTS pgrowlocks;
CREATE TABLE customers (id int PRIMARY KEY, name text NOT NULL, city text NOT NULL);
CREATE TABLE orders (
id bigserial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers (id),
total numeric(10,2) NOT NULL,
status text NOT NULL DEFAULT 'new',
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO customers SELECT g, 'customer ' || g, (ARRAY['Riyadh','Jeddah','Dammam','Abha'])[1 + g % 4] FROM generate_series(1, 1000) g;
INSERT INTO orders (customer_id, total) SELECT 1 + g % 1000, (g % 500) + 0.99 FROM generate_series(1, 200000) g;
CREATE INDEX ON orders (customer_id);
CREATE TABLE accounts (id int PRIMARY KEY, owner text NOT NULL, balance bigint NOT NULL);
INSERT INTO accounts VALUES (1, 'Sara', 1000), (2, 'Khalid', 1000);
CREATE TABLE website (id int PRIMARY KEY, hits int NOT NULL);
INSERT INTO website VALUES (1, 9), (2, 10);
VACUUM ANALYZE;ولتشغيل جلستين بالترتيب نستخدم السكربت sessions.sh الذي شرحناه في مقال التخزين: ملف خطوات كل سطر فيه A: أو B: ثم جملة SQL، وفي المخرج السطر A> هو ما أرسلناه والأسطر A| هي الرد، وإذا لم يظهر رد بعد خطوة فالجلسة تنتظر قفلاً. ولاحظ أن السكربت يشغل psql بالخيار -q، فلا تظهر رسائل مثل BEGIN وCOMMIT وUPDATE 1، وإنما تظهر النتائج والأخطاء فقط. والملفات كلها في مجلد lab مع المقال.
أقفال الجداول: من يتعارض مع من؟
كل أمر في PostgreSQL يأخذ قفلاً على كل جدول يلمسه، حتى SELECT العادي، والقفل يبقى حتى نهاية المعاملة وليس حتى نهاية الأمر. وهناك ثمانية أنواع من أقفال الجداول، والمهم فيها ليس أسماؤها وإنما من يتعارض مع من، والجدول التالي يلخص الأوامر التي تقابلها كل يوم من توثيق الأقفال الصريحة:
| القفل | من يأخذه | يتعارض مع |
|---|---|---|
| ACCESS SHARE | SELECT | ACCESS EXCLUSIVE فقط |
| ROW SHARE | SELECT … FOR UPDATE / FOR SHARE | EXCLUSIVE وACCESS EXCLUSIVE |
| ROW EXCLUSIVE | INSERT وUPDATE وDELETE وMERGE | SHARE وما فوقه |
| SHARE UPDATE EXCLUSIVE | VACUUM وANALYZE وCREATE INDEX CONCURRENTLY | نفسه وما فوقه، فلا يمنع القراءة ولا الكتابة |
| SHARE | CREATE INDEX دون CONCURRENTLY | كل كتابة على الجدول |
| SHARE ROW EXCLUSIVE | CREATE TRIGGER، وADD FOREIGN KEY على الجدولين | كل كتابة، ونفسه |
| EXCLUSIVE | REFRESH MATERIALIZED VIEW CONCURRENTLY | كل شيء ما عدا القراءة |
| ACCESS EXCLUSIVE | أغلب صيغ ALTER TABLE، وDROP وTRUNCATE وVACUUM FULL | كل شيء، حتى SELECT |
والملاحظة الأهم في الجدول أن أغلب صيغ ALTER TABLE تأخذ ACCESS EXCLUSIVE، حتى إضافة عمود بلا قيمة افتراضية، وهي عملية تنتهي في ملي ثانية لأنها تغير الوصف فقط ولا تعيد كتابة الجدول. وقد يتساءل البعض: إذا كانت تنتهي في ملي ثانية، فأين المشكلة؟ والإجابة في الطابور، والصورة التالية تبين النوعين الأكثر حدوثاً:

طابور الأقفال: كيف يوقف ALTER TABLE كل شيء
الأقفال في PostgreSQL تمنح بالترتيب، فإذا كان أمر ينتظر قفلاً، فكل من يأتي بعده ويطلب قفلاً يتعارض مع القفل المنتظر يقف خلفه في الطابور، حتى لو كان قفله يتفق مع القفل الممسوك حالياً. والنتيجة أن SELECT الذي لا يتعارض مع التقرير الطويل ينتظر لأنه يتعارض مع ALTER TABLE الذي ينتظر التقرير. ولكي نرى ذلك تحت حمل حقيقي، نشغل من Container آخر أربعة عملاء بالأداة pgbench يقرؤون طلبات عميل عشوائي طوال 24 ثانية، ويطبعون عدد الاستعلامات في كل ثانية:
\set c random(1, 1000)
SELECT count(*), sum(total) FROM orders WHERE customer_id = :c;وبعد أربع ثوان نبدأ تقريراً يقرأ الجدول داخل معاملة تبقى مفتوحة 12 ثانية، وبعد ثانيتين نشغل الترحيل Migration:
docker run --rm --name lab-lock-load --network lab-lock_default -e PGPASSWORD=lab-only-password \
-v "$PWD:/lab:ro" postgres:18.6 pgbench -h pg -U postgres -n -c 4 -T 24 -P 1 -f /lab/read-order.sql shop &
sleep 4
docker exec lab-lock-pg psql -U postgres -d shop -c "BEGIN; SELECT count(*) FROM orders; SELECT pg_sleep(12); COMMIT;" &
sleep 2
docker exec lab-lock-pg psql -U postgres -d shop -c "\timing on" -c "ALTER TABLE orders ADD COLUMN note text;" &
sleep 3
docker exec -i lab-lock-pg psql -U postgres -d shop < who-blocks.sqlوهذا ما طبعته pgbench، وقد حذفنا الأسطر الأخيرة من الملخص:
progress: 1.0 s, 2123.2 tps, lat 1.643 ms stddev 3.570, 0 failed
progress: 2.0 s, 2628.4 tps, lat 1.492 ms stddev 3.731, 0 failed
progress: 3.0 s, 4293.3 tps, lat 0.917 ms stddev 2.526, 0 failed
progress: 4.0 s, 5355.1 tps, lat 0.697 ms stddev 1.557, 0 failed
progress: 5.0 s, 2874.3 tps, lat 1.342 ms stddev 2.481, 0 failed
progress: 6.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 7.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 8.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 9.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 10.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 11.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 12.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 13.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 14.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 15.0 s, 713.7 tps, lat 56.268 ms stddev 725.861, 0 failed
progress: 16.0 s, 2708.9 tps, lat 1.441 ms stddev 3.660, 0 failed
progress: 17.0 s, 2230.5 tps, lat 1.724 ms stddev 4.637, 0 failed
progress: 18.0 s, 2306.6 tps, lat 1.649 ms stddev 4.418, 0 failed
progress: 19.0 s, 2434.6 tps, lat 1.651 ms stddev 4.220, 0 failed
progress: 20.0 s, 2440.9 tps, lat 1.595 ms stddev 4.294, 0 failed
progress: 21.0 s, 2421.9 tps, lat 1.610 ms stddev 4.095, 0 failed
progress: 22.0 s, 3852.2 tps, lat 1.008 ms stddev 2.102, 0 failed
progress: 23.0 s, 3957.0 tps, lat 0.968 ms stddev 1.564, 0 failed
progress: 24.0 s, 2624.1 tps, lat 1.473 ms stddev 2.892, 0 failedمن الثانية السادسة إلى الرابعة عشرة صفر استعلام، أي أن قراءة طلبات العملاء توقفت تماماً تسع ثوان، والأمر ALTER TABLE نفسه انتهى بعد أن انتظر:
Timing is on.
ALTER TABLE
Time: 9717.730 ms (00:09.718)من يقفل من؟ pg_stat_activity وpg_locks
أثناء التوقف شغلنا الاستعلام التالي، وهو أول ما تشغله عندما يقول لك أحد إن قاعدة البيانات «معلقة»:
SELECT pid, state, wait_event_type AS wait, pg_blocking_pids(pid) AS blocked_by,
round(extract(epoch FROM now() - query_start)::numeric, 1) AS secs, left(query, 52) AS query
FROM pg_stat_activity
WHERE datname = 'shop' AND pid <> pg_backend_pid() AND state <> 'idle'
ORDER BY query_start; pid | state | wait | blocked_by | secs | query
-----+--------+---------+------------+------+------------------------------------------------------
100 | active | Timeout | {} | 4.6 | BEGIN; SELECT count(*) FROM orders; SELECT pg_sleep(
108 | active | Lock | {100} | 2.3 | ALTER TABLE orders ADD COLUMN note text;
90 | active | Lock | {108} | 2.3 | SELECT count(*), sum(total) FROM orders WHERE custom
92 | active | Lock | {108} | 2.3 | SELECT count(*), sum(total) FROM orders WHERE custom
91 | active | Lock | {108} | 2.3 | SELECT count(*), sum(total) FROM orders WHERE custom
93 | active | Lock | {108} | 2.3 | SELECT count(*), sum(total) FROM orders WHERE custom
(6 rows)اقرأ العمود blocked_by كسلسلة: الاستعلامات الأربعة من pgbench تنتظرها الجلسة 108، أي ALTER TABLE، وليس التقرير، و108 تنتظر 100، أي التقرير. والدالة pg_blocking_pids تعيد من يمنع الجلسة سواء كان يمسك القفل أو كان قبلها في الطابور. والتفصيل في العرض pg_locks:
SELECT l.pid, l.mode, l.granted, left(a.query, 45) AS query
FROM pg_locks l JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'relation' AND l.relation = 'orders'::regclass
ORDER BY l.granted DESC, a.query_start; pid | mode | granted | query
-----+---------------------+---------+-----------------------------------------------
100 | AccessShareLock | t | BEGIN; SELECT count(*) FROM orders; SELECT pg
108 | AccessExclusiveLock | f | ALTER TABLE orders ADD COLUMN note text;
90 | AccessShareLock | f | SELECT count(*), sum(total) FROM orders WHERE
92 | AccessShareLock | f | SELECT count(*), sum(total) FROM orders WHERE
91 | AccessShareLock | f | SELECT count(*), sum(total) FROM orders WHERE
93 | AccessShareLock | f | SELECT count(*), sum(total) FROM orders WHERE
(6 rows)في المخرج أعلاه لاحظ التالي:
- التقرير 100 يمسك
AccessShareLockوgranted = t. - الاستعلامات الأربعة تطلب
AccessShareLockأيضاً، وهو يتفق تماماً مع قفل التقرير، ومع ذلكgranted = f، لأن أمامها في الطابور ALTER TABLE الذي يطلبAccessExclusiveLock. - كل من يأتي بعد ذلك سوف ينضم إلى الطابور، ومجمع الاتصالات Pool في التطبيق يمتلئ بطلبات تنتظر، ثم تبدأ الطلبات بالفشل بانتهاء المهلة، كما رأينا في دليل الطلب البطيء.
وسجل السيرفر يقول الشيء نفسه بفضل log_lock_waits، فبعد ثانية من الانتظار، وهي قيمة deadlock_timeout، كتب سطراً لكل جلسة تنتظر مع الطابور كاملاً:
2026-10-11 05:20:41.755 UTC [108] LOG: process 108 still waiting for AccessExclusiveLock on relation 16398 of database 16384 after 1000.137 ms
2026-10-11 05:20:41.755 UTC [108] DETAIL: Process holding the lock: 100. Wait queue: 108, 90, 92, 91, 93.
2026-10-11 05:20:41.755 UTC [108] STATEMENT: ALTER TABLE orders ADD COLUMN note text;
2026-10-11 05:20:41.755 UTC [90] LOG: process 90 still waiting for AccessShareLock on relation 16398 of database 16384 after 1000.090 ms at character 34
2026-10-11 05:20:41.755 UTC [90] DETAIL: Process holding the lock: 100. Wait queue: 108, 90, 92, 91, 93.
2026-10-11 05:20:41.755 UTC [90] STATEMENT: SELECT count(*), sum(total) FROM orders WHERE customer_id = 471;والصورة التالية تبين الطابور والتوقف في الحمل:

الحل: lock_timeout وإعادة المحاولة
الحل البسيط أن تقول «سوف ننشر الترحيل في الليل»، وهو لا يحل شيئاً، لأن مهمة خلفية أو تقريراً أو جلسة نسيها أحد مفتوحة قد يكون موجوداً في أي ساعة. والحل الصحيح أن تقبل أن الترحيل قد لا يحصل على القفل، وأن تجعله يستسلم بسرعة بدل أن يقف في الطابور، ثم يحاول مرة أخرى. والإعداد lock_timeout يلغي الأمر إذا انتظر قفلاً أطول من المدة المحددة، وهذا سكربت صغير يطبق ذلك:
#!/bin/bash
# Runs one migration statement with a short lock_timeout and retries it, so it never queues traffic for long.
# docker exec lab-lock-pg bash /tmp/migrate.sh "ALTER TABLE orders ADD COLUMN note2 text"
for try in 1 2 3 4 5 6 7 8 9 10; do
if psql -U postgres -d shop -X -q -v ON_ERROR_STOP=1 \
-c "SET lock_timeout = '500ms'" -c "$1" 2>/tmp/migrate.err; then
echo "$(date +%T) try $try: done"; exit 0
fi
echo "$(date +%T) try $try: $(grep -o 'ERROR.*' /tmp/migrate.err)"
sleep $((try < 5 ? try : 5))
done
echo "giving up"; exit 1وأعدنا التجربة نفسها، والتقرير هذه المرة يبقى ثماني ثوان:
05:21:24 try 1: ERROR: canceling statement due to lock timeout
05:21:26 try 2: ERROR: canceling statement due to lock timeout
05:21:29 try 3: ERROR: canceling statement due to lock timeout
05:21:32 try 4: doneثلاث محاولات فشلت بالخطأ canceling statement due to lock timeout، والرابعة نجحت بعد أن انتهى التقرير، وهذا ما رأته pgbench في الوقت نفسه:
progress: 1.0 s, 4574.0 tps, lat 0.795 ms stddev 0.825, 0 failed
progress: 2.0 s, 4619.2 tps, lat 0.824 ms stddev 0.993, 0 failed
progress: 3.0 s, 4408.2 tps, lat 0.869 ms stddev 1.096, 0 failed
progress: 4.0 s, 4936.5 tps, lat 0.775 ms stddev 1.088, 0 failed
progress: 5.0 s, 5886.4 tps, lat 0.661 ms stddev 0.658, 0 failed
progress: 6.0 s, 2816.2 tps, lat 1.394 ms stddev 18.840, 0 failed
progress: 7.0 s, 5278.1 tps, lat 0.649 ms stddev 0.933, 0 failed
progress: 8.0 s, 4521.3 tps, lat 0.967 ms stddev 14.858, 0 failed
progress: 9.0 s, 4819.4 tps, lat 0.796 ms stddev 1.890, 0 failed
progress: 10.0 s, 2199.7 tps, lat 1.779 ms stddev 21.321, 0 failed
progress: 11.0 s, 3547.7 tps, lat 1.087 ms stddev 1.387, 0 failed
progress: 12.0 s, 3532.1 tps, lat 1.077 ms stddev 1.456, 0 failed
progress: 13.0 s, 4193.1 tps, lat 0.923 ms stddev 1.642, 0 failed
progress: 14.0 s, 4268.0 tps, lat 0.908 ms stddev 2.163, 0 failed
progress: 15.0 s, 3933.7 tps, lat 0.974 ms stddev 1.488, 0 failed
progress: 16.0 s, 4340.9 tps, lat 0.889 ms stddev 0.999, 0 failed
progress: 17.0 s, 4746.2 tps, lat 0.811 ms stddev 0.627, 0 failed
progress: 18.0 s, 5294.4 tps, lat 0.730 ms stddev 1.058, 0 failed
progress: 19.0 s, 3136.0 tps, lat 1.224 ms stddev 2.269, 0 failed
progress: 20.0 s, 2325.4 tps, lat 1.659 ms stddev 3.866, 0 failed
progress: 21.0 s, 5461.9 tps, lat 0.705 ms stddev 0.536, 0 failed
progress: 22.0 s, 3321.3 tps, lat 1.163 ms stddev 3.118, 0 failed
progress: 23.0 s, 4332.2 tps, lat 0.900 ms stddev 2.346, 0 failed
progress: 24.0 s, 7828.7 tps, lat 0.494 ms stddev 0.299, 0 failedلا توجد ثانية واحدة بصفر استعلامات، والانخفاضات في الثواني 6 و8 و10 هي نصف الثانية التي انتظرها كل استعلام خلف المحاولة قبل أن تستسلم. ويجب أن يكون الإعداد داخل جلسة الترحيل فقط، وليس لكل قاعدة البيانات، وأغلب أدوات الترحيل تسمح بذلك، وضعه مع statement_timeout للأوامر التي قد تعمل طويلاً. وتذكر أيضاً أن هذا يحمي من الانتظار فقط، أما الأوامر التي تعيد كتابة الجدول كاملاً وهي تمسك القفل، مثل تغيير نوع عمود، فتحتاج طريقة أخرى: عمود جديد ونسخ على دفعات ثم تبديل، كما في قسم تغيير الجداول دون إيقاف التطبيق.
أقفال الصفوف: لماذا لا يمنع الطلب الجديد تعديل اسم العميل؟
أقفال الصفوف لا تحفظ في pg_locks وإنما في الصف نفسه، في الحقل xmax الذي رأيناه في مقال التخزين، لذلك لا يوجد حد لعددها. وهناك أربعة أنواع، من الأضعف إلى الأقوى: FOR KEY SHARE وFOR SHARE وFOR NO KEY UPDATE وFOR UPDATE. وUPDATE العادي يأخذ FOR NO KEY UPDATE إذا لم يغير المفتاح، وDELETE وتغيير المفتاح يأخذان FOR UPDATE. والمكان الذي تظهر فيه أهمية هذا التقسيم هو المفتاح الأجنبي: عندما تدخل طلباً جديداً للعميل 7، يجب أن يتأكد PostgreSQL أن العميل لن يحذف قبل أن تنتهي معاملتك، فيقفل صف العميل بأضعف قفل ممكن. والتجربة التالية تبين ذلك مع الإضافة pgrowlocks التي تقرأ أقفال الصفوف:
A: BEGIN;
A: INSERT INTO orders (customer_id, total) VALUES (7, 50.00) RETURNING id;
B: SELECT locked_row, modes, pids FROM pgrowlocks('customers');
B: UPDATE customers SET name = 'Customer Seven' WHERE id = 7 RETURNING id, name;
B: SELECT id FROM customers WHERE id = 7 FOR UPDATE;
A: ROLLBACK;A> BEGIN;
A> INSERT INTO orders (customer_id, total) VALUES (7, 50.00) RETURNING id;
A| id
A| --------
A| 200001
A| (1 row)
A|
B> SELECT locked_row, modes, pids FROM pgrowlocks('customers');
B| locked_row | modes | pids
B| ------------+-------------------+-------
B| (0,7) | {"For Key Share"} | {239}
B| (1 row)
B|
B> UPDATE customers SET name = 'Customer Seven' WHERE id = 7 RETURNING id, name;
B| id | name
B| ----+----------------
B| 7 | Customer Seven
B| (1 row)
B|
B> SELECT id FROM customers WHERE id = 7 FOR UPDATE;
A> ROLLBACK;
B| id
B| ----
B| 7
B| (1 row)
B|في المخرج أعلاه لاحظ التالي:
- إدخال الطلب أخذ
For Key Shareعلى صف العميل 7، دون أن تكتب أنت أي قفل. - تعديل اسم العميل نجح فوراً، لأن
FOR NO KEY UPDATEيتفق معFOR KEY SHARE، فهو لا يغير المفتاح الذي يعتمد عليه الطلب. FOR UPDATEعلى الصف نفسه انتظر حتى ألغت A معاملتها، لأنه القفل الذي يحتاجه الحذف أو تغيير المفتاح.
وقبل PostgreSQL 9.3 كان المفتاح الأجنبي يأخذ قفلاً أقوى، فكان إدخال طلب يمنع حتى تعديل اسم العميل، وهذا أحد أسباب نصيحة قديمة تسمعها بتجنب المفاتيح الأجنبية في الأنظمة المزدحمة، وهي نصيحة لم تعد صحيحة بهذا الشكل.
الجمود Deadlock: نسببه عمداً
الجمود يحدث عندما تنتظر معاملتان كل منهما قفلاً تمسكه الأخرى، فلا تنتهي أي منهما أبداً. والمثال المشهور صفان يقفلان بترتيبين مختلفين، وقد حللنا رسالته وحله بترتيب الأقفال في تمرين Kata رقم 16. وهنا نسبب نوعاً أقل شهرة وأكثر حدوثاً مع الترحيلات، وهو ترقية القفل Lock Upgrade: معاملتان تقرآن الجدول نفسه، أي تأخذ كل منهما AccessShareLock، ثم تحاول كل منهما تغيير الجدول، فتحتاج AccessExclusiveLock الذي لا يتفق مع قفل الأخرى:
A: BEGIN;
A: SELECT count(*) FROM customers;
B: BEGIN;
B: SELECT count(*) FROM customers;
A: ALTER TABLE customers ADD COLUMN phone text;
B: ALTER TABLE customers ADD COLUMN email text;
A: COMMIT;
B: ROLLBACK;A> BEGIN;
A> SELECT count(*) FROM customers;
A| count
A| -------
A| 1000
A| (1 row)
A|
B> BEGIN;
B> SELECT count(*) FROM customers;
B| count
B| -------
B| 1000
B| (1 row)
B|
A> ALTER TABLE customers ADD COLUMN phone text;
B> ALTER TABLE customers ADD COLUMN email text;
B| ERROR: deadlock detected
B| DETAIL: Process 174 waits for AccessExclusiveLock on relation 16387 of database 16384; blocked by process 173.
B| Process 173 waits for AccessExclusiveLock on relation 16387 of database 16384; blocked by process 174.
B| HINT: See server log for query details.
A> COMMIT;
B> ROLLBACK;وفي سجل السيرفر:
2026-10-11 05:22:08.876 UTC [173] LOG: process 173 still waiting for AccessExclusiveLock on relation 16387 of database 16384 after 1000.198 ms
2026-10-11 05:22:08.876 UTC [173] DETAIL: Process holding the lock: 174. Wait queue: 173.
2026-10-11 05:22:08.876 UTC [173] STATEMENT: ALTER TABLE customers ADD COLUMN phone text;
2026-10-11 05:22:09.881 UTC [174] ERROR: deadlock detected
2026-10-11 05:22:09.881 UTC [174] DETAIL: Process 174 waits for AccessExclusiveLock on relation 16387 of database 16384; blocked by process 173.
Process 173 waits for AccessExclusiveLock on relation 16387 of database 16384; blocked by process 174.
Process 174: ALTER TABLE customers ADD COLUMN email text;
Process 173: ALTER TABLE customers ADD COLUMN phone text;
2026-10-11 05:22:09.881 UTC [174] HINT: See server log for query details.
2026-10-11 05:22:09.881 UTC [174] STATEMENT: ALTER TABLE customers ADD COLUMN email text;
2026-10-11 05:22:09.882 UTC [173] LOG: process 173 acquired AccessExclusiveLock on relation 16387 of database 16384 after 2006.371 ms
2026-10-11 05:22:09.882 UTC [173] STATEMENT: ALTER TABLE customers ADD COLUMN phone text;اقرأ ما حدث بالترتيب:
- A طلبت AccessExclusiveLock فانتظرت B التي تمسك AccessShareLock، وهذا انتظار عادي.
- B طلبت القفل نفسه فانتظرت A، وهنا أصبحت دائرة.
- PostgreSQL لا يبحث عن الدوائر مع كل انتظار لأن ذلك مكلف، وإنما بعد أن تنتظر الجلسة مدة
deadlock_timeout، كما في توثيق إعدادات الأقفال، فوجد الدائرة وألغى معاملة B بالخطأdeadlock detectedورمزه 40P01. - A حصلت على القفل بعد 2006 ملي ثانية كما يقول السطر
acquired، وأكملت.
والصورة التالية تبين الدائرة:

والقواعد التي تمنع الجمود قليلة: اقفل الصفوف والجداول بترتيب ثابت في كل الكود، واطلب أقوى قفل تحتاجه من البداية بدل أن تقرأ ثم ترقي، مثل SELECT … FOR UPDATE بدل SELECT ثم UPDATE، أو LOCK TABLE … IN ACCESS EXCLUSIVE MODE في أول معاملة الترحيل، واجعل الترحيلات تعمل واحداً بعد الآخر، ثم أعد المعاملة الملغاة تلقائياً عند الخطأ 40P01، لأنها ألغيت كاملة وإعادتها آمنة. ولا تقم برفع deadlock_timeout لإخفاء المشكلة، فهو لا يمنع الجمود وإنما يؤخر اكتشافه.
ظواهر العزل التي لم نرها بعد
في الجزء الثالث من سلسلة تعلم SQL رأينا القراءة غير المتكررة Nonrepeatable Read في Read Committed، والتحديث المفقود الذي يرفضه Repeatable Read، وانحراف الكتابة Write Skew الذي لا يمنعه إلا Serializable، ورأينا التحديث المفقود على الرصيد في مقال المحفظة. وهنا ثلاث حالات أخرى تحدث في المستوى الافتراضي Read Committed، كما يصفها توثيق عزل المعاملات.
انحراف القراءة Read Skew: مجموع لا يساوي شيئاً
تقرير يحسب مجموع الأرصدة بقراءة كل حساب على حدة، وفي الوقت نفسه تحويل 100 من سارة إلى خالد. المجموع الصحيح 2000 قبل التحويل وبعده:
A: BEGIN;
A: SELECT balance AS sara FROM accounts WHERE id = 1;
B: BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT;
A: SELECT balance AS khalid FROM accounts WHERE id = 2;
A: COMMIT;
B: UPDATE accounts SET balance = 1000;
A: BEGIN ISOLATION LEVEL REPEATABLE READ;
A: SELECT balance AS sara FROM accounts WHERE id = 1;
B: BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT;
A: SELECT balance AS khalid FROM accounts WHERE id = 2;
A: COMMIT;A> BEGIN;
A> SELECT balance AS sara FROM accounts WHERE id = 1;
A| sara
A| ------
A| 1000
A| (1 row)
A|
B> BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT;
A> SELECT balance AS khalid FROM accounts WHERE id = 2;
A| khalid
A| --------
A| 1100
A| (1 row)
A|
A> COMMIT;
B> UPDATE accounts SET balance = 1000;
A> BEGIN ISOLATION LEVEL REPEATABLE READ;
A> SELECT balance AS sara FROM accounts WHERE id = 1;
A| sara
A| ------
A| 1000
A| (1 row)
A|
B> BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT;
A> SELECT balance AS khalid FROM accounts WHERE id = 2;
A| khalid
A| --------
A| 1000
A| (1 row)
A|
A> COMMIT;في Read Committed قرأ التقرير سارة قبل التحويل 1000، وخالد بعده 1100، فالمجموع 2100، أي أن التقرير أظهر 100 ريال غير موجودة، مع أن كل قراءة صحيحة وحدها، لأن كل جملة في هذا المستوى ترى آخر ما أكد لحظة بدئها. وفي Repeatable Read رأى التقرير لقطة واحدة لكل المعاملة، فالمجموع 2000. لذلك أي تقرير أو تصدير أو نسخة احتياطية منطقية يقرأ أكثر من جملة يجب أن يعمل بمستوى Repeatable Read على الأقل، وهذا أيضاً ما يحدث في كود التطبيق الذي يقرأ رصيدين في استعلامين منفصلين ثم يقارن بينهما.
إعادة التقييم في Read Committed: DELETE لم يحذف شيئاً
هذا المثال من التوثيق نفسه، ونعيده هنا لأنه يفاجئ كل من يراه أول مرة. جدول زيارات فيه صفحتان بالقيمتين 9 و10، والجلسة A تزيد كل الصفوف بواحد، والجلسة B تحذف كل صف قيمته 10:
A: SELECT id, hits FROM website ORDER BY id;
A: BEGIN;
A: UPDATE website SET hits = hits + 1 RETURNING id, hits;
B: DELETE FROM website WHERE hits = 10 RETURNING id, hits;
A: COMMIT;
B: SELECT id, hits FROM website ORDER BY id;A> SELECT id, hits FROM website ORDER BY id;
A| id | hits
A| ----+------
A| 1 | 9
A| 2 | 10
A| (2 rows)
A|
A> BEGIN;
A> UPDATE website SET hits = hits + 1 RETURNING id, hits;
A| id | hits
A| ----+------
A| 1 | 10
A| 2 | 11
A| (2 rows)
A|
B> DELETE FROM website WHERE hits = 10 RETURNING id, hits;
A> COMMIT;
B| id | hits
B| ----+------
B| (0 rows)
B|
B> SELECT id, hits FROM website ORDER BY id;
B| id | hits
B| ----+------
B| 1 | 10
B| 2 | 11
B| (2 rows)
B|كان في الجدول صف قيمته 10 قبل التحديث، وصف قيمته 10 بعده، ومع ذلك لم يحذف DELETE أي صف. والسبب أن B بدأت بلقطة فيها الصفان 9 و10، فاختارت الصف الثاني فقط، ووجدته مقفلاً من A فانتظرت، وبعد أن أكدت A أعادت تقييم الشرط على النسخة الجديدة من ذلك الصف فقط، فوجدتها 11 فتجاوزتها، أما الصف الأول الذي أصبح 10 فلم يكن في اختيارها أصلاً. وهذه الحالة نادرة مع الأوامر التي تحدد صفاً بمفتاحه، ولكنها تحدث مع الأوامر التي تختار صفوفاً بشرط على قيمة تتغير، مثل حذف الحجوزات المنتهية أو تحديث حالة الطلبات، والحل أن تجعل المهمة تعيد التشغيل حتى لا يبقى ما تعالجه، أو أن تستخدم Repeatable Read وتعيد المعاملة عند خطأ التسلسل.
لماذا لا توجد قراءة قذرة Dirty Read في PostgreSQL؟
القراءة القذرة أن ترى معاملة تعديلاً لم يؤكد بعد، والمعيار يسمح بها في المستوى Read Uncommitted، ولكن PostgreSQL يعامل هذا المستوى مثل Read Committed تماماً، كما يذكر التوثيق، والسبب أن MVCC يعطي كل معاملة النسخة المؤكدة دائماً، فلا يوجد ما يكسبه من القراءة القذرة:
A: BEGIN;
A: UPDATE accounts SET balance = 0 WHERE id = 1;
B: BEGIN ISOLATION LEVEL READ UNCOMMITTED;
B: SELECT balance FROM accounts WHERE id = 1;
B: COMMIT;
A: ROLLBACK;A> BEGIN;
A> UPDATE accounts SET balance = 0 WHERE id = 1;
B> BEGIN ISOLATION LEVEL READ UNCOMMITTED;
B> SELECT balance FROM accounts WHERE id = 1;
B| balance
B| ---------
B| 900
B| (1 row)
B|
B> COMMIT;
A> ROLLBACK;رأت B الرصيد المؤكد 900، وهو ما بقي بعد آخر تحويل، ولم تر الصفر الذي كتبته A ثم ألغته. وهذا يختلف بين قواعد البيانات، ففي SQL Server مثلاً تسمح Read Uncommitted والتلميح NOLOCK بالقراءة القذرة فعلاً كما في توثيق تلميحات الجداول، لذلك لا تنقل افتراضات العزل من محرك إلى آخر دون أن تقرأ توثيقه. والصورة التالية تلخص الحالتين الأوليين:

الأقفال الاستشارية Advisory Locks
أحياناً تحتاج قفلاً على شيء ليس صفاً ولا جدولاً: مهمة إصدار الفواتير الليلية يجب ألا تعمل مرتين لو شغلها سيرفران في الوقت نفسه، أو عملية على مخزون منتج واحد تمر بعدة جداول. والأقفال الاستشارية قفل على رقم تختاره أنت، ولا يعرف PostgreSQL معناه، وإنما يضمن أن جلسة واحدة فقط تمسكه، كما في قسم الأقفال الاستشارية ودوال الإدارة. ونحول اسم المهمة إلى رقم بالدالة hashtext:
A: SELECT pg_try_advisory_lock(hashtext('nightly-invoices')) AS got_it;
B: SELECT pg_try_advisory_lock(hashtext('nightly-invoices')) AS got_it;
B: SELECT locktype, objid, mode, granted, pid FROM pg_locks WHERE locktype = 'advisory';
A: BEGIN; SELECT 1/0; ROLLBACK;
B: SELECT pg_try_advisory_lock(hashtext('nightly-invoices')) AS still_locked;
A: SELECT pg_advisory_unlock(hashtext('nightly-invoices')) AS released;
B: SELECT pg_try_advisory_lock(hashtext('nightly-invoices')) AS got_it;
B: BEGIN;
B: SELECT pg_advisory_xact_lock(hashtext('stock:sku-991'));
A: SELECT pg_try_advisory_xact_lock(hashtext('stock:sku-991')) AS got_it;
B: COMMIT;
A: SELECT pg_try_advisory_xact_lock(hashtext('stock:sku-991')) AS got_it;A> SELECT pg_try_advisory_lock(hashtext('nightly-invoices')) AS got_it;
A| got_it
A| --------
A| t
A| (1 row)
A|
B> SELECT pg_try_advisory_lock(hashtext('nightly-invoices')) AS got_it;
B| got_it
B| --------
B| f
B| (1 row)
B|
B> SELECT locktype, objid, mode, granted, pid FROM pg_locks WHERE locktype = 'advisory';
B| locktype | objid | mode | granted | pid
B| ----------+-----------+---------------+---------+-----
B| advisory | 620809430 | ExclusiveLock | t | 493
B| (1 row)
B|
A> BEGIN; SELECT 1/0; ROLLBACK;
A| ERROR: division by zero
B> SELECT pg_try_advisory_lock(hashtext('nightly-invoices')) AS still_locked;
B| still_locked
B| --------------
B| f
B| (1 row)
B|
A> SELECT pg_advisory_unlock(hashtext('nightly-invoices')) AS released;
A| released
A| ----------
A| t
A| (1 row)
A|
B> SELECT pg_try_advisory_lock(hashtext('nightly-invoices')) AS got_it;
B| got_it
B| --------
B| t
B| (1 row)
B|
B> BEGIN;
B> SELECT pg_advisory_xact_lock(hashtext('stock:sku-991'));
B| pg_advisory_xact_lock
B| -----------------------
B|
B| (1 row)
B|
A> SELECT pg_try_advisory_xact_lock(hashtext('stock:sku-991')) AS got_it;
A| got_it
A| --------
A| f
A| (1 row)
A|
B> COMMIT;
A> SELECT pg_try_advisory_xact_lock(hashtext('stock:sku-991')) AS got_it;
A| got_it
A| --------
A| t
A| (1 row)
A|في المخرج أعلاه لاحظ التالي:
- الدالة
pg_try_advisory_lockلا تنتظر، وإنما تعيد true أو false فوراً، فالسيرفر الثاني يعرف أن المهمة تعمل في مكان آخر ويتجاوزها، وهذا أنسب لمهام Cron من الدالة التي تنتظر. - القفل على مستوى الجلسة بقي بعد خطأ وROLLBACK في الجلسة نفسها، فهو لا يرتبط بالمعاملة، ولا يتحرر إلا بالدالة
pg_advisory_unlockأو بإغلاق الاتصال، فإذا نسيت تحريره في مسار خطأ بقيت المهمة مقفلة حتى يغلق الاتصال. - القفل على مستوى المعاملة
pg_advisory_xact_lockيتحرر تلقائياً مع COMMIT أو ROLLBACK، وهو الأسلم عندما يكون العمل كله داخل معاملة واحدة.
وإذا كان ما تحتاجه طابور مهام يأخذ فيه كل Worker مهمة لا يأخذها غيره، فالأنسب ليس القفل الاستشاري وإنما FOR UPDATE SKIP LOCKED الذي شرحناه في PostgreSQL لكل شيء.
قائمة تحقق للأقفال
- كل ترحيل يبدأ ب
SET lock_timeoutقصير، مع إعادة محاولة، ومعstatement_timeout. CREATE INDEX CONCURRENTLYبدل CREATE INDEX على الجداول التي يكتب فيها التطبيق.- لا تترك المعاملات مفتوحة، وضع
idle_in_transaction_session_timeout، فالمعاملة المفتوحة تمسك أقفالها وتمنع VACUUM أيضاً كما رأينا في مقال التخزين. - فعل
log_lock_waitsفي الإنتاج، فهو يكتب سطراً لكل انتظار أطول منdeadlock_timeoutمع الطابور كاملاً. - اقفل الصفوف بترتيب ثابت، واطلب القفل الأقوى من البداية، وأعد المعاملة عند 40P01 و40001.
- التقارير التي تقرأ أكثر من جملة تعمل بمستوى Repeatable Read.
- عند أي «تعليق» شغل استعلام
pg_blocking_pidsقبل أي شيء آخر، وأنه الجلسة التي في رأس السلسلة وليس التي تنتظر.
حذف المختبر
docker compose -p lab-lock down -vالخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- كل أمر يأخذ قفلاً على الجداول التي يلمسها حتى نهاية المعاملة، وأغلب صيغ ALTER TABLE تأخذ أقوى قفل يتعارض حتى مع SELECT.
- الأقفال تمنح بالترتيب، فالأمر المنتظر يوقف كل من بعده، وقد أوقف ALTER TABLE ينتظر تقريراً كل القراءة تسع ثوان.
- مهلة القفل
lock_timeoutمع إعادة المحاولة حولت التوقف الكامل إلى انتظارات قصيرة لم يلاحظها الحمل. pg_blocking_pidsوpg_locksيقولان من ينتظر من، والسلسلة تنتهي دائماً عند جلسة تمسك القفل.- أقفال الصفوف أربعة أنواع، والمفتاح الأجنبي يأخذ أضعفها، فلا يمنع تعديل الصف الأب إلا إذا تغير مفتاحه أو حذف.
- الجمود دائرة انتظار يكسرها PostgreSQL بعد
deadlock_timeoutبإلغاء معاملة، ويمنعه ترتيب ثابت للأقفال وطلب القفل الأقوى من البداية. - في Read Committed قد يرى التقرير نصف تحويل، وقد يفوت أمر بشرط صفاً تغير أثناء انتظاره، ولا توجد قراءة قذرة في PostgreSQL في أي مستوى.
- الأقفال الاستشارية تحمي مهمة يجب أن تعمل مرة واحدة، وانتبه للفرق بين قفل الجلسة وقفل المعاملة خلف PgBouncer.
دليلكيف تخزن قاعدة البيانات بياناتك من الداخلماذا يحدث على القرص عند INSERT وUPDATE وCOMMIT، ولماذا تنجو قاعدة البيانات من الانهيار، وكيف تختلف LSM عن B-tree.
دليلالفهارس Indexes: لماذا تسرع الاستعلام وكيف تقرأ EXPLAINكيف يبحث PostgreSQL داخل الفهرس، ومتى يتجاهله، وكم يكلفك كل فهرس عند الكتابة، وكيف تقرأ خطة EXPLAIN سطراً سطراً.
دليلتقسيم الجداول Partitioning وتوزيعها على السيرفرات Shardingمتى تقسم الجدول داخل سيرفر واحد، ومتى توزع البيانات على عدة سيرفرات، وكيف تضيف سيرفراً وتتعامل مع العميل الضخم.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب على Docker Engine 29.8.0 مع PostgreSQL 18.6.