Redis 座位预订系统:使用 SET NX PX 锁定座位和自动过期

1. 概述

在电影票、机票或餐厅桌位预订系统中,用户通常被“锁定座位”几分钟用于支付。时间到了还没支付,座位必须自动释放给其他人。本文指导你使用 Redis 的 SET key value NX PX 命令来原子性地锁定座位,使锁自动过期,并通过 Lua 脚本安全地释放或延长锁。读完本文后,你将拥有一个可在 Spring Boot 3.x 上运行的 SeatHoldService,并包含使用 Testcontainers 的测试。

2. 座位锁定问题

两个顾客几乎同时点击选择第 42 场放映的座位 E7。系统必须保证三点:

  • 只有一个人能锁定座位。
  • 如果那个人离开,座位将在一段时间后(例如 5 分钟)自动释放。
  • 只有锁定座位的人才能释放或延长座位锁。

你可以用数据库配合 SELECT ... FOR UPDATE 和一个 held_until 列来实现。这样可以工作,但需要一个定时清理过期记录的 job。大促期间大量写入也容易堵塞数据库。Redis 解决得更简洁,因为它内置了两样东西:原子性的“仅在不存在时设置”操作,以及 key 可以自动过期。

3. SET NX PX 如何工作

用 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

各部分的含义:

  • NX:仅当 key 不存在时设置。Bob 的第二条命令返回 nil,因为 Alice 已经锁定了座位。
  • PX 300000:设置 TTL 为 300,000 毫秒,即 5 分钟。时间到了,Redis 自动删除 key。
  • 值 token-alice 是锁定令牌,用于识别谁在锁定座位。后面会用这个令牌安全地释放锁。

关键是 NX 和 PX 在一条命令中,因此是原子执行的。老方法先 SETNX 再 PEXPIRE 有风险:如果应用在两条命令之间崩溃,key 会永久存在,座位被永久锁定。

Key 中的 {show:42} 部分是哈希标签。在 Redis Cluster 中,具有相同哈希标签的所有 key 都在同一个 slot。这样你就可以在同一场放映的多个座位上使用 Lua 脚本,如第 7 节所示。

4. Spring Boot 配置

添加 Spring Data Redis 依赖。Lettuce 是默认客户端:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

配置连接:

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=2s

Spring Boot 自动创建 StringRedisTemplate bean。Key 和 value 都是字符串,适合这个问题。

5. 使用 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)));
    }
}

解释:

  • setIfAbsent(key, value, Duration) 生成 SET key value NX PX <ms> 命令。如果 ttl 是整数秒,Spring 可能会用 EX 代替 PX,但意思相同。
  • 每次锁定座位都会生成一个新的 UUID 作为令牌。令牌返回给客户端,例如在 POST /holds API 的响应中。客户端在支付或取消时重新发送令牌。
  • setIfAbsent 在管道或事务中运行时可能返回 null 的 Boolean。因此代码使用 Boolean.TRUE.equals(ok) 比较,而不是直接拆箱。

在 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());
}

后来者收到 409 Conflict。前端据此提示“该座位已被他人选择”。

6. 使用 Lua 安全释放和延长锁

6.1. 为什么不直接使用 DEL

看看这个场景:

  1. Alice 以 5 分钟 TTL 锁定座位。
  2. Alice 在支付页面卡了超过 5 分钟,key 自动过期。
  3. Bob 成功锁定座位 E7。
  4. Alice 点击“取消”,服务器调用 DEL。Bob 的座位被删除。

你必须在删除前检查令牌。如果分别用 GET 和 DEL 两条命令,两条命令之间 key 仍可能过期并更换所有者。Lua 脚本在 Redis 上原子执行,消除了这个窗口。

6.2. 释放和延长

@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() 同上

    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;
    }
}

解释:

  • KEYS[1] 是座位 key,ARGV[1] 是客户端发送的令牌。只有当当前值与令牌匹配时,脚本才执行 DEL 或 PEXPIRE。
  • 脚本成功时返回 1,令牌不匹配或 key 已过期时返回 0。
  • DefaultRedisScript 自动计算 SHA1 并调用 EVALSHA。如果 Redis 还没缓存脚本,Spring 自动回退到 EVAL。应该将脚本声明为 static final,避免每次调用都重新创建。

当用户进入支付页面时,extend 很有用,你可以额外增加 2 分钟。如果 extend 返回 false,说明座位已经丢失,需要提示用户重新选择。

7. 同时锁定多个座位

一群朋友订三个座位 E7、E8、E9。要求是要么三个都锁定,要么都不锁。调用三次 hold 然后在失败时回滚也能工作,但代码冗长且有一时刻其他人会看到中间状态。Lua 脚本更简洁地处理这个:

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();
}

第一个循环检查是否有任何座位已被锁定。如果没有,第二个循环用相同的令牌和 TTL 设置所有座位。整个脚本原子执行,没有其他请求能插入其中。

由于哈希标签 {show:42},同一场放映的所有 key 都在同一个 slot。如果缺少哈希标签,当脚本访问多个 key 时,Redis Cluster 会返回 CROSSSLOT 错误。

8. 使用 Testcontainers 编写测试

添加测试依赖:

<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>

为主要行为编写测试:

@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") 告诉 Spring Boot 这个容器是 Redis。Spring Boot 自动配置 host 和 port 指向容器,你不需要 @DynamicPropertySource。测试 staleTokenCannotReleaseSomeoneElsesHold 重现了第 6.1 节的场景。Alice 的旧令牌无法删除 Bob 的锁。

你也可以用多线程验证真实的竞争:

@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);
    }
}

测试使用 Java 21 的虚拟线程并发发送 100 个请求。结果总是恰好有一个赢家。

9. 常见错误和限制

注意: Redis 锁只是一层“软”保护。确认订票时,你仍然必须在 booking 表的 (show_id, seat_id) 上有 unique constraint。Redis 在故障转移时可能丢失数据,因为副本异步接收数据。没有约束的话,两个人可能都被确认同一个座位。

其他常见错误:

  • 使用 SETNX 然后再用 EXPIRE。 如果应用在两条命令之间崩溃,锁永远不会过期。总是在一条命令中使用 SET ... NX PX。
  • 使用 DEL 释放锁而不检查令牌。 你会误删他人的锁,如第 6.1 节的场景。
  • 确认订票而不检查锁。 写 booking 前,调用脚本检查令牌是否仍然匹配,或使用 release 仅在结果为 true 时才写入。如果支付成功但锁已丢失,需要有退款流程。
  • TTL 过短或过长。 过短则用户在输入卡号时丢失座位。过长则座位在高峰期被“幽灵占用”。应保持 TTL 较短,结合每一步的 extend。
  • 在 Cluster 中忘记哈希标签。 多 key 脚本会出现 CROSSSLOT 错误。
  • 依赖应用程序时钟计算锁定期限。 让 Redis 管理 TTL。如果 UI 需要显示倒计时,读取 PTTL 而不是自己计算。

何时不应该使用此方法:

  • 当你需要在锁层绝对保证时,例如没有后续数据库约束层的金融资源。单个带异步复制的 Redis 无法提供这样的保证。
  • 当流量小且数据库已足够应对。一个 held_until 列加 unique constraint 可能比额外运维 Redis 更简单。
  • 当锁定状态需要长期保存用于对账。Redis key 会自动消失,所以你需要分别记录事件日志。

10. 总结

SET key value NX PX 命令在一次调用中给你原子性的座位锁和自动过期。结合随机令牌和 Lua 脚本,你可以释放或延长锁而不用担心删除他人的锁。你也可以用全有或全无的方式锁定多个座位。在运行 Redis Cluster 时使用哈希标签。最后,确认订票时总是让数据库的 unique constraint 作为最后一道防线。

redisspring-bootdistributed-lockjavaconcurrency