简介:这是一份基于Java的演唱会在线购票系统设计源码,主要面向Java初学者和需要完成课程设计、毕业设计的开发者。系统实现了用户注册登录、演唱会信息查询、在线选座、订单管理、支付处理等典型业务,并采用MVC分层架构,将数据访问、业务逻辑与界面展示清晰分离。压缩包内共包含37个文件,包括13个Java源文件、10个类文件、6个XML配置文件、2个SQL脚本、2个属性配置以及1个JAR依赖包等;其中SQL脚本用于建表及初始化数据,XML和属性文件用于配置数据源与运行参数,整体目录结构清晰,可直接导入开发工具学习运行。目前已有91人学习浏览。通过这份源码,可以掌握JDBC数据库操作、配置文件解析、分层模块组织等实用技能,同时借助附带的说明文档快速理解项目从启动到业务处理的全过程,是一个完整且可运行的在线购票系统参考项目,尤其适合作为毕业设计或课程设计的选题蓝本。
1. 演唱会在线购票系统,真正的难点不在CRUD而在抢票那一秒
一个演唱会购票系统,业务表不过七八张,接口加起来也就二十来个,单看功能清单,任何一个Java开发实习生都能在一两周内把CRUD写完。但一到开票日,流量瞬间冲上来,库存、订单、支付三个环节只要有一个没扛住,超卖、重复支付、座位冲突就会轮着来。基于Java开发演唱会在线购票系统的设计源码,解决的就是这个问题——它不是一个简单的增删改查练习,而是一套把「锁座、扣库存、生成订单、支付回调、超时释放」串成完整链路的工程方案。
这套源码适合两类人:一类是做Java课程设计或毕业设计的学生,需要的是一个能讲清楚技术点、能跑通流程的完整项目;另一类是刚接手票务类业务的后端开发,想看看别人是怎么处理抢票并发和订单状态机的。如果你只是想要一个能点按钮下单的demo,这篇笔记帮不到你;如果你想了解抢票系统背后的设计取舍,以及哪些地方容易翻车,那这篇内容值得你花十分钟读完。
2. 数据模型与核心选型:为什么Spring Boot + Redis是这套源码的默认答案
2.1 为什么不是纯MySQL扛一切
很多人在设计购票系统时,第一个想法就是「订单表加个唯一索引,库存扣减用UPDATE语句带条件判断」,听起来没问题,但实际压测时就会发现,MySQL在几千并发写同一行库存时,锁等待和死锁会把你拖垮。常见做法是引入Redis作为前置缓存层,把库存扣减和座位锁定放在Redis里做,MySQL只负责最终落库。这个设计不是炫技,而是让热点数据的操作从行锁竞争变成单线程原子操作,吞吐量能差一到两个数量级。
技术栈方面,我一般会选Spring Boot 2.x + MyBatis-Plus + MySQL 8.0 + Redis + Redisson。Spring Boot负责提供完整的Web能力,MyBatis-Plus减少数据层样板代码,Redis扛住高并发下的库存和座位状态,Redisson提供现成的分布式锁。定时任务用Spring自带的@Scheduled就够,不需要额外引入Quartz。
2.2 核心表结构:七张表覆盖完整购票链路
数据模型是整个系统的地基。基于这套源码最常见的设计,核心表有七张:演出表、场次表、座位表、订单表、支付流水表、用户表、场次库存表。其中最容易踩坑的是座位表和库存表的设计,座位表要区分「物理座位」和「销售状态」,库存表则要冗余一个「已售数量」字段用于快速校验。
建表SQL:
CREATE TABLE `show_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '演出名称', `venue` varchar(100) NOT NULL COMMENT '场馆', `show_time` datetime NOT NULL COMMENT '演出时间', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1-待开售 2-售票中 3-已售罄 4-已结束', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `session_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `show_id` bigint NOT NULL COMMENT '演出ID', `session_name` varchar(50) NOT NULL COMMENT '场次名称,如 2025-05-01 19:30', `total_stock` int NOT NULL COMMENT '总库存', `sold_stock` int NOT NULL DEFAULT '0' COMMENT '已售库存,Redis预热时读取此值', `price_level` varchar(20) DEFAULT NULL COMMENT '票价档位', PRIMARY KEY (`id`), KEY `idx_show_id` (`show_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `seat_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `session_id` bigint NOT NULL COMMENT '场次ID', `seat_row` varchar(10) NOT NULL COMMENT '排号', `seat_col` varchar(10) NOT NULL COMMENT '列号', `seat_type` tinyint NOT NULL DEFAULT '0' COMMENT '0-普通 1-VIP', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-可售 1-锁定 2-已售', `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_session_seat` (`session_id`, `seat_row`, `seat_col`), KEY `idx_session_status` (`session_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单表需要特别说明的是状态字段,常见状态机是:待支付 → 已支付 → 已出票 / 已取消。超时未支付关单是这个系统最核心的定时任务之一,后面会专门讲。支付流水表一定要冗余订单号和时间戳,否则对账时你会想重新设计一次表结构。
2.3 库存为什么单独拆一张表而不是直接算座位数
很多初学设计会把「库存」理解为「SELECT COUNT(*) FROM seat_info WHERE status=0」,这在低并发下没问题,但高并发场景下,每一次座位状态变更都会触发全表或大范围索引扫描,而且座位状态和库存数字之间存在时间差。单独拆一张库存表,意味着扣减动作只需要UPDATE一个整数,配合Redis中的预扣库存,既能快速响应又不会让数据库反复扫描。
Redis这边的key设计常用两个:stock:session:{sessionId}存剩余票数,seat:locked:{sessionId}存已锁定座位的Hash结构。前者负责扣减,后者负责座位维度的互斥。每次扣减前先用Lua脚本原子执行,避免并发下超卖。
2.4 服务端接口清单与请求链路
基于这套方案,后端对外最少需要八个接口:查询场次列表、查询座位图、锁定座位、创建订单、支付回调、取消订单、查询订单状态、刷新库存。其中锁定座位和创建订单在很多设计里是合并的,但建议拆开——锁座只是临时占用,订单创建失败时不需要释放锁座,只需要清掉Redis中的锁定标记。
整个请求链路可以这样描述:用户进入选座页 → 前端轮询或WebSocket推送座位状态 → 点击座位后请求锁座接口 → Redis预扣库存并标记座位 → 返回前端确认弹窗 → 前端提交创建订单 → 生成待支付订单并启动超时定时器 → 用户支付 → 支付回调更新订单状态 → 异步出票 → 座位状态改为已售。
这个链路中任何一个环节的时序错乱,都会导致主业务数据不一致。后面第三、四章会把代码层面的实现细节拆开讲。
3.核心购票链路:从锁座到支付回调,用五段代码走通全流程
3.1 锁座接口:Redis脚本原子扣减,业务里别用先查后写
锁座是整个系统最敏感的操作。常见的错误写法是先查Redis剩余库存,判断大于0再扣减,这在单线程下没问题,并发下两个请求同时读到库存为1,就双双扣减成功,超卖就发生了。正确做法是用Lua脚本把「检查库存 + 扣减 + 记录座位」做成一个原子操作。
@Autowired private StringRedisTemplate redisTemplate; private static final String LOCK_SEAT_SCRIPT = "local stock = tonumber(redis.call('get', KEYS[1])) " + "if stock == nil or stock <= 0 then return 0 end " + "local seatKey = KEYS[2] .. ':' .. ARGV[1] " + "if redis.call('hexists', seatKey, ARGV[2]) == 1 then return 0 end " + "redis.call('decr', KEYS[1]) " + "redis.call('hset', seatKey, ARGV[2], 'locked') " + "redis.call('expire', seatKey, 1800) " + "return 1"; public boolean lockSeat(Long sessionId, Long seatId) { String stockKey = "stock:session:" + sessionId; String seatKey = "seat:locked:" + sessionId; Long result = redisTemplate.execute( new DefaultRedisScript<>(LOCK_SEAT_SCRIPT, Long.class), List.of(stockKey, seatKey), sessionId.toString(), seatId.toString() ); return result != null && result == 1L; }这段脚本的逻辑是:先检查库存是否存在且大于0,然后检查这个座位是否已经被其他人锁定,两者都通过才执行扣减和锁定。注意座位锁定的value存的是"locked",实际业务中建议存userId,这样后面做座位释放和超时关单时能直接定位是谁锁的。过期时间1800秒是兜底策略,防止用户锁座后不创建订单,Redis里的key一直占着不释放。
3.2 创建订单:数据库事务只做落库,不做库存扣减
订单创建不能和库存扣减混在同一个事务里。常见做法是锁座成功之后,前端调创建订单接口,后端生成订单号并写入MySQL,订单状态为「待支付」。如果此时MySQL写入失败,需要补偿释放Redis里的座位和库存。
@Transactional public OrderDO createOrder(Long userId, Long sessionId, Long seatId, BigDecimal price) { String orderNo = generateOrderNo(sessionId); OrderDO order = new OrderDO(); order.setOrderNo(orderNo); order.setUserId(userId); order.setSessionId(sessionId); order.setSeatId(seatId); order.setPrice(price); order.setStatus(0); // 0-待支付 orderMapper.insert(order); // 发送延迟消息,30分钟后检查订单是否已支付 delayedOrderService.sendDelayCheck(orderNo, 30 * 60 * 1000L); return order; }这里有两个关键点。第一,订单表和座位表之间不要做数据库外键约束,外键在高并发插入时会带来额外的锁开销,业务层面的校验已经足够。第二,延迟消息的实现常见有两种方案:RabbitMQ的延迟消息插件,或者Redis的过期key监听。如果不想引入MQ,Redis过期监听是最轻量的替代,但要接受消息可能在Redis重启时丢失的风险,生产环境建议至少用RabbitMQ延迟队列或者定时任务扫表兜底。
3.3 支付回调:幂等校验与状态机推进
支付回调是另一个容易出问题的地方。支付宝或微信的异步通知会重试多次,如果回调处理不幂等,订单状态就会被反复更新,甚至出现「已支付」被覆盖回「待支付」的严重Bug。
public void handlePayCallback(String orderNo, String tradeNo, BigDecimal amount) { // 幂等校验:只有待支付状态才允许流转到已支付 int rows = orderMapper.updateStatusIfPending(orderNo, 1, tradeNo); if (rows == 0) { log.warn("订单 {} 状态非待支付,忽略重复回调", orderNo); return; } // 更新座位状态为已售 OrderDO order = orderMapper.selectByOrderNo(orderNo); seatMapper.updateStatusBySeatId(order.getSeatId(), 2); // 更新场次已售库存 sessionMapper.incrementSoldStock(order.getSessionId()); // 发送出票通知 notifyService.sendTicket(orderNo); }updateStatusIfPending是核心,SQL大致是UPDATE order_info SET status=1, trade_no=#{tradeNo} WHERE order_no=#{orderNo} AND status=0。这个UPDATE自带行锁和条件判断,天然防重复。座位状态更新放在事务外执行,因为即使更新座位失败,订单已经是已支付状态,可以通过对账任务补偿。我在实际项目里就遇到过支付回调成功但座位状态没更新、用户到场无法入场的线上事故,所以这里强烈建议加一个每五分钟扫描一次的对账任务。
3.4 超时关单:为什么定时扫表比延迟消息更省心
订单创建后用户迟迟不支付,座位和库存就会一直被占用,所以必须有超时释放机制。常见做法有两种:MQ延迟消息和定时任务扫表。我的经验是,小规模项目直接用Spring定时任务扫描「待支付超过30分钟」的订单,逐个调用取消接口,反而比引入MQ更可控——延迟消息一旦消费者挂了,积压消息没人处理;定时扫表虽然有一两分钟的延迟,但至少逻辑透明、可排查。
@Scheduled(fixedDelay = 60 * 1000) public void autoCancelExpiredOrders() { List<OrderDO> expiredOrders = orderMapper.selectExpiredPendingOrders(30); for (OrderDO order : expiredOrders) { cancelOrder(order.getOrderNo(), "超时未支付"); } }selectExpiredPendingOrders的SQL里要加create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE) AND status = 0,再加LIMIT 200防止一次拉太多订单把内存打爆。固定延迟60秒扫一次,意味着最坏情况下用户看到座位被占的时间比实际多了1分钟,用户可感知,但可接受。取消接口里做的事情包括:将订单状态改为已取消、释放Redis中的锁定座位、回补Redis库存、更新DB座位状态为可售。
3.5 座位图查询接口:不要每次查全部座位
前端选座页需要展示整个场馆的座位状态,如果每次进入页面都查全表几百上千个座位并渲染状态,数据库压力大且响应慢。常见做法是场次开售时把座位数据批量加载到Redis,用户在抢票高峰期查询时直接读Redis,只有缓存未命中时才回源数据库。
实际的key设计是seat:map:{sessionId},Hash的field是seatId,value是状态(0-可售 1-锁定 2-已售)。锁座和支付回调更新座位状态时,同步更新这个Hash,保证前端看到的状态和服务端真实状态一致。这里要特别提醒:座位状态更新和订单状态更新不是同一个事务,一定会有短暂的不一致,前端需要接受这种最终一致性,轮询接口可以设计为每3秒刷新一次,不要把刷新频率压到1秒以内。
4. 抢票并发防线:令牌桶限流、分布式锁与库存校验怎么配合
4.1 接口级限流:先挡掉恶意刷票,再谈业务处理
没有限流的抢票接口等于裸奔。常见的限流方案是Sentinel或Guava RateLimiter,考虑到要和Spring Boot无缝集成,我一般用Sentinel,因为它能直接针对接口路径配置QPS阈值,还能做来源IP维度限流。
spring: cloud: sentinel: transport: port: 8719 datasource: ds1: nacos: server-addr: 127.0.0.1:8848 >@Autowired private RedissonClient redissonClient; public boolean createOrderWithLock(Long userId, Long sessionId, Long seatId) { String lockKey = "seat:order:lock:" + seatId; RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { // 尝试5秒内获取锁,避免无限等待 locked = lock.tryLock(5, TimeUnit.SECONDS); if (!locked) { return false; } return createOrder(userId, sessionId, seatId); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } }Redisson的tryLock参数有两个,第一个是等待时间,第二个是锁自动释放时间。这里只设置了等待时间没有设置释放时间,是因为Redisson默认的看门狗机制会自动续期,避免业务执行时间过长导致锁过期提前释放。如果不使用Redisson而是手写Redis SETNX锁,一定要设置合理的过期时间并处理锁续期,否则长事务超过锁过期时间,另一个请求就会拿到锁,这是经典踩坑点。
4.3 库存校验的兜底:数据库乐观锁保证不超卖
即便Redis和分布式锁都正常,数据库层面的最终防线也不能省。更新座位状态时用乐观锁版本号控制:
UPDATE seat_info SET status = 2, version = version + 1 WHERE id = #{seatId} AND version = #{version} AND status = 0如果更新影响行数为0,说明座位状态已经被别人改过,当前请求需要回滚Redis中的库存扣减和座位锁定。这段逻辑建议放在支付回调后执行,因为支付是真实行为,座位状态必须确认更新成功,失败时要走补偿流程。
这里有一个取舍:锁座阶段要不要也走乐观锁更新数据库?我见过有的项目这么干,但压测后发现每次锁座都更新MySQL行,数据库压力太大,因此还是以Redis为主,DB乐观锁只放在最终出票环节。
4.4 补偿任务:三张表交叉核对,杜绝脏数据
并发控制做得再好,也会有极端情况下的数据不一致。常见做法是每小时跑一次对账任务,核对三张表的数据:Redis的剩余库存、场次表的已售库存、订单表的已支付数量。三者关系应该是:
场次库存表的 sold_stock = 订单表中 status=1(已支付)的订单数量
Redis剩余库存 = 总库存 - sold_stock - 当前锁定中的座位数
如果对不上,优先以订单表的真实支付数据为准,回刷场次库存和Redis。补偿任务里要加告警,数值偏差超过阈值就发钉钉或企业微信通知,别等用户投诉了才发现问题。
5. 避坑清单:超卖、重复支付、锁误删与库存回滚的排查记录
5.1 现象:压测时库存扣成负数,超卖订单出现
压测脚本1000个线程同时抢50张票,结束后发现订单表多了52条记录,库存变成了负数。
原因:锁座接口没有做原子扣减。当时用的是「先GET库存再DECR」两行代码,两个请求同时GET到1,然后都执行DECR,库存变成-1,但两个请求都认为自己拿到了最后的票。
解决:改成前面代码里的Lua脚本,把库存检查、扣减、座位标记合并成一个原子操作。修改后重新压测,库存始终保持在0,订单数不再超过总票数。
5.2 现象:支付回调重复执行,订单状态被覆盖为「已支付」后又变回「待支付」
支付宝异步通知会重试8次,某次回调处理逻辑是先查询订单状态,再更新为已支付。两个回调同时进来,都查到订单是待支付,然后先后更新,前一个执行完后一个又把状态覆盖成待支付。
原因:查询和更新是分开的两步操作,没有加条件约束,后执行的覆盖了先执行的。
解决:把更新SQL改成带状态条件的UPDATE ... WHERE status = 0,影响行数为0时直接忽略后续逻辑。这是最简单的幂等方案,也是我最推荐的方式,比Redis幂等键和分布式锁都简单可靠。
5.3 现象:Redisson锁释放时报错,另一个线程拿到了同一把锁
代码写的是lock.unlock(),但因为业务执行时间超过了锁的watchdog自动续期周期,锁已经自动释放,程序在finally里又调用了一次unlock,导致Redis里这把锁的持有者标识对不上,Redisson直接抛IllegalMonitorStateException。
原因:锁的看门狗续期机制默认每10秒续一次,如果业务方法在两次续期之间长时间阻塞(比如调用外部接口耗时30秒),锁就会提前过期被其他线程获取。
解决:tryLock时显式指定锁的过期时间,或者业务代码中自己手动续期。我的习惯是给锁设置一个明确固定时间比如tryLock(10, 30, TimeUnit.SECONDS),让锁的生命周期受控,同时业务内的远程调用尽量拆到锁外面执行。
5.4 现象:用户锁座但未支付,Redis座位标记一直被占用到系统重启
用户锁座成功后,前端弹窗展示订单确认页,但用户直接关了浏览器。锁座接口设置的Redis过期时间是10分钟,但是订单超时是30分钟,导致Redis锁释放了,数据库订单还挂着,用户再回来时发现座位被别人买走了。
原因:锁座的过期时间和订单超时时间不一致,Redis释放时订单还没被关掉,座位状态脱节。
解决:锁座Redis key的过期时间必须大于或等于订单超时时间。当前订单30分钟超时,锁座过期时间就设35分钟,以订单超时关单逻辑为准来释放Redis。后来我还加了一步,取消订单时主动删除Redis锁定标记,让释放更及时。
5.5 现象:库存对账永远差1,最后发现是延迟消息里更新库存少了一个字段
某天值班时报警说库存对账不平,逐条核对发现场次表sold_stock比订单表已支付数少1。查了所有代码,发现支付回调里有一段更新库存的逻辑被放在了异步线程池中,该线程池核心线程数为0,任务积压时一直没执行。
原因:异步任务线程池配置过小,回调高峰期任务排队等待,看起来就是库存更新滞后。
解决:把库存更新逻辑从异步改为同步,放在订单状态更新后立即执行。虽然理论上加了一次DB写,但订单量还没大到必须异步削峰的程度,同步反而更可靠。如果要保留异步,必须监控线程池队列深度并设置告警阈值。
6. 上线前压测与日志定位:JMeter脚本和TraceId排查技巧
6.1 用JMeter模拟真实抢票场景,五个线程组一把跑
压测不是简单地在开发环境发几个请求看响应时间,要按真实抢票场景设计:10%用户只浏览座位图,30%用户锁座后不支付,60%用户走完整购票链路。JMeter里用五个线程组模拟这些比例,每个线程组配独立的随机延时,避免所有请求同时打进来造成虚假的瞬时峰值。
<ThreadGroup> <stringProp name="ThreadGroup.num_threads">300</stringProp> <stringProp name="ThreadGroup.ramp_time">5</stringProp> <elementProp name="ThreadGroup.main_controller"> <stringProp name="LoopController.loops">10</stringProp> </elementProp> </ThreadGroup>这个配置表示300个线程在5秒内逐步启动,每个线程循环10次,也就是总共3000次请求,分布在5秒内逐步发起,比瞬间打满3000并发更接近真实用户行为。每次请求之间加一个泊松分布的随机延迟,模拟用户点击选座、思考、确认的时间间隔。
压测时重点观察的指标不是平均响应时间,而是P99延迟和错误率。P99超过1秒说明系统在峰值下有排队现象,错误率超过0.1%就要立刻看是限流拦截还是数据库异常。每次压测完,把Redis的stock:session:*和MySQL的订单数对比一次,确认不超卖,再谈性能优化。
6.2 TraceId贯穿所有日志,定位问题不再靠猜
分布式链路中一次购票涉及Redis、MySQL、MQ、支付回调四个环节,如果没有TraceId,排查问题基本靠日志时间戳硬猜,效率极低。我习惯在网关或拦截器里用MDC生成TraceId,所有日志自动带上这个ID,一次完整的购票请求从进入到结束,所有相关日志都能用同一个ID串起来。
@Component public class TraceIdInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String traceId = request.getHeader("X-Trace-Id"); if (StringUtils.isEmpty(traceId)) { traceId = UUID.randomUUID().toString().replace("-", ""); } MDC.put("traceId", traceId); response.setHeader("X-Trace-Id", traceId); return true; } }logback配置里在pattern中加上[%X{traceId}],日志瞬间从「无主孤魂」变成「按请求分组」。排障时的顺序是:先用TraceId在订单日志中找到完整的调用链路,再根据链路时间戳确定卡在哪个环节——是Redis锁等待太久,还是MySQL更新慢,还是MQ消息没消费。这个方法帮我节省了大量排查时间,尤其是高并发下的偶发问题,没有TraceId就真的只能靠玄学了。
压测通过后,我会再模拟一次服务端重启和Redis宕机场景,看系统能不能从缓存失效中恢复过来。缓存全挂时请求会直接打到MySQL,数据库大概率扛不住,所以要有一个降级开关,检测到Redis不可用时直接关闭锁座接口,只保留浏览功能。这个降级开关是上线前必须检查的一项,别等线上真的出了问题再去改代码。这套系统跑到现在,最大的领悟就是:抢票系统的核心不是代码多漂亮,而是每个关键节点都有兜底,失败了知道往哪查。在线购票系统本质上是拿Redis的吞吐换MySQL的稳定,任何抛弃这一原则的「优化」都是给自己挖坑。希望这篇笔记里的经验,能帮你在设计类似系统时少走几步弯路。
本文还有配套的精品资源,点击获取