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 /holdsAPI 的响应中。客户端在支付或取消时重新发送令牌。 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
看看这个场景:
- Alice 以 5 分钟 TTL 锁定座位。
- Alice 在支付页面卡了超过 5 分钟,key 自动过期。
- Bob 成功锁定座位
E7。 - 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 作为最后一道防线。