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ề nil vì 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-alice là 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ệnh SET key value NX PX <ms>. Nếu ttl là số giây chẵn, Spring có thể dùng EX thay cho PX, nhưng ý nghĩa không đổi.
  • Mỗi lần giữ ghế tạo một UUID mới làm token. Token được trả về cho client, ví dụ trong response của API POST /holds. Client gửi lại token khi thanh toán hoặc huỷ.
  • setIfAbsent trả về Boolean có thể null khi chạy trong pipeline hoặc transaction. Vì vậy code so sánh bằng Boolean.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:

  1. Alice giữ ghế với TTL 5 phút.
  2. Alice treo ở trang thanh toán quá 5 phút, key tự hết hạn.
  3. Bob giữ được ghế E7.
  4. 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ới DEL hoặc PEXPIRE.
  • Script trả 1 khi thành công, 0 khi token không khớp hoặc key đã hết hạn.
  • DefaultRedisScript tự tính SHA1 và gọi EVALSHA. Nếu Redis chưa cache script, Spring tự fallback sang EVAL. Bạn nên khai báo script dạng static 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ảng booking. 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 SETNX rồi EXPIRE riê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ùng SET ... NX PX trong một lệnh.
  • Nhả khoá bằng DEL khô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 release và 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 PTTL thay 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_until cù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é.

redisspring-bootdistributed-lockjavaconcurrency