
简介这是一份基于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 核心表结构七张表覆盖完整购票链路数据模型是整个系统的地基。基于这套源码最常见的设计核心表有七张演出表、场次表、座位表、订单表、支付流水表、用户表、场次库存表。其中最容易踩坑的是座位表和库存表的设计座位表要区分「物理座位」和「销售状态」库存表则要冗余一个「已售数量」字段用于快速校验。建表SQLCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单表需要特别说明的是状态字段常见状态机是待支付 → 已支付 → 已出票 / 已取消。超时未支付关单是这个系统最核心的定时任务之一后面会专门讲。支付流水表一定要冗余订单号和时间戳否则对账时你会想重新设计一次表结构。2.3 库存为什么单独拆一张表而不是直接算座位数很多初学设计会把「库存」理解为「SELECT COUNT(*) FROM seat_info WHERE status0」这在低并发下没问题但高并发场景下每一次座位状态变更都会触发全表或大范围索引扫描而且座位状态和库存数字之间存在时间差。单独拆一张库存表意味着扣减动作只需要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监听。如果不想引入MQRedis过期监听是最轻量的替代但要接受消息可能在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 status1, trade_no#{tradeNo} WHERE order_no#{orderNo} AND status0。这个UPDATE自带行锁和条件判断天然防重复。座位状态更新放在事务外执行因为即使更新座位失败订单已经是已支付状态可以通过对账任务补偿。我在实际项目里就遇到过支付回调成功但座位状态没更新、用户到场无法入场的线上事故所以这里强烈建议加一个每五分钟扫描一次的对账任务。3.4 超时关单为什么定时扫表比延迟消息更省心订单创建后用户迟迟不支付座位和库存就会一直被占用所以必须有超时释放机制。常见做法有两种MQ延迟消息和定时任务扫表。我的经验是小规模项目直接用Spring定时任务扫描「待支付超过30分钟」的订单逐个调用取消接口反而比引入MQ更可控——延迟消息一旦消费者挂了积压消息没人处理定时扫表虽然有一两分钟的延迟但至少逻辑透明、可排查。Scheduled(fixedDelay 60 * 1000) public void autoCancelExpiredOrders() { ListOrderDO 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是seatIdvalue是状态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 订单表中 status1已支付的订单数量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 nameThreadGroup.num_threads300/stringProp stringProp nameThreadGroup.ramp_time5/stringProp elementProp nameThreadGroup.main_controller stringProp nameLoopController.loops10/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的稳定任何抛弃这一原则的「优化」都是给自己挖坑。希望这篇笔记里的经验能帮你在设计类似系统时少走几步弯路。本文还有配套的精品资源点击获取