Hình dung một tấm thẻ từ mở cửa phòng khách sạn, được lập trình tự hết hiệu lực đúng giờ trả phòng trưa mai. Nếu bạn nán lại trong phòng sau giờ đó, có một chuyện kỳ lạ xảy ra mà bạn không hề cảm nhận: thẻ của nhân viên dọn phòng giờ mở được cửa, và lễ tân có thể trao phòng cho khách mới — trong khi bạn vẫn đang ngồi trong đó, đinh ninh phòng là của mình. Tấm thẻ không kêu, không báo, nó chỉ lặng lẽ thôi là của bạn. Khoá phân tán dựa trên TTL của Redis hành xử y hệt tấm thẻ ấy, và bài này đo chính xác cái khoảnh khắc nó thôi bảo vệ bạn mà không nói một lời.
SET key value NX PX 30000 là công thức khoá phân tán được chép lại nhiều nhất. Ta đo xem nó bảo đảm gì.
Khoá làm đúng việc của nó
10 worker cùng vào một vùng tô màu:
không khoá 599 lần hai người cùng vào | 0,3 giây
SET NX PX + Lua 0 lần | 3,1 giây
Chậm hơn 10 lần — đó đúng là cái giá của việc xếp hàng, không phải chi phí của Redis. Công thức đầy đủ gồm hai nửa:
SET khoa <token-ngau-nhien> NX PX 30000
và khi giải phóng, phải dùng Lua:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
DEL trần là sai: nếu khoá của bạn đã hết hạn và người khác đã lấy, DEL sẽ xoá khoá của họ. Mệnh đề Lua so token trước khi xoá, và nó nguyên tử — đúng lý do Lua tồn tại, như phần 14.
Nhưng chỉ khi công việc ngắn hơn TTL
| TTL | Công việc | Lần trùng vùng tô màu | Lần mất khoá trước khi xong |
|---|---|---|---|
| 1000 ms | 50 ms | 0 | 0 |
| 200 ms | 500 ms | 48 | 50 |
| 200 ms | 800 ms | 49 | 50 |
50 trên 50 lần lấy khoá đều mất khoá trước khi làm xong việc. Dấu vết thật với ba worker, TTL 200 ms, công việc 800 ms:
worker 0 lấy được lúc 613,082
worker 1 lấy được lúc 613,283 <- đúng 200 ms sau, khi TTL hết
worker 2 lấy được lúc 613,483
worker 0 xong lúc 613,884 <- cả ba cùng trong vùng tô màu
cả ba giải phóng trả về :0 <- "tôi không còn là chủ"
Ba tiến trình cùng lúc, mỗi tiến trình đều tin rằng mình đang giữ khoá — đúng ba người khách cùng được trao một phòng.
Mệnh đề bảo vệ đúng, nhưng muộn
Chú ý cả ba lần giải phóng đều trả về :0. Mệnh đề Lua hoạt động hoàn hảo: nó từ chối xoá khoá của người khác. Nhưng nó chỉ cho biết sau khi đã xong. Nó không ngăn được việc ba tiến trình vừa cùng ghi vào cùng một chỗ.
Đây là điểm cốt lõi: khoá dựa trên TTL bảo đảm loại trừ lẫn nhau chỉ trong khoảng TTL. Vượt quá, nó im lặng ngừng bảo vệ. Không có lỗi, không có ngoại lệ, không có gì trong nhật ký cho tới khi bạn tự kiểm tra giá trị trả về. Và công việc vượt TTL không phải chuyện hiếm: một lần dừng thu gom rác, một truy vấn cơ sở dữ liệu chậm bất thường, một lần máy ảo bị đình chỉ. Tiến trình không biết mình vừa đứng im ba giây.
Redlock không gỡ được điều này
Redlock lấy khoá trên N Redis độc lập và coi là thành công nếu quá bán đồng ý. Nó giải quyết một vấn đề thật: một Redis chết không làm mất khoá của mọi người. Nhưng nó không đụng tới vấn đề đo được ở trên. Vấn đề đó không nằm ở việc lấy khoá; nó nằm ở chỗ thời gian trôi giữa lúc lấy và lúc dùng, và Redlock cũng dựa trên đồng hồ và TTL y hệt.
Đây chính là nội dung phê bình nổi tiếng của Martin Kleppmann, và phép đo ở trên là minh hoạ trực tiếp: kể cả với một Redis duy nhất hoàn toàn khoẻ mạnh, không mất gói tin, không phân vùng mạng, khoá vẫn hỏng khi công việc dài hơn TTL.
Vậy làm gì
Nếu khoá chỉ để tối ưu — tránh làm việc trùng lặp, giảm tải — thì SET NX PX là đủ. Hai tiến trình cùng làm một việc thỉnh thoảng chỉ tốn tài nguyên, không sai. Phần lớn cách dùng khoá trong thực tế thuộc nhóm này: chỉ một máy chạy công việc định kỳ, chỉ một tiến trình làm mới bộ đệm.
Nếu khoá để bảo đảm tính đúng đắn — chỉ một tiến trình được ghi — thì khoá Redis không đủ, và Redlock cũng không. Bạn cần một trong hai:
- Rào chắn bằng số thứ tự. Mỗi lần lấy khoá cấp một số tăng dần; hệ thống lưu trữ từ chối ghi mang số nhỏ hơn số nó đã thấy. Tiến trình bị treo quay lại với số cũ sẽ bị từ chối. Điều này đòi hệ thống lưu trữ hợp tác — Redis một mình không làm được.
- Đưa tính đúng đắn về nơi lưu dữ liệu. Ràng buộc duy nhất, cập nhật có điều kiện, giao dịch. Nếu cơ sở dữ liệu của bạn từ chối ghi trùng thì bạn không cần khoá.
Nếu vẫn dùng khoá Redis, ba việc giảm thiểu:
- Đặt TTL rộng rãi hơn nhiều so với thời gian làm việc dự kiến. Không phải gấp đôi — gấp mười.
- Gia hạn định kỳ từ một luồng riêng (
PEXPIREvới cùng mệnh đề bảo vệ token). Đây là điều các thư viện khoá tốt tự làm. - Kiểm giá trị trả về lúc giải phóng. Nhận
0nghĩa là bạn vừa làm việc mà không có khoá — đáng ghi cảnh báo, vì nó là dấu hiệu TTL đang quá ngắn.
Điểm 3 rẻ nhất và ít được làm nhất. Ở phép đo trên, con số đó đi từ 0 lên 50/50 — nó là chỉ báo sớm hoàn hảo.
Mẫu số chung: thời hạn là lời hứa mà người giữ không cảm được lúc nó hết
Cái ta vừa đo là bản chất của mọi khoá dựa trên thời hạn (lease). Một lease bảo đảm loại trừ lẫn nhau chỉ trong khoảng thời hạn của nó, và điểm chết người là: người đang giữ không có cách nào cảm nhận được lúc thời hạn hết — một lần dừng GC, một lần máy ảo bị đình chỉ, một truy vấn chậm là đủ để nó tiếp tục hành động như thể còn giữ khoá trong khi khoá đã thuộc về người khác. Không đồng hồ phía khách nào tin được, và không thêm bao nhiêu bản sao khoá sửa được — vấn đề nằm ở thời gian trôi giữa kiểm tra và hành động, không ở việc lấy khoá.
Trong thế giới Java, đây đúng là lý do khoá ZooKeeper/etcd (dù đồng thuận mạnh hơn Redis nhiều) vẫn dính đúng bẫy này, và tài liệu ShedLock nói thẳng "khoá này là best effort, đừng dùng nó để bảo đảm tính đúng đắn" — nếu cần đúng, phải có fencing token: mỗi lần lấy khoá cấp một số tăng dần, và nơi lưu trữ từ chối mọi lần ghi mang số cũ hơn số nó đã thấy. Khoá không làm cho đúng; cái token bị nơi lưu trữ kiểm mới làm cho đúng.
Trong Go, khoá qua lease của etcd (concurrency.Mutex) cùng câu chuyện: lease có thể hết trong khi client bị treo, nên tính đúng đắn vẫn phải dựa trên một số fencing hoặc một giao dịch có điều kiện tại nơi ghi.
Còn ở cơ sở dữ liệu, đây là lý do SELECT ... FOR UPDATE trong một giao dịch an toàn hơn hẳn một khoá TTL bên ngoài: loại trừ lẫn nhau do chính giao dịch giữ, không do một đồng hồ đếm ngược, nên nó không thể "hết hạn giữa chừng". Và fencing token chính là cột version trong khoá lạc quan (optimistic locking) hay một UNIQUE constraint — nơi lưu dữ liệu tự từ chối ghi sai, thay vì tin rằng ai đó còn giữ khoá.
Mẫu số chung: một khoá dựa trên thời hạn chỉ bảo đảm loại trừ trong khoảng thời hạn và ngừng bảo vệ trong im lặng khi hết — nên nếu cần tính đúng đắn thật, đừng dựa vào đồng hồ phía khách mà bắt chính nơi lưu trữ thực thi nó bằng fencing token, ghi có điều kiện, hoặc ràng buộc duy nhất. Câu hỏi đúng không phải "khoá của tôi có chắc không" mà là "khoá này để tối ưu hay để đúng đắn — nếu để đúng đắn, cái gì sẽ từ chối một tiến trình treo quay lại sau khi TTL đã hết". Với Redis, câu trả lời gần như luôn là: hãy đưa tính đúng đắn về nơi lưu dữ liệu.
Muốn tự xem khoá của bạn có đang hết hạn giữa chừng không:
# do thoi gian lam viec that so voi TTL dang dat
redis-cli --scan --pattern 'lock:*' | while read k; do
echo "$(redis-cli pttl "$k") ms $k"
done | sort -n | head -20
Rồi trong mã, đổi phần giải phóng để đếm:
released = r.eval(SCRIPT, 1, key, token)
if released == 0:
metrics.increment("lock.mat_khoa_truoc_khi_xong")
Bộ đếm đó lớn hơn 0 nghĩa là hệ thống của bạn đang chạy đúng tình huống trong bài này — nhiều tiến trình cùng trong vùng tô màu — và không ai biết. Đó là con số đáng đặt cảnh báo hơn bất kỳ chỉ số nào khác về khoá.
Phần sau: bộ đệm — các mẫu dùng Redis làm bộ đệm và chỗ mỗi mẫu hỏng.