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ửi SET NX, đúng một thắng — đây là nguyên tử tính cho phép loại trừ. Người thắng nhận true, người thua nhận false.
  • PX ttl (thời gian sống tính bằng mili-giây): khóa tự động hết hạn sau ttl. Đâ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.

Ảnh chụp đoạn mã Go nền tối minh hoạ distributed lock với Redis trong Go SET NX PX và Lua unlock an toàn, vấn đề nhiều tiến trình một tài nguyên mutex chỉ khóa trong một tiến trình khi nhiều tiến trình máy chủ cùng muốn độc chiếm một tài nguyên xử lý 1 đơn chạy 1 job cron cần khóa ngoài tiến trình Redis là lựa chọn phổ biến, lấy khóa func acquire ctx rdb 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 bằng rdb SetNX ctx key token ttl Result return ok, hai điều bắt buộc token duy nhất mỗi người giữ khóa gắn một token riêng UUID để biết khóa này của ai khi trả PX ttl khóa tự hết hạn chủ khóa chết vẫn nhả được không bao giờ khóa vô thời hạn, trả khóa an toàn Lua so sánh rồi xóa sai GET rồi DEL bằng 2 lệnh giữa hai lệnh TTL hết người khác lấy khóa DEL của ta xóa nhầm khóa họ var unlockScript redis NewScript if redis call GET KEYS 1 bằng ARGV 1 then return redis call DEL KEYS 1 chỉ xóa nếu token khớp else return 0 end Lua chạy nguyên tử trên Redis func release ctx rdb key token string int64 n bằng unlockScript Run ctx rdb key token Int64 return n 1 là xóa đúng khóa của ta 0 là không phải khóa ta

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:

  1. Ta GET, thấy token khớp — đúng là khóa của ta.
  2. Ngay lúc đó TTL hết hạn, khóa tự biến mất.
  3. Người khác SET NX thành công, giờ họ giữ khóa với token của họ.
  4. 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.

Ảnh chụp bảng kết quả đo thật nền tối distributed lock Redis trong Go chạy bằng go run Go 1.23 arm64 go-redis v9 cộng miniredis Redis trong bộ nhớ, loại trừ A giữ khóa B thất bại khi A đang giữ A lấy khóa true B lấy khóa khi A đang giữ false release A 1 xóa đúng khóa của A B thử lại sau khi A trả true SET NX chỉ cho 1 người thắng B chỉ vào được sau khi A nhả, an toàn release không xóa nhầm khóa người khác release với token sai 0 từ chối khóa vẫn còn release với token đúng 1 xóa thành công Lua so sánh token trước khi xóa token sai không đụng được khóa, 50 goroutine tranh 1 khóa đếm vào critical section số lần lấy khóa 50 đỉnh cao nhất goroutine đồng thời trong CS bằng 1 cả 50 đều lần lượt lấy được khóa không đói nhưng không bao giờ có quá 1 goroutine trong vùng tranh chấp cùng lúc loại trừ đúng nghĩa kể cả dưới tranh chấp nặng, cốt lõi lấy khóa SET key token NX PX ttl NX cho 1 người PX tự hết hạn token duy nhất mỗi người giữ để biết khóa của ai khi trả TTL bắt buộc chủ khóa chết thì khóa tự nhả tránh deadlock trả khóa Lua GET so sánh DEL nguyên tử không xóa nhầm khóa người khác đánh đổi TTL ngắn dễ hết giữa chừng dài thì chết lâu mới nhả cảnh báo 1 Redis là điểm chết đơn Redlock nhiều node vẫn gây tranh cãi

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ề

  1. 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.
  2. Trả khóa phải dùng Lua so sánh-rồi-xóa nguyên tử: GET rồi DEL bằ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).
  3. 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.

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.