Distributed Lock với Redis - Single Instance
Table of Contents
Lưu ý: Các ví dụ code trong bài viết này sử dụng Go. Các khái niệm áp dụng được cho mọi ngôn ngữ.
Tại sao cần lock?⌗
Hãy tưởng tượng hai user cùng rút tiền từ một tài khoản ngân hàng. Nếu không có đồng bộ hóa, cả hai có thể đọc cùng một số dư, trừ số tiền riêng, rồi ghi lại, dẫn đến lost update. Một giao dịch rút tiền biến mất.
Đây là race condition điển hình, tính đúng đắn của kết quả phụ thuộc vào thời điểm và thứ tự thực thi.
Các loại Read Anomalies⌗
Khi các thao tác đồng thời truy cập dữ liệu chung mà không có isolation phù hợp, có thể xảy ra các anomaly sau:
| Anomaly | Mô tả |
|---|---|
| Dirty Read | Đọc dữ liệu chưa commit từ transaction khác có thể bị rollback |
| Non-repeatable Read | Cùng một query trả về giá trị khác vì transaction khác đã sửa row đó |
| Phantom Read | Cùng một query trả về các row khác vì transaction khác đã insert/delete |
| Stale Read | Đọc dữ liệu cũ do caching hoặc replication lag |
Đây là lý do database cung cấp các isolation levels khác nhau, mỗi cấp độ đánh đổi giữa consistency và performance.
Giải pháp truyền thống: Locks⌗
Trong môi trường single-process, chúng ta có các primitive quen thuộc:
- Mutex (Mutual Exclusion): Chỉ một thread được giữ lock tại một thời điểm. Đơn giản, hiệu quả, nhưng có thể gây contention.
- Semaphore: Cho phép tối đa N thread truy cập resource đồng thời. Hữu ích cho rate limiting hoặc pooled resources.
var mu sync.Mutex
func withdraw(amount int) error {
mu.Lock()
defer mu.Unlock()
if balance < amount {
return errors.New("insufficient funds")
}
balance -= amount
return nil
}
Hoạt động hoàn hảo… trên một máy duy nhất.
Nhưng với Distributed Systems thì sao?⌗
Các ứng dụng hiện đại không chạy trên một server. Chúng được deploy trên nhiều instance ở nhiều vùng địa lý. sync.Mutex trở nên vô dụng vì:
- Không có shared memory: Mỗi instance có không gian bộ nhớ riêng
- Không có shared OS: Không có kernel để điều phối giữa các process
- Network không tin cậy: Message có thể bị delay, duplicate, hoặc mất
Chúng ta cần một distributed lock, cơ chế phối hợp hoạt động qua mạng. Đây là lúc Redis xuất hiện.
Redis như một Distributed Lock⌗
Ý tưởng cơ bản⌗
Redis có thể làm điểm phối hợp chung mà tất cả application instance có thể truy cập. Vì Redis là single-threaded, mọi command được thực thi tuần tự. Điều này cho ta isolation level cao nhất, serializable. Không dirty reads, không phantom reads, không anomalies, mọi thao tác đều thấy view nhất quán của dữ liệu. Lựa chọn serialization có đánh đổi, tuy nhiên Redis là in-memory database, nhanh hơn nhiều lần so với SQL database truyền thống phải ghi xuống disk.
Đặc tính này làm Redis trở thành lựa chọn tuyệt vời cho các primitive locks.
Chiến lược rất đơn giản: dùng Redis key để làm lock. Nếu key tồn tại, lock đang được giữ bởi instance khác. Nếu không tồn tại, lock đang trống và ta có thể lấy.
Vấn đề 1: Lấy Lock⌗
Cách tiếp cận không đúng là kiểm tra lock có tồn tại không, rồi set:
func acquire(key string) bool {
exists := redis.Get(key)
if exists == nil {
redis.Set(key, "locked")
return true
}
return false
}
Cách này có lỗ hổng chết hệ thống. Hai client có thể cùng đọc thấy key không tồn tại, rồi cả hai đều set, mỗi bên đều nghĩ mình đã lấy được lock:
Giải pháp là dùng SET NX (Set if Not eXists) để thực hiện check-and-set một cách atomic:
func acquire(key string, ttl time.Duration) bool {
result := redis.SetNX(key, "locked", ttl)
return result
}
Hoặc dùng command SET đầy đủ với options:
SET lock_key "locked" NX EX 30
Command này chỉ thành công nếu key không tồn tại, và nó atomic, không thể có race condition.
Vấn đề 2: Deadlock⌗
Điều gì xảy ra nếu client lấy được lock rồi crash trước khi giải phóng?
Client A lấy lock, rồi crash. Client B cố lấy lock nhưng thất bại vì key vẫn tồn tại. B chờ, nhưng lock không bao giờ được giải phóng. Ngay cả khi Client A hồi phục lại, nó cũng không thể giúp vì khi crash, nó mất toàn bộ in-memory state, bao gồm việc nó có phải người sở hữu của lock hay không.
Giải pháp là luôn set TTL (time-to-live) cho lock:
SET lock_key "locked" NX EX 30
EX 30 nghĩa là key tự động hết hạn sau 30 giây. Ngay cả khi client crash, lock cuối cùng sẽ được giải phóng.
Cảnh báo: Chọn TTL cẩn thận!
- Quá ngắn: Lock hết hạn trong khi bạn vẫn đang làm việc, mất tính an toàn
- Quá dài: Hệ thống chờ không cần thiết nếu client crash
Với các thao tác chạy lâu, implement một watchdog định kỳ gia hạn cho TTL của lock. Ví dụ, Process A lấy lock với TTL 10 giây, nhưng thao tác mất 25 giây. Không có watchdog, lock sẽ hết hạn ở giây thứ 10, và process khác có thể giành được lock trong khi A vẫn đang làm việc. Watchdog chạy trong background goroutine, gia hạn TTL (ví dụ mỗi 3 giây) miễn là process còn sống và đang làm việc. Nếu process crash, watchdog chết theo, và lock tự nhiên hết hạn khi đến TTL.
Vấn đề 3: Giải Phóng Lock⌗
Cách tiếp cận ngây thơ là chỉ delete key:
func release(key string) {
redis.Del(key)
}
Có vẻ ổn cho đến khi bạn nghĩ: nếu bạn đang delete lock của người khác thì sao?
Trong diagram trên, Client A lấy lock với TTL 10 giây, nhưng mất quá nhiều thời gian để hoàn thành công việc. Lock hết hạn, và Client B giành được lock. Khi Client A cuối cùng hoàn thành, nó gọi DEL một cách mù quáng, xóa lock của Client B. Giờ Client B nghĩ nó vẫn giữ lock, nhưng lock đã mất, và client thứ ba có thể giành được lock.
Giải pháp là dùng unique token (fencing token) và chỉ delete nếu token khớp:
func acquire(key string, token string, ttl time.Duration) bool {
result := redis.SetNX(key, token, ttl)
return result
}
func release(key string, token string) bool {
// Phải atomic! Dùng Lua script
script := `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`
result := redis.Eval(script, []string{key}, token)
return result == 1
}
Lua script đảm bảo check-and-delete là atomic. Redis thực thi Lua scripts mà không xen kẽ các command khác.
Bạn có thể thắc mắc: tại sao không dùng GET và DEL riêng biệt? Lý do tương tự như trước, giống như GET rồi SET thất bại khi acquire lock, GET rồi DEL cũng thất bại ở đây. Giữa lúc GET (kiểm tra token) và DEL (release lock), client khác có thể giành lock, và bạn sẽ delete lock của họ thay vì của bạn. Lua script gộp cả hai thao tác thành một đơn vị atomic.
Implementation⌗
Tôi đã publish một Go implementation đầy đủ cho lock trên single-instance Redis tại github.com/trviph/redlock. Type Lock cung cấp:
- Acquire / TryAcquire: Lấy lock bằng
SET NX EX, tạo fencing token random - AcquireWithFencing: Bạn muốn tự tạo fencing token? Sử dụng hàm này
- Release: Giải phóng lock an toàn bằng Lua script
- Extend / TryExtend: Gia hạn TTL cho lock
- AcquireOrExtend: Gia hạn lock nếu bạn đã giữ nó, không thì cố giành lock mới
- Watch: Tự động gia hạn TTL cho lock
Thư viện cũng bao gồm DistributedLock, cài đặt đầy đủ Redlock algorithm trên nhiều Redis instances độc lập với quorum-based consensus và clock drift validation. Chúng ta sẽ khám phá điều đó trong bài tiếp theo.
Tham khảo⌗
- Distributed Locks with Redis — Tài liệu chính thức của Redis về distributed locking patterns