sync.Mutex (đã đo ở các bài đồng thời) khóa tuyệt vời — nhưng chỉ trong một tiến trình. Khi bạn chạy ba bản sao của cùng một dịch vụ trên ba máy chủ, và cả ba cùng muốn "xử lý đúng một lần cái đơn hàng này" hay "chỉ một bản chạy job cron này", mutex vô dụng: mỗi tiến trình có mutex riêng, chúng không thấy nhau.
Câu trả lời là distributed lock — một khóa nằm ngoài mọi tiến trình, ở một nơi tất cả cùng thấy. Redis là lựa chọn phổ biến nhất vì nhanh và có sẵn các lệnh nguyên tử phù hợp. Nhưng distributed lock đầy cạm bẫy tinh vi: làm sai thì bạn có một khóa trông như khóa nhưng thỉnh thoảng cho hai người vào cùng lúc — tệ hơn không khóa, vì bạn tưởng mình an toàn. Bài này dựng một khóa Redis đúng trong Go, đo thật nó, và chỉ ra vì sao mỗi chi tiết lại quan trọng.
Lấy khóa: SET key token NX PX ttl
Toàn bộ việc lấy khóa gói trong một lệnh Redis duy nhất:
func acquire(ctx context.Context, rdb *redis.Client, key, token string, ttl time.Duration) bool {
// NX: chỉ set nếu CHƯA tồn tại -> chỉ 1 người thắng
// PX ttl: tự hết hạn -> tránh deadlock nếu chủ khóa chết
ok, _ := rdb.SetNX(ctx, key, token, ttl).Result()
return ok
}
Hai cờ làm nên tất cả:
NX(set if Not eXists): lệnh chỉ thành công nếu khóa chưa tồn tại. Redis xử lý lệnh tuần tự, nên khi nhiều client cùng gửiSET NX, đúng một thắng — đây là nguyên tử tính cho phép loại trừ. Người thắng nhậntrue, người thua nhậnfalse.PX ttl(thời gian sống tính bằng mili-giây): khóa tự động hết hạn sauttl. Đây không phải chi tiết tùy chọn — nó là thứ cứu bạn khỏi deadlock vĩnh viễn khi tiến trình đang giữ khóa bị crash trước khi kịp trả. Không có TTL, một máy chủ chết mang theo khóa xuống mồ và không ai vào được nữa.
Hai điều bắt buộc: token duy nhất và TTL
Ngoài NX/PX, hai quyết định thiết kế là cốt lõi cho tính đúng đắn:
Token duy nhất. Mỗi client giữ khóa gắn vào nó một giá trị riêng (một UUID). Vì sao? Để khi trả khóa, ta biết chắc khóa đang giữ là của ta, không phải của người khác. Đây là mấu chốt của phần an toàn bên dưới.
TTL bắt buộc. Như đã nói, khóa phải tự hết hạn. Nhưng TTL sinh ra chính cái bẫy nguy hiểm nhất — nên phần trả khóa phải cực kỳ cẩn thận.

Hình 1: Distributed lock Redis trong Go — lấy khóa bằng SET NX PX (một người thắng, tự hết hạn), trả khóa bằng script Lua so sánh-rồi-xóa nguyên tử để không bao giờ xóa nhầm khóa người khác.
Trả khóa an toàn: vì sao phải dùng Lua
Đây là phần mà hầu hết implement sai. Cách ngây thơ: GET khóa xem có phải token của ta không, nếu đúng thì DEL. Nhưng đó là hai lệnh riêng biệt, và giữa chúng có một khe hở chết người:
- Ta
GET, thấy token khớp — đúng là khóa của ta. - Ngay lúc đó TTL hết hạn, khóa tự biến mất.
- Người khác
SET NXthành công, giờ họ giữ khóa với token của họ. - Ta chạy
DEL— và xóa nhầm khóa của người khác. Giờ hai người cùng nghĩ mình độc chiếm tài nguyên.
Cách sửa là làm cả cụm "so sánh token rồi xóa" thành một thao tác nguyên tử. Redis cho phép chạy script Lua nguyên tử (không lệnh nào chen vào giữa):
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end`)
func release(ctx context.Context, rdb *redis.Client, key, token string) int64 {
n, _ := unlockScript.Run(ctx, rdb, []string{key}, token).Int64()
return n // 1 nếu xóa đúng khóa của ta, 0 nếu không
}
Script này chạy trọn vẹn trên Redis mà không bị ngắt, nên khe hở ở bước 2-3 không tồn tại. Nó trả về 1 nếu xóa đúng khóa của ta, 0 nếu khóa không phải của ta (đã hết hạn hoặc người khác đang giữ).
Đo thật: loại trừ, an toàn, và tranh chấp nặng
Chạy demo với miniredis (một Redis thật viết bằng Go, chạy trong bộ nhớ) và client go-redis.
1. Loại trừ cơ bản:
A lấy khóa: true | B lấy khóa (khi A đang giữ): false
release A: 1 (1=xóa đúng khóa của A)
B thử lại sau khi A trả: true
A lấy được khóa (true), B thất bại trong khi A còn giữ (false). Chỉ sau khi A trả khóa (release trả 1), B mới lấy được. Đúng loại trừ.
2. An toàn release:
release với token SAI: 0 (0=từ chối, khóa vẫn còn)
release với token ĐÚNG: 1 (1=xóa thành công)
Gọi release với token sai trả 0 — script Lua từ chối xóa vì token không khớp, khóa vẫn nguyên. Chỉ token đúng mới xóa được (1). Đây chính là lớp bảo vệ chống xóa nhầm.
3. 50 goroutine tranh một khóa — phép thử khắc nghiệt nhất. Mỗi goroutine lặp lấy khóa cho đến khi được, vào critical section, rồi trả. Tôi đếm số goroutine đồng thời trong critical section:
Số lần lấy khóa: 50
Đỉnh cao nhất goroutine ĐỒNG THỜI trong CS: 1
Cả 50 goroutine đều lần lượt lấy được khóa (không có ai bị đói), nhưng đỉnh số goroutine cùng lúc trong critical section luôn là 1. Khóa giữ đúng cam kết loại trừ ngay cả dưới tranh chấp nặng — không bao giờ có hai goroutine cùng vào vùng độc chiếm.

Hình 2: Đo thật ba kịch bản — loại trừ (A giữ được, B thất bại rồi thành công sau khi A trả), an toàn release (token sai trả 0, đúng trả 1), và 50 goroutine tranh chấp giữ đỉnh critical section đúng bằng 1.
Đánh đổi cần cân nhắc
TTL là con dao hai lưỡi. Đặt quá ngắn: nếu công việc trong critical section chạy lâu hơn TTL, khóa hết hạn giữa chừng và người khác vào — mất loại trừ. Đặt quá dài: nếu chủ khóa chết, tài nguyên bị khóa cho tới khi TTL hết, có thể là hàng phút. Giải pháp production thường dùng watchdog gia hạn khóa (tự động kéo dài TTL trong khi công việc còn chạy), nhưng nó thêm phức tạp và vẫn có ca biên.
Một Redis là điểm chết đơn (single point of failure). Nếu Redis chết, không ai lấy được khóa. Nếu Redis failover sang replica chưa kịp nhận khóa vừa ghi, hai client có thể cùng giữ khóa. Đây là vấn đề thật, không phải lý thuyết.
Redlock (nhiều node Redis) vẫn gây tranh cãi. Thuật toán Redlock của chính Redis dùng nhiều node độc lập để tăng độ bền, nhưng có cuộc tranh luận nổi tiếng giữa các chuyên gia về việc nó có thực sự đúng dưới các giả định thời gian và mạng khắc nghiệt hay không. Kết luận thực dụng: distributed lock Redis phù hợp khi việc "thỉnh thoảng hai người cùng vào" gây khó chịu nhưng không thảm họa (ví dụ tránh làm việc trùng). Khi tính đúng đắn là tuyệt đối bắt buộc (không bao giờ được hai người), bạn cần một hệ đồng thuận thật sự như Raft — chủ đề bài sau — hoặc một cơ chế fencing token.
Ba ý mang về
- Distributed lock giải bài toán mutex không giải được: loại trừ giữa các tiến trình/máy chủ — lấy khóa bằng
SET key token NX PX ttl(NX cho đúng một người thắng, PX để khóa tự hết hạn tránh deadlock), đo thật loại trừ đúng 1 người và 50 goroutine tranh chấp vẫn giữ đỉnh critical section = 1. - Trả khóa phải dùng Lua so sánh-rồi-xóa nguyên tử:
GETrồiDELbằng hai lệnh riêng có khe hở khiến bạn xóa nhầm khóa người khác sau khi TTL hết — đo thật, token sai bị từ chối (trả 0), chỉ token đúng mới xóa (trả 1). - Nêu rõ đánh đổi và giới hạn: TTL ngắn dễ hết giữa chừng, dài thì chết lâu mới nhả; một Redis là điểm chết đơn và failover có thể cho hai người cùng giữ khóa; khi cần đúng tuyệt đối phải dùng đồng thuận thật thay vì khóa Redis.
Bài tập làm thử
Bài 1 (đọc hiểu). Trong lệnh rdb.SetNX(ctx, key, token, ttl), hãy giải thích vai trò riêng biệt của NX và PX ttl — nếu bỏ PX ttl (không đặt hết hạn) thì hệ quả xấu nhất là gì?
Đáp án
NX (set if Not eXists) đảm bảo lệnh chỉ thành công nếu khóa chưa tồn tại, nên khi nhiều client cùng gửi, đúng một người thắng — đây là nguyên tử tính cho loại trừ. PX ttl làm khóa tự hết hạn sau ttl, cứu hệ thống khỏi deadlock nếu tiến trình giữ khóa crash. Nếu bỏ PX ttl: một tiến trình giữ khóa mà chết trước khi kịp trả sẽ khiến khóa tồn tại mãi mãi, không ai khác vào được nữa — deadlock vĩnh viễn.
Bài 2 (sửa lỗi). Đoạn mã trả khóa sau có một khe hở nguy hiểm. Chỉ ra vấn đề và sửa lại theo đúng cách bài đã trình bày.
func release(ctx context.Context, rdb *redis.Client, key, token string) bool {
val, _ := rdb.Get(ctx, key).Result()
if val == token {
rdb.Del(ctx, key)
return true
}
return false
}
Đáp án
Vấn đề: GET rồi DEL là hai lệnh riêng biệt, không nguyên tử. Giữa hai lệnh, TTL có thể hết hạn, một client khác SET NX thành công và giữ khóa mới — nhưng DEL ở dưới vẫn chạy và xóa nhầm khóa của người khác. Sửa: dùng script Lua nguyên tử để so sánh token rồi xóa trong một thao tác duy nhất không bị ngắt giữa chừng:
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end`)
func release(ctx context.Context, rdb *redis.Client, key, token string) int64 {
n, _ := unlockScript.Run(ctx, rdb, []string{key}, token).Int64()
return n
}
Bài 3 (vận dụng thực tế). Bài đo được với 50 goroutine tranh một khóa, "đỉnh cao nhất goroutine đồng thời trong CS" luôn bằng 1. Giả sử bạn đặt TTL rất ngắn (ví dụ 10ms) trong khi công việc trong critical section trung bình mất 50ms. Điều gì có thể xảy ra, và vì sao?
Đáp án
Nếu công việc trong critical section (50ms) chạy lâu hơn TTL (10ms), khóa sẽ tự hết hạn giữa chừng trong khi tiến trình vẫn đang xử lý — trước khi công việc xong. Một client khác lúc đó SET NX thành công và cũng vào critical section. Kết quả: hai tiến trình cùng xử lý đồng thời — mất loại trừ, dù trông như đang dùng khóa an toàn. Đây chính là đánh đổi "TTL là con dao hai lưỡi" nêu trong bài: TTL quá ngắn so với thời lượng công việc thực tế là nguy hiểm.
Bài 4 (bẫy/đánh đổi). Vì sao bài nói "Redlock (nhiều node Redis) vẫn gây tranh cãi", và theo kết luận thực dụng của bài, khi nào nên dùng distributed lock Redis, khi nào nên chuyển sang một hệ đồng thuận thật (như Raft)?
Đáp án
Redlock dùng nhiều node Redis độc lập để tăng độ bền, nhưng có tranh luận nổi tiếng giữa các chuyên gia hệ phân tán về việc nó có thực sự đúng dưới các giả định thời gian và mạng khắc nghiệt hay không (chưa có đồng thuận tuyệt đối về tính đúng đắn của nó). Kết luận thực dụng: dùng distributed lock Redis khi việc "thỉnh thoảng hai người cùng vào" gây khó chịu nhưng không thảm họa (ví dụ tránh làm việc trùng lặp). Khi tính đúng đắn là tuyệt đối bắt buộc (không bao giờ được phép hai người cùng giữ), cần một hệ đồng thuận thật như Raft, hoặc cơ chế fencing token.
Bài 5 (đọc hiểu output Go). Kết quả đo "an toàn release" cho: release với token SAI: 0, release với token ĐÚNG: 1. Giải thích chính xác điều gì xảy ra bên trong script Lua ở mỗi trường hợp, và vì sao giá trị trả về 0 không có nghĩa là "xóa thất bại do lỗi" mà là điều mong muốn.
Đáp án
Với token sai: redis.call("GET", KEYS[1]) trả về token thật đang lưu trong Redis, so sánh với ARGV[1] (token sai được truyền vào) là không khớp, nên nhánh else chạy và trả 0 — script từ chối xóa, khóa vẫn còn nguyên trong Redis. Với token đúng: điều kiện khớp, redis.call("DEL", KEYS[1]) chạy và trả về số key đã xóa (1). Giá trị 0 không phải lỗi kỹ thuật — nó là hành vi đúng theo thiết kế: ngăn một client không sở hữu khóa (do khóa đã hết hạn và bị người khác lấy, hoặc gọi nhầm) xóa mất khóa hiện tại của chủ sở hữu thật.
Phần sau ta đi vào chính cái "đồng thuận thật" vừa nhắc: thuật toán Raft cơ bản trong Go — cách nhiều node bầu leader và thống nhất một log, nền tảng của khóa và cấu hình phân tán đáng tin cậy.