Distributed Locking: Redis, PostgreSQL và những trade-off
Lock phân tán khó hơn nhiều so với vẻ ngoài của một lệnh SET NX. Khi nào Redis là đủ, khi nào nên dựa vào database, và vì sao bạn cần fencing token.
Mục lục
Bạn có ba instance của một service cùng chạy cron job gửi email báo cáo cuối ngày. Không muốn khách hàng nhận ba email, bạn cần đảm bảo chỉ một instance làm việc đó. Giải pháp đầu tiên ai cũng nghĩ tới: một lock phân tán.
Câu hỏi quan trọng nhất lại hiếm khi được hỏi: nếu lock thất bại thì chuyện gì xảy ra?
Hai lý do để dùng lock
Kleppmann phân biệt hai mục đích rất khác nhau của lock[2]:
- Hiệu quả (efficiency): tránh làm cùng một việc hai lần. Nếu lock thỉnh thoảng hỏng, hậu quả là tốn thêm tài nguyên hoặc một email trùng — khó chịu nhưng chấp nhận được.
- Tính đúng đắn (correctness): nếu hai tiến trình cùng vào vùng găng, dữ liệu bị hỏng — trừ tiền hai lần, ghi đè file của nhau.
Redis: SET NX với TTL
Cách phổ biến nhất là tạo một key chỉ khi nó chưa tồn tại, kèm thời gian hết hạn để lock không bị giữ mãi nếu client chết[1]. Giá trị của key là một token ngẫu nhiên, để khi giải phóng ta chỉ xoá lock của chính mình:
package redislock
import ( "context" "crypto/rand" "encoding/hex" "errors" "time"
"github.com/redis/go-redis/v9")
var ErrNotAcquired = errors.New("redislock: lock đang được giữ bởi client khác")
// Chỉ xoá key nếu giá trị vẫn là token của mình — kiểm tra và xoá trong một bước nguyên tử.var releaseScript = redis.NewScript(`if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1])endreturn 0`)
type Lock struct { rdb *redis.Client key string token string}
func Acquire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return nil, err } token := hex.EncodeToString(buf)
ok, err := rdb.SetNX(ctx, key, token, ttl).Result() if err != nil { return nil, err } if !ok { return nil, ErrNotAcquired } return &Lock{rdb: rdb, key: key, token: token}, nil}
func (l *Lock) Release(ctx context.Context) error { return releaseScript.Run(ctx, l.rdb, []string{l.key}, l.token).Err()}Vấn đề của TTL
TTL giải quyết chuyện client chết, nhưng tạo ra một vấn đề mới: client không chết mà chỉ chậm. Một đợt GC pause dài, một lần process bị swap, hay mạng chậm bất thường có thể khiến client vẫn tin mình đang giữ lock trong khi lock đã hết hạn và client khác đã lấy nó[2].
Không có giá trị TTL nào loại bỏ hoàn toàn được rủi ro này, vì trong một hệ thống bất đồng bộ không có giới hạn trên cho độ trễ của một tiến trình[5].
Fencing token: để storage tự bảo vệ mình
Giải pháp là mỗi lần cấp lock đi kèm một số tăng dần (fencing token). Client gửi token này cùng mọi thao tác ghi, và hệ thống lưu trữ từ chối mọi request mang token cũ hơn token lớn nhất nó đã thấy.
| Thời điểm | Sự kiện | Token | Storage chấp nhận? |
|---|---|---|---|
| Client A lấy lock | 33 | — | |
| A bị GC pause, lock hết hạn; client B lấy lock | 34 | — | |
| B ghi dữ liệu | 34 | ✅ Có | |
| A tỉnh dậy, ghi dữ liệu với token cũ | 33 | ❌ Không (33 < 34) |
Điểm mấu chốt: an toàn không còn phụ thuộc vào việc lock hoạt động hoàn hảo, mà được đảm bảo tại nơi dữ liệu thực sự nằm.
PostgreSQL: advisory lock
Nếu dữ liệu cần bảo vệ đã nằm trong PostgreSQL, bạn có thể không cần thêm hệ thống nào. Advisory lock là lock do ứng dụng định nghĩa ý nghĩa, định danh bằng một số nguyên 64-bit[3].
Có hai loại: lock theo session (giữ đến khi unlock hoặc ngắt kết nối) và lock theo transaction (tự giải phóng khi transaction kết thúc)[4]. Loại theo transaction an toàn hơn với connection pool vì không thể “quên” unlock:
package pglock
import ( "context" "database/sql" "errors")
var ErrLocked = errors.New("pglock: job đang được instance khác chạy")
// RunExclusive chạy fn khi và chỉ khi giành được advisory lock lockID.// Lock tự giải phóng khi transaction commit hoặc rollback.func RunExclusive(ctx context.Context, db *sql.DB, lockID int64, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // no-op nếu đã commit
var locked bool if err := tx.QueryRowContext(ctx, "SELECT pg_try_advisory_xact_lock($1)", lockID).Scan(&locked); err != nil { return err } if !locked { return ErrLocked }
if err := fn(tx); err != nil { return err } return tx.Commit()}Ưu điểm lớn nhất: khi công việc được bảo vệ cũng ghi vào chính database đó trong cùng transaction, lock và dữ liệu cùng chung số phận. Không có khoảng hở nào giữa “lock hết hạn” và “ghi dữ liệu”.
So sánh
| Tiêu chí | Redis (1 instance) | PostgreSQL advisory lock | etcd / ZooKeeper |
|---|---|---|---|
| Độ trễ lấy lock | Rất thấp | Thấp (một round-trip tới DB) | Thấp–trung bình (cần đồng thuận) |
| Khi node lock bị lỗi | Mất lock; có thể 2 client cùng giữ | Lock mất cùng session/transaction | Chịu lỗi nhờ đồng thuận (Raft/ZAB) |
| Fencing token | Không có sẵn | Có thể tự xây bằng sequence trong DB | Có sẵn (revision / zxid tăng dần) |
| Chi phí vận hành thêm | Thêm Redis nếu chưa có | Không, nếu đã dùng PostgreSQL | Thêm một cluster đồng thuận |
| Phù hợp nhất | Lock vì hiệu quả | Job/dữ liệu đã nằm trong PostgreSQL | Lock vì tính đúng đắn, leader election |
Kết luận
- Hỏi trước: lock này để tiết kiệm công sức hay để giữ dữ liệu đúng?
- Với mục đích hiệu quả: Redis
SET NX+ TTL + Lua release là đủ và đơn giản. - Với mục đích đúng đắn: không lock nào dựa trên thời gian là đủ an toàn. Hãy dùng fencing token, hoặc để database tự đảm bảo bằng transaction và ràng buộc.
- Nếu dữ liệu đã ở PostgreSQL, advisory lock theo transaction thường là lựa chọn ít thành phần nhất.
Tài liệu tham khảo
- [1]Distributed Locks with Redis. Redis Documentation.
- [2]Martin Kleppmann. How to do distributed locking, 2016.
- [3]Explicit Locking — Advisory Locks. PostgreSQL Documentation.
- [4]System Administration Functions — Advisory Lock Functions. PostgreSQL Documentation.
- [5]Martin Kleppmann. Designing Data-Intensive Applications. O'Reilly Media, 2017. Chương 8 — The Trouble with Distributed Systems.