Redlock: distributed lock trên nhiều node Redis và tranh cãi quanh nó
1. Tổng quan
Khi nhiều instance của một service cùng xử lý một tài nguyên, ví dụ trừ tồn kho cho một Order, bạn cần một distributed lock. Lock trên một node Redis duy nhất thì đơn giản, nhưng node đó chết là lock biến mất. Redlock là thuật toán do tác giả Redis (antirez) đề xuất để lấy lock trên nhiều node Redis độc lập.
Bài này trình bày cách Redlock hoạt động, cách tự cài đặt bằng Jedis, cách viết test với Testcontainers. Phần cuối phân tích tranh cãi giữa Martin Kleppmann và antirez. Đọc xong, bạn sẽ biết khi nào Redlock đủ dùng và khi nào nên chọn công cụ khác.
2. Vì sao một node Redis là chưa đủ
Lock cơ bản trên một node chỉ cần một lệnh:
SET lock:order:42 "c3a1-uuid" NX PX 30000
NX nghĩa là chỉ set khi key chưa tồn tại. PX 30000 đặt TTL 30 giây để lock tự hết hạn nếu client chết. Giá trị là một token ngẫu nhiên, dùng để chỉ client sở hữu lock mới được xoá nó.
Vấn đề nằm ở tính sẵn sàng. Nếu bạn dùng replication master–replica thì có kịch bản sau:
- Client A lấy lock trên master.
- Master chết trước khi kịp replicate key sang replica (replication của Redis là bất đồng bộ).
- Replica được promote thành master mới, và trên đó không có key lock.
- Client B lấy được cùng lock đó.
Lúc này A và B cùng giữ lock. Redlock ra đời để xử lý kịch bản này.
3. Thuật toán Redlock
Redlock dùng N node Redis độc lập, không replication và không cluster với nhau. Thường N = 5. Client lấy lock theo các bước:
- Ghi lại thời điểm bắt đầu.
- Lần lượt gửi
SET key token NX PX ttltới cả N node. Mỗi request có timeout ngắn (vài chục ms) để node chết không làm client bị treo. - Tính thời gian đã trôi qua. Lock được coi là lấy thành công khi cả hai điều kiện đúng:
- lấy được trên ít nhất
N/2 + 1node (quorum), - thời gian đã trôi qua nhỏ hơn TTL.
- lấy được trên ít nhất
- Thời gian hiệu lực thực của lock bằng
ttl - elapsed - drift, trong đódriftbù cho sai lệch đồng hồ giữa các node. - Nếu thất bại, client gửi lệnh unlock tới tất cả node, kể cả node mà nó tưởng là chưa lấy được.
Ý tưởng cốt lõi là hai client không thể cùng chiếm đa số node trong cùng một khoảng thời gian.
4. Cài đặt Redlock bằng Jedis
4.1. Dependency
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>5.1.0</version>
</dependency>
4.2. Lớp Redlock
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.exceptions.JedisException;
import redis.clients.jedis.params.SetParams;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public class Redlock {
private static final String UNLOCK_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""";
private static final double CLOCK_DRIFT_FACTOR = 0.01;
private final List<JedisPool> nodes;
private final int quorum;
public Redlock(List<JedisPool> nodes) {
this.nodes = nodes;
this.quorum = nodes.size() / 2 + 1;
}
public Optional<LockHandle> tryLock(String resource, long ttlMillis) {
String token = UUID.randomUUID().toString();
long start = System.nanoTime();
int acquired = 0;
for (JedisPool pool : nodes) {
if (lockInstance(pool, resource, token, ttlMillis)) {
acquired++;
}
}
long elapsed = (System.nanoTime() - start) / 1_000_000;
long drift = (long) (ttlMillis * CLOCK_DRIFT_FACTOR) + 2;
long validity = ttlMillis - elapsed - drift;
if (acquired >= quorum && validity > 0) {
return Optional.of(new LockHandle(resource, token, validity));
}
unlockAll(resource, token);
return Optional.empty();
}
public void unlock(LockHandle handle) {
unlockAll(handle.resource(), handle.token());
}
private boolean lockInstance(JedisPool pool, String resource, String token, long ttl) {
try (Jedis jedis = pool.getResource()) {
return "OK".equals(jedis.set(resource, token, SetParams.setParams().nx().px(ttl)));
} catch (JedisException e) {
return false; // node chết hoặc timeout: coi như không lấy được
}
}
private void unlockAll(String resource, String token) {
for (JedisPool pool : nodes) {
try (Jedis jedis = pool.getResource()) {
jedis.eval(UNLOCK_SCRIPT, List.of(resource), List.of(token));
} catch (JedisException ignored) {
// node không phản hồi thì key sẽ tự hết hạn theo TTL
}
}
}
public record LockHandle(String resource, String token, long validityMillis) {}
}
Các điểm chính:
quorum = N/2 + 1. Với 3 node, quorum là 2. Với 5 node, quorum là 3.lockInstancenuốt exception và trảfalse. Một node chết chỉ làm giảm số phiếu, không làm hỏng cả thao tác.validitylà thời gian bạn thực sự được phép coi mình đang giữ lock. Thời gian này luôn nhỏ hơn TTL.- Unlock dùng Lua script để thao tác “so sánh token rồi xoá” chạy atomic. Nếu bạn
GETrồiDELbằng hai lệnh riêng, bạn có thể xoá nhầm lock mà client khác vừa lấy. - Khi thất bại,
unlockAllgửi tới mọi node. Lý do là một lệnhSETcó thể đã thành công phía server nhưng response bị timeout phía client.
4.3. Khởi tạo các node với timeout ngắn
import redis.clients.jedis.JedisPoolConfig;
List<JedisPool> pools = List.of(
new JedisPool(new JedisPoolConfig(), "redis-1", 6379, 50),
new JedisPool(new JedisPoolConfig(), "redis-2", 6379, 50),
new JedisPool(new JedisPoolConfig(), "redis-3", 6379, 50));
Redlock redlock = new Redlock(pools);
Tham số cuối là timeout 50 ms. Nếu bạn để timeout mặc định là 2 giây, một node treo có thể “ăn” gần hết TTL của lock.
4.4. Dùng trong InventoryService
public class InventoryService {
private final Redlock redlock;
private final InventoryRepository repository;
public InventoryService(Redlock redlock, InventoryRepository repository) {
this.redlock = redlock;
this.repository = repository;
}
public boolean reserve(String sku, int quantity) {
var handle = redlock.tryLock("lock:sku:" + sku, 10_000);
if (handle.isEmpty()) {
return false; // để caller retry với backoff
}
try {
int stock = repository.findStock(sku);
if (stock < quantity) return false;
repository.updateStock(sku, stock - quantity);
return true;
} finally {
redlock.unlock(handle.get());
}
}
}
Unlock luôn nằm trong finally. Đoạn code trong vùng lock phải chạy xong trong validityMillis. Nếu thao tác có thể kéo dài, bạn cần cơ chế gia hạn lock (watchdog) hoặc chia nhỏ công việc.
5. Kiểm thử với Testcontainers
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
@Testcontainers
class RedlockTest {
@Container static GenericContainer<?> r1 = redis();
@Container static GenericContainer<?> r2 = redis();
@Container static GenericContainer<?> r3 = redis();
static GenericContainer<?> redis() {
return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
}
static Redlock newClient() {
return new Redlock(Stream.of(r1, r2, r3)
.map(c -> new JedisPool(new JedisPoolConfig(),
c.getHost(), c.getMappedPort(6379), 200))
.toList());
}
@Test
void secondClientCannotAcquireWhileFirstHoldsLock() {
Redlock clientA = newClient();
Redlock clientB = newClient();
var lockA = clientA.tryLock("lock:order:42", 5_000);
var lockB = clientB.tryLock("lock:order:42", 5_000);
assertThat(lockA).isPresent();
assertThat(lockA.get().validityMillis()).isLessThan(5_000);
assertThat(lockB).isEmpty();
clientA.unlock(lockA.get());
assertThat(clientB.tryLock("lock:order:42", 5_000)).isPresent();
}
@Test
void stillAcquiresWhenOneNodeIsDown() {
Redlock client = newClient();
r3.stop();
assertThat(client.tryLock("lock:order:99", 5_000)).isPresent();
}
}
Test thứ nhất kiểm tra tính loại trừ: B thất bại khi A đang giữ lock, và lấy được ngay sau khi A unlock. Test thứ hai cho thấy Redlock vẫn hoạt động khi mất 1 trên 3 node, vì 2 node còn lại đủ quorum. Test này dừng hẳn container nên bạn hãy đặt nó cuối, hoặc tách sang một class riêng.
6. Còn Redisson thì sao?
Trong thực tế, ít ai tự viết Redlock. Redisson từng cung cấp RedissonRedLock, nhận vào nhiều RLock từ các client độc lập.
Lưu ý: Ở các phiên bản Redisson gần đây,
RedissonRedLockvàRedissonMultiLockđã bị đánh dấu deprecated. Redisson khuyến nghị dùngRLockthông thường, vốn có watchdog tự gia hạn và có thể đồng bộ sang replica, hoặcRFencedLocknếu bạn cần fencing token. Hãy kiểm tra phiên bản bạn đang dùng trước khi chọn API.
Việc chính thư viện phổ biến nhất cũng rời bỏ Redlock dẫn ta tới phần tranh cãi.
7. Tranh cãi: Kleppmann vs antirez
7.1. Lập luận của Martin Kleppmann
Kleppmann, tác giả cuốn Designing Data-Intensive Applications, chỉ ra rằng Redlock không an toàn cho bài toán cần correctness. Có hai lý do chính.
Process pause. Hãy xem kịch bản sau:
t=0 Client A lấy lock (TTL 10s)
t=1 A bị GC stop-the-world 15 giây
t=10 Lock hết hạn trên mọi node
t=11 Client B lấy lock, ghi dữ liệu vào DB
t=16 A tỉnh dậy, vẫn tưởng mình giữ lock, ghi đè dữ liệu của B
Không thuật toán lock dựa trên TTL nào ngăn được kịch bản này. Kiểm tra validity ngay trước khi ghi cũng không giúp được, vì pause có thể xảy ra ngay sau lần kiểm tra. Nguyên nhân pause không chỉ là GC. Page fault, VM bị di chuyển hay gói tin bị trễ trên mạng đều gây hiệu ứng tương tự.
Phụ thuộc vào đồng hồ. Redlock giả định đồng hồ các node chạy với tốc độ xấp xỉ nhau. Nếu đồng hồ một node bị nhảy, ví dụ do NTP chỉnh mạnh hay admin sửa giờ, key trên node đó có thể hết hạn sớm. Kịch bản cụ thể với 5 node:
A lấy lock trên node 1, 2, 3
Đồng hồ node 3 nhảy tới trước, key trên node 3 hết hạn
B lấy lock trên node 3, 4, 5
=> A và B cùng giữ quorum
Kết luận của Kleppmann: muốn an toàn, bạn cần fencing token, tức một số tăng đơn điệu đi kèm lock. Redlock không cung cấp số này, vì token của nó là chuỗi ngẫu nhiên.
7.2. Fencing token trông như thế nào
Ý tưởng là storage tự từ chối các lần ghi mang token cũ:
public class FencedInventoryRepository {
private final JdbcTemplate jdbc;
public FencedInventoryRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public boolean updateStock(String sku, int newStock, long fencingToken) {
int rows = jdbc.update("""
UPDATE inventory SET stock = ?, last_token = ?
WHERE sku = ? AND last_token < ?
""", newStock, fencingToken, sku, fencingToken);
return rows == 1;
}
}
Áp vào kịch bản GC ở trên: B ghi với token 34, A tỉnh dậy và ghi với token 33. Câu UPDATE của A ảnh hưởng 0 dòng, nên dữ liệu của B được giữ nguyên. Điểm mấu chốt là storage, chứ không phải lock, mới là thứ đảm bảo correctness. Token phải sinh ra từ một hệ thống có consensus, như ZooKeeper (zxid) hay etcd (revision).
7.3. Phản hồi của antirez
antirez đáp lại với các ý chính sau:
- Nếu storage đã hỗ trợ kiểm tra token kiểu compare-and-set, bạn có thể dùng chính token ngẫu nhiên để kiểm tra, và khi đó lock gần như không còn cần thiết.
- Redlock có bước kiểm tra thời gian sau khi lấy lock, nên trễ mạng trong lúc lấy lock được phát hiện. Pause sau bước đó thì đúng là mọi lock có TTL đều chịu chung.
- Giả định về đồng hồ là hợp lý nếu bạn vận hành đúng: không để NTP nhảy giờ đột ngột (dùng chế độ slew) và không chỉnh giờ tay.
Đến nay chưa có bên nào “thắng” tuyệt đối. Tuy vậy, cộng đồng phần lớn đồng ý với cách phân loại của Kleppmann, trình bày ở mục tiếp theo.
8. Khi nào dùng, khi nào không
Hãy tự hỏi: nếu hai client cùng giữ lock thì hậu quả là gì?
- Efficiency lock (tránh làm trùng việc): gửi trùng email báo cáo, chạy trùng một cron job, tính lại cache. Thỉnh thoảng trùng cũng không sao. Trường hợp này một node Redis với
SET NX PXlà đủ. Redlock tốn gấp 3–5 lần hạ tầng mà gần như không cải thiện gì đáng kể. - Correctness lock (sai là hỏng dữ liệu): trừ tiền, trừ tồn kho, cấp số hoá đơn. Redlock không đủ. Bạn nên dùng etcd hoặc ZooKeeper kèm fencing token, hoặc tốt hơn là đẩy ràng buộc xuống database bằng optimistic locking (
@Versiontrong JPA),SELECT ... FOR UPDATEhay unique constraint.
Lưu ý: Các lỗi hay gặp khi dùng Redlock:
- Dùng Redis Cluster hoặc master–replica làm các “node” của Redlock. Các node phải hoàn toàn độc lập.
- Để timeout kết nối mặc định quá dài.
- Unlock bằng
DELthay vì Lua script kiểm tra token.- Bật persistence nhưng không cấu hình
fsync=always, hoặc không trì hoãn khởi động lại node. Một node restart và mất key có thể khiến hai client cùng đạt quorum. antirez khuyến nghị cho node đã restart chờ ít nhất một khoảng bằng TTL lớn nhất rồi mới tham gia lại.
9. Kết luận
Redlock lấy lock trên đa số trong N node Redis độc lập, kèm tính toán thời gian hiệu lực để bù trễ mạng và lệch đồng hồ. Bạn đã thấy cách cài đặt nó bằng Jedis, cách unlock an toàn bằng Lua script và cách kiểm thử bằng Testcontainers.
Redlock vẫn dựa trên TTL và đồng hồ, nên không chống được process pause hay đồng hồ nhảy. Vì vậy, với lock chỉ nhằm tránh làm trùng việc, một node Redis là đủ. Với lock bảo vệ tính đúng đắn của dữ liệu, bạn cần fencing token từ một hệ thống có consensus, hoặc để database tự giữ ràng buộc.