Redis cho hệ thống đặt chỗ: khoá ghế bằng SET NX PX và hết hạn tự động
1. Tổng quan
Trong hệ thống đặt vé xem phim, vé máy bay hay bàn nhà hàng, người dùng thường được “giữ chỗ” vài phút để thanh toán. Hết thời gian mà chưa trả tiền, ghế phải tự trả về cho người khác. Bài này hướng dẫn bạn dùng lệnh SET key value NX PX của Redis để khoá ghế nguyên tử, cho khoá hết hạn tự động, và giải phóng hoặc gia hạn khoá an toàn bằng Lua script. Đọc xong, bạn sẽ có một SeatHoldService chạy được trên Spring Boot 3.x, kèm test với Testcontainers.
2. Bài toán giữ ghế
Hai khách hàng cùng bấm chọn ghế E7 của suất chiếu 42 gần như cùng lúc. Hệ thống phải đảm bảo ba điều:
- Chỉ một người giữ được ghế.
- Nếu người đó bỏ đi, ghế được tự động nhả sau một khoảng thời gian, ví dụ 5 phút.
- Chỉ chính người giữ mới được nhả hoặc gia hạn ghế.
Bạn có thể làm điều này bằng database với SELECT ... FOR UPDATE và một cột held_until. Cách đó vẫn chạy được, nhưng cần một job dọn dẹp các bản ghi hết hạn. Lượng ghi lớn vào giờ mở bán cũng dễ làm nghẽn database. Redis giải quyết gọn hơn vì có sẵn hai thứ: thao tác “set nếu chưa tồn tại” là nguyên tử, và key có TTL tự hết hạn.
3. SET NX PX hoạt động thế nào
Thử trực tiếp bằng redis-cli:
redis-cli SET seat-hold:{show:42}:E7 "token-alice" NX PX 300000
redis-cli SET seat-hold:{show:42}:E7 "token-bob" NX PX 300000
redis-cli PTTL seat-hold:{show:42}:E7
OK
(nil)
(integer) 299412
Ý nghĩa từng phần:
NX: chỉ set khi key chưa tồn tại. Lệnh thứ hai của Bob trả vềnilvì Alice đã giữ ghế.PX 300000: đặt TTL 300.000 mili giây, tức 5 phút. Hết thời gian, Redis tự xoá key.- Giá trị
token-alicelà hold token để nhận diện ai đang giữ ghế. Phần sau sẽ dùng token này để nhả khoá an toàn.
Điểm quan trọng là NX và PX nằm trong một lệnh, nên được thực hiện nguyên tử. Cách cũ SETNX rồi mới PEXPIRE có rủi ro: nếu ứng dụng chết giữa hai lệnh, key tồn tại mãi và ghế bị khoá vĩnh viễn.
Phần {show:42} trong key là hash tag. Với Redis Cluster, mọi key có cùng hash tag nằm trên cùng một slot. Nhờ vậy bạn có thể dùng Lua script trên nhiều ghế của cùng suất chiếu, như ở mục 7.
4. Cấu hình Spring Boot
Thêm dependency Spring Data Redis. Lettuce là client mặc định:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Cấu hình kết nối:
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=2s
Spring Boot tự tạo bean StringRedisTemplate. Key và value đều là chuỗi, phù hợp với bài toán này.
5. Giữ ghế bằng setIfAbsent
@Service
public class SeatHoldService {
private final StringRedisTemplate redis;
public SeatHoldService(StringRedisTemplate redis) {
this.redis = redis;
}
static String key(long showId, String seatId) {
return "seat-hold:{show:%d}:%s".formatted(showId, seatId);
}
public Optional<String> hold(long showId, String seatId, Duration ttl) {
String token = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue().setIfAbsent(key(showId, seatId), token, ttl);
return Boolean.TRUE.equals(ok) ? Optional.of(token) : Optional.empty();
}
public boolean isHeld(long showId, String seatId) {
return Boolean.TRUE.equals(redis.hasKey(key(showId, seatId)));
}
}
Giải thích:
setIfAbsent(key, value, Duration)sinh ra lệnhSET key value NX PX <ms>. Nếuttllà số giây chẵn, Spring có thể dùngEXthay choPX, nhưng ý nghĩa không đổi.- Mỗi lần giữ ghế tạo một
UUIDmới làm token. Token được trả về cho client, ví dụ trong response của APIPOST /holds. Client gửi lại token khi thanh toán hoặc huỷ. setIfAbsenttrả vềBooleancó thểnullkhi chạy trong pipeline hoặc transaction. Vì vậy code so sánh bằngBoolean.TRUE.equals(ok)thay vì unbox trực tiếp.
Ví dụ dùng trong controller:
@PostMapping("/shows/{showId}/seats/{seatId}/hold")
public ResponseEntity<Map<String, String>> hold(@PathVariable long showId,
@PathVariable String seatId) {
return seatHoldService.hold(showId, seatId, Duration.ofMinutes(5))
.map(token -> ResponseEntity.ok(Map.of("holdToken", token)))
.orElseGet(() -> ResponseEntity.status(HttpStatus.CONFLICT).build());
}
Người đến sau nhận 409 Conflict. Frontend dựa vào đó để báo “Ghế đã có người chọn”.
6. Nhả và gia hạn khoá an toàn bằng Lua
6.1. Vì sao không dùng DEL trực tiếp
Hãy xem kịch bản sau:
- Alice giữ ghế với TTL 5 phút.
- Alice treo ở trang thanh toán quá 5 phút, key tự hết hạn.
- Bob giữ được ghế
E7. - Alice bấm “Huỷ”, server gọi
DEL. Ghế của Bob bị xoá.
Bạn phải kiểm tra token trước khi xoá. Nếu làm bằng GET rồi DEL ở hai lệnh riêng, giữa hai lệnh key vẫn có thể hết hạn và đổi chủ. Lua script chạy nguyên tử trên Redis, nên loại bỏ được khoảng hở này.
6.2. Release và extend
@Service
public class SeatHoldService {
private static final RedisScript<Long> RELEASE = new DefaultRedisScript<>("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
""", Long.class);
private static final RedisScript<Long> EXTEND = new DefaultRedisScript<>("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
""", Long.class);
// ... constructor, key(), hold(), isHeld() như trên
public boolean release(long showId, String seatId, String token) {
Long n = redis.execute(RELEASE, List.of(key(showId, seatId)), token);
return n != null && n == 1L;
}
public boolean extend(long showId, String seatId, String token, Duration ttl) {
Long n = redis.execute(EXTEND, List.of(key(showId, seatId)),
token, String.valueOf(ttl.toMillis()));
return n != null && n == 1L;
}
}
Giải thích:
KEYS[1]là key ghế,ARGV[1]là token client gửi lên. Chỉ khi giá trị hiện tại khớp token thì script mớiDELhoặcPEXPIRE.- Script trả
1khi thành công,0khi token không khớp hoặc key đã hết hạn. DefaultRedisScripttự tính SHA1 và gọiEVALSHA. Nếu Redis chưa cache script, Spring tự fallback sangEVAL. Bạn nên khai báo script dạngstatic finalđể không tạo lại mỗi lần gọi.
extend hữu ích khi người dùng chuyển sang cổng thanh toán và bạn muốn cộng thêm 2 phút. Nếu extend trả false, bạn biết chắc ghế đã mất và cần báo người dùng chọn lại.
7. Giữ nhiều ghế cùng lúc
Một nhóm bạn đặt ba ghế E7, E8, E9. Yêu cầu là giữ được cả ba hoặc không ghế nào. Gọi hold ba lần rồi rollback khi thất bại thì vẫn chạy được, nhưng dài dòng và có thời điểm người khác thấy trạng thái nửa vời. Lua script xử lý việc này gọn hơn:
private static final RedisScript<Long> HOLD_ALL = new DefaultRedisScript<>("""
for _, k in ipairs(KEYS) do
if redis.call('EXISTS', k) == 1 then return 0 end
end
for _, k in ipairs(KEYS) do
redis.call('SET', k, ARGV[1], 'PX', ARGV[2])
end
return 1
""", Long.class);
public Optional<String> holdAll(long showId, List<String> seatIds, Duration ttl) {
String token = UUID.randomUUID().toString();
List<String> keys = seatIds.stream().map(s -> key(showId, s)).toList();
Long n = redis.execute(HOLD_ALL, keys, token, String.valueOf(ttl.toMillis()));
return n != null && n == 1L ? Optional.of(token) : Optional.empty();
}
Vòng lặp đầu kiểm tra xem có ghế nào đã bị giữ chưa. Nếu không có, vòng lặp thứ hai set tất cả với cùng một token và cùng TTL. Toàn bộ script chạy nguyên tử, nên không có request nào chen vào giữa.
Nhờ hash tag {show:42}, mọi key của cùng suất chiếu nằm chung một slot. Nếu thiếu hash tag, Redis Cluster sẽ trả lỗi CROSSSLOT khi script đụng tới nhiều key.
8. Viết test với Testcontainers
Thêm dependency test:
<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>
Test cho các hành vi chính:
@SpringBootTest
@Testcontainers
class SeatHoldServiceTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired SeatHoldService service;
@Autowired StringRedisTemplate template;
@BeforeEach
void clean() {
template.getConnectionFactory().getConnection().serverCommands().flushAll();
}
@Test
void onlyOneCustomerCanHoldTheSameSeat() {
Optional<String> alice = service.hold(42, "E7", Duration.ofMinutes(5));
Optional<String> bob = service.hold(42, "E7", Duration.ofMinutes(5));
assertThat(alice).isPresent();
assertThat(bob).isEmpty();
}
@Test
void holdExpiresAutomatically() throws InterruptedException {
service.hold(42, "E7", Duration.ofMillis(200));
Thread.sleep(400);
assertThat(service.isHeld(42, "E7")).isFalse();
assertThat(service.hold(42, "E7", Duration.ofMinutes(5))).isPresent();
}
@Test
void staleTokenCannotReleaseSomeoneElsesHold() {
String aliceToken = service.hold(42, "E7", Duration.ofMillis(100)).orElseThrow();
await(200);
String bobToken = service.hold(42, "E7", Duration.ofMinutes(5)).orElseThrow();
assertThat(service.release(42, "E7", aliceToken)).isFalse();
assertThat(service.isHeld(42, "E7")).isTrue();
assertThat(service.release(42, "E7", bobToken)).isTrue();
}
@Test
void holdAllIsAllOrNothing() {
service.hold(42, "E8", Duration.ofMinutes(5));
assertThat(service.holdAll(42, List.of("E7", "E8", "E9"), Duration.ofMinutes(5))).isEmpty();
assertThat(service.isHeld(42, "E7")).isFalse();
assertThat(service.isHeld(42, "E9")).isFalse();
}
private static void await(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
@ServiceConnection(name = "redis") cho Spring Boot biết container này là Redis. Spring Boot tự cấu hình host và port trỏ vào container, bạn không cần @DynamicPropertySource. Test staleTokenCannotReleaseSomeoneElsesHold tái hiện đúng kịch bản ở mục 6.1. Token cũ của Alice không thể xoá khoá của Bob.
Bạn cũng có thể kiểm tra tranh chấp thật bằng nhiều thread:
@Test
void hundredConcurrentRequestsYieldExactlyOneWinner() throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Optional<String>>> results = IntStream.range(0, 100)
.mapToObj(i -> executor.submit(() -> service.hold(42, "E7", Duration.ofMinutes(5))))
.toList();
long winners = 0;
for (var f : results) if (f.get().isPresent()) winners++;
assertThat(winners).isEqualTo(1);
}
}
Test dùng virtual thread của Java 21 để bắn 100 request đồng thời. Kết quả luôn có đúng một người thắng.
9. Lỗi hay gặp và giới hạn
Lưu ý: Khoá Redis chỉ là lớp bảo vệ “mềm”. Khi xác nhận đặt vé, bạn vẫn phải có unique constraint trên
(show_id, seat_id)trong bảngbooking. Redis có thể mất dữ liệu khi failover, vì replica nhận dữ liệu bất đồng bộ. Nếu không có constraint, hai người có thể cùng được xác nhận một ghế.
Một số lỗi hay gặp khác:
- Dùng
SETNXrồiEXPIREriêng. Nếu ứng dụng chết giữa hai lệnh, khoá không bao giờ hết hạn. Luôn dùngSET ... NX PXtrong một lệnh. - Nhả khoá bằng
DELkhông kiểm tra token. Bạn sẽ xoá nhầm khoá của người khác như kịch bản ở mục 6.1. - Xác nhận đặt vé mà không kiểm tra khoá. Trước khi ghi booking, hãy gọi một script kiểm tra token còn khớp hay không, hoặc dùng
releasevà chỉ ghi khi kết quả làtrue. Nếu thanh toán đã thành công nhưng khoá đã mất, cần có luồng hoàn tiền. - TTL quá ngắn hoặc quá dài. Quá ngắn thì người dùng mất ghế khi đang nhập thẻ. Quá dài thì ghế bị “chiếm ảo” lúc cao điểm. Nên giữ TTL ngắn kết hợp
extendở từng bước. - Quên hash tag khi chạy Cluster. Script nhiều key sẽ lỗi
CROSSSLOT. - Dựa vào đồng hồ ứng dụng để tính hạn giữ. Hãy để Redis quản lý TTL. Nếu UI cần hiển thị đếm ngược, đọc
PTTLthay vì tự tính.
Khi nào không nên dùng cách này:
- Khi bạn cần đảm bảo tuyệt đối tại tầng khoá, ví dụ tài nguyên tài chính không có lớp database ràng buộc phía sau. Một Redis đơn lẻ với replication bất đồng bộ không cung cấp đảm bảo đó.
- Khi lưu lượng nhỏ và database đã đủ đáp ứng. Một cột
held_untilcùng unique constraint có thể đơn giản hơn so với việc vận hành thêm Redis. - Khi trạng thái giữ chỗ phải được lưu lại lâu dài để đối soát. Key Redis tự biến mất, nên bạn cần ghi log sự kiện riêng.
10. Kết luận
Lệnh SET key value NX PX cho bạn một khoá ghế nguyên tử và tự hết hạn chỉ trong một lần gọi. Kết hợp với token ngẫu nhiên và Lua script, bạn có thể nhả hoặc gia hạn khoá mà không lo xoá nhầm của người khác. Bạn cũng có thể giữ nhiều ghế theo kiểu all-or-nothing. Hãy dùng hash tag khi chạy Redis Cluster. Cuối cùng, luôn để database với unique constraint làm lớp bảo vệ sau cùng khi xác nhận đặt vé.