ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java演唱会抢票系统设计:Redis+Lua高并发锁座与强一致性实战

2026/9/28 14:52:48 拓冰建站 浏览量
Java演唱会抢票系统设计:Redis+Lua高并发锁座与强一致性实战 简介本资源为基于Java开发的演唱会在线购票系统设计源码面向学习Java Web开发、课程设计或毕业设计的学生与开发者帮助理解在线购票业务从用户管理、票务查询到订单处理的核心实现。压缩包共37个文件约2.1MB包含13个Java源文件、10个class编译文件、6个XML配置、2个SQL脚本、2个properties属性文件以及jar依赖包、iml工程配置和readme说明覆盖源码、数据库脚本与项目配置目录结构清晰便于导入IDE后直接阅读与调试。系统采用MVC分层思路Model层处理业务逻辑View层负责页面展示Controller层接收请求并分发XML与properties文件用于数据源和环境参数配置SQL文件完成表结构创建与数据初始化。已有91人学习适合作为在线购票系统设计与实现的参考范例也可用于二次开发或功能扩展练习。1. 演唱会抢票系统的核心矛盾为什么高并发下库存总是对不上做过电商的人第一次接演唱会票务需求往往会低估难度。普通商品超卖了大不了补发演唱会票超卖一张就是现场座位冲突退票、赔付、舆情全来了。这个标题——基于 Java 开发的演唱会在线购票系统设计源码——本质上是让你用 Java 技术栈解决一个「瞬时高并发 强一致性 座位唯一性」的三重约束问题。它适合正在做 Java 课程设计、毕业设计或者想拿一个真实业务场景练手 Spring Boot Redis MySQL 的开发者。选座、锁座、订单超时释放、支付回调幂等这几个环节串起来就是一套完整的分布式事务演练场。下面我按实际落地顺序把选型理由、表结构、核心代码和踩过的坑一次讲清楚。2. 技术选型与数据库设计为什么不用纯 MySQL 扛库存2.1 技术栈组合的取舍逻辑演唱会购票系统的读写比极度倾斜。一场 5 万人的演出开票瞬间可能有 50 万请求涌入其中 90% 是查座位状态和下单只有不到 10% 最终支付成功。如果所有请求都打到 MySQL行锁竞争会让数据库连接池瞬间耗尽。常见做法是 Spring Boot MyBatis-Plus 做业务层Redis 做座位状态缓存和分布式锁MySQL 做最终落库RocketMQ 或 RabbitMQ 做订单超时延迟消息。为什么不用纯 MySQL 乐观锁UPDATE seat SET status1 WHERE id? AND status0在几千 QPS 下还能撑但演唱会场景是秒级十万级请求乐观锁的重试风暴会把 CPU 打满。Redis 的SETNX或 Lua 脚本能把锁座操作压到内存级别响应时间从几十毫秒降到亚毫秒。代价是引入缓存与数据库的一致性问题这个后面避坑章节会展开。技术栈版本上我一般用 JDK 17 Spring Boot 3.x MyBatis-Plus 3.5.x Redis 7.x MySQL 8.x。JDK 17 的虚拟线程在 IO 密集的抢票场景下能显著降低线程池开销但如果你还在用 JDK 8用ThreadPoolExecutor自定义线程池也能跑通只是吞吐量差一截。2.2 核心表结构与索引设计座位表是整个系统的命脉。设计不好后面所有优化都是徒劳。下面是我实际项目里用的建表语句字段和索引都经过压测验证。CREATE TABLE seat ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 座位主键, show_id BIGINT NOT NULL COMMENT 演出场次ID, area VARCHAR(32) NOT NULL COMMENT 区域如A区、B区, row_num INT NOT NULL COMMENT 排号, col_num INT NOT NULL COMMENT 列号, price DECIMAL(10,2) NOT NULL COMMENT 票价, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可售 1锁定 2已售, lock_user_id BIGINT DEFAULT NULL COMMENT 锁定用户ID, lock_time DATETIME DEFAULT NULL COMMENT 锁定时间, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_show_seat (show_id,area,row_num,col_num), KEY idx_show_status (show_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT座位表;uk_show_seat唯一索引保证同一场次同一区域同一排同一列不会重复插入这是数据层面的最后一道防线。idx_show_status支撑「查询某场次所有可售座位」这个最高频的读操作。version字段用于数据库层的乐观锁兜底虽然主要锁座逻辑在 Redis但最终落库时仍需要它防止并发更新覆盖。订单表需要记录锁座超时时间配合延迟消息做自动释放。CREATE TABLE ticket_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL, show_id BIGINT NOT NULL, seat_ids VARCHAR(512) NOT NULL COMMENT 座位ID逗号分隔, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退款, expire_time DATETIME NOT NULL COMMENT 支付截止时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id,status), KEY idx_expire (status,expire_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;idx_expire索引是给定时任务扫超时订单用的。虽然延迟消息更优雅但生产环境里消息丢失或消费失败是常态必须有一个兜底扫描任务这个索引就是为它建的。2.3 Redis 座位缓存的 Key 设计与预热Redis 里座位状态用 Hash 结构存储Key 为seat:stock:{showId}Field 为seatIdValue 为状态码。为什么用 Hash 而不是 String因为一场演出几万个座位用 String 会产生几万个 KeyRedis 内存碎片和网络往返都吃不消。Hash 结构在 field 数量小于 512 时用 ziplist 编码内存效率极高。预热时机很关键。开票前 10 分钟用定时任务把 MySQL 里该场次所有status0的座位加载到 Redis。加载时用HSET批量写入每批 500 个 field避免单次命令过大阻塞 Redis。Component public class SeatCacheWarmer { Autowired private StringRedisTemplate redisTemplate; Autowired private SeatMapper seatMapper; public void warmUp(Long showId) { String key seat:stock: showId; ListSeat seats seatMapper.selectList( new LambdaQueryWrapperSeat() .eq(Seat::getShowId, showId) .eq(Seat::getStatus, 0) ); // 分批写入每批500个 MapString, String batch new HashMap(500); for (int i 0; i seats.size(); i) { batch.put(String.valueOf(seats.get(i).getId()), 0); if (batch.size() 500 || i seats.size() - 1) { redisTemplate.opsForHash().putAll(key, batch); batch.clear(); } } // 设置过期时间演出结束后自动清理 redisTemplate.expire(key, Duration.ofHours(6)); } }warmUp方法里batch大小设为 500 是压测出来的经验值。太小则网络往返多太大则单次putAll耗时超过 10ms 会阻塞其他命令。expire设 6 小时是防止演出结束后缓存常驻内存实际项目里可以根据演出时长动态调整。3. 锁座与下单Redis Lua 脚本如何保证原子性3.1 为什么必须用 Lua 脚本而不是 SETNX很多人第一反应是用SETNX seat:lock:{seatId} userId来锁座。这个方案在单座位场景下没问题但演唱会选座通常是多选用户一次选 2 到 4 个座位。如果用多个 SETNX要么全部成功要么全部失败中间任何一个失败都要回滚前面已成功的锁这就不是原子操作了。更严重的是如果回滚过程中服务宕机部分座位会永久锁死。Lua 脚本在 Redis 里是原子执行的脚本内的所有命令要么全执行要么全不执行。把「检查座位状态 锁定座位 设置过期时间」写在一个 Lua 脚本里就能保证多座位锁定的原子性。-- lock_seats.lua -- KEYS[1]: seat:stock:{showId} -- ARGV[1]: userId -- ARGV[2]: 锁定时长秒 -- ARGV[3..n]: seatId列表 local key KEYS[1] local userId ARGV[1] local ttl tonumber(ARGV[2]) local seatIds {} for i 3, #ARGV do seatIds[#seatIds 1] ARGV[i] end -- 第一阶段检查所有座位是否可售 for _, seatId in ipairs(seatIds) do local status redis.call(HGET, key, seatId) if status false then return -1 -- 座位不存在 end if status ~ 0 then return -2 -- 座位已被锁定或售出 end end -- 第二阶段全部可售执行锁定 for _, seatId in ipairs(seatIds) do redis.call(HSET, key, seatId, 1: .. userId .. : .. ttl) end -- 设置整个Hash的过期时间作为兜底 redis.call(EXPIRE, key, ttl 60) return 1脚本分两阶段先全量检查再全量写入。返回值-1表示座位不存在-2表示座位已被占1表示锁定成功。调用方根据返回值决定是提示用户重新选座还是进入下单流程。3.2 Java 侧调用与订单创建Java 调用 Lua 脚本用RedisTemplate.execute(RedisScript, keys, args)。注意RedisScript的返回类型要设为Long否则反序列化会报错。Service public class SeatLockService { Autowired private StringRedisTemplate redisTemplate; Autowired private TicketOrderMapper orderMapper; private static final RedisScriptLong LOCK_SCRIPT new DefaultRedisScript( new ClassPathResource(lua/lock_seats.lua).getContentAsString(), Long.class ); public String lockAndCreateOrder(Long userId, Long showId, ListLong seatIds) { String key seat:stock: showId; ListString args new ArrayList(); args.add(String.valueOf(userId)); args.add(300); // 锁定5分钟 seatIds.forEach(id - args.add(String.valueOf(id))); Long result redisTemplate.execute( LOCK_SCRIPT, Collections.singletonList(key), args.toArray() ); if (result null || result -1) { throw new BizException(座位不存在请刷新后重试); } if (result -2) { throw new BizException(手慢了部分座位已被锁定); } // 锁定成功创建订单 TicketOrder order new TicketOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setShowId(showId); order.setSeatIds(StringUtils.join(seatIds, ,)); order.setAmount(calculateAmount(showId, seatIds)); order.setStatus(0); order.setExpireTime(LocalDateTime.now().plusMinutes(5)); orderMapper.insert(order); // 发送延迟消息5分钟后检查支付状态 rocketMQTemplate.syncSendDelayTimeMills( order-timeout-topic, order.getOrderNo(), 5 * 60 * 1000 ); return order.getOrderNo(); } }lockAndCreateOrder方法里Lua 脚本执行成功后才创建订单订单创建成功后才发延迟消息。这个顺序不能乱如果先发消息再创建订单消息消费时订单可能还不存在如果先创建订单再锁座锁座失败就要删订单多一次数据库操作。expireTime设为 5 分钟后延迟消息的延迟时间必须和它一致否则会出现订单已过期但消息还没到的情况。3.3 支付回调与库存扣减支付成功后需要把 Redis 里的座位状态从1:userId:ttl改为2已售同时更新 MySQL 的座位状态和订单状态。这一步必须做幂等因为支付平台可能重复回调。Transactional(rollbackFor Exception.class) public void handlePayCallback(String orderNo, String payTradeNo) { TicketOrder order orderMapper.selectByOrderNo(orderNo); if (order null) { throw new BizException(订单不存在); } // 幂等已支付直接返回 if (order.getStatus() 1) { return; } if (order.getStatus() ! 0) { throw new BizException(订单状态异常无法支付); } // 更新订单状态 int updated orderMapper.updateStatus(orderNo, 0, 1, payTradeNo); if (updated 0) { // 并发情况下可能已被其他回调更新再次检查 TicketOrder latest orderMapper.selectByOrderNo(orderNo); if (latest.getStatus() 1) { return; } throw new BizException(订单状态更新失败); } // 更新座位状态 ListLong seatIds Arrays.stream(order.getSeatIds().split(,)) .map(Long::valueOf).collect(Collectors.toList()); seatMapper.batchUpdateStatus(seatIds, 2); // 更新Redis座位状态为已售 String key seat:stock: order.getShowId(); for (Long seatId : seatIds) { redisTemplate.opsForHash().put(key, String.valueOf(seatId), 2); } }updateStatus的 SQL 是UPDATE ticket_order SET status1 WHERE order_no? AND status0用状态条件保证幂等。如果返回 0 行说明已经被其他回调处理过再查一次确认状态即可。座位表的batchUpdateStatus同样要加AND status1条件防止把已售座位重复更新。4. 避坑与排查抢票系统上线后最容易翻车的 5 个点4.1 现象Redis 锁座成功但订单创建失败座位被永久锁死原因Lua 脚本锁定座位后Java 侧创建订单时数据库连接超时或抛异常没有回滚 Redis 锁。锁的 TTL 虽然设了 5 分钟但如果 Redis 的EXPIRE因为某种原因没生效比如脚本里EXPIRE被跳过座位就永远锁着。解决在 Lua 脚本里对每个锁定的 field 单独设置过期时间不可行因为 Hash field 不支持独立 TTL。我的做法是在订单创建失败时捕获异常并主动调用解锁脚本删除对应 field。同时加一个定时任务每 10 分钟扫描 Redis 里锁定时间超过 10 分钟的座位强制释放。Scheduled(fixedDelay 600000) public void releaseStaleLocks() { SetString keys redisTemplate.keys(seat:stock:*); for (String key : keys) { MapObject, Object entries redisTemplate.opsForHash().entries(key); for (Map.EntryObject, Object entry : entries.entrySet()) { String value (String) entry.getValue(); if (value.startsWith(1:)) { String[] parts value.split(:); long lockTime Long.parseLong(parts[2]); if (System.currentTimeMillis() / 1000 - lockTime 600) { redisTemplate.opsForHash().put(key, entry.getKey(), 0); } } } } }4.2 现象支付回调重复执行座位状态被覆盖原因支付平台在 30 秒内发了两次回调两次都通过了order.getStatus() 0的检查然后都执行了座位更新。虽然订单状态更新用了条件更新但座位更新没有加状态条件第二次回调把已售座位又更新了一遍。解决座位更新 SQL 必须加AND status1并且检查影响行数。如果影响行数为 0说明座位状态不是锁定态可能是被定时任务释放了或者已经被其他回调更新此时要记录日志并告警。4.3 现象Redis 和 MySQL 座位状态不一致用户看到可售但下单提示已售原因Redis 预热时只加载了status0的座位但 MySQL 里可能有部分座位在预热后被后台管理员手动改为status1Redis 没有同步。或者 Redis 锁座成功但 MySQL 落库失败回滚了 Redis 但 MySQL 没回滚。解决所有座位状态变更必须走同一套服务方法禁止直接操作数据库或 Redis。服务方法里先更新 MySQL成功后再更新 Redis如果 Redis 更新失败发消息到补偿队列重试。另外在查询座位列表时以 Redis 为准但下单时以 MySQL 的status为准做最终校验。4.4 现象延迟消息重复消费订单被多次取消原因RocketMQ 的延迟消息在消费失败时会重试如果消费逻辑不是幂等的同一订单会被多次取消导致已支付的订单被误取消。解决消费端先查订单状态只有status0且expire_time now()才执行取消。取消时用条件更新UPDATE ticket_order SET status2 WHERE order_no? AND status0影响行数为 0 就跳过。同时释放 Redis 锁座时也要检查座位当前状态是否为1:userId只有匹配才释放。4.5 现象开票瞬间 Redis 连接池被打满大量请求超时原因每个锁座请求都要从连接池拿连接如果 Lua 脚本执行慢或者网络抖动连接被长时间占用后续请求排队等待最终超时。解决连接池参数要调优。maxTotal设为 200maxIdle设为 50minIdle设为 20maxWaitMillis设为 200ms。同时给 Lua 脚本加超时控制在 Java 侧用CompletableFuture包装超过 500ms 未返回就降级为数据库乐观锁。另外开票前对 Redis 做一次CLUSTER INFO检查确保没有节点处于 fail 状态。5. 压测验证与一个容易被忽略的细节座位图渲染的懒加载系统写完后怎么验证我一般用 JMeter 做三组压测第一组模拟 1000 并发查座位验证 Redis 读性能第二组模拟 500 并发锁座验证 Lua 脚本原子性第三组模拟 200 并发支付回调验证幂等逻辑。压测时重点看三个指标Redis 的instantaneous_ops_per_sec、MySQL 的Threads_running、以及订单表的状态分布是否出现status0但expire_time已过期的记录。这里有一个容易被忽略的细节前端座位图渲染。一场演出几万个座位如果一次性返回所有座位坐标和状态JSON 体积可能超过 2MB移动端加载要好几秒。我的做法是分区域懒加载用户点击某个区域才请求该区域的座位数据。后端接口按show_id area查询Redis 里用seat:stock:{showId}:{area}作为 Key进一步降低单个 Hash 的大小。GetMapping(/seats) public ResultListSeatVO listSeats( RequestParam Long showId, RequestParam String area) { String key seat:stock: showId : area; MapObject, Object cache redisTemplate.opsForHash().entries(key); if (cache.isEmpty()) { // 缓存未命中从数据库加载并回写 ListSeat seats seatMapper.selectByShowAndArea(showId, area); MapString, String map seats.stream().collect( Collectors.toMap(s - String.valueOf(s.getId()), s - String.valueOf(s.getStatus())) ); redisTemplate.opsForHash().putAll(key, map); redisTemplate.expire(key, Duration.ofHours(2)); return seats.stream().map(SeatVO::from).collect(Collectors.toList()); } // 从缓存组装 ListSeatVO result new ArrayList(); for (Map.EntryObject, Object entry : cache.entrySet()) { SeatVO vo new SeatVO(); vo.setSeatId(Long.valueOf(entry.getKey().toString())); String status entry.getValue().toString(); vo.setStatus(status.startsWith(2) ? 2 : status.startsWith(1) ? 1 : 0); result.add(vo); } return result; }这个接口里status的解析逻辑要注意Redis 里存的值可能是0、1:userId:ttl、2三种格式前端只需要知道0可售、1锁定、2已售。用startsWith判断比split再比较更高效因为大部分座位状态是0或2只有少量是锁定态。压测时我还发现一个反直觉的现象当 Redis 里 Hash 的 field 数量超过 1000 时HGETALL的耗时从 1ms 飙升到 15ms。所以分区域存储不仅是为了前端懒加载也是为了避免单个 Hash 过大导致 Redis 单线程阻塞。如果你的演出场地没有明显分区可以按排号每 50 排切一个 Key。最后说一个我踩过的坑压测环境用单机 Redis生产环境用集群 RedisLua 脚本里的KEYS[1]在集群模式下必须落在同一个 slot。seat:stock:{showId}这种 Key 因为{showId}是 Hash Tag能保证同一场次的所有座位在同一个节点。但如果你写成seat:stock:{showId}:{area}{showId}和{area}两个 Hash Tag 会导致 Key 分布到不同节点Lua 脚本直接报错。正确写法是seat:stock:{showId}:area只保留一个 Hash Tag。这套系统我前后迭代了三个版本从最初纯 MySQL 乐观锁被压测打崩到 Redis Lua 脚本稳定支撑 8000 QPS 锁座中间翻车最多的不是代码逻辑而是缓存与数据库的一致性和集群环境的 Key 分布。如果你正在做类似的项目建议先把锁座和支付回调的幂等做扎实再考虑性能优化。希望帮到你。本文还有配套的精品资源点击获取