Redis 锁座 + 数据库双检:影评票务系统防超卖实战拆解
这部分代码我当时改了很多轮,原因很简单:只要这里出错,就会一票多卖。 我不想把正确性赌在一个组件上,所以把入口清洗、Redis 锁、业务校验、数据库兜底、异常回滚都做成了串联防线。 我在下面看到的代码都是我自己课程项目里实际写过并跑过的,不是示意图。 我这篇只讲链路里的技术动作,不写空泛总结。 如果我也在做库存占用类下单系统,这套组合可以直接对照着落。
1. 并发问题不是“慢”而是“错。
在票务场景里,同一时刻会有大量请求命中同一排片和同一座位。 如果只盯着接口耗时优化,很容易忽略真正的风险点:请求结果错误。 一旦发生一票多卖,后续的退款、客服、补偿和口碑成本都会被放大。 因此系统设计优先级必须是先保证一致性,再谈吞吐和延迟。 我在实现时把“座位不会卖错”定义成首要约束,而不是可选优化项。
我把超卖风险拆成了三类并分别处理。 第一类是入口数据污染,例如重复座位、非法格式、空字符串混入。 第二类是并发竞争,多个请求同时争抢同一资源。 第三类是状态穿透,即应用层判定可卖,但数据库事实已不可卖。 这三类问题必须分别落地策略,单点方案无法覆盖全部边界。
2. 下单入口先做数据清洗,再做并发控。
真实线上请求永远比理。DTO 更脏。 我在 createOrder 入口先把前端 seats 。split、trim、去重和空值过滤,保证后续逻辑只处理干净数据。 这个步骤看起来基础,但可以提前消灭大量隐藏 bug,例如同一订单重复提交同座位、座位字符串夹杂空格等。 如果输入层不收敛,后面的锁与事务只会在错误数据上浪费资源。 所以我把这一步放在分布式锁之前,避免无意义上锁。
public String createOrder(OrderDTO dto, Long userId) {
List<String> rawSeats = dto.getSeats();
if (rawSeats == null || rawSeats.isEmpty()) {
throw new RuntimeException("请选择座位");
}
Set<String> distinctSet = new HashSet<>();
for (String seatStr : rawSeats) {
String[] splitSeats = seatStr.split(",");
for (String s : splitSeats) {
String cleanSeat = s.trim();
if (cleanSeat.isEmpty()) continue;
if (distinctSet.contains(cleanSeat)) {
throw new RuntimeException("订单中包含重复座。 " + cleanSeat);
}
distinctSet.add(cleanSeat);
}
}
if (distinctSet.isEmpty()) {
throw new RuntimeException("有效座位为空");
}
List<String> finalSeats = new ArrayList<>(distinctSet);
// 后续逻辑只使。finalSeats
}3. Redis 原子锁座只做第一层门卫,不做最终事。
清洗后的座位数据进入并发控制阶段。 我使。Redis 锁服务做原子锁座,把“资源竞争”尽量在应用层快速拦下。 这样做的收益是可以在数据库前过滤掉大部分冲突请求,降低主库竞争压力。 。Redis 锁只负责“先占坑”,并不代表最终可售事实。 所以我把它定义为第一层门卫,而不是唯一可信来源。
拿到锁之后,我仍然继续做排片存在性、开场时间、影厅配置和坏座信息校验。 这些检查和业务语义强相关,不能简单靠缓存状态替代。 一旦任何校验失败,我会在异常路径立即释放已加的座位锁。 这个释放动作必须覆盖所。RuntimeException 分支,不允许遗漏。 否则最典型的后果是座位被“锁死”但没有任何订单持有。
boolean lockSuccess = redisLockService.tryLockSeats(dto.getScheduleId(), finalSeats, userId);
if (!lockSuccess) {
throw new RuntimeException("部分座位已被锁定,请重新选择");
}
try {
Schedule schedule = scheduleService.getById(dto.getScheduleId());
if (schedule == null) {
throw new RuntimeException("排片不存。);
}
if (schedule.getStartTime().isBefore(LocalDateTime.now())) {
redisLockService.releaseSeatLocks(dto.getScheduleId(), finalSeats);
throw new RuntimeException("电影已开场,停止售票。);
}
// ... 业务校验继续
} catch (RuntimeException e) {
redisLockService.releaseSeatLocks(dto.getScheduleId(), finalSeats);
throw e;
}4. 坏座与边界校验落在业务层,数据库再做最终兜。
我在影厅配置里维。rows、cols 。broken_seats,创建订单时会解。seat_config 并逐座校验。 这一步可以直接拦截“座位越界”和“坏座购买”这种逻辑错误。 通过这一层可以确保后续事务只处理合法且可售的座位集合。 随后再进入数据库兜底检查,确认已支付订单中不存在同座位占用。 只有两层都通过,才进入订单创建和金额计算。
很多系统只做到缓存锁就结束,这在高并发异常场景下不够稳。 我的做法是把数据库状态当作最终事实源,尤其是 status=已支。的记录。 即使上游出现锁过期、重试、网络抖动等情况,数据库兜底仍能挡住错误落库。 从工程角度看,这是用一次额外查询换高代价事故规避。 在交易系统里,这个成本是值得的。
5. 前端协同:防重复点击 + 失败后刷新座位图
并发正确性不仅是后端事务问题,前端交互也会放大或抑制并发冲突。 我在前端提交下单时加。loading 状态,防止用户连续点击造成重复请求。 当后端返回抢座失败时,前端会立即刷新座位图,缩短用户与真实状态之间的差距。 这能显著减少用户误操作和无意义重试,也能降低服务端噪音请求。 前后端在并发场景里的目标应该一致:尽快收敛到同一份真实状态。
const submitOrder = async () => {
if (selectedSeats.value.length === 0) {
ElMessage.warning("请至少选择一个座。);
return;
}
const orderForm = {
scheduleId: route.params.scheduleId,
seats: selectedSeats.value,
};
try {
loading.value = true;
const res = await createOrder(orderForm);
if (res.code === 200) {
ElMessage.success("锁定座位成功,请。5分钟内支。);
router.push({ name: "OrderPay", params: { orderNo: res.data } });
} else {
ElMessage.error(res.message || "选座失败,座位可能已被抢");
refreshSeatMap();
}
} finally {
loading.value = false;
}
};