Lưu ý: Ví dụ code trong bài dùng Go, nhưng các khái niệm cũng áp dụng được với những ngôn ngữ khác.

Tại sao cần lock?⌗

Thử tưởng tượng hai người cùng rút tiền từ một tài khoản ngân hàng. Nếu không đồng bộ đúng cách, cả hai có thể đọc cùng một số dư, tự trừ khoản tiền mình rút rồi ghi kết quả lại. Khi đó xảy ra lost update: một lần rút tiền coi như biến mất khỏi số dư.

Hai user đọc cùng một số dư đồng thời rồi cả hai đều ghi lại, làm mất một lần rút tiền

Đây là một race condition điển hình: kết quả có đúng hay không phụ thuộc vào thời điểm thực hiện các thao tác.

Các bất thường khi đọc dữ liệu⌗

Khi nhiều thao tác cùng truy cập dữ liệu chung mà không có isolation phù hợp, việc đọc dữ liệu có thể gặp những bất thường sau:

AnomalyMô tả
Dirty ReadĐọc dữ liệu chưa commit từ một transaction khác, trong khi transaction đó có thể rollback
Non-repeatable ReadCùng một query trả về giá trị khác vì transaction khác đã sửa dòng dữ liệu đó
Phantom ReadCùng một query trả về những dòng dữ liệu khác vì transaction khác đã thêm hoặc xóa bản ghi
Stale ReadĐọc dữ liệu cũ do cache hoặc độ trễ replication

Vì vậy, database cung cấp nhiều isolation level, mỗi level có sự đánh đổi riêng giữa tính nhất quán và hiệu năng.

Giải pháp truyền thống: lock⌗

Trong môi trường chỉ có một process, chúng ta có những công cụ đồng bộ quen thuộc:

  • Mutex (Mutual Exclusion): Chỉ một thread được giữ lock tại một thời điểm. Đơn giản và hiệu quả, nhưng có thể gây tranh chấp lock.
  • Semaphore: Cho phép tối đa N thread cùng truy cập tài nguyên. Hữu ích khi cần rate limiting hoặc quản lý tài nguyên dùng chung trong một pool.
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
}

Cách này hoạt động rất tốt… trên một máy duy nhất.

Với hệ thống phân tán thì sao?⌗

Ứng dụng hiện đại thường được triển khai trên nhiều instance ở nhiều vùng địa lý. Lúc này, sync.Mutex không giúp đồng bộ giữa các instance được nữa, vì:

  1. Không dùng chung bộ nhớ: Mỗi instance có không gian bộ nhớ riêng.
  2. Không dùng chung hệ điều hành: Không có kernel chung để điều phối các process.
  3. Mạng không đáng tin cậy: Message có thể đến chậm, bị lặp hoặc bị mất.

Chúng ta cần một distributed lock, tức cơ chế phối hợp giữa các bên thông qua mạng. Redis có thể giúp ở đây.

Dùng Redis làm distributed lock⌗

Ý tưởng cơ bản⌗

Redis có thể làm điểm phối hợp chung mà mọi instance của ứng dụng đều truy cập được. Vì Redis là single-threaded, các lệnh được thực hiện tuần tự, lần lượt từng lệnh. Điều này cho chúng ta isolation level cao nhất, serializable: không có dirty read, phantom read hay các bất thường khác; mỗi thao tác đều thấy dữ liệu nhất quán. Thực thi tuần tự có chi phí, nhưng Redis lưu dữ liệu trong bộ nhớ nên nhanh hơn nhiều bậc so với database SQL truyền thống phải ghi xuống ổ đĩa để đảm bảo durability.

Đặc tính ấy khiến Redis phù hợp để xây dựng các công cụ phối hợp như lock.

Cách làm khá đơn giản: dùng một key Redis làm lock. Key tồn tại nghĩa là lock đang được giữ. Key không tồn tại nghĩa là có thể lấy lock.

Vấn đề 1: lấy lock⌗

Cách làm đơn giản ban đầu là kiểm tra key có tồn tại không, rồi mới ghi key:

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ó một lỗ hổng nghiêm trọng. Hai client có thể cùng thấy key chưa tồn tại, rồi cùng ghi key. Cả hai đều tưởng mình đã lấy được lock:

Check rồi set không phải atomic, nên cả hai client đều tưởng mình đang giữ lock

Giải pháp là dùng SET NX (Set if Not eXists) để kiểm tra và ghi key trong một thao tác atomic:

func acquire(key string, ttl time.Duration) bool {
    result := redis.SetNX(key, "locked", ttl)
    return result
}

Hoặc dùng lệnh SET đầy đủ với các tùy chọn:

SET lock_key "locked" NX EX 30

Lệnh này chỉ thành công nếu key chưa tồn tại. Việc kiểm tra và ghi key là atomic, nên không xảy ra race condition như cách trên.

Vấn đề 2: deadlock⌗

Nếu client lấy được lock rồi crash trước khi giải phóng thì sao?

Lock không có thời hạn làm hệ thống bế tắc khi client đang giữ nó crash

Client A lấy lock rồi crash. Client B cố lấy lock nhưng không được vì key vẫn tồn tại. B cứ chờ, còn lock không bao giờ được giải phóng. Ngay cả khi A chạy lại cũng không giúp được: lần crash đã làm mất toàn bộ trạng thái trong bộ nhớ, kể cả thông tin A có đang sở hữu lock hay không.

Giải pháp là luôn đặt TTL (time-to-live) cho lock:

SET lock_key "locked" NX EX 30

EX 30 nghĩa là key tự hết hạn sau 30 giây. Dù client crash, lock cuối cùng 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 khi công việc vẫn đang chạy, làm mất tính an toàn.
  • Quá dài: Hệ thống phải chờ không cần thiết nếu client crash.

Với công việc chạy lâu, chúng ta có thể dùng watchdog để gia hạn TTL định kỳ. Ví dụ, process A lấy lock với TTL 10 giây nhưng cần 25 giây để xử lý. Không có watchdog, lock sẽ hết hạn ở giây thứ 10 và process khác có thể lấy lock khi A vẫn đang làm việc. Watchdog chạy trong một goroutine nền, gia hạn TTL, chẳng hạn mỗi 3 giây, miễn là process vẫn còn chạy và xử lý công việc. Nếu process crash, watchdog cũng dừng theo và lock sẽ tự hết hạn.

Vấn đề 3: giải phóng lock⌗

Cách làm đơn giản ban đầu là xóa key:

func release(key string) {
    redis.Del(key)
}

Có vẻ ổn, nhưng nếu vô tình xóa lock của client khác thì sao?

Lock hết hạn giữa lúc đang xử lý, client thứ hai acquire được, rồi lệnh DEL của client đầu release một lock mà nó không còn sở hữu

Trong sơ đồ trên, client A lấy lock với TTL 10 giây nhưng xử lý quá lâu. Lock hết hạn và client B lấy được lock. Khi A hoàn thành, nó cứ thế gọi DEL và xóa lock của B. B vẫn tưởng mình đang giữ lock, nhưng key đã mất và client thứ ba có thể lấy lock.

Giải pháp là dùng một ownership token (token xác nhận quyền sở hữu lock) có giá trị riêng cho mỗi lần lấy lock. Chỉ xóa key nếu giá trị đang lưu khớp với token đó:

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 việc kiểm tra và xóa key là atomic. Redis chạy Lua script mà không cho các lệnh khác xen vào.

Token này dùng để nhận diện lần lấy lock. Nó khác với fencing token: fencing token phải tăng sau mỗi lần lấy lock, và tài nguyên được bảo vệ phải kiểm tra token để từ chối thao tác đã lỗi thời.

Vậy tại sao không gọi riêng GET rồi DEL? Vấn đề giống hệt cách GET rồi SET khi lấy lock. Sau khi GET kiểm tra token nhưng trước khi DEL giải phóng lock, client khác có thể đã lấy được lock. Khi đó, chúng ta sẽ xóa lock của họ. Lua script gộp hai bước thành một thao tác atomic để tránh tình huống này.

Cài đặt⌗

Mình đã chia sẻ bản cài đặt đầy đủ bằng Go cho lock trên một Redis instance tại github.com/trviph/redlock. Kiểu Lock cung cấp:

  • Acquire / TryAcquire: Lấy lock bằng thao tác atomic SET NX EX và tự động tạo UUID làm ownership token.
  • AcquireWithFencing: Dùng ownership token do bạn cung cấp để retry theo cách idempotent.
  • Release: Giải phóng lock an toàn bằng Lua script kiểm tra ownership token.
  • Extend / TryExtend: Gia hạn TTL mà không giải phóng lock.
  • AcquireOrExtend: Lấy lock nếu đang trống, hoặc gia hạn nếu bạn đã giữ lock.
  • Watch: Watchdog tích hợp để tự động gia hạn TTL.

Dù API có tên AcquireWithFencing, mặc định thư viện tạo UUID làm ownership token. Chỉ truyền vào một token chưa đủ để có cơ chế fencing. Token phải tăng đơn điệu, và tài nguyên được bảo vệ phải kiểm tra nó.

Thư viện còn có DistributedLock, cài đặt đầy đủ thuật toán Redlock trên nhiều Redis instance độc lập, với cơ chế đồng thuận dựa trên quorum và kiểm tra độ lệch đồng hồ. Chúng ta sẽ tìm hiểu ở bài tiếp theo.

Tham khảo⌗