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.

Deadlock, thời gian phát hiện, thứ tự khoá, và SKIP LOCKED

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.

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 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.

Thử ba mươi giây

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.