ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java演唱会购票系统高并发防超卖实战:库存扣减与订单一致性

Java演唱会购票系统高并发防超卖实战:库存扣减与订单一致性 简介本资源为基于Java开发的演唱会在线购票系统设计源码面向具备一定Java基础、希望学习完整购票业务实现的学生与开发者。项目围绕用户注册登录、演唱会信息查询、座位浏览、在线选座支付、订单管理与用户反馈等核心场景展开采用MVC架构分离数据层、控制层与视图层可作为课程设计、毕业设计或自学练手的参考范例。压缩包共37个文件约2.1MB包含13个Java源文件承载核心业务逻辑10个class编译产物6个XML配置文件用于数据源与环境参数设置另有SQL建表脚本、properties数据库连接配置、JAR依赖包及readme说明文档目录结构清晰便于按模块阅读与二次开发。目前已有91人学习下载。通过研读源码读者可掌握购票系统从数据库设计到业务分层的完整实现思路理解JDBC连接、票务管理与支付处理等关键环节并借鉴其项目组织方式与版本控制配置快速搭建属于自己的在线购票应用。1. 演唱会在线购票系统为什么高并发下超卖比崩溃更致命抢票这件事用户看到的是「点一下按钮」工程师看到的是库存扣减、订单落库、支付回调三件事在几百毫秒内必须对齐。基于 Java 开发的演唱会在线购票系统核心难点从来不是页面好不好看而是同一张票在 10 万人同时点击时只能卖给一个人。我见过太多课程设计级别的购票系统单机跑得好好的一上压测就出现超卖、重复下单、订单和库存对不上最后只能靠人工对账擦屁股。这套系统的适用人群很明确正在做 Java 课程设计、毕业设计的学生想拿一个真实业务场景练 Spring Boot MyBatis 的初中级工程师以及需要快速搭一套票务原型验证业务的小团队。它要解决的问题是「选座、锁座、下单、支付、出票」这条链路上每一步的数据一致性怎么保证。源码本身不是重点重点是你能不能看懂库存扣减那几行 SQL 为什么必须那样写。下面我按实际落地顺序把选型、建表、锁座、下单、避坑、压测验证一层层拆开讲。2. 技术选型与数据库设计先把库存表的地基打对2.1 为什么是 Spring Boot MyBatis-Plus 而不是 JPA演唱会购票系统的读写特征非常极端查询场次和座位图是高频读下单扣库存是低频但强一致的写。这种场景下我一般会选 Spring Boot 做 Web 层MyBatis-Plus 做持久层原因是扣库存这种操作需要精确控制 SQL比如update seat set status1 where id? and status0这种带条件的更新用 JPA 的自动脏检查反而容易写出先查后改的并发漏洞。MyBatis-Plus 还有个实际好处可以根据 Java 实体类反向生成建表 SQL课程设计阶段改字段很频繁这个能力能省不少手工同步的功夫。下面是一个座位实体和对应的建表语句注意version字段是后面乐观锁要用的。// Seat.java 座位实体 Data TableName(t_seat) public class Seat { TableId(type IdType.AUTO) private Long id; private Long sessionId; // 场次ID private String seatNo; // 座位号 如 A-12-03 private Integer status; // 0可售 1锁定 2已售 private Integer price; // 分单位避免浮点误差 Version private Integer version; // 乐观锁版本号 private Date lockTime; // 锁定时间用于超时释放 }-- 由实体生成的建表语句MySQL 8.0 CREATE TABLE t_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL, seat_no VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 0, price INT NOT NULL, version INT NOT NULL DEFAULT 0, lock_time DATETIME DEFAULT NULL, UNIQUE KEY uk_session_seat (session_id, seat_no), KEY idx_session_status (session_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明uk_session_seat唯一索引是防止同一场次出现重复座位记录的最后一道防线即使代码写错也不可能插入两条 A-12-03。idx_session_status支撑「查某场次所有可售座位」这个高频查询。价格用INT存分而不是DECIMAL或FLOAT是因为浮点数在金额累加时会出现0.10.2!0.3的经典问题血泪经验别在这上面翻车。参数说明status用TINYINT而不是ENUM因为状态机后续可能扩展比如加「退票中」ENUM改起来要锁表。version字段配合 MyBatis-Plus 的Version注解更新时会自动带上versionold1的条件这是乐观锁的基础。2.2 库存到底该放 Redis 还是 MySQL这是被问得最多的问题。我的结论是MySQL 是唯一真相源Redis 只做前置拦截。纯 Redis 扣库存看起来快但一旦 Redis 宕机或主从切换丢数据库存就对不上了演唱会票这种涉及真金白银的场景丢一张票就是一次投诉。常见做法是两层Redis 用DECR做原子预扣扣到 0 直接返回「售罄」挡住 90% 的无效流量真正下单时再走 MySQL 的条件更新用affected rows判断是否抢到。下面这段是下单核心逻辑。// OrderService.java 下单扣库存 Transactional(rollbackFor Exception.class) public Result createOrder(Long userId, Long seatId) { // 1. Redis 预扣key 为场次维度库存 Long left redisTemplate.opsForValue().decrement(stock:session: sessionId); if (left null || left 0) { redisTemplate.opsForValue().increment(stock:session: sessionId); // 回补 return Result.fail(已售罄); } // 2. MySQL 条件更新status0 才允许锁定 int rows seatMapper.lockSeat(seatId, userId); if (rows 0) { redisTemplate.opsForValue().increment(stock:session: sessionId); // 回补 return Result.fail(该座位已被抢); } // 3. 写订单状态待支付 orderMapper.insert(buildOrder(userId, seatId)); return Result.ok(); }!-- SeatMapper.xml 条件更新这是防超卖的关键 -- update idlockSeat UPDATE t_seat SET status 1, lock_time NOW(), version version 1 WHERE id #{seatId} AND status 0 /update逻辑说明WHERE id? AND status0这一句是整条链路的核心。InnoDB 行锁保证同一行更新串行执行第一个事务把status改成 1 并提交后第二个事务的WHERE status0不再匹配rows返回 0直接判定抢票失败。这比「先 select 查状态再 update」安全得多后者在并发下两个线程可能都查到status0。参数说明Redis 回补那一步不能省否则 MySQL 扣失败但 Redis 已经减了库存会越跑越少。Transactional只覆盖 MySQL 操作Redis 操作在事务外所以回补要手动做。如果追求更严谨可以把 Redis 扣减放到事务提交后但那样挡不住并发权衡下来还是前置拦截更实用。3. 锁座与订单状态机把「占着不付钱」这件事管住3.1 座位锁定与超时释放的实现用户点了座位但没付款这个座位不能一直锁着否则黄牛用脚本占座就能把票全囤了。标准做法是锁定后给一个支付窗口比如 15 分钟超时自动释放。实现上有两种定时任务扫表或者延迟队列。课程设计级别用定时任务就够了生产环境我一般用 Redis 的过期 key 加监听。// 定时任务每分钟释放超时未支付的锁定座位 Scheduled(cron 0 * * * * ?) public void releaseTimeoutSeats() { Date deadline new Date(System.currentTimeMillis() - 15 * 60 * 1000L); // 查出超时锁定的座位 ListSeat timeoutSeats seatMapper.selectList( new LambdaQueryWrapperSeat() .eq(Seat::getStatus, 1) .lt(Seat::getLockTime, deadline) ); for (Seat seat : timeoutSeats) { // 条件更新防止释放时用户刚好付款 int rows seatMapper.releaseSeat(seat.getId()); if (rows 0) { redisTemplate.opsForValue().increment(stock:session: seat.getSessionId()); // 关联订单置为已取消 orderMapper.cancelBySeatId(seat.getId()); } } }update idreleaseSeat UPDATE t_seat SET status 0, lock_time NULL WHERE id #{seatId} AND status 1 /update逻辑说明释放时同样用条件更新WHERE status1因为存在一种竞态——定时任务查到座位超时正准备释放用户在这一瞬间完成了支付座位状态变成 2。如果没有status1这个条件就会把已售座位错误地改回可售造成一票两卖。这个坑我在早期项目里踩过排查了半天才发现是释放逻辑没加状态判断。参数说明cron 0 * * * * ?表示每分钟执行一次课程设计够用。生产环境座位量大时全表扫描会拖慢数据库应该按lock_time建索引或者改用 Redis 的zset存锁定座位按分数范围取超时的。15 分钟这个窗口是行业惯例太短用户来不及付款太长占座成本太低。3.2 订单状态机怎么设计才不乱订单状态如果只有「待支付/已支付」两个很快就会乱退款、超时取消、支付失败重试这些情况没地方放。我一般会定义五个状态并且用一张状态流转表约束合法迁移。当前状态允许迁移到触发动作0 待支付1 已支付支付回调成功0 待支付2 已取消超时释放 / 用户取消1 已支付3 退款中用户申请退款3 退款中4 已退款退款回调成功1 已支付5 已出票出票任务完成// 状态流转校验非法迁移直接抛异常 private static final MapInteger, SetInteger TRANSFER Map.of( 0, Set.of(1, 2), 1, Set.of(3, 5), 3, Set.of(4) ); public void transfer(Order order, int target) { SetInteger allowed TRANSFER.get(order.getStatus()); if (allowed null || !allowed.contains(target)) { throw new BizException(非法状态流转: order.getStatus() - target); } order.setStatus(target); orderMapper.updateById(order); }逻辑说明把合法迁移写成常量表任何状态变更都走transfer方法这样出问题时能立刻定位是哪一步绕过了校验。支付回调是最容易出问题的地方第三方可能重复回调所以回调入口要先查订单当前状态已经是「已支付」就直接返回成功不要重复处理。参数说明状态值用数字而不是字符串是为了数据库存储和索引效率但要在代码里用常量类包一层别在业务代码里裸写1、2。退款状态单独拆出「退款中」是因为退款是异步的中间态必须可见否则用户申请退款后订单直接变「已退款」但钱还没到账客服会被打爆。4. 避坑与排查那些压测才暴露的并发问题4.1 超卖现象是卖出票数大于座位数现象压测 500 并发抢 100 张票最后订单表里有 103 条待支付记录。原因扣库存用了「先 select 查余量再 update 减一」的写法两个线程同时查到余量 1都判断可以买都执行了减一。解决改成UPDATE ... WHERE status0的条件更新用返回的affected rows判断成败彻底去掉「先查后改」。这是最经典也最容易犯的错冒泡排序写错顶多结果不对这个写错是真赔钱。4.2 重复下单同一用户同一座位生成两条订单现象用户手抖连点两次或者网络重试订单表出现两条相同user_id seat_id的记录。原因接口没有幂等控制两次请求都通过了座位锁定校验。解决在t_order表上加UNIQUE KEY uk_user_seat (user_id, seat_id)数据库层面兜底同时在接口入口用 Redis 的SETNX做用户维度的短时锁key 为order:lock:userId:seatId过期时间 5 秒挡住重复提交。4.3 支付回调丢失导致座位永久锁定现象用户明明付了钱订单还是「待支付」15 分钟后座位被释放用户投诉。原因支付平台的异步回调因为网络问题没到达或者到达时服务正在重启。解决加一个主动查询任务对「待支付」且超过 5 分钟的订单主动调支付平台的查询接口核对状态同时把回调接口做成幂等的重复回调不报错。后悔药就是这层对账别等出事才加。4.4 座位图查询慢拖垮首页现象热门场次座位图接口响应 3 秒以上首页直接卡死。原因每次请求都SELECT * FROM t_seat WHERE session_id?全量查几千行还带status过滤。解决座位图这种读多写少的数据用 Redis 缓存整个场次的座位 JSON下单成功后再删缓存或者把座位按区域分片查询前端只加载可视区域。注意缓存和数据库的一致性扣库存成功后必须删对应场次的缓存 key。4.5 定时任务释放座位时误伤已支付订单现象极低概率出现已支付订单的座位被改回可售然后被别人买走。原因释放任务查到座位超时但用户刚好在这一秒完成支付释放逻辑没判断订单状态就执行了。解决释放座位前先查关联订单只有订单是「待支付」或「已取消」才释放释放 SQL 带上AND status1条件。这个 bug 复现概率极低但一旦发生就是重大事故压测时要用「支付和释放同时触发」的用例专门验证。5. 压测验证与进阶用 JMeter 把并发问题逼出来写完代码不压测等于没写。我一般用 JMeter 做三组用例纯抢票、抢票加支付、抢票加超时释放每组都观察订单数、座位状态、Redis 库存三者是否一致。下面是一个用 JMeter 命令行跑压测的最小配置重点是「断言响应里不能出现超卖成功」。# 用 JMeter 非 GUI 模式压测下单接口 jmeter -n -t order_test.jmx -l result.jtl -e -o report/ # 参数说明 # -n 非GUI模式省资源 # -t 测试计划文件 # -l 结果数据文件 # -e -o 生成HTML报告到 report 目录压测后核对一致性的 SQL 是关键我习惯跑这三条-- 1. 已售锁定座位数不能超过总座位数 SELECT COUNT(*) FROM t_seat WHERE session_id1 AND status IN (1,2); -- 2. 订单数应等于已锁定已售座位数排除已取消 SELECT COUNT(*) FROM t_order WHERE session_id1 AND status ! 2; -- 3. 检查有没有同一座位多条有效订单 SELECT seat_id, COUNT(*) c FROM t_order WHERE status ! 2 GROUP BY seat_id HAVING c 1;第三条查询返回任何一行就说明幂等控制失效了必须回去查唯一索引和 Redis 锁。压测的并发数要逐步加从 50 到 500 再到 2000观察 QPS 和错误率的变化拐点那个拐点就是当前架构的容量上限。进阶方向有两个。一是把 Redis 预扣改成 Lua 脚本把「判断库存 扣减 记录用户」合成一个原子操作避免网络往返带来的竞态。二是引入消息队列削峰下单请求先写 MQ后端消费者串行扣库存把瞬时高并发变成平稳消费代价是用户要等一小会儿才看到结果。这两个方向都值得动手做一遍做完你对「高并发」的理解会完全不一样。最后说个我自己的习惯每次改完扣库存相关的代码不管多小的改动都要跑一遍那三条一致性 SQL再手动模拟一次「支付和释放同时发生」。这个习惯帮我挡掉过至少三次线上事故。购票系统的代码可以写得不够优雅但库存和订单的一致性不能有半点侥幸。希望帮到你。本文还有配套的精品资源点击获取
返回列表