Fencing Tokens: Vì Sao Khóa Phân Tán TTL Vẫn Gây Ghi Đè Dữ Liệu

Distributed lock dùng TTL không thể ngăn race condition khi tiến trình bị dừng đột ngột. Tìm hiểu cách fencing token bảo vệ dữ liệu.

Trong bài viết này

Khóa phân tán (distributed lock) sử dụng cơ chế thời gian sống (TTL lease) giúp hệ thống tránh rơi vào bế tắc (deadlock) khi một tiến trình đột ngột gặp sự cố. Tuy nhiên, bản thân TTL không thể đảm bảo tính loại trừ tương hỗ tuyệt đối (mutual exclusion). Khi xảy ra các khoảng dừng ngoài dự tính—chẳng hạn như dừng tiến trình để thu gom rác (garbage collection pause), quá tải CPU hoặc nghẽn mạng cục bộ—khóa sẽ tự hết hạn trong âm thầm, dẫn đến việc worker cũ ghi đè dữ liệu hợp lệ của worker mới.

Để đảm bảo tính toàn vẹn dữ liệu cho các hệ thống phân tán, bộ quản lý khóa cần cấp phát một fencing token (mã chặn tăng dần đơn điệu) và bắt buộc tầng lưu trữ phải kiểm tra token này trên từng thao tác ghi.

Ảo Tưởng An Toàn Từ Khóa Dựa Trên TTL

Cách tiếp cận phổ biến nhất khi triển khai distributed lock trong các dịch vụ backend là tận dụng bộ nhớ chia sẻ như Redis hoặc Valkey. Một worker muốn giành quyền xử lý tài nguyên sẽ gửi lệnh cấp khóa kèm thời gian tồn tại:

SET lock:invoice:42 "worker-1" NX PX 10000

Lệnh trên có nghĩa: cấp khóa cho worker-1 trong vòng 10 giây nếu chưa có ai giữ khóa. Nếu worker-1 hoàn tất công việc trong 200 mili-giây, nó chủ động xóa key và giải phóng tài nguyên. Nếu worker-1 bị sập nguồn giữa chừng, sau 10 giây TTL key sẽ tự động biến mất, giúp các worker khác không bị chặn vô thời hạn.

Vấn đề cốt lõi nằm ở chỗ: trong môi trường mạng phân tán bất đồng bộ, các tiến trình không có đồng hồ vật lý đồng bộ tuyệt đối và tốc độ thực thi không bao giờ cố định. Việc khóa hết hạn chỉ phản ánh quan điểm của server lưu khóa (Redis), chứ không có bất kỳ cơ chế nào thông báo hay cưỡng chế tạm dừng tiến trình đang giữ khóa.

Giải Phẫu Lỗi Ghi Đè Dữ Liệu Ngầm

Sơ đồ biểu diễn hai worker đồng thời ghi dữ liệu vào database khi khóa phân tán hết hạn ngoài ý muốn

Hãy quan sát kịch bản thực tế khi hệ thống chịu tải nặng:

  1. Worker A xin thành công lock:invoice:42 với thời hạn lease là 10 giây.
  2. Worker A đọc dữ liệu hóa đơn, tính toán số dư và chuẩn bị ghi kết quả vào database.
  3. Worker A bất ngờ dính phải một đợt Garbage Collection kéo dài 12 giây hoặc nghẽn I/O ổ đĩa trên máy ảo.
  4. Trong lúc Worker A đang "bất tỉnh", 10 giây TTL trên Redis trôi qua và khóa hết hiệu lực.
  5. Worker B đến xin lock:invoice:42, nhận khóa thành công, tính toán hóa đơn và ghi dữ liệu mới phiên bản V2 vào database.
  6. Worker A tỉnh lại sau đợt GC. Vì luồng xử lý tiếp tục thực thi câu lệnh tiếp theo trong bộ nhớ mà không hề biết thời gian đã trôi qua bao lâu, Worker A ung dung ghi đè dữ liệu cũ V1 lên database.

Kết quả là dữ liệu hợp lệ của Worker B đã bị Worker A ghi đè hoàn toàn. Việc dùng thread gia hạn khóa ngầm (watchdog timer / auto-renew) chỉ giảm bớt rủi ro cho các tác vụ chạy chậm bình thường, chứ hoàn toàn bất lực khi chính luồng gia hạn cũng bị đóng băng theo tiến trình.

Cơ Chế Khắc Phục Bằng Fencing Token

Trong bài phân tích kinh điển về độ an toàn của distributed lock, kỹ sư Martin Kleppmann đã chỉ rõ rằng không thể dựa vào mốc thời gian để đảm bảo an toàn tuyệt đối trong mạng phân tán. Nơi duy nhất có thể ngăn chặn ghi đè sai lệch chính là tầng lưu trữ dữ liệu cuối cùng (database/storage).

Fencing token là một số nguyên tăng dần đơn điệu (monotonically increasing number) được cấp phát mỗi khi có một worker xin khóa thành công:

  • Khi Worker A nhận khóa, bộ quản lý khóa cấp token 101.
  • Khi Worker A bị trễ và khóa hết hạn, Worker B nhận khóa mới với token 102.
  • Worker B gửi dữ liệu kèm token 102 vào cơ sở dữ liệu. Database ghi nhận token hợp lệ gần nhất là 102.
  • Khi Worker A tỉnh lại và gửi dữ liệu kèm token cũ 101, database kiểm tra điều kiện 101 < 102 và từ chối yêu cầu của Worker A ngay lập tức.
Lock Service         Worker A (Token 101)        Worker B (Token 102)        Database (Token gần nhất: 0)
     |                        |                           |                             |
     |--- Cấp khóa (101)----->|                           |                             |
     |                        | [Dừng 12s do GC...]       |                             |
     | (TTL hết hạn)          |                           |                             |
     |--- Cấp khóa (102)--------------------------------->|                             |
     |                        |                           |--- Ghi dữ liệu (Token 102)->| (Chấp nhận: 102 > 0)
     |                        |                           |                             | [Lưu Token: 102]
     |                        |--- Ghi dữ liệu (Token 101)----------------------------->| (Từ chối: 101 < 102)

Kiểm Tra Fencing Token Tại Tầng Cơ Sở Dữ Liệu

Để áp dụng fencing token, cơ sở dữ liệu đích phải hỗ trợ thao tác ghi có điều kiện (conditional update). Trong cơ sở dữ liệu quan hệ (PostgreSQL, MySQL), chúng ta thêm một cột lưu token của lần cập nhật gần nhất:

UPDATE invoices
SET 
    status = 'PROCESSED',
    total_amount = $amount,
    last_fencing_token = $current_token
WHERE 
    id = $invoice_id
    AND last_fencing_token < $current_token;

Nếu số lượng dòng bị ảnh hưởng (rowCount) trả về là 0, ứng dụng biết ngay rằng đã có một worker mới hơn xử lý bản ghi này. Khi đó, ứng dụng sẽ hủy giao dịch mà không làm hỏng dữ liệu.

Trong code backend (ví dụ Node.js / TypeScript), chúng ta xử lý ngoại lệ này một cách chủ động:

async function updateWithFencing(invoiceId: string, data: InvoiceData, token: number) {
  const result = await db.query(
    `UPDATE invoices 
     SET status = $1, total_amount = $2, last_fencing_token = $3 
     WHERE id = $4 AND last_fencing_token < $3`,
    [data.status, data.total, token, invoiceId]
  );

  if (result.rowCount === 0) {
    throw new Error(`Phát hiện stale lock. Fencing token ${token} bị từ chối.`);
  }
}

Với các kho dữ liệu NoSQL như DynamoDB hay S3, các biểu thức điều kiện như ConditionExpression (last_fencing_token < :token) hoặc ETag If-Match cũng mang lại khả năng bảo vệ tương đương.

Khóa Tối Ưu Hiệu Năng Đối Lập Khóa Tính Đúng

Khi thiết kế kiến trúc hệ thống, lập trình viên cần phân định rõ ràng giữa hai mục đích sử dụng distributed lock:

  1. Khóa hiệu năng (Efficiency Locks): Dùng để tránh việc nhiều worker lãng phí tài nguyên xử lý trùng lặp một công việc (ví dụ: tạo thumbnail ảnh, làm nóng cache). Nếu một lỗi hiếm hoi xảy ra khiến hai tiến trình cùng chạy, kết quả vẫn không gây sai lệch dữ liệu kinh doanh. Với mục đích này, lock Redis cơ bản dựa vào TTL là đủ tốt và tiết kiệm chi phí.
  2. Khóa tính đúng (Correctness Locks): Dùng cho các luồng nghiệp vụ quan trọng nơi việc ghi đè gây mất mát tiền bạc hoặc hỏng trạng thái (ví dụ: thanh toán, quyết toán sổ cái, trừ tồn kho kho hàng). Với những nghiệp vụ này, chỉ dựa vào TTL lock là vô cùng rủi ro.

Nếu hệ thống đích của bạn không có khả năng kiểm tra token (ví dụ gọi sang một API bên thứ ba không hỗ trợ versioning), bản thân distributed lock không thể bảo đảm an toàn trước sự cố trễ mạng. Chốt chặn an toàn duy nhất luôn phải nằm tại nơi dữ liệu thực sự được ghi xuống.

Nguồn tham khảo

  1. vertexaisearch.cloud.google.com
  2. vertexaisearch.cloud.google.com
  3. vertexaisearch.cloud.google.com
  4. vertexaisearch.cloud.google.com
  5. vertexaisearch.cloud.google.com
  6. vertexaisearch.cloud.google.com

Có hỗ trợ AI · PKN duyệt lại

0

Phản hồi

Đang tải bình luận…