الأدلة التقنية

الأقفال Locks والجمود Deadlock في قواعد البيانات

لماذا يوقف أمر ALTER TABLE صغير كل الاستعلامات خلفه، وكيف تقرأ pg_locks وتتجنب الجمود وظواهر العزل في PostgreSQL.

الأقفال Locks والجمود Deadlock في قواعد البيانات

يضيف المطور عموداً جديداً إلى جدول الطلبات في نشر عادي بعد الظهر، والأمر 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 SHARESELECTACCESS EXCLUSIVE فقط
ROW SHARESELECT … FOR UPDATE / FOR SHAREEXCLUSIVE وACCESS EXCLUSIVE
ROW EXCLUSIVEINSERT وUPDATE وDELETE وMERGESHARE وما فوقه
SHARE UPDATE EXCLUSIVEVACUUM وANALYZE وCREATE INDEX CONCURRENTLYنفسه وما فوقه، فلا يمنع القراءة ولا الكتابة
SHARECREATE INDEX دون CONCURRENTLYكل كتابة على الجدول
SHARE ROW EXCLUSIVECREATE TRIGGER، وADD FOREIGN KEY على الجدولينكل كتابة، ونفسه
EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLYكل شيء ما عدا القراءة
ACCESS EXCLUSIVEأغلب صيغ ALTER TABLE، وDROP وTRUNCATE وVACUUM FULLكل شيء، حتى SELECT

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

مصفوفة تعارض بين خمسة أوامر شائعة: SELECT وUPDATE وCREATE INDEX وCREATE INDEX CONCURRENTLY وALTER TABLE. SELECT مع أي أمر مسموح ما عدا ALTER TABLE، وUPDATE يتعارض مع CREATE INDEX العادي ومع ALTER TABLE، وCREATE INDEX CONCURRENTLY لا يتعارض مع القراءة ولا الكتابة، وALTER TABLE يتعارض مع كل شيء
أغلب صيغ ALTER TABLE تتعارض مع كل شيء، وCREATE INDEX العادي يمنع الكتابة فقط

طابور الأقفال: كيف يوقف 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;

والصورة التالية تبين الطابور والتوقف في الحمل:

أعلى الصورة: الجلسة 100 تقرير يمسك AccessShareLock، ثم ALTER TABLE في الجلسة 108 ينتظر AccessExclusiveLock، ثم أربعة استعلامات SELECT تنتظر خلفه مع أنها لا تتعارض مع التقرير. أسفل الصورة: رسمان لعدد الاستعلامات في الثانية خلال 24 ثانية، الأول دون lock_timeout يهبط إلى صفر من الثانية 6 إلى 14، والثاني مع lock_timeout 500ms وإعادة المحاولة يبقى فوق ألفي استعلام في الثانية مع انخفاضات قصيرة عند كل محاولة
الأمر المنتظر في الطابور يوقف كل من يأتي بعده، ومهلة القفل القصيرة تحوله إلى انتظار قصير يتكرر
📌
نشرت شركة المدفوعات GoCardless في مدونتها تحليلاً لتوقف واجهتها البرمجية API قرابة 15 ثانية أثناء ترحيل مخطط له، رغم أن الفريق يتبع قواعد الترحيل دون توقف. والسبب كان أمراً يضيف مفتاحاً أجنبياً ويحتاج قفلاً قوياً على جدولين، فانتظر معاملة أخرى، ووقف خلفه في الطابور كل ما يلمس الجدولين. والدرس الذي خرجوا به هو ما نطبقه في القسم التالي: مهلة قصيرة للقفل في كل ترحيل.

الحل: 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;

اقرأ ما حدث بالترتيب:

  1. A طلبت AccessExclusiveLock فانتظرت B التي تمسك AccessShareLock، وهذا انتظار عادي.
  2. B طلبت القفل نفسه فانتظرت A، وهنا أصبحت دائرة.
  3. PostgreSQL لا يبحث عن الدوائر مع كل انتظار لأن ذلك مكلف، وإنما بعد أن تنتظر الجلسة مدة deadlock_timeout، كما في توثيق إعدادات الأقفال، فوجد الدائرة وألغى معاملة B بالخطأ deadlock detected ورمزه 40P01.
  4. A حصلت على القفل بعد 2006 ملي ثانية كما يقول السطر acquired، وأكملت.

والصورة التالية تبين الدائرة:

دائرة بين جلستين 173 و174: كل منهما تمسك AccessShareLock على customers بعد SELECT، وكل منهما تطلب AccessExclusiveLock لأمر ALTER TABLE فتنتظر الأخرى. بعد deadlock_timeout يكتشف PostgreSQL الدائرة ويلغي 174 بالخطأ 40P01 فتحصل 173 على القفل بعد 2006 ملي ثانية
ترقية القفل من القراءة إلى التعديل في معاملتين تصنع دائرة، ويكسرها PostgreSQL بإلغاء إحداهما

والقواعد التي تمنع الجمود قليلة: اقفل الصفوف والجداول بترتيب ثابت في كل الكود، واطلب أقوى قفل تحتاجه من البداية بدل أن تقرأ ثم ترقي، مثل 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 بالقراءة القذرة فعلاً كما في توثيق تلميحات الجداول، لذلك لا تنقل افتراضات العزل من محرك إلى آخر دون أن تقرأ توثيقه. والصورة التالية تلخص الحالتين الأوليين:

يسار الصورة انحراف القراءة: التقرير يقرأ سارة 1000 ثم يحدث تحويل 100 ثم يقرأ خالد 1100 فيصبح المجموع 2100 في Read Committed، و2000 في Repeatable Read. يمين الصورة إعادة التقييم: الجدول فيه 9 و10، والتحديث يجعلهما 10 و11، وDELETE WHERE hits = 10 كان قد اختار الصف الثاني فقط ثم أعاد فحصه فوجده 11 فلم يحذف شيئاً
كل جملة في Read Committed ترى لحظتها، فقد يرى التقرير نصف تحويل، وقد يفوت DELETE صفاً تغير أثناء انتظاره

الأقفال الاستشارية 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، وهو الأسلم عندما يكون العمل كله داخل معاملة واحدة.
⚠️
القفل الاستشاري على مستوى الجلسة لا يعمل خلف PgBouncer في وضع transaction، لأن الاتصال الحقيقي بقاعدة البيانات يتغير بين معاملة وأخرى، فقد يأخذ القفل اتصال ويحاول تحريره اتصال آخر، كما شرحنا في ما الذي ينكسر في وضع transaction. استخدم في هذه الحالة القفل على مستوى المعاملة، أو اتصالاً مباشراً لقاعدة البيانات للمهام المجدولة.

وإذا كان ما تحتاجه طابور مهام يأخذ فيه كل 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.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.