Hai đầu bếp cùng cần muối và tiêu để nêm. Người thứ nhất chộp lọ muối rồi với tay lấy lọ tiêu; người thứ hai vừa kịp chộp lọ tiêu và giờ với tay lấy lọ muối. Không ai chịu buông thứ mình đang cầm, và cả hai đứng đơ ở đó mãi mãi. Đó chính xác là một deadlock, và mọi thứ trong bài này — cách nó xảy ra, vì sao nó tự gỡ, cách phòng nó — đều là bản dịch của cái bếp ấy sang ngôn ngữ cơ sở dữ liệu. Phần trước kết thúc bằng một lựa chọn chưa đo: giữ read committed rồi dùng khoá tường minh. Phần này đo cách khoá hàng hoạt động, cách dựng lại một deadlock có chủ đích, và một câu lệnh làm hàng đợi công việc nhanh gấp gần sáu lần.

Dựng lại một deadlock

Công thức tối giản: hai phiên khoá hai dòng theo thứ tự ngược nhau.

-- Phiên 1                              -- Phiên 2
begin;                                  begin;
update tk2 set so_du = so_du - 100      update tk2 set so_du = so_du - 100
  where id = 1;                           where id = 2;
-- chờ                                  -- chờ
update tk2 set so_du = so_du + 100      update tk2 set so_du = so_du + 100
  where id = 2;                           where id = 1;
commit;                                 commit;

Phiên 1 giữ khoá dòng 1 và đợi dòng 2. Phiên 2 giữ khoá dòng 2 và đợi dòng 1. Không ai nhường ai — đúng hai đầu bếp, mỗi người một lọ.

Kết quả sau 2,04 giây:

phiên 1: commit thành công

phiên 2:
ERROR:  deadlock detected
DETAIL:  Process 10379 waits for ShareLock on transaction 5199;
         blocked by process 10378.
HINT:  See server log for query details.

Số dư cuối cùng: A=900, B=1100 — đúng bằng kết quả của chỉ một lần chuyển khoản. Giao dịch bị huỷ được hoàn tác trọn vẹn, không để lại nửa vời.

Chú ý dòng DETAIL: phiên 2 đợi ShareLock trên giao dịch số 5199, không phải trên dòng dữ liệu. Đó là cách PostgreSQL cài đặt khoá hàng — nó không giữ một bảng khoá riêng cho từng dòng (sẽ tốn bộ nhớ vô hạn), mà ghi số hiệu giao dịch đang giữ vào chính dòng đó. Ai muốn khoá dòng ấy thì đi đợi giao dịch kia kết thúc.

Phát hiện tốn đúng deadlock_timeout

PostgreSQL không dò deadlock liên tục — việc đó quá tốn kém. Nó chỉ dò khi một phiên đã đợi quá deadlock_timeout:

deadlock_timeout Phát hiện sau
200 ms 0,59 s
1 s (mặc định) 1,41 s
3 s 3,38 s

Con số đó là thời gian cả hai phiên bị treo hoàn toàn. Với mặc định 1 giây, mỗi deadlock lấy đi ít nhất một giây của hai kết nối. Trên hệ thống có deadlock thường xuyên, đó là lượng thông lượng đáng kể bị đốt.

Hạ deadlock_timeout xuống thì phát hiện nhanh hơn, nhưng mỗi lần một phiên đợi quá ngưỡng — kể cả khi không có deadlock, chỉ là chờ bình thường — PostgreSQL đều chạy thuật toán dò. Mặc định 1 giây là lựa chọn để việc dò gần như không bao giờ chạy trong tình huống chờ thông thường.

Cách chặn triệt để

Deadlock luôn cần một vòng tròn trong đồ thị chờ. Nếu mọi giao dịch khoá tài nguyên theo cùng một thứ tự, vòng tròn không hình thành được — cũng như nếu cả hai đầu bếp đều có luật "luôn cầm muối trước", sẽ không bao giờ kẹt.

Cùng phép thử ở trên nhưng cả hai phiên đều khoá dòng 1 trước rồi mới tới dòng 2, chạy 5 vòng:

0 lần deadlock,  10/10 giao dịch hoàn thành

Trong mã ứng dụng, quy tắc này thường có nghĩa là sắp xếp khoá theo khoá chính trước khi cập nhật:

-- thay vì cập nhật theo thứ tự nghiệp vụ
select id from tk2 where id in (:a, :b) order by id for update;
-- rồi mới cập nhật

Một hàm chuyển khoản viết đúng sẽ luôn khoá tài khoản có id nhỏ hơn trước, bất kể ai chuyển cho ai. Đây là biện pháp rẻ nhất và hiệu quả nhất trong bài, và nó là biện pháp phòng chứ không phải chữa.

Bốn chế độ khoá hàng

PostgreSQL có bốn mức khoá hàng, từ chặt tới lỏng. Tôi đo trực tiếp bằng cách giữ một khoá rồi thử lấy khoá kia với nowait:

Đang giữ FOR UPDATE FOR NO KEY UPDATE FOR SHARE FOR KEY SHARE
FOR UPDATE chặn chặn chặn chặn
FOR NO KEY UPDATE chặn chặn chặn qua
FOR SHARE chặn chặn qua qua
FOR KEY SHARE chặn qua qua qua

Ma trận này giải thích một chuyện hay gây bối rối: khoá ngoại lấy FOR KEY SHARE. Khi bạn chèn một dòng con tham chiếu tới dòng cha, PostgreSQL khoá dòng cha ở mức nhẹ nhất — chỉ đủ để chặn việc xoá hoặc đổi khoá chính của nó.

Nhờ vậy, một câu UPDATE bình thường trên dòng cha (lấy FOR NO KEY UPDATE) không bị chặn bởi hàng loạt lần chèn dòng con. Nếu PostgreSQL dùng FOR UPDATE cho khoá ngoại như các phiên bản rất cũ, mọi bảng cha có nhiều bảng con sẽ trở thành điểm nghẽn.

Thực tế bạn hầu như chỉ cần hai cái: FOR UPDATE khi sắp sửa dòng đó, và FOR SHARE khi cần bảo đảm dòng không đổi trong lúc mình đọc.

SKIP LOCKED: hàng đợi công việc

Đây là phép đo có chênh lệch lớn nhất trong bài.

Mẫu hàng đợi kinh điển: nhiều tiến trình cùng lấy việc từ một bảng, mỗi việc chỉ được xử lý một lần.

begin;
select id from viec where trang_thai = 'cho' order by id limit 1 for update;
-- xử lý việc, mất 0,25 giây
update viec set trang_thai = 'xong' where id = :id;
commit;

Sáu tiến trình, mỗi tiến trình lấy 12 việc:

Cách viết Thời gian
for update 18,4 giây
for update skip locked 3,2 giây

Nhanh gấp 5,75 lần.

Lý do nằm ở order by id limit 1. Cả sáu tiến trình đều nhắm vào cùng một dòng — dòng chờ có id nhỏ nhất. Tiến trình đầu khoá được và giữ 0,25 giây; năm tiến trình còn lại xếp hàng đợi chính dòng đó. Khi nó xong, dòng đã đổi trạng thái, chúng phải chạy lại truy vấn, và lại cùng nhắm vào dòng tiếp theo.

Kết quả là sáu tiến trình chạy hoàn toàn tuần tự: 72 việc × 0,25 giây = 18 giây, đúng bằng 18,4 giây đo được. Sáu tiến trình cho không một chút song song nào.

SKIP LOCKED bảo PostgreSQL bỏ qua những dòng đang bị khoá thay vì đợi. Mỗi tiến trình nhận một dòng khác nhau, và 18 giây công việc chia cho sáu tiến trình ra đúng 3 giây — bằng 3,2 giây đo được.

Đây là lý do bạn không cần một hệ thống hàng đợi riêng cho phần lớn công việc nền. Một bảng, một câu lệnh, và SKIP LOCKED.

Người anh em của nó là NOWAIT: thay vì bỏ qua dòng bị khoá, nó báo lỗi ngay. Dùng khi bạn muốn nói với người dùng "bản ghi này đang được người khác sửa" thay vì để họ ngồi đợi.

Chẩn đoán khi có người bị treo

select a.pid,
       pg_blocking_pids(a.pid) as bi_chan_boi,
       round(extract(epoch from now() - a.query_start), 1) as doi_bao_lau,
       left(a.query, 60) as cau_lenh
from pg_stat_activity a
where cardinality(pg_blocking_pids(a.pid)) > 0;

Kết quả đo được:

pid 12915  bị chặn bởi {12909}  đợi 2,1 giây  câu lệnh: update tk2 set so_du=2 where id=1

pg_blocking_pids là hàm quan trọng nhất ở đây — nó trả về mảng các tiến trình đang chặn tiến trình này, đã tính sẵn qua chuỗi chờ. Không cần tự nối pg_locks với chính nó.

Nhìn vào pg_locks của tiến trình bị chặn thấy rõ cơ chế:

locktype = transactionid   mode = ShareLock   granted = false

Nó không đợi một khoá trên dòng hay trên bảng — nó đợi ShareLock trên giao dịch đang giữ dòng đó, đúng như thông báo deadlock đã nói.

Muốn giải phóng ngay thì huỷ tiến trình đang chặn:

select pg_cancel_backend(12909);    -- huỷ câu lệnh đang chạy, nhẹ hơn
select pg_terminate_backend(12909); -- ngắt hẳn kết nối

Ưu tiên pg_cancel_backend trước — nó chỉ huỷ câu lệnh hiện tại. Nếu tiến trình đang ở trạng thái idle in transaction thì không có câu lệnh nào để huỷ, và bạn phải dùng pg_terminate_backend.

lock_timeout: đừng đợi mãi

Mặc định, một câu lệnh đợi khoá sẽ đợi vô hạn. Đặt trần cho nó:

set lock_timeout = '500ms';

Đo với một dòng đang bị giữ 8 giây:

ERROR:  canceling statement due to lock timeout      sau 0,57 s

Đây là tham số tôi thấy đáng đặt nhất trong toàn bộ bài, và nó thường bị bỏ quên. Không có nó, một giao dịch quên commit sẽ khiến hàng chờ dài dần cho tới khi hết kết nối trong pool — và lúc đó cả ứng dụng dừng, không chỉ phần chạm vào dòng ấy.

Đặt ở mức kết nối trong ứng dụng web, hoặc mặc định cho cả một vai:

alter role ung_dung set lock_timeout = '3s';
alter role ung_dung set statement_timeout = '30s';
alter role ung_dung set idle_in_transaction_session_timeout = '60s';

Ba tham số này bổ sung cho nhau: lock_timeout chặn chờ khoá, statement_timeout chặn câu lệnh chạy quá lâu, và idle_in_transaction_session_timeout chặn chính cái nguyên nhân đã đo ở phần 21.

Tóm lại

Vấn đề Cách xử lý
Deadlock Khoá theo thứ tự nhất quán; và vẫn phải có vòng thử lại cho 40P01
Hàng đợi công việc chạy tuần tự for update skip locked
Muốn báo ngay thay vì đợi for update nowait
Chờ khoá vô hạn lock_timeout
Không biết ai đang chặn ai pg_blocking_pids

Một điều đáng nhớ từ phần 25 vẫn đúng ở đây: deadlock trả về SQLSTATE 40P01, và vòng thử lại của bạn phải bắt cả nó lẫn 40001. Deadlock không phải lỗi lập trình cần sửa bằng mọi giá — trên hệ thống có tranh chấp thật, nó là chuyện sẽ xảy ra, và cách xử lý đúng là thử lại.

Mẫu số chung

Cái bếp ở đầu bài không phải phép ẩn dụ vui: deadlock không phải một đặc tính riêng của PostgreSQL mà là hiểm hoạ tất yếu của bất kỳ tập hợp bên nào cùng giữ-một-tài-nguyên-độc-quyền-rồi-đợi-một-cái-khác. Khi đồ thị "ai đợi ai" khép thành một vòng tròn, mọi bên trong vòng đó đứng im vĩnh viễn. Và vì đó là một tính chất của cấu trúc chờ chứ không của công nghệ, cách phòng cũng giống nhau ở mọi tầng: áp một thứ tự toàn cục lên việc giành tài nguyên thì vòng tròn không thể hình thành; còn khi không phòng được thì phải dò-và-thử-lại, vì trên tranh chấp thật nó sẽ xảy ra.

Cùng bài toán, đổi ngăn xếp:

  • Trong Java, deadlock cổ điển là hai luồng lấy hai synchronized/ReentrantLock theo thứ tự ngược nhau. Cách chữa sách giáo khoa đúng là thứ tự khoá nhất quán — khoá theo id đối tượng (hoặc System.identityHashCode) trước — y hệt "khoá tài khoản id nhỏ trước" ở đây. Còn jstack / thread dump của JVM đóng vai pg_blocking_pids: nó in thẳng ra "Found one Java-level deadlock" kèm luồng nào giữ khoá nào.
  • Trong Go, hai goroutine lấy hai sync.Mutex ngược thứ tự cũng kẹt như vậy; và với trường hợp cả chương trình đứng, runtime tự phát hiện rồi panic all goroutines are asleep - deadlock! — đúng tinh thần bộ dò của PostgreSQL, chỉ khác là nó giết cả tiến trình.
  • Ở tầng khái niệm, đây chính là bài toán triết gia ăn tối: N triết gia, N chiếc đũa, ai cũng cầm đũa trái rồi đợi đũa phải thì cả bàn chết đói. Lời giải kinh điển vẫn là phá tính đối xứng — bắt một người cầm đũa phải trước — tức áp một thứ tự.

Nên câu hỏi đúng khi code của bạn giữ nhiều khoá cùng lúc không phải "có bị deadlock không" mà là "tôi đã áp một thứ tự nhất quán lên việc lấy khoá chưa, và tôi có vòng thử lại cho lúc nó vẫn xảy ra chưa". Muốn biết hệ mình đang ở đâu, soát nhanh một câu:

select datname, deadlocks,
       round(deadlocks::numeric / nullif(xact_commit + xact_rollback, 0) * 100000, 2)
         as deadlock_tren_100k_giao_dich
from pg_stat_database
where datname = current_database();

Cột deadlocks đếm từ lần đặt lại thống kê gần nhất. Vài cái mỗi ngày trên hệ thống bận là bình thường. Hàng trăm mỗi giờ nghĩa là có một cặp bảng đang được khoá theo hai thứ tự khác nhau ở đâu đó trong mã — và log máy chủ ghi đủ chi tiết truy vấn để tìm ra chỗ đó.

Phần sau đo khoá ở mức bảng: tám chế độ khoá, lệnh nào lấy khoá nào, và vì sao một câu ALTER TABLE tưởng vô hại có thể làm dừng cả hệ thống.