فصل ۶: تراکنش، MVCC و نگهداری

قفل‌ها، deadlock، صف با SKIP LOCKED و advisory lock

چه کسی منتظر چه کسی است؟

پستگرس دو دسته قفل دارد: قفل جدول (هشت حالت، از ACCESS SHARE که هر SELECT می‌گیرد تا ACCESS EXCLUSIVE که DROP و بیشتر ALTER TABLEها می‌گیرند) و قفل ردیف (FOR UPDATE، FOR NO KEY UPDATE، FOR SHARE، FOR KEY SHARE). قفل‌های ردیف روی خود tuple نوشته می‌شوند، نه در حافظه؛ برای همین قفل کردن میلیون‌ها ردیف حافظه‌ی مشترک را پر نمی‌کند.

پیدا کردن زنجیره‌ی انتظار

SELECT a.pid,
       pg_blocking_pids(a.pid) AS blocked_by,
       a.wait_event_type, a.state,
       now() - a.query_start AS waiting,
       left(a.query, 70) AS query
FROM pg_stat_activity a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0;

-- جزئیات قفل‌ها
SELECT l.pid, l.locktype, l.relation::regclass, l.mode, l.granted
FROM pg_locks l
WHERE NOT l.granted OR l.relation = 'factory.orders'::regclass;

صف قفل: چرا یک ALTER ساده سایت را می‌خواباند

فرض کنید یک گزارش ۵ دقیقه‌ای روی orders در حال اجراست (ACCESS SHARE). شما ALTER TABLE orders ADD COLUMN note text می‌زنید که ACCESS EXCLUSIVE می‌خواهد و منتظر می‌ماند. حالا هر SELECT تازه پشت ALTER شما در صف می‌ایستد، چون قفل‌ها به ترتیب صف داده می‌شوند. نتیجه: ۵ دقیقه هیچ صفحه‌ای باز نمی‌شود. درمان: در migrationها همیشه lock_timeout کوتاه بگذارید و در صورت شکست دوباره تلاش کنید.

SET lock_timeout = '3s';
ALTER TABLE factory.orders ADD COLUMN note text;

deadlock

تراکنش A ردیف ۱ را قفل کرده و ردیف ۲ را می‌خواهد؛ B ردیف ۲ را دارد و ردیف ۱ را می‌خواهد. پستگرس پس از deadlock_timeout (پیش‌فرض ۱ ثانیه) چرخه را پیدا می‌کند و یکی را با خطای deadlock detected (SQLSTATE 40P01) قربانی می‌کند. راه پیشگیری: ردیف‌ها را همیشه به یک ترتیب ثابت قفل کنید.

-- انتقال نخ بین دو انبار: همیشه به ترتیب id قفل کنید
SELECT id FROM factory.yarn_stock WHERE id IN (7, 3) ORDER BY id FOR UPDATE;

صف کار با FOR UPDATE SKIP LOCKED

برای صف کارهای پس‌زمینه (ساخت PDF فاکتور، ارسال پیامک) لازم نیست Redis یا RabbitMQ بیاورید. با SKIP LOCKED هر worker ردیفی برمی‌دارد که دیگری قفل نکرده، بی‌آن‌که منتظر بماند:

CREATE TABLE factory.jobs (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  kind       text NOT NULL,
  payload    jsonb NOT NULL,
  status     text NOT NULL DEFAULT 'pending',
  attempts   int NOT NULL DEFAULT 0,
  run_after  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX jobs_pending_idx ON factory.jobs (run_after) WHERE status = 'pending';

-- هر worker در یک تراکنش:
WITH next AS (
  SELECT id FROM factory.jobs
  WHERE status = 'pending' AND run_after <= now()
  ORDER BY run_after
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE factory.jobs j
SET status = 'running', attempts = attempts + 1
FROM next WHERE j.id = next.id
RETURNING j.id, j.kind, j.payload;

advisory lock: قفل روی یک مفهوم

گاهی می‌خواهید چیزی را قفل کنید که ردیف نیست؛ مثلاً «فقط یک نمونه از اسکریپت محاسبه‌ی حقوق ماهانه اجرا شود».

SELECT pg_try_advisory_lock(hashtext('payroll-1405-01'));      -- true یا false، بدون انتظار
-- ... کار ...
SELECT pg_advisory_unlock(hashtext('payroll-1405-01'));

-- نسخه‌ی تراکنشی: با COMMIT یا ROLLBACK خودکار آزاد می‌شود
SELECT pg_advisory_xact_lock(42, 1001);

نکته‌هایی که کمتر کسی می‌داند

  • INSERT در جدول فرزند روی ردیف والد قفل FOR KEY SHARE می‌گیرد؛ به همین دلیل UPDATE ستون‌های غیرکلیدی والد (که FOR NO KEY UPDATE می‌گیرد) با آن تداخل ندارد، اما UPDATE کلید اصلی والد دارد.
  • advisory lock سطح نشست با PgBouncer در حالت transaction pooling خطرناک است: قفل روی اتصال سرور می‌ماند و ممکن است به کلاینت دیگری برسد؛ آن‌جا فقط pg_advisory_xact_lock استفاده کنید.
  • log_lock_waits = on هر انتظار قفل طولانی‌تر از deadlock_timeout را با pid طرفین لاگ می‌کند؛ بهترین ابزار برای پیدا کردن قفل‌هایی که فقط گاهی پیش می‌آیند.
  • در صف SKIP LOCKED، کار رهاشده (worker که وسط کار مرد) با ROLLBACK خودکار دوباره pending می‌شود، به شرطی که status را در همان تراکنشی که کار را انجام می‌دهید عوض کرده باشید.
  • NOWAIT به‌جای SKIP LOCKED بلافاصله خطا می‌دهد؛ برای رابط کاربری («این سفارش را کاربر دیگری در حال ویرایش است») مناسب‌تر از انتظار است.

برای ذخیره‌ی پیشرفت و شرکت در آزمون، وارد شوید — رایگان است.