PostgreSQL Deadlock Debugging trong Production: đọc lock graph, tìm root cause và vá đúng chỗ

Nhiều team chỉ thực sự để ý tới locking khi production bắt đầu ném lỗi kiểu này:

ERROR: deadlock detected
DETAIL: Process 18231 waits for ShareLock on transaction 918273; blocked by process 18244.
Process 18244 waits for ShareLock on transaction 918271; blocked by process 18231.
HINT: See server log for query details.

Điều khó chịu của deadlock không nằm ở chuyện database “bị chậm”. Nó nằm ở chỗ database chủ động giết một transaction để phá vòng chờ, và transaction đó thường rơi đúng vào flow nghiệp vụ nhạy cảm: thanh toán, đồng bộ tồn kho, cập nhật trạng thái đơn hàng, hoặc batch worker đang giữ nhiều side effect dở dang.

Phản xạ sai phổ biến là:

  • tăng timeout rồi hy vọng hết lỗi;
  • retry toàn bộ request mà không hiểu transaction nào đang tạo vòng khóa;
  • blame PostgreSQL vì “khó đoán”, trong khi root cause nằm ở thứ tự cập nhật dữ liệu hoặc phạm vi transaction bị kéo quá rộng;
  • thêm index hoặc tách table bừa dù deadlock không đến từ query plan.

Deadlock là bài toán concurrency design nhiều hơn là bài toán syntax SQL. PostgreSQL chỉ là nơi hậu quả lộ ra rõ ràng nhất.

Bài này đi theo góc nhìn production: deadlock xuất hiện từ đâu, cách đọc thông tin deadlock detected, dùng pg_locks, pg_stat_activity, log và transaction trace để dựng lại lock graph, khi nào nên retry, và quan trọng hơn là sửa logic nào để nó không quay lại.

Deadlock trong PostgreSQL thực chất là gì?

Deadlock xảy ra khi có một vòng chờ tài nguyên.

Ví dụ đơn giản nhất:

  • Transaction A khóa row của `orders` id=10 rồi muốn cập nhật `payments` id=55.
  • Transaction B khóa `payments` id=55 trước rồi muốn cập nhật `orders` id=10.
  • A chờ B nhả khóa.
  • B chờ A nhả khóa.
  • Không ai tự tiến tiếp được.

Khi PostgreSQL phát hiện vòng chờ này, nó sẽ chọn một transaction làm nạn nhân và abort transaction đó với lỗi deadlock detected.

Điểm cần nhớ:

  • deadlock **khác lock wait thông thường**;
  • deadlock không có nghĩa là database đang quá tải toàn cục;
  • deadlock thường phản ánh **design concurrency không ổn định** ở application layer;
  • số deadlock ít nhưng lặp lại ở flow quan trọng vẫn là một defect P1.

Deadlock khác gì lock contention bình thường?

Lock contention chỉ có nghĩa là nhiều transaction muốn cùng truy cập tài nguyên có xung đột. Một transaction chờ transaction khác xong rồi chạy tiếp vẫn là bình thường.

Deadlock chỉ xảy ra khi có chu trình chờ.

Điều này quan trọng vì cách fix khác nhau:

  • lock contention -> có thể cần rút ngắn transaction, thêm index để update/select nhanh hơn, giảm batch size, hoặc đổi isolation/queueing strategy;
  • deadlock -> phải tìm ra **vòng chờ** và **thứ tự truy cập tài nguyên** gây ra nó.

Nếu không phân biệt hai thứ, team dễ nhìn mọi lỗi locking như một khối mơ hồ rồi vá sai chỗ.

Những nguồn gây deadlock phổ biến trong production

1. Cập nhật nhiều bảng theo thứ tự không nhất quán

Đây là nguyên nhân phổ biến nhất ở backend nghiệp vụ.

Ví dụ:

  • flow A: update `orders` -> update `payments`
  • flow B: update `payments` -> update `orders`

Chỉ cần hai flow cùng chạm vào cùng record hoặc cùng tập bản ghi liên quan, deadlock đã có cơ hội xuất hiện.

2. Batch worker và request online chạm cùng dữ liệu

Worker nền thường giữ transaction lâu hơn request bình thường:

  • select một batch lớn;
  • update nhiều row liên tiếp;
  • gọi thêm service ngoài trong transaction;
  • commit muộn.

Nếu request online cũng update cùng dữ liệu nhưng theo đường khác, nguy cơ deadlock tăng mạnh.

3. `SELECT ... FOR UPDATE` không có ordering ổn định

Team thường dùng FOR UPDATE đúng ý định, nhưng quên rằng khi nhiều worker lấy một tập row theo thứ tự không ổn định, mỗi worker có thể khóa row theo thứ tự khác nhau. Đây là cách rất hiệu quả để tự tạo deadlock ngẫu nhiên.

4. Transaction làm quá nhiều việc

Transaction càng dài, càng giữ nhiều lock, xác suất giao nhau với transaction khác càng cao. Những pattern nguy hiểm:

  • đọc dữ liệu;
  • gọi API ngoài;
  • tính toán;
  • ghi audit;
  • ghi outbox;
  • cập nhật thêm bảng aggregate;
  • cuối cùng mới commit.

Khi một transaction vừa dài vừa chạm nhiều bảng, lock graph sẽ phức tạp rất nhanh.

5. Foreign key, trigger hoặc cascade update/delete bị quên mất

Có những deadlock nhìn bề ngoài tưởng chỉ liên quan một bảng, nhưng thực tế transaction còn đụng vào:

  • foreign key validation;
  • trigger side effect;
  • cascade delete/update;
  • unique index check trên bảng khác.

Nếu chỉ nhìn câu SQL ở application mà quên side effect của schema, anh sẽ dựng sai lock graph.

Sơ đồ deadlock PostgreSQL khi hai transaction khóa chéo hai tài nguyên trong production
Deadlock là vòng chờ tài nguyên: PostgreSQL sẽ abort một transaction để phá vòng.

PostgreSQL cho anh những tín hiệu nào khi deadlock xảy ra?

1. Lỗi trả về cho application

Application thường chỉ thấy lỗi rất ngắn:

ERROR: deadlock detected
SQLSTATE: 40P01

Nếu chỉ log mỗi message này ở app, gần như chưa đủ để debug.

2. Server log chứa lock graph hữu ích hơn nhiều

Khi logging đủ chi tiết, PostgreSQL thường ghi:

  • process nào đang chờ lock gì;
  • bị process nào chặn;
  • query của mỗi bên;
  • context transaction.

Đây là nguyên liệu đầu tiên để dựng lại vòng chờ.

3. `pg_locks` và `pg_stat_activity`

Trong lúc incident còn đang diễn ra, hai view này là cặp công cụ quan trọng nhất:

  • `pg_stat_activity` cho biết session nào đang chạy gì, wait event gì, state gì;
  • `pg_locks` cho biết session đang giữ hoặc chờ loại lock nào.

Một query thực dụng để nhìn blocker/waiter:

select
  a.pid as waiting_pid,
  a.query as waiting_query,
  a.usename as waiting_user,
  a.application_name as waiting_app,
  a.wait_event_type,
  a.wait_event,
  pg_blocking_pids(a.pid) as blocking_pids
from pg_stat_activity a
where cardinality(pg_blocking_pids(a.pid)) > 0;

Để đi sâu hơn, cần join với pg_locks theo pid, relation, transactionid, tuple hoặc advisory key.

Minh họa quan hệ blocker waiter trong pg_locks và pg_stat_activity của PostgreSQL
Blocker/waiter map giúp nối PID, lock type và query path về cùng một câu chuyện incident.

Cách dựng lock graph đúng thay vì đoán mò

Bước 1: khóa mốc thời gian và flow nghiệp vụ bị ảnh hưởng

Trước khi đào pg_locks, hãy trả lời:

  • deadlock xuất hiện từ khi nào;
  • endpoint/job nào bị lỗi;
  • lỗi xuất hiện liên tục hay theo burst;
  • có deploy, migration, batch backfill hoặc traffic pattern mới nào ngay trước đó không.

Nếu không khóa mốc này, anh có thể phân tích nhầm một lock wait bình thường không liên quan.

Bước 2: lấy query của các session trong deadlock

Từ log hoặc pg_stat_activity, gom lại:

  • query text;
  • transaction start time;
  • application name;
  • user/worker type;
  • endpoint hoặc job id tương ứng ở application log.

Mục tiêu không phải chỉ biết “query nào chậm”, mà là biết hai hoặc nhiều transaction nào tham gia vào vòng chờ.

Bước 3: xác định tài nguyên bị khóa

Trong pg_locks, deadlock có thể liên quan tới:

  • row lock gián tiếp qua `transactionid` wait;
  • relation lock;
  • advisory lock;
  • tuple/page trong vài trường hợp chi tiết hơn.

Nhiều team thấy ShareLock on transaction rồi bối rối. Thực tế đây thường là dấu hiệu một transaction đang chờ outcome của transaction khác do row-level conflict. Muốn hiểu đúng, phải ghép nó với query và thứ tự thao tác dữ liệu.

Bước 4: dựng thứ tự lock acquisition

Đây là bước quan trọng nhất.

Ví dụ lock graph thực tế có thể được kể lại như sau:

  1. Worker A lấy `orders.id=10`.
  2. Worker B lấy `payments.id=55`.
  3. Worker A sang update `payments.id=55` và bị chặn.
  4. Worker B sang update `orders.id=10` và bị chặn.
  5. Vòng chờ hoàn tất -> PostgreSQL abort một bên.

Khi kể được câu chuyện theo thứ tự thời gian như vậy, hướng sửa thường lộ ra khá rõ.

Một playbook debug deadlock thực chiến

1. Đảm bảo app log giữ đủ context

Mỗi request/job quan trọng nên log được:

  • request id / job id;
  • tenant/account id;
  • transaction boundary nếu có abstraction rõ;
  • SQL operation chính hoặc ít nhất tên repository/service method;
  • retry count nếu framework tự retry.

Nếu app log chỉ có stack trace chung chung, anh sẽ phải suy luận quá nhiều từ DB log.

2. Bật hoặc kiểm tra logging liên quan lock đủ dùng

Tùy policy của hệ thống, nên có ít nhất:

  • deadlock log từ PostgreSQL;
  • `log_lock_waits` khi cần điều tra contention kéo dài;
  • `deadlock_timeout` hợp lý để phát hiện vòng chờ sớm vừa đủ, không spam quá mức.

Không cần để mọi mức log cực cao mãi mãi, nhưng incident mode phải có đường bật nhanh.

3. Snapshot activity khi incident đang nóng

Một bộ query thường dùng:

select now(), pid, usename, application_name, state,
       wait_event_type, wait_event,
       xact_start, query_start, query
from pg_stat_activity
where datname = current_database()
order by xact_start nulls last;
select *
from pg_locks
where pid in (
  select pid from pg_stat_activity
  where cardinality(pg_blocking_pids(pid)) > 0
     or pid = any(
       select unnest(pg_blocking_pids(pid)) from pg_stat_activity
     )
);

Snapshot nhiều lần cách nhau vài giây thường có ích hơn một snapshot đơn lẻ.

4. Đối chiếu với trace ứng dụng

Nếu có APM hoặc trace id, nối session/query về đúng endpoint hoặc worker. Rất nhiều deadlock chỉ thực sự hiểu được khi thấy code path:

  • web request update order status;
  • webhook worker reconcile payment;
  • nightly reconciliation backfill.

Chỉ nhìn SQL mà không biết business flow sẽ dễ vá triệu chứng.

Retry deadlock thế nào cho đúng?

Deadlock là lỗi có thể retry, nhưng retry chỉ là giảm blast radius, không phải root-cause fix.

Khi retry hợp lý

  • transaction idempotent hoặc có idempotency key;
  • side effect ngoài DB chưa xảy ra hoặc được bảo vệ;
  • retry count thấp, có backoff + jitter;
  • hệ thống không đang deadlock hàng loạt vì design lỗi nghiêm trọng.

Khi retry nguy hiểm

  • transaction ghi ra ngoài DB trước commit;
  • retry đồng loạt từ nhiều worker làm tăng cạnh tranh;
  • framework auto-retry mà không log đủ context;
  • deadlock đến từ batch lớn giữ lock quá lâu.

Rule thực tế: bật retry có kiểm soát để user đỡ đau, nhưng song song phải điều tra transaction ordering. Nếu không, deadlock sẽ quay lại dưới tải cao hơn.

So sánh thứ tự khóa nhất quán và không nhất quán gây deadlock trong backend production
Đồng bộ lock ordering giữa request và worker là một trong những cách cắt deadlock hiệu quả nhất.

Những hướng fix gốc hiệu nhất

1. Chuẩn hóa thứ tự truy cập tài nguyên

Nếu nhiều flow cần chạm cùng các bảng/row, hãy buộc chúng theo cùng một thứ tự.

Ví dụ toàn hệ thống đều theo quy ước:

  1. lock `orders`
  2. rồi mới lock `payments`
  3. rồi mới lock `shipments`

Chỉ riêng việc đồng bộ ordering đã loại bỏ rất nhiều deadlock kinh điển.

2. Rút ngắn transaction

Transaction càng ngắn càng tốt:

  • không gọi HTTP trong transaction;
  • không serialize response nặng trong transaction;
  • không làm nhiều update phụ có thể tách ra async;
  • không giữ transaction mở chỉ để chờ logic ứng dụng khác.

Nếu cần side effect đáng tin cậy sau commit, bài toán thường nối tốt với <a href="https://softwareengineer.vn/transactional-outbox-event-driven-consistency/">transactional outbox</a> thay vì kéo transaction chính quá dài.

3. Khóa theo tập dữ liệu với ordering ổn định

Nếu phải SELECT ... FOR UPDATE nhiều row, hãy:

  • chọn ordering rõ ràng, thường theo primary key;
  • xử lý batch nhỏ hơn;
  • cân nhắc `SKIP LOCKED` cho worker queue pattern khi phù hợp;
  • tránh để nhiều worker scan cùng tập row theo cách không quyết định.

4. Tách nóng/lạnh hoặc online/batch

Nếu batch job thường đụng cùng record với traffic online:

  • chuyển batch sang queue riêng;
  • giới hạn concurrency;
  • chạy ngoài peak time;
  • tách data partition hoặc ownership rõ hơn.

Đừng để một job nền “ít quan trọng” tranh lock ngang hàng với checkout flow.

5. Kiểm tra index nhưng đừng lạc đề

Index không trực tiếp “sửa deadlock”, nhưng query chậm hơn có thể giữ lock lâu hơn, làm xác suất deadlock tăng.

Vì vậy vẫn nên kiểm:

  • có scan quá rộng không;
  • row lookup có dùng index hợp lý không;
  • transaction đang giữ lock lâu vì query plan tệ hay không.

Nếu nghi query shape hoặc statistics làm transaction kéo dài bất thường, bài <a href="https://softwareengineer.vn/postgresql-query-plan-regression-production/">PostgreSQL query plan regression</a> là điểm nối tự nhiên.

Advisory lock: cứu tinh hay bẫy mới?

Một số team dùng advisory lock để serialize thao tác theo business key. Đây có thể là cách tốt nếu dùng đúng:

  • lock theo `order_id`, `account_id`, `inventory_item_id`;
  • đặt phạm vi rõ ràng;
  • release theo transaction/session đúng nghĩa.

Nhưng advisory lock không miễn nhiễm deadlock. Nếu anh lấy nhiều advisory key theo thứ tự không nhất quán, vẫn có thể tạo vòng chờ mới. Nó chỉ chuyển deadlock sang lớp khác nếu design cẩu thả.

Những anti-pattern nên audit ngay khi thấy deadlock

  • cập nhật nhiều bảng cùng quan hệ nhưng không có lock ordering thống nhất;
  • worker batch lớn giữ transaction hàng nghìn row;
  • retry vô điều kiện khi gặp `40P01`;
  • transaction vừa ghi DB vừa gọi external API;
  • ORM lazy-load hoặc callback làm phát sinh query phụ trong transaction mà team không biết;
  • trigger/cascade âm thầm mở rộng lock footprint;
  • “fix” bằng cách tăng timeout và coi như ổn.

Checklist production sau khi đã vá

Về database/logging

  • [ ] Có lưu log `deadlock detected` với đủ query/context
  • [ ] Có cách bật nhanh `log_lock_waits` khi cần
  • [ ] Có dashboard/alert cho deadlock count theo service hoặc endpoint
  • [ ] Có query snapshot blocker/waiter trong runbook

Về ứng dụng

  • [ ] Có request/job id gắn với SQL/transaction path
  • [ ] Retry `40P01` có giới hạn và có jitter
  • [ ] Các flow chạm cùng tài nguyên dùng cùng ordering
  • [ ] Transaction không ôm HTTP call hoặc side effect ngoài DB
  • [ ] Worker batch có concurrency và batch size rõ ràng

Về kiến trúc

  • [ ] Core online flow không bị batch nền tranh lock vô tội vạ
  • [ ] Các side effect sau commit được tách bằng outbox/event nếu cần
  • [ ] Team đã document lock ownership theo domain quan trọng
Playbook điều tra deadlock PostgreSQL trong production từ log đến root cause fix
Playbook điều tra tốt giúp team chuyển từ retry cảm tính sang root-cause fix có hệ thống.

Kết luận

Deadlock không phải tín hiệu để “chỉnh PostgreSQL cho mạnh hơn”. Nó là tín hiệu cho thấy mô hình concurrent access của hệ thống đang thiếu kỷ luật ở đâu đó: thứ tự lock không nhất quán, transaction quá dài, worker và request online giẫm chân nhau, hoặc side effect schema bị xem nhẹ.

Cách chữa đúng gần như luôn bắt đầu bằng việc dựng lock graph rõ ràng:

  • ai đang giữ gì;
  • ai chờ ai;
  • query nào thuộc flow nào;
  • thứ tự lock acquisition diễn ra ra sao.

Khi đã thấy vòng chờ, hướng sửa sẽ chuyển từ cảm tính sang cơ học: đồng bộ ordering, rút ngắn transaction, tách workload, hoặc đổi ownership dữ liệu. Retry chỉ là đệm an toàn. Root cause vẫn là thiết kế concurrency.

Nếu hệ thống của anh đang có cả lock wait kéo dài lẫn throughput tụt mạnh, nên đọc thêm <a href="https://softwareengineer.vn/postgresql-connection-pooling-pgbouncer-production/">PostgreSQL connection pooling với PgBouncer</a> và <a href="https://softwareengineer.vn/request-collapsing-backend-production/">request collapsing trong backend production</a> để nhìn rộng hơn ở tầng ứng dụng và database boundary.