基于Java的演唱会在线购票系统:高并发选座锁座与订单落库实战 简介本资源为基于Java开发的演唱会在线购票系统设计源码面向学习Java Web开发、课程设计或毕业设计的学生与开发者帮助理解在线购票业务从用户管理、票务查询到订单支付的核心实现。压缩包共37个文件、约2.1MB包含13个Java源文件、10个class编译文件、6个XML配置、2个SQL脚本、2个properties属性文件以及jar依赖包、iml工程配置和readme说明覆盖源码、数据库脚本与项目配置可直接导入IDE运行调试。系统采用MVC分层思路Model处理业务逻辑View负责页面展示Controller接收并分发请求SQL文件用于建表与数据初始化properties文件存放数据库连接参数XML则配置数据源与开发环境。目前已有91人学习浏览适合作为在线购票系统设计与实现的参考范例便于快速掌握项目结构、数据库设计与核心业务代码的编写方式。1. 演唱会在线购票系统从选座锁座到订单落库Java 这套骨架怎么搭抢票这件事做过的人都懂开票那一秒几万人同时点同一个座位谁先拿到锁谁就赢。演唱会在线购票系统要解决的核心不是展示票而是在高并发下保证一个座位只卖给一个人。这个标题里的基于 Java 开发意味着整套后端用 Spring Boot 打底、MyBatis 做持久层、Redis 扛热点、MySQL 存订单前端可以是网页也可以是别的入口但真正决定成败的是后端那几层锁和事务。适合谁看正在做 Java 课程设计、想拿一个真实业务练手的同学已经会写 CRUD但没处理过超卖和重复下单的初中级工程师以及想搞清楚秒杀类系统到底难在哪的开发者。下面这套方案不依赖任何特定框架版本思路是通用的你换成自己熟悉的 ORM 和缓存组件也能落地。2. 选座与库存模型为什么不能把座位状态直接塞进一张表2.1 座位、场次、票档三张表的关系演唱会购票和电影选座最大的区别是同一场演出可能有多个票档内场、看台、VIP每个票档对应一批座位而座位本身是物理固定的。所以建模时不要把所有信息压成一张seat表常见做法是拆成三层show场次一场演出的时间、场馆、开票时间ticket_area票档属于某场次有价格、总库存seat座位属于某个票档有行列号、状态库存的真相是票档的剩余数量是座位状态的聚合结果但你不能每次都去 count 座位表那样在抢票时数据库会被打穿。所以票档表上要冗余一个remaining字段座位表上要有status0 可售 / 1 锁定 / 2 已售。CREATE TABLE ticket_area ( id BIGINT PRIMARY KEY AUTO_INCREMENT, show_id BIGINT NOT NULL, name VARCHAR(64) NOT NULL, price DECIMAL(10,2) NOT NULL, total INT NOT NULL, remaining INT NOT NULL, version INT DEFAULT 0, INDEX idx_show (show_id) ); CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, area_id BIGINT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, status TINYINT DEFAULT 0, order_id BIGINT DEFAULT NULL, UNIQUE KEY uk_area_seat (area_id, row_no, col_no), INDEX idx_area_status (area_id, status) );version字段是给乐观锁用的uk_area_seat保证同一个票档下不会出现重复座位。idx_area_status是为了快速查出某个票档还有哪些可售座位。2.2 用 Redis 做座位锁而不是直接改数据库如果每次选座都去UPDATE seat SET status1在开票瞬间会产生大量行锁竞争MySQL 的锁等待会直接拖垮响应。我一般会把锁定这一步放到 Redis 里用SETNX或 Lua 脚本保证原子性。// 选座时先抢 Redis 锁key 精确到座位 public boolean lockSeat(Long showId, Long seatId, Long userId) { String key seat:lock: showId : seatId; // 锁 5 分钟过期自动释放避免用户选完不付款导致死锁 Boolean ok redisTemplate.opsForValue() .setIfAbsent(key, userId.toString(), 5, TimeUnit.MINUTES); return Boolean.TRUE.equals(ok); }逻辑说明setIfAbsent对应 Redis 的SET key value NX EX只有 key 不存在时才写入成功天然互斥。参数上过期时间设 5 分钟是个经验值——太短用户还没付款锁就没了太长会导致座位被占着卖不出去。实际项目里还会加一个定时任务扫描超时未支付的订单主动释放锁并回滚座位状态。提示Redis 锁只能防并发抢同一座位不能替代数据库的最终一致性。下单成功那一刻必须再回数据库把seat.status改成 2并扣减ticket_area.remaining这两步要在同一个事务里。2.3 扣库存的两种写法与选择扣减票档库存有两种常见写法。第一种是乐观锁UPDATE ticket_area SET remaining remaining - 1, version version 1 WHERE id ? AND remaining 0 AND version ?;判断影响行数是否为 1为 0 说明被别人抢先改了重试或直接返回失败。第二种是直接带条件的原子更新UPDATE ticket_area SET remaining remaining - 1 WHERE id ? AND remaining 0;第二种更简单remaining 0这个条件本身就保证了不会超卖MySQL 的行锁会串行化同一行的更新。我一般用第二种因为票档级别的并发远小于座位级别行锁竞争可以接受代码也更少出错。乐观锁更适合读多写少且冲突概率低的场景抢票这种高冲突场景反而容易大量重试。3. 下单链路从选座到订单落库的完整代码3.1 接口分层与请求参数一个下单接口至少要接收场次 ID、座位 ID 列表、用户 ID。不要信任前端传来的价格价格必须后端根据票档重新查。参数校验用注解做别在业务代码里写一堆 if。public class CreateOrderRequest { NotNull private Long showId; NotEmpty private ListLong seatIds; // userId 从登录态取不放在请求体里防止越权 }seatIds用NotEmpty而不是NotNull因为空列表也意味着无效请求。用户身份从 token 解析绝不能由前端传否则改个参数就能帮别人下单。3.2 下单主流程Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, CreateOrderRequest req) { // 1. 校验场次是否在售 Show show showMapper.selectById(req.getShowId()); if (show null || show.getStatus() ! 1) { throw new BizException(场次不可售); } // 2. 逐个抢 Redis 锁任何一个失败就整体回滚 ListString lockedKeys new ArrayList(); try { for (Long seatId : req.getSeatIds()) { if (!seatLockService.lockSeat(req.getShowId(), seatId, userId)) { throw new BizException(座位已被抢占); } lockedKeys.add(seat:lock: req.getShowId() : seatId); } // 3. 查座位真实状态防止 Redis 锁过期后数据不一致 ListSeat seats seatMapper.selectByIds(req.getSeatIds()); for (Seat s : seats) { if (s.getStatus() ! 0) { throw new BizException(座位状态异常); } } // 4. 扣库存 int affected areaMapper.decreaseStock(seats.get(0).getAreaId(), seats.size()); if (affected 0) { throw new BizException(库存不足); } // 5. 更新座位状态并生成订单 seatMapper.markSold(req.getSeatIds(), userId); Order order buildOrder(userId, seats); orderMapper.insert(order); return toVO(order); } catch (Exception e) { // 失败时释放已抢到的锁 lockedKeys.forEach(redisTemplate::delete); throw e; } }逻辑说明整个方法加了Transactional数据库操作要么全成功要么全回滚。Redis 锁的释放放在 catch 里手动做因为 Redis 不在事务管理范围内事务回滚不会自动删 key。第 3 步的二次校验很关键——Redis 锁可能因为超时被自动释放此时数据库里的座位状态才是最终真相。参数说明decreaseStock的第二个参数是本次购买数量SQL 里用remaining #{count}做条件避免扣成负数。markSold要带上userId或orderId方便后续对账和退票。3.3 订单表设计与状态机订单状态不要用魔法数字散落在代码里定义成枚举。常见状态流转是待支付 → 已支付 → 已出票或者待支付 → 已取消。CREATE TABLE order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, show_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, expire_time DATETIME NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_status_expire (status, expire_time) );idx_status_expire是给超时取消任务用的定时扫描status0 AND expire_time now()的订单逐个取消并释放座位。order_no用唯一索引防止重复提交生成两笔订单。4. 高并发下的避坑与排查五个真实踩过的坑4.1 坑一Redis 锁过期了但订单还没付座位被重复卖出现象两个用户几乎同时下单都成功了但只有一个座位。原因第一个用户的锁 5 分钟到期自动释放此时他还没付款第二个用户抢到了锁并完成下单。解决锁的过期时间要大于支付超时时间或者用 Redisson 的看门狗机制自动续期更稳妥的是下单成功后立即把座位状态改成锁定并写数据库Redis 只作为第一道闸门。4.2 坑二事务里调用远程服务导致锁持有时间过长现象下单接口响应忽快忽慢高峰期大量超时。原因有人在Transactional方法里调用了短信、支付等外部接口数据库连接被长时间占用。解决把远程调用挪到事务提交之后用TransactionSynchronizationManager的afterCommit回调或者发消息异步处理。4.3 坑三座位状态更新用了 IN 但没校验数量现象用户传了 3 个座位 ID其中 2 个是别人的结果只锁了 1 个也下单成功。原因UPDATE seat SET status2 WHERE id IN (...)不检查影响行数。解决更新后判断affected seatIds.size()不相等就抛异常回滚。4.4 坑四库存扣减和座位更新顺序反了导致死锁现象数据库出现死锁日志两个事务互相等待。原因一个事务先锁票档行再锁座位行另一个顺序相反。解决统一加锁顺序永远先扣票档库存再更新座位或者干脆把座位更新放在库存扣减之前全项目保持一致。4.5 坑五超时取消任务和用户支付并发现象用户刚付完款订单被定时任务取消了。原因取消任务查到订单是待支付但没加锁支付回调同时把状态改成了已支付。解决取消时用UPDATE ... WHERE status0带条件更新判断影响行数支付回调同理用状态机保证只有合法流转才能成功。5. 压测验证与一个提效技巧把座位锁做成可观测的5.1 用 JMeter 验证不超卖写完代码别急着交付先压一轮。用 JMeter 开 500 个线程每个线程随机选座位下单跑完后查数据库SELECT COUNT(*) FROM seat WHERE status2应该等于订单里的座位总数且ticket_area.remaining不能为负。如果对不上八成是锁或事务边界出了问题。# 压测后核对库存的 SQL SELECT a.id, a.total, a.remaining, (SELECT COUNT(*) FROM seat s WHERE s.area_ida.id AND s.status2) AS sold FROM ticket_area a;total - remaining应该等于sold不等就说明有座位被锁了但没落库或者库存扣了座位没改。5.2 给锁加监控别让它变成黑匣子Redis 里的锁 key 是排查问题的关键。我习惯在锁服务里加一个统计每次抢锁成功/失败都打点用redisTemplate.opsForValue().get(key)能直接看到某个座位被谁锁着、还剩多久过期。线上出问题时先看锁的分布再看数据库状态基本能定位到是锁没释放还是事务没提交。// 排查用查看某个座位的锁信息 public String inspectLock(Long showId, Long seatId) { String key seat:lock: showId : seatId; Long ttl redisTemplate.getExpire(key, TimeUnit.SECONDS); Object owner redisTemplate.opsForValue().get(key); return String.format(owner%s, ttl%ds, owner, ttl); }这个技巧看起来简单但在抢票系统里能省下大量猜谜时间。我自己的习惯是任何涉及分布式锁的地方都要留一个能直接查锁状态的入口别等出事了才去翻日志。座位锁、库存锁、订单锁一个都别漏。希望帮到你。本文还有配套的精品资源点击获取