Lập trình · 21/09/2026

Khóa phân tán an toàn cho Backend: Lease, Fencing Token và xử lý mất quyền sở hữu

Khóa phân tán không chỉ là một key có TTL và cũng không thể biến mạng thành một môi trường đồng bộ tuyệt đối. Một tiến trình có thể lấy khóa, tạm dừng vì garbage collection hoặc máy ảo bị treo, để lease hết hạn rồi tiếp tục chạy như thể nó vẫn còn quyền. Trong lúc đó, tiến trình khác đã lấy khóa mới và ghi dữ liệu. Nếu tài nguyên đích không phân biệt được hai “thế hệ” chủ sở hữu, tác vụ cũ có thể ghi đè kết quả mới dù dịch vụ khóa đã hoạt động đúng theo hợp đồng hết hạn.

Khóa phân tán an toàn cho Backend: Lease, Fencing Token và xử lý mất quyền sở hữu

Khóa phân tán không chỉ là một key có TTL và cũng không thể biến mạng thành một môi trường đồng bộ tuyệt đối. Một tiến trình có thể lấy khóa, tạm dừng vì garbage collection hoặc máy ảo bị treo, để lease hết hạn rồi tiếp tục chạy như thể nó vẫn còn quyền. Trong lúc đó, tiến trình khác đã lấy khóa mới và ghi dữ liệu. Nếu tài nguyên đích không phân biệt được hai “thế hệ” chủ sở hữu, tác vụ cũ có thể ghi đè kết quả mới dù dịch vụ khóa đã hoạt động đúng theo hợp đồng hết hạn.

Bài viết này trình bày cách thiết kế khóa phân tán thực dụng cho backend: xác định khi nào thật sự cần khóa, tách mutual exclusion khỏi quyền ghi lên tài nguyên, dùng lease và ownership token đúng vai trò, thêm fencing token tăng đơn điệu, giới hạn thời gian chờ, xử lý trạng thái kết quả chưa biết, và kiểm thử các lỗi khó như pause, partition hay gia hạn thất bại. Trọng tâm không phải một thư viện cụ thể mà là các bất biến cần giữ khi nhiều process, container hoặc vùng mạng cùng cạnh tranh một tài nguyên.

1. Trước tiên, hãy hỏi có thật sự cần khóa phân tán không

Khóa thường được đưa vào quá sớm để che một bài toán có thể giải gọn hơn tại nơi lưu dữ liệu. Nếu cần chống tạo trùng đơn hàng, unique constraint hoặc idempotency key đáng tin cậy hơn một mutex đặt bên ngoài database. Nếu cần trừ tồn kho, một câu lệnh cập nhật có điều kiện nguyên tử bảo vệ bất biến tốt hơn quy trình đọc rồi khóa rồi ghi. Nếu cần chỉ một consumer xử lý mỗi partition, cơ chế phân công sẵn có của message broker thường rõ nghĩa hơn một khóa tự chế.

Nhu cầuCơ chế nên ưu tiênKhi khóa phân tán có thể phù hợp
Không tạo bản ghi trùngUnique constraint, idempotency keyHiếm khi cần
Cập nhật một hàng dữ liệuTransaction, compare-and-set, row lockKhi tài nguyên nằm ngoài database
Chỉ một scheduler chủ độngLeader election hoặc scheduler có coordinationCó, nếu dịch vụ cung cấp session/lease rõ ràng
Điều khiển thiết bị hay file dùng chungAPI tài nguyên có version hoặc fencingCó, nhưng khóa phải đi kèm fencing
Phân phối hàng triệu job nhỏQueue, partition, work stealingKhông nên dùng một khóa nóng

Khóa phân tán hợp lý nhất cho coordination thô, thời gian giữ ngắn và số bên cạnh tranh có kiểm soát: bầu leader, chuyển một shard, chạy tác vụ bảo trì duy nhất, hoặc điều phối quyền truy cập một hệ thống cũ không có primitive nguyên tử phù hợp. Nó không nên trở thành transaction manager tổng quát cho mọi request.

2. Bốn sự cố làm vỡ trực giác “tôi đang giữ khóa”

Trong một process, mutex gắn với vòng đời luồng và bộ nhớ chung. Trong hệ thống phân tán, dịch vụ khóa chỉ quan sát request qua mạng. Nó không biết ứng dụng đang chạy, bị pause, mất kết nối hay đã chết. Vì vậy cần thiết kế cho ít nhất bốn trường hợp:

  1. Process pause: tiến trình giữ khóa dừng lâu hơn TTL rồi thức dậy, vẫn còn stack và dữ liệu cũ để tiếp tục ghi.
  2. Network partition: client không liên lạc được với dịch vụ khóa nhưng vẫn liên lạc được với database hoặc thiết bị đích.
  3. Phản hồi thất lạc: lệnh acquire hoặc renew đã thành công ở server nhưng client timeout, nên không biết kết quả thật.
  4. Clock và scheduling: đồng hồ tường có thể nhảy; timer, event loop và thread có thể bị trễ dù process chưa crash.

Không thể suy ra “process còn sống nên lease còn hiệu lực”, cũng không thể suy ra “request timeout nên acquire chắc chắn thất bại”. Thiết kế phải coi quyền sở hữu là trạng thái có hạn, có thể mất bất kỳ lúc nào, và mọi side effect quan trọng cần một hàng rào độc lập ở tài nguyên đích.

Lease cho biết một client được phép thử làm việc trong một khoảng thời gian; fencing token cho tài nguyên đích biết yêu cầu nào đã lỗi thời.

3. Lease, ownership token và fencing token khác nhau ra sao

Lease là quyền sở hữu có thời hạn. TTL giúp hệ thống cuối cùng tiến lên khi holder chết mà không kịp unlock. Ownership token là giá trị ngẫu nhiên gắn với một lần acquire, dùng để client chỉ gia hạn hoặc giải phóng đúng khóa của mình. Fencing token là số tăng đơn điệu được cấp cho từng thế hệ sở hữu; tài nguyên đích từ chối thao tác mang token nhỏ hơn token lớn nhất đã chấp nhận.

acquire("invoice-export")
  -> lease_id: "random-owner-value"
  -> fencing_token: 731
  -> expires_in_ms: 15000

Ba giá trị không thay thế nhau. TTL không ngăn holder cũ tiếp tục chạy. Random ownership token ngăn client A xóa nhầm khóa mới của B, nhưng không cho storage biết A đã lỗi thời. Fencing token bảo vệ thứ tự thế hệ, nhưng chỉ có tác dụng khi nơi thực hiện side effect lưu và kiểm tra token một cách nguyên tử.

4. Kịch bản stale holder và cách fencing chặn ghi cũ

Giả sử worker A nhận fencing token 41 và bắt đầu tạo file báo cáo. A bị pause 30 giây, lease 15 giây hết hạn. Worker B lấy khóa, nhận token 42, ghi file mới và storage lưu last_fence = 42. Sau đó A thức dậy và gửi kết quả với token 41. Nếu storage chỉ kiểm tra chữ ký hoặc đường dẫn, kết quả cũ có thể ghi đè file của B. Nếu storage so sánh fencing token, yêu cầu của A bị từ chối.

UPDATE export_targets
SET object_version = :new_version,
    last_fence = :token,
    updated_at = CURRENT_TIMESTAMP
WHERE id = :target_id
  AND last_fence < :token;

Client phải kiểm tra số hàng bị ảnh hưởng. Kết quả bằng 0 có nghĩa token đã cũ hoặc tài nguyên không tồn tại; đó không phải thành công idempotent mặc định. Với object storage hay thiết bị không hỗ trợ điều kiện fencing trực tiếp, có thể đặt một gateway duy nhất chịu trách nhiệm kiểm tra token trước khi ghi, hoặc thiết kế side effect theo version bất biến rồi cập nhật con trỏ hiện hành bằng compare-and-set.

Fencing token cần tăng theo thứ tự mà dịch vụ coordination xác nhận quyền sở hữu. Một sequence trong database, revision của kho khóa tuyến tính hóa, hoặc counter được cấp nguyên tử có thể làm nguồn token. Timestamp từ đồng hồ client không phải fencing token đáng tin cậy vì có thể trùng, lùi hoặc lệch giữa máy.

5. Acquire và release phải có hợp đồng nguyên tử

Một acquire tối thiểu phải tạo khóa chỉ khi khóa chưa tồn tại, gắn TTL và ownership token trong cùng thao tác nguyên tử. Không dùng chuỗi “kiểm tra rồi set” vì hai client có thể cùng thấy khóa trống. Release phải xóa chỉ khi token hiện tại khớp token của holder; lệnh DEL vô điều kiện có thể xóa lease mới sau khi lease cũ đã hết hạn.

owner = secureRandom()
acquired = putIfAbsent(
  key = "locks/report:monthly",
  value = owner,
  ttl = 15 seconds
)

releaseAtomicallyIfValueEquals(key, owner)

Gia hạn cũng phải là compare-and-renew nguyên tử. Nếu token không còn khớp, client đã mất quyền và phải chuyển sang trạng thái hủy; không được tạo lại key rồi tiếp tục cùng công việc. Giới hạn số lần gia hạn hoặc tổng thời gian giữ để một holder sống nhưng lỗi không chiếm tài nguyên vô hạn.

6. Chọn TTL, heartbeat và ngân sách thực thi

TTL quá ngắn làm lease hết hạn khi có pause bình thường; quá dài kéo dài thời gian phục hồi khi holder chết. Hãy đo thời gian acquire, độ trễ mạng, pause runtime và thời gian thực thi theo percentile thay vì chọn số đẹp. Tác vụ không có giới hạn thời gian thì không thể có TTL an toàn chỉ bằng cấu hình.

  • Đặt deadline tổng cho công việc và dừng nhận bước mới trước khi lease gần hết.
  • Gia hạn sớm hơn TTL với khoảng dự phòng đủ cho jitter và một lần retry có giới hạn.
  • Dùng monotonic clock trong client để đo thời lượng cục bộ; không suy luận quyền từ wall clock.
  • Đánh dấu context là “ownership lost” ngay khi renew thất bại hoặc không còn đủ thời gian xác nhận.
  • Side effect cuối cùng vẫn phải kèm fencing token dù heartbeat hoạt động ổn định.

Ví dụ lease 30 giây có thể heartbeat mỗi 8 giây và coi quyền đã mất nếu không xác nhận renew trước một safety margin đã định. Các con số phải dựa trên telemetry và đặc tính workload; chúng không phải mặc định dùng chung cho mọi hệ thống.

7. Kết quả chưa biết không được xử lý như thất bại chắc chắn

Nếu acquire timeout, server có thể đã cấp lease nhưng phản hồi bị mất. Retry mù có thể tạo thêm session hoặc làm client tự cạnh tranh với chính mình. API nên hỗ trợ request ID ổn định, truy vấn trạng thái session, hoặc bảo đảm acquire cùng session là idempotent. Nếu không thể phân giải, client phải để lease nghi ngờ tự hết hạn trước khi thử lại theo chính sách an toàn.

Tương tự, timeout khi ghi tài nguyên không chứng minh side effect chưa xảy ra. Kết hợp operation ID idempotent với fencing token: token trả lời “thế hệ nào được phép”, còn operation ID trả lời “đây có phải cùng một thao tác được gửi lại hay không”. Hai cơ chế giải quyết hai chiều lỗi khác nhau.

8. Quy trình holder nên được viết như một state machine

state = ACQUIRING
lease = acquire(deadline)
if lease.failed: return BUSY

state = ACTIVE
start heartbeat(lease)
try:
  while hasMoreSteps():
    require state == ACTIVE
    require lease.remaining() > safetyMargin
    performBoundedStep()
  commitSideEffect(fence = lease.fencingToken,
                   operationId = command.id)
finally:
  state = RELEASING
  releaseIfOwner(lease.ownerToken)

Heartbeat chạy nền phải có cách báo mất quyền về luồng chính. Chỉ log lỗi renew rồi để công việc tiếp tục là một lỗi correctness. Mỗi bước dài cần hỗ trợ cancellation hoặc được chia nhỏ để kiểm tra trạng thái giữa các bước. Nếu lời gọi bên ngoài không thể hủy, kết quả của nó vẫn phải đi qua kiểm tra fencing trước khi trở thành trạng thái hiện hành.

Release là best effort để giảm thời gian chờ, không phải nền tảng duy nhất của an toàn. Khi process crash, TTL phải giải phóng lease. Khi release timeout, client không được giả định khóa đã biến mất và giao cùng tài nguyên cho một tác vụ khác trong chính process đó.

9. Phạm vi khóa và contention quyết định khả năng mở rộng

Một khóa toàn cục như locks/import dễ hiểu nhưng biến mọi tenant thành một hàng chờ. Đặt key theo bất biến nhỏ nhất cần tuần tự hóa, ví dụ locks/customer:{id}:billing-sync. Tuy nhiên key quá chi tiết có thể bỏ sót bất biến liên quan nhiều tài nguyên. Hãy xác định rõ aggregate và thứ tự lấy khóa nếu một thao tác cần nhiều key.

  • Dùng thứ tự khóa toàn cục để giảm deadlock khi buộc phải lấy nhiều khóa.
  • Giới hạn thời gian chờ acquire; trả busy hoặc đưa vào queue thay vì giữ request vô hạn.
  • Retry với exponential backoff và jitter để tránh herd cùng thức dậy.
  • Không giữ lease trong lúc chờ người dùng hoặc gọi dependency không có timeout.
  • Nếu contention cao liên tục, chuyển sang queue/partition theo key thường rõ ràng hơn.

10. Chọn dịch vụ coordination theo mức bảo đảm

Đừng chọn chỉ vì hệ thống đã có Redis, database hoặc một key-value store. Cần đọc hợp đồng consistency, failover, session, lease và revision của sản phẩm cụ thể. Một dịch vụ dựa trên consensus có thể cung cấp revision tăng và transaction so sánh phù hợp cho fencing. Một cache replicated bất đồng bộ có thể chấp nhận mất lock state khi failover, điều không phù hợp với side effect có yêu cầu correctness cao.

Câu hỏi đánh giáĐiều cần xác nhận
Acquire có tuyến tính hóa không?Hai client có thể cùng tin mình thắng trong fault model nào?
Lease hết hạn dựa trên gì?Ảnh hưởng của clock shift, pause và partition
Có revision tăng đơn điệu không?Có thể dùng làm fencing token hay phải cấp riêng
Failover giữ bảo đảm nào?Dữ liệu đã xác nhận có thể mất hay không
Client library xử lý session ra sao?Renew, reconnect, unknown outcome và cancellation

Không nên tự triển khai consensus hoặc thuật toán khóa đa node từ vài đoạn mã blog. Dùng client được duy trì, pin phiên bản tương thích, nhưng vẫn đặt fencing tại tài nguyên vì thư viện không thể biết side effect của ứng dụng.

11. Quan sát đúng tín hiệu thay vì chỉ đếm acquire thành công

Dashboard cần cho thấy thời gian chờ acquire, thời gian giữ, số lần renew, renew failure, lease expiry khi holder còn chạy, fencing rejection và số thao tác kết thúc sau khi mất quyền. Tách timeout, busy, session lost và lỗi dịch vụ coordination thành các outcome khác nhau.

Log có cấu trúc nên chứa lock key đã chuẩn hóa, operation ID, fencing token, trạng thái chuyển tiếp, thời lượng và kết quả; không ghi payload nhạy cảm hoặc tạo metric label theo ID có cardinality vô hạn. Cảnh báo khi p99 hold time tiến gần TTL, tỷ lệ fencing rejection tăng, hoặc một key nóng chiếm phần lớn thời gian chờ.

12. Kiểm thử các lỗi mà happy path không thể hiện

Test quan trọng nhất cần chứng minh holder cũ không thể commit sau holder mới. Tạm dừng A vượt TTL, cho B acquire và commit với token cao hơn, sau đó cho A tiếp tục; storage phải từ chối A. Ngoài ra, hãy mô phỏng mất phản hồi acquire, renew timeout, release đến muộn, process crash, clock shift ở môi trường thử nghiệm, failover dịch vụ khóa và dependency đích chậm.

  • Hai contender khởi động cùng lúc: chỉ một bên nhận thế hệ hiện hành.
  • Holder chết không release: contender khác cuối cùng tiến lên sau expiry.
  • Holder cũ thức dậy: side effect bị fencing từ chối.
  • Release cũ đến muộn: không xóa lease mới vì ownership token khác.
  • Acquire outcome chưa biết: retry không tạo hai workflow hoạt động.
  • Tải cao trên một key: hệ thống giữ giới hạn chờ và không retry đồng loạt.

Checklist production

  • Bất biến và phạm vi tài nguyên được viết rõ; đã loại các giải pháp đơn giản hơn khóa phân tán.
  • Acquire, renew và release đều nguyên tử theo ownership token.
  • Lease có deadline, safety margin và tổng thời gian giữ hữu hạn.
  • Mất renew làm holder dừng nhận việc và không được tự nhận lại quyền.
  • Mọi side effect quan trọng mang fencing token và nơi đích từ chối token cũ.
  • Operation ID bảo vệ retry khỏi lặp side effect; không nhầm nó với fencing token.
  • Contention, hold time, session loss và fencing rejection đều có telemetry.
  • Fault test đã bao phủ pause, partition, unknown outcome, failover và late release.

Một khóa phân tán an toàn không kết thúc ở câu trả lời “ai đang giữ key”. Thiết kế phải chấp nhận rằng client có thể mất quyền mà chưa biết, rằng timeout tạo ra kết quả chưa xác định, và rằng holder cũ vẫn có thể gửi lệnh. Lease giúp phục hồi, ownership token giúp thao tác đúng session, còn fencing token biến quyền sở hữu thành điều kiện mà tài nguyên đích có thể thực thi. Khi không thể đặt hàng rào đó, hãy giảm mức bảo đảm được tuyên bố hoặc thiết kế lại workflow thay vì dựa vào TTL như một bằng chứng tuyệt đối.

Tài liệu tham khảo

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.