Hai phần trước nói mạng không tin được và đồng hồ không tin được. Phần này ghép cả hai vào một công cụ mà hầu như ai cũng từng viết trong mười phút: khoá phân tán bằng SET key value NX PX ttl. Nó đúng — cho tới khi tiến trình giữ khoá không chết mà chỉ chậm: GC dừng vài trăm mili-giây, máy ảo bị tạm dừng, một breakpoint trong debugger. Khoá hết hạn, người khác lấy, rồi tiến trình đầu tỉnh dậy và tiếp tục như chưa có gì. Bài này đo chuyện đó xảy ra bao nhiêu lần, và thứ duy nhất chặn được nó không nằm ở Redis.

Khoá, mở khoá, và token

Ảnh chụp đoạn mã nền tối khoá phân tán SET NX TTL và token để tầng lưu trữ tự vệ với Go và go-redis v9 Redis 7 khoá TTL 300 ms PostgreSQL 16 ví thêm redis-lab2 redis-lab3 cho Redlock, lấy khoá và cấp fencing token trong một script Lua gồm nếu SET KEYS1 ARGV1 NX PX ARGV2 tức khoá tên chủ TTL thì return INCR KEYS2 tức lấy được thì trả token tăng dần ngược lại return 0 là không lấy được, mở khoá chỉ xoá khi mình còn là chủ vì DEL trần là xoá khoá của người khác gồm nếu GET KEYS1 bằng ARGV1 thì DEL KEYS1 ngược lại return 0, vùng găng đọc rồi làm việc có thể bị treo quá TTL rồi ghi với fence ở tầng lưu trữ gồm QueryRow select so_du from vi where id 1, time.Sleep treo với ghi chú GC pause hoặc swap khoá hết hạn mình không biết, không fence thì update vi set so_du where id 1 ghi đè bất kể ai đã ghi sau khi mình đọc, có fence thì update vi set so_du fence where id 1 and fence nhỏ hơn token tức chỉ ghi nếu token của mình mới hơn token của lần ghi cuối và nếu RowsAffected bằng 0 thì bị chặn khoá đã mất lấy khoá mới rồi làm lại, Redlock xin ở 3 Redis độc lập cần 2 trên 3 và tổng thời gian xin phải nhỏ hơn TTL gồm vòng for từng rdbs SetNX rl khoa chu ttl nếu ok thì n tăng và nếu n lớn hơn hoặc bằng 2 và time.Since t nhỏ hơn ttl thì return true còn không đủ hoặc quá chậm thì thả hết đã lấy

Hình 1: Khoá và token cấp trong một script. Mở khoá so chủ trước khi xoá. Fence kiểm ở tầng lưu trữ, không phải ở Redis. Redlock xin ở ba Redis độc lập.

Ba mảnh, và mảnh nào cũng là cách chữa một lỗi cụ thể:

  • Lấy khoá và cấp token trong một script Lua. SET NX PX giữ chỗ; nếu được thì INCR một bộ đếm và trả về. Token tăng dần qua mọi lần lấy khoá — người lấy khoá sau luôn có token lớn hơn người lấy trước. Ở phần 2, đây chính là một đồng hồ logic: nó xếp thứ tự "ai giữ khoá sau ai" mà không cần đồng hồ tường.
  • Mở khoá chỉ khi mình còn là chủ. GET rồi so với tên mình rồi mới DEL, trong một script để không ai chen vào giữa. DEL trần thì xoá bất kể khoá đang là của ai.
  • Fence ở tầng lưu trữ. Mỗi lần ghi mang theo token, và câu UPDATE chỉ chạy khi token mới hơn token của lần ghi cuối (WHERE fence < $2). Redis không tham gia bước này — nó chỉ cấp số.

Đo thật: ba cách khoá hỏng

Khoá TTL 300 ms. Vùng găng: đọc số dư ví trong PostgreSQL, cộng 1, ghi lại. Mỗi kịch bản 100 vòng:

Ảnh chụp output thật nền tối treo quá TTL mất 100 trên 100 fencing chặn đủ 100 với Go 1.23 go-redis v9 pgx v5 Redis 7 TTL 300 ms PostgreSQL 16 100 vòng mỗi kịch bản, mục 1 A lấy khoá rồi treo 500 ms TTL 300 ms B lấy khoá lúc 350 ms làm bình thường cho thấy khoá cộng TTL không fence kỳ vọng 200 số dư thật 100 ghi cũ bị chặn 0 với ghi chú A ghi đè B không ai biết, khoá cộng fencing token kỳ vọng 200 số dư thật 200 ghi cũ bị chặn 100 với ghi chú lưu trữ từ chối token cũ A làm lại, mục 2 A treo quá TTL rồi tỉnh dậy mở khoá mà khoá lúc đó là của B cho thấy DEL trần B và C cùng giữ khoá 100 trên 100 vòng với ghi chú A xoá khoá của B C vào, Lua so chủ rồi mới DEL B và C cùng giữ khoá 0 trên 100 vòng, mục 3 một Redis so với Redlock 3 node cần 2 trên 3 200 lần lấy khoá cho thấy một Redis lấy được 200 trên 200 p50 0.13 ms p99 0.29 ms, Redlock 3 trên 3 sống lấy được 200 trên 200 p50 0.13 ms p99 0.30 ms loopback nên rẻ, một Redis chính nó chết lấy được 0 trên 200 p50 89.95 ms p99 109.85 ms không ai lấy được, Redlock 2 trên 3 sống lấy được 200 trên 200 p50 86.98 ms p99 105.86 ms trả giá 1 timeout, nói thẳng TTL là thứ giữ hệ thống sống khi chủ khoá chết và cũng chính nó làm khoá mất an toàn khi chủ khoá chỉ chậm không khoá nào biết được tiến trình của bạn đang treo chỉ tầng lưu trữ với một token tăng dần mới từ chối được bản ghi cũ

Hình 2: Kết quả thật. Mục 1 là lỗi im lặng nhất, mục 2 là lỗi dễ mắc nhất, mục 3 là câu hỏi "một Redis có đủ không".

Mục 1 — treo quá TTL. A lấy khoá, đọc số dư, rồi treo 500 ms (giả lập bằng Sleep, nhưng GC pause hay VM bị tạm dừng cho đúng hiệu ứng này). Lúc 300 ms khoá hết hạn. Lúc 350 ms B lấy được khoá, đọc, cộng 1, ghi, thả khoá. Lúc 500 ms A tỉnh dậy, không hề biết khoá đã mất, ghi số dư nó đọc từ trước cộng 1 — đè lên bản của B. 100 vòng, 100 cập nhật của B biến mất, số dư 100 thay vì 200. Không exception, không log, không 409: với A mọi thứ thành công.

Thêm fencing token: B lấy khoá sau A nên token của B lớn hơn; B ghi kèm token, cột fence giờ là token của B. A tỉnh dậy, UPDATE ... WHERE fence < token_của_A — không khớp dòng nào, RowsAffected = 0. A biết mình đã mất khoá, lấy khoá mới (token mới, lớn hơn), đọc lại, ghi lại. 100 bản ghi cũ bị chặn, số dư đúng 200. Điểm mấu chốt: Redis không thể chặn chuyện này vì Redis không biết A đang treo — chỉ tầng lưu trữ, nơi bản ghi cuối cùng hạ cánh, mới có đủ thông tin để từ chối.

Mục 2 — mở khoá bằng DEL trần. A treo quá TTL, B lấy khoá và đang làm việc, A tỉnh dậy và "dọn dẹp" bằng DEL. Khoá của B biến mất. C xin khoá ngay sau đó và được — trong khi B vẫn đang trong vùng găng. 100/100 vòng hai bên cùng giữ khoá. Script so chủ trước khi xoá: 0/100. Lỗi này nằm trong gần như mọi bản khoá tự viết, và nó không cần GC pause dài — chỉ cần vùng găng thỉnh thoảng lâu hơn TTL.

Mục 3 — một Redis hay Redlock. Khi cả ba Redis sống, Redlock (xin ở ba, cần hai) tốn như một Redis trên loopback: 0,13 ms. Khi một Redis chết: Redis đơn — nếu đó là nó — cho 0/200, không ai lấy được khoá nữa và mọi vùng găng dừng; Redlock vẫn 200/200, với giá mỗi lần lấy khoá ăn một timeout 100 ms chờ node chết. Redlock mua tính sẵn sàng của việc lấy khoá, không mua tính đúng đắn: nó vẫn dựa vào TTL và vẫn cần fencing token như mục 1.

Vì sao TTL vừa cứu vừa hại

Không có TTL, tiến trình giữ khoá chết là khoá bị giữ mãi, cả hệ thống dừng. TTL là cách nói "nếu quá lâu không thả thì coi như chết". Nhưng Redis — hay bất kỳ dịch vụ khoá nào — không phân biệt được chết với chậm: cả hai đều là "quá TTL không thấy gì". Khi tiến trình chỉ chậm, TTL trả khoá cho người khác trong lúc chủ cũ vẫn tin mình đang giữ. Đây không phải lỗi cài đặt, là giới hạn của bất kỳ khoá nào dựa trên thời gian — phần 1 đã đo: không có cách nào biết bên kia đang làm gì ngoài việc chờ.

Fencing token thoát khỏi giới hạn đó bằng cách không cần biết ai đang giữ khoá. Tầng lưu trữ chỉ cần biết thứ tự: bản ghi mang số nhỏ hơn số đã thấy thì từ chối. Thứ tự đó là thứ tự lấy khoá, không phải thứ tự theo đồng hồ, nên nó miễn nhiễm với mọi thứ phần 2 đã đo.

Đánh đổi cần cân nhắc

Fencing đòi tầng lưu trữ hợp tác. PostgreSQL thì dễ: một cột và một điều kiện WHERE. Lưu trữ không có so-sánh-rồi-ghi (một API bên thứ ba, một file trên S3 không có If-Match) thì không làm được, và lúc đó khoá chỉ là tối ưu hoá để giảm xung đột, không phải cơ chế để ngăn xung đột. Nên thiết kế với giả định xung đột vẫn xảy ra.

Token phải tăng dần qua mọi lần cấp, kể cả khi Redis khởi động lại. INCR trong demo nằm trong Redis; Redis mất dữ liệu (không bật persistence, hoặc failover sang bản sao chưa đồng bộ) là bộ đếm quay về 0 và token mới nhỏ hơn token cũ — tầng lưu trữ sẽ từ chối người giữ khoá hợp lệ. Hệ thống khoá nghiêm túc (ZooKeeper, etcd) cấp token từ log đã đồng thuận; phần 4 sẽ dùng etcd lease, nơi revision của etcd chính là fencing token miễn phí.

TTL dài hay ngắn. Ngắn (300 ms như demo) thì khoá bị mất oan khi vùng găng thỉnh thoảng chậm; dài (30 giây) thì tiến trình chết thật làm cả hệ thống chờ 30 giây. Cách thường dùng là TTL vừa phải kèm gia hạn định kỳ (watchdog) trong lúc còn làm việc — và chấp nhận rằng watchdog cũng bị treo cùng tiến trình, nên fencing vẫn cần.

Redlock có tranh cãi, và tranh cãi đó đúng chỗ. Lập luận của Martin Kleppmann: Redlock không an toàn khi đồng hồ nhảy hoặc tiến trình treo, và mục 1 ở đây là chính xác kịch bản đó. Lập luận của Salvatore Sanfilippo: Redlock giải quyết tính sẵn sàng khi một node Redis chết, không hứa gì hơn. Cả hai đúng. Dùng Redlock khi khoá là hiệu quả (tránh hai worker làm trùng việc, làm trùng cũng không hỏng); dùng khoá có fencing — hoặc giao dịch trong chính kho dữ liệu — khi khoá là đúng đắn.

Số đo loopback, treo bằng Sleep. 500 ms treo là cố ý để chắc chắn vượt TTL. GC pause thật của Go hiếm khi quá vài ms; của JVM có thể vài giây; VM live-migrate có thể hàng chục giây. Con số 100/100 là của một kịch bản chắc chắn xảy ra; xác suất ngoài đời thấp hơn nhiều — nhưng mỗi lần xảy ra là một cập nhật mất không dấu vết.

Ba ý mang về

  1. Khoá có TTL mất an toàn khi chủ khoá chỉ chậm, không chết: A treo 500 ms với TTL 300 ms là 100/100 cập nhật của B bị đè, không lỗi, không log. Redis không phân biệt được chậm với chết, và không khoá dựa trên thời gian nào làm được.
  2. Fencing token ở tầng lưu trữ là thứ duy nhất chặn được: token tăng dần cấp cùng lúc với khoá, UPDATE ... WHERE fence < token từ chối 100/100 bản ghi cũ và A biết để làm lại — số dư đúng 200. Nó cần kho dữ liệu có so-sánh-rồi-ghi.
  3. Mở khoá phải so chủ, và Redlock chỉ mua sẵn sàng: DEL trần xoá khoá của người khác 100/100 lần; Redlock cho lấy khoá 200/200 khi một Redis chết (Redis đơn: 0/200) nhưng vẫn cần TTL và fencing như mọi khoá khác.

Nguồn

Phần sau chuyển từ "ai giữ khoá" sang "ai là chủ": bầu leader bằng etcd lease, giết leader rồi đo thời gian tới khi có leader mới, và vì sao revision của etcd chính là fencing token miễn phí.