Bài 10: Khóa phân tán (Distributed Lock) - Giải quyết Race Condition trong Microservices
Mục tiêu bài học:
-
Hiểu rõ bài toán Race Condition khi scale ứng dụng lên nhiều server (Microservices).
-
Nắm vững 3 nguyên tắc sống còn của một Distributed Lock an toàn.
-
Tự build cơ chế Distributed Lock bằng
go-redisvà kịch bản Lua (Lua Script).
1. Nỗi đau mang tên Race Condition trong Microservices
Giả sử bạn có một trang TMĐT. Sản phẩm "iPhone 16" chỉ còn đúng 1 chiếc trong kho.
-
Cùng một khoảnh khắc (millisecond), User A và User B cùng bấm "Mua hàng".
-
Request của User A được chuyển đến Server (Pod) số 1. Request của User B vào Server số 2.
-
Cả 2 server cùng gọi DB kiểm tra tồn kho:
SELECT stock FROM products WHERE id = 1. Cả 2 đều nhận kết quảstock = 1. -
Cả 2 server đều cho khách thanh toán thành công và trừ kho:
UPDATE products SET stock = stock - 1. -
Kết quả: Kho bị âm (-1), bạn bán quá số lượng thực tế (Overselling).
Trong một server đơn lẻ (Monolith), bạn có thể dùng Mutex (sync.Mutex trong Go) để khóa luồng. Nhưng khi có nhiều server khác nhau, sync.Mutex vô tác dụng vì chúng không chia sẻ bộ nhớ. Distributed Lock (Khóa phân tán) ra đời để giải quyết vấn đề này, và Redis là ứng cử viên số 1.
2. Ba nguyên tắc sống còn của Distributed Lock
-
Mutual Exclusion (Loại trừ lẫn nhau): Tại một thời điểm, chỉ duy nhất 1 server giữ được khóa.
-
Deadlock Free (Không chết khóa): Nếu server giữ khóa bị sập (crash) hoặc rớt mạng trước khi kịp nhả khóa, khóa đó phải tự động được giải phóng sau một khoảng thời gian (TTL - Time To Live).
-
Safety (An toàn tuyệt đối): Server nào tạo khóa thì chỉ server đó mới có quyền giải phóng (nhả) khóa. Tránh tình trạng Server A tạo khóa, xử lý quá lâu dẫn đến khóa hết hạn tự nhả; Server B lấy được khóa, nhưng sau đó Server A xử lý xong lại gọi lệnh xóa khóa của Server B.
3. Triển khai Distributed Lock bằng go-redis
Để đảm bảo nguyên tắc số 3, mỗi lần lấy khóa, chúng ta phải tạo ra một chuỗi ngẫu nhiên (UUID). Khi nhả khóa, ta phải kiểm tra xem giá trị trong Redis có đúng bằng UUID của mình không thì mới được xóa.
Tuy nhiên, thao tác "Kiểm tra giá trị Nếu đúng thì Xóa" phải diễn ra một cách nguyên tử (Atomic). Do đó, ta bắt buộc phải sử dụng Lua Script. Redis đảm bảo quá trình chạy Lua Script sẽ không bị bất kỳ lệnh nào khác chen ngang.
Code mẫu hoàn chỉnh:
Go
package main
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)
var ctx = context.Background()
// Lua script để nhả khóa an toàn
// So sánh giá trị hiện tại của key (KEYS[1]) với giá trị của mình (ARGV[1])
// Nếu khớp thì thực hiện DEL, không thì trả về 0.
const releaseLockScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
type RedisLock struct {
client *redis.Client
key string
value string
ttl time.Duration
}
// Khởi tạo một đối tượng Lock
func NewRedisLock(client *redis.Client, key string, ttl time.Duration) *RedisLock {
return &RedisLock{
client: client,
key: key,
value: uuid.New().String(), // Tạo ID duy nhất cho mỗi lần lấy khóa
ttl: ttl,
}
}
// Cố gắng lấy khóa (Try Lock)
func (l *RedisLock) TryAcquire() (bool, error) {
// Sử dụng SetNX (Set if Not Exists) kèm TTL trong cùng 1 câu lệnh để đảm bảo Atomicity
success, err := l.client.SetNX(ctx, l.key, l.value, l.ttl).Result()
if err != nil {
return false, err
}
return success, nil
}
// Nhả khóa an toàn bằng Lua Script (Release Lock)
func (l *RedisLock) Release() (bool, error) {
script := redis.NewScript(releaseLockScript)
// Thực thi script truyền vào key và value (uuid)
res, err := script.Run(ctx, l.client, []string{l.key}, l.value).Result()
if err != nil {
return false, err
}
// res == 1 nghĩa là đã xóa thành công, 0 là khóa không tồn tại hoặc thuộc về người khác
return res.(int64) == 1, nil
}
4. Ứng dụng Lock vào bài toán Tồn kho
Go
func processOrder(rdb *redis.Client, orderID string) {
lockKey := "lock:product:iphone16"
// Thiết lập TTL đủ dài để xử lý xong nghiệp vụ (vd: 5 giây)
// Tránh set quá dài gây gián đoạn lâu nếu server sập
lock := NewRedisLock(rdb, lockKey, 5*time.Second)
// 1. Cố gắng lấy khóa
acquired, err := lock.TryAcquire()
if err != nil {
fmt.Printf("[Order %s] Lỗi kết nối Redis: %v\n", orderID, err)
return
}
if !acquired {
fmt.Printf("[Order %s] Hệ thống bận (không lấy được khóa). Vui lòng thử lại sau!\n", orderID)
return
}
// 2. Nếu lấy khóa thành công, ĐẢM BẢO luôn gọi Release() khi xử lý xong
defer func() {
released, _ := lock.Release()
if released {
fmt.Printf("[Order %s] Đã nhả khóa thành công.\n", orderID)
} else {
fmt.Printf("[Order %s] Không thể nhả khóa (có thể đã hết TTL).\n", orderID)
}
}()
// 3. Vùng an toàn (Critical Section): Chỉ 1 worker được chạy đoạn code này
fmt.Printf("[Order %s] Đã lấy khóa! Bắt đầu kiểm tra và trừ kho...\n", orderID)
// Giả lập thời gian truy vấn DB và xử lý nghiệp vụ
time.Sleep(2 * time.Second)
fmt.Printf("[Order %s] Trừ kho thành công!\n", orderID)
}
5. Góc kinh nghiệm Production
Đoạn code trên hoạt động cực kỳ tốt với Redis Standalone hoặc một cụm Redis có 1 node Master duy nhất. Tuy nhiên, nếu bạn xây dựng hệ thống Core Banking hoặc các hệ thống đòi hỏi độ chính xác tuyệt đối trên Redis Cluster, bạn cần đối mặt với độ trễ đồng bộ (Replication Lag) giữa Master và Slave. Khi Master sập ngay sau khi cấp khóa mà chưa kịp đồng bộ sang Slave, Slave lên làm Master mới có thể sẽ cấp khóa đó cho một người khác.
Để giải quyết vấn đề này, tác giả Redis (Salvatore Sanfilippo) đã tạo ra thuật toán Redlock.
- Lời khuyên: Thay vì tự viết Redlock từ đầu, hãy sử dụng thư viện
[github.com/go-redsync/redsync](https://github.com/go-redsync/redsync). Thư viện này hỗ trợ Redlock chuẩn chỉ và cung cấp tính năng Watchdog (tự động gia hạn TTL nếu nghiệp vụ của bạn xử lý lâu hơn dự kiến).
All rights reserved