Ba phần vừa rồi đều khoá một thứ có thật trong cơ sở dữ liệu — một dòng, một bảng. Nhưng đôi khi thứ cần bảo vệ không phải dữ liệu: "chỉ một tiến trình được gửi báo cáo hàng ngày", "chỉ một máy chủ được chạy công việc dọn dẹp". Advisory lock giải quyết đúng chuyện đó.

Bốn hàm, chống chạy trùng, so sánh giá, và hai cái bẫy

Bốn hàm, hai chiều lựa chọn

Chờ đến khi lấy được Không chờ, báo ngay
Giữ tới khi thả tay pg_advisory_lock pg_try_advisory_lock
Tự thả khi commit pg_advisory_xact_lock pg_try_advisory_xact_lock

Khác biệt giữa hai hàng là phạm vi, và tôi đo nó trực tiếp:

begin;
select pg_advisory_lock(42);
commit;
-- sau commit, còn giữ không?  true
select pg_advisory_unlock(42);  -- phải thả bằng tay
begin;
select pg_advisory_xact_lock(43);
-- trong giao dịch, đang giữ: 1
commit;
-- sau commit, còn lại: 0

Bản _xact_ tự thả khi giao dịch kết thúc — kể cả khi giao dịch bị rollback hoặc kết nối rơi giữa chừng. Bản thường phải gọi pg_advisory_unlock tương ứng, và nếu mã của bạn ném ngoại lệ giữa chừng thì khoá còn đó.

Khác biệt giữa hai cột là cách xử lý khi không lấy được:

pg_try_advisory_lock(99) khi đang bị giữ   ->  trả về f sau 0,09 giây
pg_advisory_lock(99) với lock_timeout=500ms ->  lỗi sau 0,59 giây

try trả về boolean ngay lập tức. Bản thường đợi, và chỉ dừng nếu bạn đã đặt lock_timeout (phần 26) — không có nó thì nó đợi vô hạn.

Mặc định nên dùng pg_try_advisory_xact_lock. Nó vừa không đợi vừa không quên thả.

Ứng dụng chính: chống chạy trùng

Bài toán quen thuộc: bạn chạy ba bản sao của ứng dụng để chịu lỗi, nhưng công việc định kỳ chỉ được chạy một lần. Nhiều nơi giải bằng cách thêm một máy chủ "đặc biệt" chỉ để chạy cron — và thế là mất luôn tính chịu lỗi.

begin;
with kq as (
  insert into log_chay(ai) select :ten
  where pg_try_advisory_xact_lock(777)
  returning 1
)
select case when exists(select 1 from kq) then 'chạy' else 'bỏ qua' end;
-- ... làm việc ...
commit;

Mười tiến trình cùng khởi động:

Kết quả
Không dùng khoá công việc chạy 10 lần
pg_try_advisory_xact_lock(777) 1 chạy, 9 bỏ qua

Chín tiến trình kia nhận false ngay lập tức và thoát. Không ai xếp hàng đợi, không ai bị treo, và không cần bảng nào để phối hợp.

Khoá được thả tự động khi giao dịch kết thúc — kể cả khi tiến trình đang giữ nó bị kill -9, vì PostgreSQL dọn khi kết nối đứt. Đây là khác biệt quan trọng so với việc tự làm cờ trong một bảng: cờ trong bảng thì tiến trình chết để lại cờ bật, và bạn phải viết thêm cơ chế dọn theo thời gian.

Nó không rẻ hơn khoá hàng

Tôi vào bài này với giả định advisory lock nhẹ hơn vì nó không đụng tới dữ liệu. Tám tiến trình cùng tăng một biến đếm, 2.400 giao dịch:

Cách Thời gian
select ... for update rồi update 1,04 s
pg_advisory_xact_lock rồi update 1,19 s

Ngang nhau, advisory còn chậm hơn một chút. Cả hai đều là khoá trong bộ nhớ chia sẻ với cùng cơ chế chờ; việc một bên gắn với dòng còn bên kia không chẳng làm thay đổi chi phí.

Vậy giá trị của advisory lock không nằm ở tốc độ. Nó nằm ở chỗ thứ cần bảo vệ không bắt buộc phải là một dòng có thật:

Tình huống Vì sao khoá hàng không hợp
Công việc định kỳ chạy một lần Không có dòng nào đại diện cho "công việc này"
Chỉ một tiến trình được nhập tệp X Tệp nằm ngoài cơ sở dữ liệu
Nối tiếp thao tác trên nhiều bảng Không có một dòng nào bao trùm cả nhóm
Giới hạn số tiến trình gọi một API ngoài Tài nguyên nằm ở hệ thống khác

Ngược lại, khi thứ cần bảo vệ một dòng, hãy khoá dòng đó. Advisory lock nằm ở một không gian riêng, hoàn toàn không biết gì về bảng và dòng — nên nếu một phần mã dùng advisory lock còn phần khác dùng FOR UPDATE trên cùng dữ liệu, hai bên không thấy nhau và bạn không được bảo vệ gì cả.

Bẫy 1: khoá là số, tên phải tự băm

Advisory lock nhận một số 64 bit, hoặc hai số 32 bit. Không nhận chuỗi. Nên người ta băm tên công việc:

select pg_try_advisory_xact_lock(hashtext('gui-email-hang-ngay'));
-- hashtext trả về 344092897

hashtext trả về integer — 32 bit. Va chạm là chuyện có thật, và tôi đo trên các tên ngẫu nhiên:

Số tên khác nhau Số cặp trùng khoá
1.000 0
10.000 0
100.000 1

Con số này khớp nghịch lý sinh nhật: với không gian 2³², số cặp trùng kỳ vọng ở n = 100.000 là khoảng 1,16.

Hậu quả của một va chạm: hai công việc chẳng liên quan gì nhau bỗng chặn lẫn nhau. Công việc gửi email không chạy được vì công việc đồng bộ kho đang chạy, và không có gì trong log nói lên điều đó.

Với vài chục tên công việc thì rủi ro bằng không. Nhưng nếu bạn băm thứ gì đó nhiều giá trị — mã người dùng, mã đơn hàng, đường dẫn tệp — thì va chạm sẽ tới.

Hai cách tránh:

Dùng dạng hai tham số để tách không gian:

select pg_try_advisory_xact_lock(1, don_hang_id);   -- 1 = nhóm "đơn hàng"
select pg_try_advisory_xact_lock(2, nguoi_dung_id); -- 2 = nhóm "người dùng"

Đơn hàng số 500 và người dùng số 500 không còn đụng nhau.

Hoặc tự đánh số bằng tay. Với vài chục công việc định kỳ, một hằng số trong mã rõ ràng hơn hẳn hashtext, và nhìn vào pg_locks là biết ngay công việc nào đang giữ khoá.

Bẫy 2: pool kết nối không thả khoá mức phiên

Khoá mức phiên được thả khi kết nối đóng — tôi kiểm chứng: một phiên lấy pg_advisory_lock(555), không thả, rồi thoát. Sau đó pg_locks còn 0 dòng.

Nghe an toàn. Nhưng ứng dụng thật không đóng kết nối — nó trả kết nối về pool, và kết nối đó vẫn mở.

Mô phỏng: một kết nối lấy pg_advisory_lock(556) rồi giữ nguyên. Từ kết nối khác:

pg_try_advisory_lock(556)  ->  f
ai đang giữ: pid 238822  loại advisory  cấp=true

Kết nối đó sẽ được pool giao cho một yêu cầu HTTP khác, và yêu cầu ấy vô tình mang theo một khoá nó không hề biết. Vài giờ sau bạn có một pool đầy kết nối mỗi cái giữ vài khoá cũ, và các công việc định kỳ lặng lẽ ngừng chạy — luôn nhận false mà không có lỗi nào.

Chuyện này khó chẩn đoán vì triệu chứng là không có gì xảy ra. Công việc không chạy, không lỗi, không log.

Cách tránh triệt để: luôn dùng bản _xact_. Giao dịch kết thúc là khoá thả, không có đường nào rò rỉ. Chỉ dùng bản mức phiên khi bạn thật sự cần giữ khoá qua nhiều giao dịch, và khi đó phải bảo đảm thả trong khối finally.

Xem ai đang giữ gì

select l.pid,
       l.classid,
       l.objid,
       l.granted,
       a.state,
       round(extract(epoch from now() - a.xact_start)) as giao_dich_mo_giay,
       left(a.query, 50) as cau_lenh
from pg_locks l
join pg_stat_activity a using (pid)
where l.locktype = 'advisory';

Với dạng một tham số 64 bit, PostgreSQL tách nó thành classid (32 bit cao) và objid (32 bit thấp). Với dạng hai tham số, classid là tham số đầu và objid là tham số sau — nên nếu bạn dùng dạng hai tham số với nhóm rõ ràng, cột classid đọc được ngay.

Dòng có granted = false là ai đó đang đợi. Nếu bạn thấy dòng như vậy tồn tại lâu, có nghĩa là có mã đang dùng pg_advisory_lock thay vì pg_try_advisory_lock và đang chờ vô hạn.

Bảng chọn nhanh

Cần Dùng
Công việc định kỳ chạy một lần trên nhiều máy chủ pg_try_advisory_xact_lock(hằng_số)
Nối tiếp thao tác theo một khoá nghiệp vụ pg_advisory_xact_lock(nhóm, id)
Bảo vệ một dòng cụ thể select ... for update (đừng dùng advisory)
Hàng đợi công việc for update skip locked (phần 27)
Giữ khoá qua nhiều giao dịch pg_advisory_lock + thả trong finally

Thử ba mươi giây

select count(*) as so_khoa_advisory,
       count(*) filter (where not granted) as dang_doi
from pg_locks where locktype = 'advisory';

Trên hệ thống dùng advisory lock đúng cách, con số đầu tiên gần như luôn bằng 0 hoặc bằng số công việc đang chạy. Nếu nó lớn và ổn định ở mức cao, khả năng cao là bạn đang gặp đúng bẫy thứ hai: khoá mức phiên bị kẹt trong pool kết nối.

Phần sau đo khoá ở mức bảng: tám chế độ, lệnh nào lấy chế độ 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.