Distributed lock với Redis trong Spring Boot
1. Tổng quan
Khi ứng dụng chạy trên nhiều instance, synchronized hay ReentrantLock không còn đủ. Các lock này chỉ có tác dụng trong một JVM. Bài này hướng dẫn bạn xây dựng distributed lock bằng Redis theo hai cách: tự cài với Spring Data Redis, và dùng thư viện Redisson. Đọc xong, bạn sẽ biết cách lấy lock và nhả lock an toàn, cách viết test cho lock, và những trường hợp lock Redis không đủ an toàn.
2. Vì sao cần distributed lock
Giả sử bạn có một OrderService chạy trên 3 pod. Hai khách hàng cùng đặt món hàng cuối cùng trong kho. Hai request rơi vào hai pod khác nhau. Cả hai cùng đọc thấy stock = 1, cùng trừ kho, và kết quả là bán vượt số lượng có.
Một ví dụ khác là job @Scheduled tạo báo cáo lúc 2 giờ sáng. Nếu có 3 instance thì job chạy 3 lần, và khách hàng nhận 3 email giống hệt nhau.
Trong cả hai trường hợp, bạn cần một lock mà mọi instance đều nhìn thấy. Redis phù hợp với vai trò này vì ba lý do:
- Redis xử lý lệnh tuần tự trên một thread, nên mỗi lệnh là atomic.
- Key có thể gắn TTL, nên lock sẽ tự hết hạn nếu tiến trình giữ nó bị crash.
- Độ trễ thấp và hầu hết hệ thống đã có sẵn Redis.
3. Nguyên lý: lệnh SET NX PX
Toàn bộ cơ chế dựa trên một lệnh duy nhất:
redis-cli SET lock:product:42 "c1f9-token" NX PX 10000
Ý nghĩa từng phần:
NX: chỉ ghi khi key chưa tồn tại. Đây là bước “lấy lock”.PX 10000: key tự hết hạn sau 10 giây. Nhờ vậy, nếu tiến trình chết giữa chừng thì lock không bị kẹt mãi."c1f9-token": một giá trị ngẫu nhiên, duy nhất cho mỗi lần lấy lock. Giá trị này dùng để xác định ai đang sở hữu lock.
Gọi lệnh trên hai lần liên tiếp, bạn sẽ thấy:
OK
(nil)
Lần đầu trả về OK, nghĩa là bạn đã lấy được lock. Lần thứ hai trả về (nil) vì key đã tồn tại.
Lưu ý: Đừng dùng
SETNXrồi gọiEXPIREở lệnh riêng. Nếu tiến trình chết giữa hai lệnh, key sẽ không bao giờ hết hạn và bạn bị deadlock.
4. Tự cài đặt với Spring Data Redis
4.1. Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Cấu hình kết nối trong application.properties:
spring.data.redis.host=localhost
spring.data.redis.port=6379
4.2. Lấy lock và nhả lock
@Component
public class RedisLock {
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = new DefaultRedisScript<>("""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""", Long.class);
private final StringRedisTemplate redis;
public RedisLock(StringRedisTemplate redis) {
this.redis = redis;
}
public Optional<String> tryLock(String key, Duration ttl) {
String token = UUID.randomUUID().toString();
Boolean acquired = redis.opsForValue().setIfAbsent(key, token, ttl);
return Boolean.TRUE.equals(acquired) ? Optional.of(token) : Optional.empty();
}
public boolean unlock(String key, String token) {
Long deleted = redis.execute(RELEASE_SCRIPT, List.of(key), token);
return deleted != null && deleted == 1L;
}
}
Giải thích:
setIfAbsent(key, token, ttl)sinh ra đúng lệnhSET key token NX PX ttl. Kết quảtruenghĩa là bạn đã lấy được lock.tryLocktrả về token nếu thành công. Bạn phải giữ token này để nhả lock sau đó.unlockchạy một Lua script. Script kiểm tra giá trị của key có đúng là token của bạn không, và chỉ xóa key khi đúng.
Tại sao không gọi thẳng DEL? Hãy xét kịch bản sau. Instance A lấy lock với TTL 10 giây nhưng xử lý mất 12 giây. Ở giây thứ 10, lock hết hạn và instance B lấy được lock. Ở giây thứ 12, A gọi DEL và xóa mất lock của B. Lúc này instance C có thể vào, và B với C chạy song song.
Nếu bạn tách thành GET rồi mới DEL thì vẫn có khe hở giữa hai lệnh. Lua script được Redis thực thi atomic, nên khe hở đó không còn.
4.3. Sử dụng trong service
public record Order(long productId, int quantity) {}
@Service
public class OrderService {
private final RedisLock redisLock;
private final InventoryRepository inventory;
public OrderService(RedisLock redisLock, InventoryRepository inventory) {
this.redisLock = redisLock;
this.inventory = inventory;
}
public void placeOrder(Order order) {
String key = "lock:product:" + order.productId();
String token = redisLock.tryLock(key, Duration.ofSeconds(10))
.orElseThrow(() -> new IllegalStateException("Sản phẩm đang được xử lý, vui lòng thử lại"));
try {
inventory.decrease(order.productId(), order.quantity());
} finally {
redisLock.unlock(key, token);
}
}
}
Có ba điểm cần chú ý:
- Key lock nên chi tiết theo tài nguyên, ở đây là
lock:product:{id}. Nếu dùng một lock chung cho mọi sản phẩm, toàn bộ đơn hàng sẽ bị xếp hàng tuần tự. unlockluôn nằm trongfinally, để lock được nhả kể cả khi có exception.- Ví dụ này fail-fast khi không lấy được lock. Nếu muốn chờ, bạn có thể retry vài lần với backoff ngắn. Không nên dùng vòng lặp
while(true)không giới hạn.
InventoryRepository là repository bất kỳ trong dự án của bạn, ví dụ một Spring Data JPA repository.
5. Dùng Redisson
Tự cài đặt giúp bạn hiểu rõ cơ chế, nhưng còn thiếu vài tính năng: gia hạn lock khi xử lý lâu, reentrant, và chờ lock bằng pub/sub thay vì polling. Redisson cung cấp sẵn những thứ này.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.45.1</version>
</dependency>
Bạn nên kiểm tra phiên bản mới nhất tương thích với Spring Boot mà dự án đang dùng. Starter này đọc cấu hình spring.data.redis.* và tạo sẵn bean RedissonClient.
@Service
public class DailyReportJob {
private static final Logger log = LoggerFactory.getLogger(DailyReportJob.class);
private final RedissonClient redisson;
public DailyReportJob(RedissonClient redisson) {
this.redisson = redisson;
}
@Scheduled(cron = "0 0 2 * * *")
public void generate() throws InterruptedException {
RLock lock = redisson.getLock("lock:daily-report");
if (!lock.tryLock(0, TimeUnit.SECONDS)) {
log.info("Instance khác đang chạy báo cáo, bỏ qua");
return;
}
try {
log.info("Bắt đầu tạo báo cáo ngày");
// truy vấn dữ liệu, xuất file, gửi email...
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Khi job chạy trên 3 instance, log sẽ trông như sau:
[pod-1] Bắt đầu tạo báo cáo ngày
[pod-2] Instance khác đang chạy báo cáo, bỏ qua
[pod-3] Instance khác đang chạy báo cáo, bỏ qua
Những điểm quan trọng:
tryLock(0, TimeUnit.SECONDS)không chờ. Nếu lock đang bị giữ, hàm trả vềfalsengay lập tức.- Khi bạn không truyền
leaseTime, Redisson bật watchdog. Lock có TTL mặc định 30 giây (lockWatchdogTimeout) và được gia hạn khoảng mỗi 10 giây, miễn là thread vẫn còn giữ lock. Nếu JVM chết, việc gia hạn dừng lại và lock tự hết hạn. - Khi bạn gọi
tryLock(wait, lease, unit)vớileaseTimecụ thể, watchdog bị tắt. Lock sẽ hết hạn đúng saulease, dù công việc đã xong hay chưa. RLockgắn với thread và là reentrant. Nếu gọiunlock()từ thread khác, bạn sẽ nhậnIllegalMonitorStateException. Điều này cần đặc biệt chú ý khi dùng vớiCompletableFuturehoặc code reactive.
Lưu ý: Vì
RLockgắn với thread, bạn cần cẩn thận khi dùng với virtual thread và các framework tự chuyển thread giữa chừng. Lock phải được lấy và nhả trên cùng một thread.
6. Viết test với Testcontainers
Bạn nên test lock trên Redis thật, không nên dùng mock. Thêm các dependency test sau (phiên bản do Spring Boot quản lý):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@DataRedisTest
@Import(RedisLock.class)
@Testcontainers
class RedisLockTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired
RedisLock redisLock;
@Autowired
StringRedisTemplate template;
@Test
void givenConcurrentRequests_whenTryLock_thenOnlyOneSucceeds() throws Exception {
List<Callable<Optional<String>>> tasks = IntStream.range(0, 20)
.<Callable<Optional<String>>>mapToObj(i -> () -> redisLock.tryLock("lock:concurrent", Duration.ofSeconds(5)))
.toList();
try (ExecutorService executor = Executors.newFixedThreadPool(20)) {
long acquired = executor.invokeAll(tasks).stream()
.map(Future::resultNow)
.filter(Optional::isPresent)
.count();
assertThat(acquired).isEqualTo(1);
}
}
@Test
void givenWrongToken_whenUnlock_thenLockIsKept() {
String token = redisLock.tryLock("lock:owner", Duration.ofSeconds(5)).orElseThrow();
assertThat(redisLock.unlock("lock:owner", "not-my-token")).isFalse();
assertThat(template.hasKey("lock:owner")).isTrue();
assertThat(redisLock.unlock("lock:owner", token)).isTrue();
assertThat(template.hasKey("lock:owner")).isFalse();
}
}
Giải thích:
@DataRedisTestchỉ nạp phần cấu hình Redis, nên test chạy nhanh.@Import(RedisLock.class)đưa bean cần test vào context.@ServiceConnection(name = "redis")tự trỏStringRedisTemplatevào container vừa khởi động. Bạn không cần khai báo host và port bằng tay.- Test đầu tiên cho 20 thread cùng tranh một lock và khẳng định chỉ đúng 1 thread thắng.
- Test thứ hai xác nhận Lua script hoạt động đúng: token sai không xóa được lock, token đúng thì xóa được.
7. Lỗi hay gặp và giới hạn
7.1. TTL ngắn hơn thời gian xử lý
Đây là lỗi phổ biến nhất. Lock hết hạn trong khi công việc vẫn đang chạy, và một instance khác chen vào. Nguyên nhân không chỉ là code chậm. GC pause, network chậm, hay việc chờ connection pool cũng có thể làm bạn vượt quá TTL.
Có hai cách xử lý. Cách thứ nhất là dùng watchdog của Redisson. Cách thứ hai là đặt TTL dư dả, ví dụ gấp vài lần p99 thời gian xử lý.
7.2. Lock không đảm bảo tuyệt đối
Redis replication là bất đồng bộ. Giả sử master nhận lock nhưng crash trước khi kịp sao chép sang replica. Replica được promote lên làm master mới, và trên đó không có key lock. Lúc này một client khác có thể lấy được cùng lock đó.
Thuật toán Redlock (lấy lock trên đa số trong N node độc lập) giảm rủi ro này. Tuy vậy, Redlock vẫn còn tranh cãi vì phụ thuộc vào giả định về đồng hồ và độ trễ. Trong Redisson, RedissonRedLock hiện đã bị đánh dấu deprecated.
Nếu dữ liệu sai gây hậu quả nghiêm trọng, ví dụ trừ tiền hai lần, đừng chỉ dựa vào lock. Hãy kết hợp với một fencing token: mỗi lần lấy lock, bạn nhận một số tăng dần (ví dụ từ INCR lock:product:42:fence). Tầng lưu trữ sẽ từ chối các lần ghi mang token cũ:
UPDATE inventory
SET stock = stock - :qty, last_fence = :fence
WHERE product_id = :id AND last_fence < :fence;
Nếu câu lệnh cập nhật 0 dòng, nghĩa là một tiến trình mang token mới hơn đã ghi trước. Request cũ sẽ bị loại.
7.3. Các lỗi khác
- Dùng chung một key cho mọi tài nguyên, khiến throughput sụt giảm mạnh.
- Quên
finally, nên lock chỉ được nhả khi TTL hết hạn. - Gọi
unlock()của Redisson khi không còn giữ lock (do đã hết hạn). Hãy kiểm traisHeldByCurrentThread()trước khi gọi. - Retry lấy lock liên tục không có backoff, khiến Redis bị dội request.
7.4. Khi nào KHÔNG nên dùng
- Ứng dụng chỉ có một instance:
ReentrantLocklà đủ và đơn giản hơn nhiều. - Dữ liệu nằm trong một database quan hệ: optimistic locking (
@Version),SELECT ... FOR UPDATE, hoặc unique constraint thường an toàn hơn. Chúng nằm cùng transaction với dữ liệu. - Cần đảm bảo đúng tuyệt đối: hãy cân nhắc dùng hệ thống có đồng thuận như ZooKeeper hoặc etcd, kết hợp với fencing token.
- Chỉ cần mỗi message xử lý một lần: idempotency key hoặc consumer group của message broker thường phù hợp hơn lock.
8. Kết luận
Distributed lock với Redis dựa trên ba nguyên tắc: lấy lock bằng một lệnh atomic SET NX PX, gắn token duy nhất cho mỗi lần lấy lock, và nhả lock bằng Lua script có kiểm tra token. Bạn có thể tự cài đặt trong vài chục dòng với StringRedisTemplate, hoặc dùng Redisson để có thêm watchdog, reentrant và cơ chế chờ hiệu quả.
Hãy nhớ rằng lock Redis giúp tránh xử lý trùng lặp trong đa số trường hợp, nhưng không phải là bảo đảm tuyệt đối. Với các thao tác quan trọng, bạn nên kết hợp lock với fencing token hoặc ràng buộc ở tầng database. Ngoài ra, luôn kiểm thử lock trên Redis thật bằng Testcontainers.