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, 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ệ là 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.