Advisory locks của PostgreSQL cho luồng công việc an toàn khi đồng thời
Tìm hiểu advisory locks của PostgreSQL để ngăn xử lý trùng lặp trong phê duyệt, thanh toán và scheduler với mẫu thực tế, đoạn SQL và kiểm tra đơn giản.

Vấn đề thực tế: hai tiến trình làm cùng một việc
Xử lý trùng lặp xảy ra khi cùng một mục bị xử lý hai lần vì hai tác nhân khác nhau đều nghĩ họ chịu trách nhiệm. Trong ứng dụng thực tế, nó có thể là khách hàng bị tính tiền hai lần, một phê duyệt được áp dụng hai lần, hoặc email “hóa đơn sẵn sàng” gửi đi hai lần. Mọi thứ có thể ổn trong test, nhưng vỡ khi có tải thực tế.
Thường nó xảy ra khi thời gian trở nên chặt chẽ và hơn một thứ có thể hành động:
Hai worker lấy cùng một job cùng lúc. Một retry chạy vì một cuộc gọi mạng chậm, trong khi lần đầu vẫn đang chạy. Người dùng click Approve hai lần vì UI đơ trong giây lát. Hai scheduler chồng nhau sau deploy hoặc lệch đồng hồ. Ngay cả một lần chạm cũng có thể trở thành hai yêu cầu nếu app mobile gửi lại sau timeout.
Phần đau đầu là mỗi tác nhân đều hành xử “hợp lý” theo cách riêng. Lỗi nằm ở khoảng trống giữa chúng: không ai biết người kia đã đang xử lý cùng một bản ghi.
Mục tiêu đơn giản: với một mục nhất định (một order, một yêu cầu phê duyệt, một hóa đơn), chỉ một tác nhân được phép thực hiện công việc quan trọng tại một thời điểm. Những người khác hoặc chờ trong thời gian ngắn hoặc lùi lại và thử lại.
PostgreSQL advisory locks có thể giúp. Chúng cung cấp một cách nhẹ để nói “tôi đang làm mục X” bằng chính database mà bạn đã tin tưởng cho tính nhất quán.
Nhưng hãy đặt kỳ vọng. Một khóa không phải hệ thống hàng đợi đầy đủ. Nó không lập lịch job cho bạn, không đảm bảo thứ tự, và không lưu trữ thông điệp. Nó là cổng an toàn quanh phần workflow không được phép chạy hai lần.
Advisory locks của PostgreSQL là gì (và không phải là gì)
Advisory locks của PostgreSQL là cách đảm bảo chỉ một worker làm một việc tại một thời điểm. Bạn chọn một khóa (ví dụ “invoice 123”), yêu cầu DB khóa nó, làm việc, rồi thả khóa.
Từ “advisory” quan trọng. Postgres không hiểu ý nghĩa khóa của bạn và sẽ không bảo vệ tự động điều gì khác. Nó chỉ theo dõi một chuyện: key này đang bị khóa hay không. Mã của bạn phải nhất trí về định dạng key và phải lấy khóa trước khi chạy phần rủi ro.
Nên so sánh advisory locks với row locks. Row lock (như SELECT ... FOR UPDATE) bảo vệ các hàng trong bảng. Chúng tuyệt khi công việc khớp vừa vặn với một hàng. Advisory locks bảo vệ một key do bạn chọn, hữu ích khi workflow chạm nhiều bảng, gọi dịch vụ ngoài, hoặc bắt đầu trước khi hàng cuối cùng chưa tồn tại.
Advisory locks hữu ích khi bạn cần:
- Hành động một-lần-một cho mỗi thực thể (một phê duyệt cho một yêu cầu, một charge cho một hóa đơn)
- Điều phối giữa nhiều app server mà không thêm dịch vụ khóa riêng
- Bảo vệ quanh một bước workflow lớn hơn một cập nhật hàng đơn lẻ
Chúng không thay thế các công cụ an toàn khác. Chúng không làm cho thao tác trở nên idempotent, không thực thi luật nghiệp vụ, và không ngăn trùng nếu một đường dẫn mã quên lấy khóa.
Người ta gọi chúng “nhẹ” vì bạn có thể dùng mà không thay đổi schema hay hạ tầng. Trong nhiều trường hợp, bạn có thể sửa lỗi xử lý trùng bằng cách thêm một lần gọi khóa quanh một đoạn quan trọng, giữ thiết kế còn lại như cũ.
Loại khóa bạn thực sự sẽ dùng
Khi nói “advisory locks của PostgreSQL”, thường là một số hàm nhỏ. Chọn đúng hàm thay đổi hành vi khi có lỗi, timeout, và retry.
Khóa session vs transaction
Khóa ở cấp session (pg_advisory_lock) tồn tại miễn là kết nối DB còn sống. Điều này tiện cho worker chạy lâu, nhưng cũng có nghĩa khóa có thể tồn tại nếu app crash theo cách để lại kết nối trong pool.
Khóa ở cấp transaction (pg_advisory_xact_lock) gắn vào transaction hiện tại. Khi bạn commit hoặc rollback, Postgres giải phóng nó tự động. Với hầu hết workflow request-response (phê duyệt, click thanh toán, hành động admin), đây là mặc định an toàn hơn vì khó quên thả khóa.
Blocking vs try-lock
Gọi blocking chờ cho đến khi khóa sẵn sàng. Đơn giản, nhưng có thể làm request web cảm thấy mắc kẹt nếu session khác giữ khóa.
Try-lock trả về ngay lập tức:
pg_try_advisory_lock(session-level)pg_try_advisory_xact_lock(transaction-level)
Try-lock thường tốt hơn cho hành động UI. Nếu khóa đã bị chiếm, bạn có thể trả về thông điệp rõ ràng như “Đang được xử lý” và yêu cầu người dùng thử lại.
Shared vs exclusive
Khóa exclusive là “một-lần-một”. Khóa shared cho phép nhiều holder nhưng chặn exclusive. Hầu hết vấn đề xử lý trùng dùng exclusive. Shared hữu ích khi nhiều reader cùng được phép chạy nhưng writer hiếm hoi phải chạy độc quyền.
Cách khóa được giải phóng
Việc giải phóng phụ thuộc loại:
- Session locks: được giải phóng khi disconnect, hoặc gọi
pg_advisory_unlockrõ ràng - Transaction locks: được giải phóng tự động khi transaction kết thúc
Chọn key khóa đúng
Advisory lock chỉ hiệu quả nếu mọi worker cố gắng khóa chính xác cùng một key cho cùng một công việc. Nếu một đường dẫn khóa “invoice 123” và đường khác khóa “customer 45”, bạn vẫn bị trùng.
Bắt đầu bằng việc đặt tên “đối tượng” bạn muốn bảo vệ. Hãy cụ thể: một invoice, một yêu cầu phê duyệt, một lần chạy job lên lịch, hoặc chu kỳ thanh toán hàng tháng của một khách hàng. Quyết định này xác định mức độ song song bạn cho phép.
Chọn phạm vi phù hợp với rủi ro
Hầu hết đội chọn một trong các:
- Theo bản ghi: an toàn nhất cho phê duyệt và hóa đơn (khóa theo invoice_id hoặc request_id)
- Theo khách hàng/tài khoản: hữu ích khi cần tuần tự hóa theo khách hàng (thanh toán, thay đổi credit)
- Theo bước workflow: khi các bước khác nhau có thể chạy song song, nhưng mỗi bước phải chạy một-lần-một
Xem phạm vi như quyết định sản phẩm, không phải chi tiết DB. “Theo bản ghi” ngăn double-click làm trừ tiền hai lần. “Theo khách hàng” ngăn hai job nền tạo sao kê chồng lấn.
Chọn chiến lược key ổn định
Bạn thường có hai lựa chọn: hai số nguyên 32-bit (thường là namespace + id), hoặc một số 64-bit (bigint), đôi khi tạo bằng hashing chuỗi.
Hai-int dễ chuẩn hóa: chọn một số namespace cố định cho mỗi workflow (ví dụ phê duyệt so với thanh toán), và dùng record ID làm giá trị thứ hai.
Hashing hữu ích khi identifier là UUID, nhưng bạn phải chấp nhận rủi ro va chạm nhỏ và nhất quán mọi nơi. Dù chọn gì, hãy viết định dạng xuống và đặt ở chỗ dùng chung. “Gần giống” ở hai chỗ khác nhau là cách phổ biến để tái tạo lỗi trùng.
Bước từng bước: mẫu an toàn cho xử lý một-lần-một
Một workflow khóa tốt là đơn giản: khóa, kiểm tra lại, hành động, ghi lại, commit. Khóa không phải là luật nghiệp vụ tự thân. Nó là hàng rào bảo đảm khiến luật đó đáng tin cậy khi hai worker cùng chạm bản ghi.
Một mẫu thực tế:
- Mở transaction khi kết quả phải nguyên tử.
- Lấy khóa cho đơn vị công việc cụ thể. Ưu tiên khóa cấp transaction (
pg_advisory_xact_lock) để nó tự giải phóng. - Kiểm tra lại trạng thái trong DB. Đừng cho rằng bạn là người đầu tiên. Xác nhận bản ghi vẫn đủ điều kiện.
- Thực hiện công việc và ghi một dấu hiệu “đã xong” bền vững vào DB (cập nhật trạng thái, ghi sổ, hàng audit).
- Commit và để khóa được thả. Nếu dùng session-level lock thì unlock trước khi trả kết nối về pool.
Ví dụ: hai app server nhận “Approve invoice #123” trong cùng một giây. Cả hai bắt đầu, nhưng chỉ một cái lấy được khóa cho 123. Người thắng kiểm tra invoice #123 vẫn là pending, chuyển sang approved, ghi audit/payment, rồi commit. Server thứ hai hoặc thất bại nhanh (try-lock) hoặc chờ rồi lấy khóa sau khi lần đầu xong, thấy trạng thái đã approved và thoát mà không tạo bản ghi trùng.
Nơi advisory locks phù hợp: phê duyệt, thanh toán, scheduler
Advisory locks phù hợp khi luật đơn giản: với một thứ cụ thể, chỉ một process có thể làm công việc “chiến thắng” tại một thời điểm. Bạn giữ DB và mã ứng dụng hiện tại, nhưng thêm cổng nhỏ khiến race khó xảy ra.
Phê duyệt
Phê duyệt là bẫy đồng thời kinh điển. Hai người duyệt (hoặc cùng người click hai lần) có thể nhấn Approve trong vài mili giây. Với khóa theo request ID, chỉ một transaction thực hiện thay đổi trạng thái. Những người khác nhanh chóng biết kết quả và hiển thị thông báo như “đã được phê duyệt” hoặc “đã từ chối”.
Điều này phổ biến trong cổng khách hàng và bảng admin nơi nhiều người xem cùng một hàng đợi.
Thanh toán
Thanh toán thường cần quy tắc nghiêm ngặt hơn: chỉ một lần thử thanh toán cho mỗi hóa đơn, ngay cả khi retry xảy ra. Timeout mạng có thể khiến người dùng click Pay lại, hoặc retry nền chạy khi lần đầu vẫn đang thực thi.
Khóa theo invoice ID đảm bảo chỉ một đường dẫn liên hệ nhà cung cấp thanh toán cùng lúc. Lần thử thứ hai có thể trả về “đang xử lý” hoặc đọc trạng thái thanh toán mới nhất. Điều này ngăn công việc trùng và giảm rủi ro trừ tiền đôi.
Scheduler và worker nền
Trong cấu hình multi-instance, scheduler có thể vô tình chạy cùng một cửa sổ song song. Khóa theo tên job cộng cửa sổ thời gian (ví dụ daily-settlement:2026-01-29) đảm bảo chỉ một instance chạy.
Cách tiếp cận tương tự cho worker kéo mục từ bảng: khóa theo item ID để chỉ một worker xử lý.
Các key phổ biến: một approval request ID, một invoice ID, tên job + cửa sổ thời gian, customer ID cho “một export tại một thời điểm”, hoặc một idempotency key riêng cho retry.
Ví dụ thực tế: ngăn phê duyệt trùng trong portal
Hình dung một yêu cầu phê duyệt trong portal: một PO đang chờ, và hai manager click Approve cùng giây. Không có bảo vệ, cả hai có thể đọc “pending” và cả hai ghi “approved”, tạo audit trùng, thông báo trùng, hoặc kích hoạt downstream hai lần.
PostgreSQL advisory locks cho bạn cách trực tiếp để làm hành động này một-lần-một theo yêu cầu phê duyệt.
Luồng xử lý
Khi API nhận action approve, nó lấy khóa dựa trên approval id (để các approval khác vẫn xử lý song song).
Một mẫu phổ biến: khóa theo approval_id, đọc trạng thái hiện tại, cập nhật trạng thái, rồi ghi audit, tất cả trong một transaction.
BEGIN;
-- One-at-a-time per approval_id
SELECT pg_try_advisory_xact_lock($1) AS got_lock; -- $1 = approval_id
-- If got_lock = false, return "someone else is approving, try again".
SELECT status FROM approvals WHERE id = $1 FOR UPDATE;
-- If status != 'pending', return "already processed".
UPDATE approvals
SET status = 'approved', approved_by = $2, approved_at = now()
WHERE id = $1;
INSERT INTO approval_audit(approval_id, actor_id, action, created_at)
VALUES ($1, $2, 'approved', now());
COMMIT;
Trải nghiệm của click thứ hai
Yêu cầu thứ hai hoặc không lấy được khóa (và trả về “Đang được xử lý”) hoặc lấy khóa sau khi lần đầu hoàn tất, rồi thấy trạng thái đã approved và thoát mà không thay đổi gì. Dù sao thì bạn tránh được xử lý trùng trong khi giữ UI phản hồi tốt.
Để debug, ghi log đủ thông tin: request id, approval id và key tính được, actor id, outcome (lock_busy, already_approved, approved_ok), và thời gian.
Xử lý chờ, timeout và retry mà không làm treo app
Chờ một khóa nghe có vẻ vô hại cho đến khi nó biến thành nút bấm xoay tròn, worker bị kẹt, hoặc backlog không bao giờ hết. Khi không lấy được khóa, fail fast cho đường dẫn có người chờ và chỉ chờ nơi an toàn.
Với hành động người dùng: try-lock và trả về rõ ràng
Nếu ai đó click Approve hoặc Charge, đừng block request vài giây. Dùng try-lock để app trả lời ngay.
Cách thực tế: thử lấy khóa, nếu thất bại thì trả về thông báo “bận, thử lại” (hoặc refresh trạng thái mục). Điều này giảm timeout và hạn chế click lặp lại.
Giữ phần bị khóa ngắn: validate trạng thái, áp dụng thay đổi, commit.
Với job nền: chờ có thể chấp nhận nhưng cần giới hạn
Với scheduler và worker nền, chờ được chấp nhận vì không có người chờ. Nhưng vẫn cần giới hạn, nếu không một job chậm có thể làm tắc cả fleet.
Dùng timeout để worker có thể bỏ cuộc và tiếp tục:
SET lock_timeout = '2s';
SET statement_timeout = '30s';
SELECT pg_advisory_lock(123456);
Cũng đặt thời gian chạy tối đa cho job. Nếu billing thường xong dưới 10s, thì coi 2 phút là sự cố. Theo dõi thời gian bắt đầu, job id và thời lượng giữ khóa. Nếu runner hỗ trợ hủy, hủy các task vượt quá giới hạn để session kết thúc và khóa được giải phóng.
Lên kế hoạch retry có chủ ý. Khi không lấy được khóa, quyết định: lên lịch lại với backoff (và chút randomness), bỏ qua công việc best-effort cho chu kỳ này, hoặc đánh dấu mục là contended nếu thất bại lặp lại cần chú ý.
Sai lầm phổ biến khiến khóa bị kẹt hoặc vẫn trùng
Bất ngờ phổ biến nhất là session-level lock không bao giờ bị thả. Connection pool giữ kết nối mở, nên session có thể sống lâu hơn request. Nếu bạn lấy session lock và quên unlock, khóa có thể tồn tại cho đến khi kết nối được tái chế. Worker khác sẽ chờ (hoặc lỗi) và khó thấy nguyên nhân.
Nguồn khác của trùng là khóa nhưng không kiểm tra lại trạng thái. Khóa chỉ đảm bảo một worker chạy critical section tại một thời điểm. Nó không bảo đảm bản ghi vẫn đủ điều kiện. Luôn kiểm tra lại trong cùng transaction (ví dụ, xác nhận pending trước khi chuyển sang approved).
Key khóa cũng hay vướng: nếu một service khóa theo order_id và service khác khóa theo một key tính khác cho cùng một tài nguyên, bạn có hai khóa khác nhau. Cả hai đường dẫn có thể chạy cùng lúc, tạo cảm giác an toàn giả tạo.
Giữ khóa lâu thường là tự gây ra. Nếu bạn làm các cuộc gọi mạng chậm trong khi giữ khóa (payment provider, email/SMS, webhooks), một hàng rào ngắn trở thành cổ chai. Giữ phần khóa tập trung vào công việc DB nhanh: validate trạng thái, ghi trạng thái mới, ghi điều cần làm tiếp theo. Sau đó kích hoạt side effects khi transaction commit.
Cuối cùng, advisory locks không thay thế idempotency hoặc ràng buộc DB. Xem chúng như đèn giao thông, không phải bằng chứng tuyệt đối. Dùng unique constraint nơi phù hợp, và dùng idempotency key cho các cuộc gọi ra ngoài.
Checklist nhanh trước khi deploy
Xem advisory locks như một hợp đồng nhỏ: mọi người trong team cần biết khóa nghĩa là gì, bảo vệ gì, và được làm gì trong khi nó đang giữ.
Checklist ngắn bắt được hầu hết vấn đề:
- Một key rõ ràng cho mỗi tài nguyên, viết xuống và dùng lại mọi nơi
- Lấy khóa trước khi làm điều gì không thể đảo ngược (thanh toán, email, API ngoài)
- Kiểm tra lại trạng thái sau khi có khóa và trước khi ghi thay đổi
- Giữ phần bị khóa ngắn và đo được (log thời gian chờ khóa và thời gian thực thi)
- Quyết định “lock busy” nghĩa là gì cho mỗi đường dẫn (thông báo UI, retry với backoff, bỏ qua)
Bước tiếp theo: áp dụng mẫu và giữ dễ bảo trì
Chọn một chỗ nơi trùng gây hại nhất và bắt đầu từ đó. Mục tiêu tốt để bắt đầu là các hành động tốn tiền hoặc thay đổi trạng thái vĩnh viễn, như “charge invoice” hoặc “approve request.” Bọc chỉ đoạn then chốt đó bằng advisory lock, rồi mở rộng khi bạn đã tin tưởng hành vi.
Thêm observability cơ bản sớm. Ghi lại khi worker không lấy được khóa, và thời gian công việc bị khóa. Nếu thời gian chờ khóa tăng đột biến, thường nghĩa là đoạn critical quá lớn hoặc một query chậm đang ẩn.
Khóa hoạt động tốt khi chạy trên nền tảng an toàn dữ liệu, không phải thay thế cho nó. Giữ các trường trạng thái rõ ràng (pending, processing, done, failed) và hỗ trợ bằng constraints nơi có thể. Nếu retry xảy ra vào lúc tồi tệ nhất, unique constraint hoặc idempotency key có thể là hàng phòng ngự thứ hai.
Nếu bạn xây workflow trong AppMaster (appmaster.io), bạn có thể áp dụng cùng mẫu bằng cách giữ thay đổi trạng thái then chốt trong một transaction và thêm một bước SQL nhỏ để lấy transaction-level advisory lock trước bước “finalize”.
Advisory locks phù hợp cho tới khi bạn thực sự cần tính năng hàng đợi (ưu tiên, job trì hoãn, dead-letter), khi bạn có contention nặng và cần song song thông minh hơn, khi bạn phải điều phối qua nhiều database không chia sẻ Postgres, hoặc khi bạn cần các quy tắc cô lập khắt khe hơn. Mục tiêu là độ tin cậy nhàm chán: giữ mẫu nhỏ, nhất quán, hiển thị trong log, và được hỗ trợ bởi constraints.
Câu hỏi thường gặp
Dùng advisory lock khi bạn cần “chỉ một tác nhân tại một thời điểm” cho một đơn vị công việc cụ thể, như phê duyệt một yêu cầu, trừ tiền một hóa đơn, hoặc chạy một cửa sổ lịch. Nó đặc biệt hữu ích khi nhiều instance ứng dụng có thể thao tác cùng một mục và bạn không muốn thêm dịch vụ khóa riêng.
Row lock bảo vệ các hàng hiện có mà bạn chọn (ví dụ SELECT ... FOR UPDATE) và phù hợp khi toàn bộ thao tác gắn chặt với một hàng duy nhất. Advisory lock thì bảo vệ một key do bạn chọn, nên hữu ích khi workflow chạm nhiều bảng, gọi dịch vụ ngoài, hoặc bắt đầu trước khi hàng cuối cùng chưa tồn tại.
Ưu tiên pg_advisory_xact_lock (cấp giao dịch) cho các thao tác request/response vì nó được PostgreSQL giải phóng tự động khi commit hoặc rollback. Dùng pg_advisory_lock (cấp session) chỉ khi thực sự cần khóa tồn tại ngoài transaction và bạn chắc chắn sẽ unlock trước khi trả kết nối về pool.
Với hành động do người dùng khởi xướng (UI), ưu tiên try-lock (pg_try_advisory_xact_lock) để yêu cầu có thể trả về ngay và báo "đang xử lý" rõ ràng. Với worker nền, chờ (blocking) có thể chấp nhận được, nhưng nên giới hạn thời gian bằng lock_timeout để một task chậm không làm tắc cả hệ thống.
Khóa phần tử nhỏ nhất mà không thể chạy hai lần — thường là “một hóa đơn” hoặc “một yêu cầu phê duyệt”. Khóa quá rộng (ví dụ theo khách hàng) có thể giảm throughput; khóa quá hẹp thì vẫn có khả năng trùng lặp nếu các đường dẫn dùng key khác nhau.
Chọn một định dạng key ổn định và dùng nó ở mọi nơi có thể thực hiện cùng hành động quan trọng. Một cách phổ biến là hai số nguyên: một namespace cố định cho workflow và ID thực thể, tránh tình trạng các workflow khác nhau chặn nhau vô ý.
Không. Khóa chỉ ngăn thực thi đồng thời; nó không chứng minh thao tác có an toàn khi chạy lặp. Bạn vẫn phải kiểm tra lại trạng thái trong transaction (ví dụ xác nhận mục vẫn ở pending) và dựa vào ràng buộc duy nhất hoặc idempotency cho các phần phù hợp.
Giữ phần được khóa ngắn và tập trung vào DB: lấy lock, kiểm tra điều kiện, ghi trạng thái mới và commit. Thực hiện các tác vụ chậm (thanh toán, email, webhooks) sau khi commit hoặc qua outbox để không giữ khóa trong thời gian gọi mạng.
Nguyên nhân phổ biến nhất là khóa cấp session do kết nối trong pool giữ mà không được unlock vì lỗi đường đi thực thi. Ưu tiên khóa cấp transaction; nếu phải dùng session locks, đảm bảo pg_advisory_unlock luôn chạy trước khi trả kết nối về pool.
Ghi log ID thực thể và key tính toán, thông tin có lấy được lock không, mất bao lâu để lấy lock, và transaction chạy bao lâu. Ghi cả kết quả như lock_busy, already_processed, hoặc processed_ok để phân biệt tranh chấp với trường hợp trùng thực sự.


