简介:本资源为基于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 status=1 where id=? and status=0这种带条件的更新,用 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:uk_session_seat唯一索引是防止同一场次出现重复座位记录的最后一道防线,即使代码写错也不可能插入两条 A-12-03。idx_session_status支撑「查某场次所有可售座位」这个高频查询。价格用INT存分而不是DECIMAL或FLOAT,是因为浮点数在金额累加时会出现0.1+0.2!=0.3的经典问题,血泪经验,别在这上面翻车。
参数说明:status用TINYINT而不是ENUM,因为状态机后续可能扩展(比如加「退票中」),ENUM改起来要锁表。version字段配合 MyBatis-Plus 的@Version注解,更新时会自动带上version=old+1的条件,这是乐观锁的基础。
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 条件更新,status=0 才允许锁定 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 id="lockSeat"> UPDATE t_seat SET status = 1, lock_time = NOW(), version = version + 1 WHERE id = #{seatId} AND status = 0 </update>逻辑说明:WHERE id=? AND status=0这一句是整条链路的核心。InnoDB 行锁保证同一行更新串行执行,第一个事务把status改成 1 并提交后,第二个事务的WHERE status=0不再匹配,rows返回 0,直接判定抢票失败。这比「先 select 查状态再 update」安全得多,后者在并发下两个线程可能都查到status=0。
参数说明: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); // 查出超时锁定的座位 List<Seat> timeoutSeats = seatMapper.selectList( new LambdaQueryWrapper<Seat>() .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 id="releaseSeat"> UPDATE t_seat SET status = 0, lock_time = NULL WHERE id = #{seatId} AND status = 1 </update>逻辑说明:释放时同样用条件更新WHERE status=1,因为存在一种竞态——定时任务查到座位超时,正准备释放,用户在这一瞬间完成了支付,座位状态变成 2。如果没有status=1这个条件,就会把已售座位错误地改回可售,造成一票两卖。这个坑我在早期项目里踩过,排查了半天才发现是释放逻辑没加状态判断。
参数说明:cron = "0 * * * * ?"表示每分钟执行一次,课程设计够用。生产环境座位量大时,全表扫描会拖慢数据库,应该按lock_time建索引,或者改用 Redis 的zset存锁定座位,按分数范围取超时的。15 分钟这个窗口是行业惯例,太短用户来不及付款,太长占座成本太低。
3.2 订单状态机怎么设计才不乱
订单状态如果只有「待支付/已支付」两个,很快就会乱:退款、超时取消、支付失败重试这些情况没地方放。我一般会定义五个状态,并且用一张状态流转表约束合法迁移。
| 当前状态 | 允许迁移到 | 触发动作 |
|---|---|---|
| 0 待支付 | 1 已支付 | 支付回调成功 |
| 0 待支付 | 2 已取消 | 超时释放 / 用户取消 |
| 1 已支付 | 3 退款中 | 用户申请退款 |
| 3 退款中 | 4 已退款 | 退款回调成功 |
| 1 已支付 | 5 已出票 | 出票任务完成 |
// 状态流转校验,非法迁移直接抛异常 private static final Map<Integer, Set<Integer>> TRANSFER = Map.of( 0, Set.of(1, 2), 1, Set.of(3, 5), 3, Set.of(4) ); public void transfer(Order order, int target) { Set<Integer> 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 status=0的条件更新,用返回的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 status=1条件。这个 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_id=1 AND status IN (1,2); -- 2. 订单数应等于已锁定+已售座位数(排除已取消) SELECT COUNT(*) FROM t_order WHERE session_id=1 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,再手动模拟一次「支付和释放同时发生」。这个习惯帮我挡掉过至少三次线上事故。购票系统的代码可以写得不够优雅,但库存和订单的一致性不能有半点侥幸。希望帮到你。
本文还有配套的精品资源,点击获取