做票务系统这些年,我最怕的不是业务复杂度,而是开票瞬间那波流量。这个“基于Java的大型赛事门票预订与座位选择系统”,本质上就是要在几十万人同时点进来的时候,保证座位不超卖、订单不丢、支付能对上账。大多数刚接触这类项目的朋友,往往卡在“我知道要写一个预订系统,但不知道从哪下手”——是先做数据库?还是先做并发控制?选座和抢票到底怎么融合成一条链路?
这篇文章是我做完整个项目后的一次完整复盘,会从需求拆解、数据建模、核心链路实现、性能优化到线上踩坑逐个讲透。适合正在做毕业设计、准备Java后端面试、或者想独立从零搭建一个高并发业务系统的朋友。我会尽量把每一步为什么这么做、当时踩了什么坑、实际压测数据长什么样都写清楚,你照着这个思路走,至少能少折腾半个月。
1. 项目整体设计与技术选型
1.1 核心需求拆解:一场票务系统要扛住什么
拿到“大型赛事门票预订与座位选择”这个需求,第一步不是写代码,而是把业务场景里的矛盾点列出来。我习惯把所有需求抽象成四个核心问题:
- 用户要什么:能快速看到赛事场次、选到心仪座位、下单支付、收到电子票。
- 座位要什么:一个座位同一时刻只能被一个订单锁定,不能超卖,不能卖重。
- 系统要什么:高峰期扛得住高并发,库存扣减准确,订单状态流转闭环。
- 运营要什么:能看到每场次的售卖情况,能手动释放座位,能处理退款。
这四个问题里,最难的是第二个和第三个交叉的部分——座位唯一性和高并发。一场热门比赛如果有5万个座位,开票瞬间可能有几十万人同时在线抢,而数据库里每一行座位记录的状态变更必须绝对准确。这就决定了你选的技术方案,必须同时满足强一致性和高吞吐,这俩天然有冲突,所以整个架构设计的核心其实就是“怎么在冲突里做平衡”。
从需求层面还要考虑一个容易被忽略的点:赛事门票和普通商品不一样,它有强位置属性。用户不是买一个抽象SKU,而是买“C区3排12座”这个具体坐标,所以传统电商那套“库存总量减一”的打法只能作为辅助,真正的主流程必须围绕“座位级锁定”来做,这也是这个系统和普通秒杀系统的本质区别。
1.2 技术栈选型:为什么是这套组合
技术栈我最终选的是:Spring Boot + MyBatis-Plus + MySQL + Redis + RabbitMQ + Vue3,核心服务用Java 8,部署上Nginx做负载均衡。先说为什么用Java而不是Go或者Node——不是因为Java性能碾压,而是因为这个场景需要的中间件生态、事务管理、并发控制工具,Java这边最成熟,网上能查到的生产级案例也最多,团队招人、排查问题都方便。
具体到框架层面:
| 组件 | 选型 | 理由 |
|---|---|---|
| 核心框架 | Spring Boot 2.7 | 生态成熟,自动配置省事,和中间件集成方便 |
| ORM | MyBatis-Plus | SQL可控性强,分页、条件构造器好用,性能损耗低 |
| 数据库 | MySQL 8.0 InnoDB | 行级锁支持好,事务可靠,运维成本低 |
| 缓存/分布式锁 | Redis 6.x + Redisson | 热点数据缓存、库存计数、分布式锁一把梭 |
| 消息队列 | RabbitMQ | 异步处理订单通知、支付回调,削峰填谷 |
| 前端 | Vue3 + SVG/Canvas | 座位图可视化渲染,交互响应快 |
选型过程中我做过一次对比,像秒杀这种场景,有人会说用纯Redis做库存,订单异步落库,但这套方案在票务系统里有个问题——座位选择需要实时反馈“这个座位能不能点”,纯异步会导致用户选完座、提交订单时才发现座位没了,体验很糟糕。所以我的主线还是数据库事务保证一致性,Redis做加速和计数,两条腿走路。
1.3 系统总体架构与核心模块
我把系统按业务边界拆成了五个模块,单体应用内部分层,没有一上来就上微服务,因为前期业务量用单体完全够,微服务带来的分布式事务问题反而会拖慢开发进度。五个模块分别是:
赛事与场次模块:维护体育赛事、场馆、场次信息,提供查询接口。这个模块比较简单,就是标准的CRUD,唯一要注意的是赛事列表和座位图这类读多写少的数据要做缓存。
场馆与座位模块:管理座位布局和座位状态,是整个系统的核心模块之一。座位状态的变更(空闲、锁定、已售、预留)必须走统一接口,不允许其他模块直接改库。
订票下单模块:处理用户从选座到生成订单的完整链路,包含座位锁定、防超卖校验、订单创建,是并发压力最集中的地方。
订单与支付模块:负责订单状态流转、支付回调处理、超时关闭、退款。这里要和支付渠道对接,幂等是重中之重。
消息与通知模块:通过RabbitMQ发送下单成功通知、支付结果通知、座位释放提醒,顺便在高峰期做削峰。
模块划分的原则是“高内聚、低耦合”,每个模块对外只暴露接口,内部实现可以随时替换。比如后期如果要把座位锁定逻辑从数据库方案换成Redis Lua脚本方案,只改座位模块内部就可以了,不会影响订单模块。
2. 数据模型设计:座位与订单怎么组织才不乱
2.1 核心表结构设计
数据模型是这种系统的地基,设计错了后面所有代码都在填坑。我最终沉淀下来五张核心表,先看建表SQL的关键字段:
-- 场次表 CREATE TABLE `session` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `event_id` BIGINT NOT NULL COMMENT '关联赛事ID', `venue_id` BIGINT NOT NULL COMMENT '场馆ID', `session_time` DATETIME NOT NULL COMMENT '场次时间', `sale_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0未开售 1预售中 2已售罄 3已结束', `total_seat_count` INT NOT NULL, `sold_seat_count` INT NOT NULL DEFAULT 0, KEY `idx_event_id` (`event_id`), KEY `idx_session_time` (`session_time`) ) ENGINE=InnoDB COMMENT='场次表'; -- 座位表 CREATE TABLE `seat` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `session_id` BIGINT NOT NULL COMMENT '场次ID', `area_code` VARCHAR(10) NOT NULL COMMENT '区域编码,如A/B/C', `row_no` INT NOT NULL COMMENT '排号', `col_no` INT NOT NULL COMMENT '列号', `seat_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1锁定 2已售 3预留', `lock_expire_time` DATETIME DEFAULT NULL COMMENT '锁定过期时间', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `seat_code` VARCHAR(32) NOT NULL COMMENT '座位编码,如A-03-12', UNIQUE KEY `uk_session_seat` (`session_id`, `seat_code`), KEY `idx_session_status` (`session_id`, `seat_status`) ) ENGINE=InnoDB COMMENT='座位表'; -- 订单表 CREATE TABLE `ticket_order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL, `session_id` BIGINT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3已退款 4已完成', `expire_time` DATETIME NOT NULL COMMENT '支付截止时间', `pay_time` DATETIME DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `version` INT NOT NULL DEFAULT 0, UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`, `order_status`), KEY `idx_session_status` (`session_id`, `order_status`) ) ENGINE=InnoDB COMMENT='订单表'; -- 订单元数据表(订单和座位的关联) CREATE TABLE `order_seat` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `seat_id` BIGINT NOT NULL, `session_id` BIGINT NOT NULL, UNIQUE KEY `uk_seat` (`seat_id`) ) ENGINE=InnoDB COMMENT='订单座位关系表'; -- 支付流水表 CREATE TABLE `payment_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `payment_no` VARCHAR(32) NOT NULL COMMENT '支付流水号', `order_no` VARCHAR(32) NOT NULL, `pay_amount` DECIMAL(10,2) NOT NULL, `pay_status` TINYINT NOT NULL COMMENT '0处理中 1成功 2失败', `third_trade_no` VARCHAR(64) DEFAULT NULL COMMENT '第三方支付流水号', `callback_time` DATETIME DEFAULT NULL, UNIQUE KEY `uk_payment_no` (`payment_no`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB COMMENT='支付流水表';这几张表里,最核心的设计是order_seat表对seat_id建了唯一索引。这意味着同一个座位ID永远不可能出现在两条订单记录里,即使上层代码有逻辑漏洞,数据库这层也能兜底防重。这是票务系统的安全底线,必须靠数据库约束而不是靠应用层逻辑来保证。
2.2 座位编码规则与前端可视化映射
座位编码看着简单,但设计得好不好直接影响后续所有逻辑。我采用的规则是“区域编码-排号-列号”,比如A-03-12代表A区3排12座,编码里不存放场馆ID和场次ID,因为这两个信息已经在seat表里通过session_id关联了,编码只需要在场次内唯一就行。
设计时要注意两点:一是区域编码建议用字母而不是数字,因为用户在选座页面上看到的是“A区”“B区”,字母和前端展示直接对应,排查问题也直观;二是排号和列号要支持两位数,别用一位数存,不然以后场馆扩容要改表结构。
前端座位图我用SVG渲染,因为座位数量大时Canvas虽然性能更好,但SVG的DOM映射天然方便做事件绑定和状态样式切换。后端的座位接口直接返回seat_code和状态,前端按“区域 -> 排 -> 列”构建坐标系,每排座位根据列号等距排列,锁定状态的座位直接置灰加锁图标。这里有一个小细节:返回给前端的座位列表要按区域和排号排序,不然前端渲染时会出现座位错位,看起来像bug,实际是排序问题。
2.3 事务边界与索引优化
事务边界这块我踩过一个大坑,刚开始做的时候把“锁定座位 + 创建订单 + 扣减库存 + 发送消息”全部放在一个事务里,结果高峰期事务持有时间过长,数据库连接被占满,系统直接雪崩。后来我把事务拆成两个:
第一个事务只做核心操作:锁定座位、创建订单、扣减场次已售计数。这个事务内的SQL必须精简化,能一条UPDATE完成的绝不用两条。第二个事务或异步任务负责发送通知、记录日志这类非关键操作,不参与主事务。
设计事务边界时还有一个原则:事务内绝对不能调用远程接口或等待外部响应。比如用户选座后要调支付接口,这件事绝对不能放在锁定座位的事务里,因为支付接口的响应时间不可控,一个慢支付能拖死整个事务池。正确的做法是先锁定座位并创建待支付订单,事务提交后,再异步发起支付链接或前端跳转支付。
索引优化方面,核心是两条:座位表要建(session_id, seat_status)联合索引,因为选座页面最常见的查询是“某个场次下所有空闲座位”;订单表要建(user_id, order_status)和(session_id, order_status)联合索引,分别支撑“我的订单列表”和“后台按场次查订单”这两个高频查询。另外,所有订单号、支付流水号这类业务唯一键都要建唯一索引,这不仅是查询优化,更是幂等控制的最后防线。
2.4 订单状态机设计
订单状态机我是按这个思路设计的:待支付 -> 已支付 -> 已完成,待支付超时后转已取消,已支付后可以申请退款转已退款。这个状态机看起来很简单,但落地时要注意两个细节。
第一个细节是状态流转必须带版本号。也就是说更新订单状态时要带上version条件,比如UPDATE ticket_order SET order_status = 1, version = version + 1 WHERE order_no = ? AND order_status = 0 AND version = ?,防止并发情况下两个线程同时把同一笔订单从待支付改成已支付。第二个细节是取消和支付是一对矛盾操作——用户可能在最后几秒完成支付,同时定时任务判断超时把订单取消了。所以取消订单时不能只判断当前时间,还要判断支付流水表里是否有成功的流水记录,有就不能取消,要等支付状态通知。
我额外加了一张payment_record表来记录支付流水,这样即使订单状态流转出错,也能通过流水表追溯到底发生了什么。支付回调模块按payment_no做幂等,同一个回调到达十次也只更新一次。
3. 核心环节实现:抢票、选座、支付这条链路
3.1 防超卖的库存扣减设计
票务系统最怕的就是超卖——5万个座位卖出5万零1张票,这在体育赛事票务里是重大事故。防超卖我用了三层防护,只有三层全过才算真正稳。
第一层是SQL级的条件更新。锁定座位的SQL不能写成“先SELECT查状态,再UPDATE改状态”,因为查和更新之间存在时间窗口,两个并发请求可能同时查到座位空闲,然后都执行UPDATE,导致一个座位被锁两次。正确写法是一条 UPDATE 带上状态条件:
int rows = seatMapper.lockSeat(sessionId, seatId, System.currentTimeMillis() + LOCK_EXPIRE_MS); // 实际SQL: // UPDATE seat SET seat_status = 1, lock_expire_time = ? // WHERE session_id = ? AND id = ? AND seat_status = 0 // AND (lock_expire_time IS NULL OR lock_expire_time < NOW())只有当影响的rows等于1时,才说明这个座位被你成功锁定,否则就是被别人抢先了。这条SQL的意义在于把“检查状态”和“更新状态”合并成一个原子操作,完全依赖数据库行锁来保证并发安全。
第二层是order_seat表的唯一索引。即使第一层SQL因为某些极端情况没拦住,比如通过后台接口强行插入数据,唯一索引也会直接报错,从数据库层面拒绝同一个座位出现在两个订单里。这层是最终兜底,绝对不能省。
第三层是Redis库存计数。我在Redis里维护每个场次的剩余座位数,用decr命令做预扣,扣到负数就拒绝下单。这一层主要用来挡流量高峰——如果所有请求都打到MySQL上去做行锁竞争,数据库很快会扛不住,所以先用Redis秒级判断“还有没有票”。
3.2 座位锁定与临时占用
座位锁定是整个系统的灵魂。用户点了一个座位,这个座位不能立刻变成“已售”,因为用户还没付钱,但也不能让别人也能点,否则会出现两个人同时买同一个座位的纠纷。正确的做法是引入“临时锁定”状态,相当于把座位占住,给用户留出支付的时间。
我设定的锁定策略是15分钟,在座位表里用seat_status = 1和lock_expire_time两个字段配合表达“锁定中”这个状态。用户选座后,执行上面那条UPDATE语句,把座位从空闲改成锁定,同时写入过期时间。事情还没完,锁定状态必须能被释放,否则用户占着座位不支付,座位就永远被锁死了。
释放分三种途径:
- 用户主动取消订单,直接改状态为空闲;
- 定时任务扫描过期的锁定记录,批量释放;
- 用户点击另一个座位时,前端先请求释放之前的锁定座位。
这里要提醒一个细节:释放操作也必须带条件更新,比如WHERE seat_status = 1 AND lock_expire_time < NOW(),不能无条件清状态,否则可能出现用户正在支付,定时任务把他座位释放了,然后另一个用户买走了这个座位,支付成功的用户反而没座了。
3.3 分布式锁在选座中的落地
单机部署时,MySQL的行锁已经能保证并发安全,但如果系统扩展成了多个实例,就涉及到分布式锁的问题。我一开始没用分布式锁,因为座位锁定的SQL本身是原子的,多个实例执行同一条UPDATE,InnoDB的行锁会自动排队,不会出现两个实例同时锁同一个座位。
那分布式锁用在哪里?我用在两个场景。第一个是后台批量锁座,比如运营人员给赞助商预留500个座位,这个批量操作必须用分布式锁保证同一时间只有一个线程在跑,防止两个运营同时操作同一个区域。第二个是订单超时释放任务,这个任务在多个实例上会同时触发,不加分布式锁就会重复扫描、重复发通知。
分布式锁我用的Redisson,因为它的看门狗机制能自动续期,不用担心业务执行时间超过锁过期时间。加锁粒度一定要细,比如lock("seat:" + sessionId + ":" + seatId),千万不要用lock("seat:" + sessionId)锁住整个场次,那样的话A用户的选座操作会把B用户的选座操作全部阻塞掉,性能直接崩。Redisson的锁本身也不是银弹,极端情况下Redis主从切换会丢锁,但对于票务这种业务来说,座位状态最终由数据库兜底,Redis锁就算偶尔失效也不会造成超卖,只是会把并发压力打回数据库而已。
3.4 订单超时关闭与座位释放
订单超时关闭,我用了“定时任务扫表 + 延迟消息兜底”两条路并行。定时任务每30秒扫描一次订单表,把expire_time小于当前时间且状态为待支付的订单批量改为已取消,然后释放对应座位。这种方式实现简单,但有一个天然问题:扫描存在延迟,用户可能在订单过期后30秒内还能完成支付。
为了解决这个时间窗口问题,我引入了RabbitMQ的延迟队列。用户下单时发送一条延迟15分钟的消息,消息到期后进入死信队列,消费端收到消息后检查订单是否已支付,未支付就执行关闭。延迟队列的优点是准点触发,不会像定时任务那样延迟几十秒。
两条路径都执行关闭操作也不用担心重复问题,因为关闭订单的SQL带了状态条件,UPDATE ticket_order SET order_status = 2 WHERE order_no = ? AND order_status = 0,第一次执行成功,第二次执行时受影响行数是0,直接忽略。这是典型的“幂等操作设计”——同一个操作执行多少次,结果都一样。
4. 性能优化与压测实录
4.1 连接池、JVM与线程池调优
系统上线前我做了两轮压测,第一轮压测让我深刻地认识到:很多时候性能瓶颈根本不在这段代码高不高深,而在线程池参数、数据库连接池参数这些看起来灰头土脸的配置上。
数据库连接池用的HikariCP,我最初的配置是maximum-pool-size=50,压测发现数据库CPU飙到90%以上,但接口吞吐量上不去。后来我把连接池降到了20,吞吐量反而提升了,因为连接数过多时,大量线程在争抢数据库CPU和行锁,上下文切换开销巨大。这里有个经验值可以参考:连接池大小设置为CPU核心数 * 2 + 1左右,如果单库单机16核,设30个连接足够了,不需要贪多。
JVM参数方面,我用的是JDK 8 + G1垃圾回收器,堆大小设置4GB,压测时观察Full GC频率,只要压测期间Full GC次数不超过个位数就算正常。线程池方面,我自定义了一个处理异步任务的线程池,核心线程数8、最大线程数16、队列容量1000,拒绝策略用CallerRunsPolicy——宁可让调用线程自己慢慢执行,也不能把任务直接丢弃。
4.2 热点数据缓存与Redis Bitmap方案
赛事详情、场次列表这些读多写少的数据,我全部缓存到Redis,缓存时间设置30分钟。座位图的查询很特殊——一场5万个座位,每个座位就是一个对象,如果全部用JSON存,一次查询要序列化海量数据,速度很慢。我最终用的是Redis Bitmap方案。
Bitmap的思路是:每个场次的座位状态用一段连续的二进制位表示,每一位代表一个座位的状态(0空闲,1占用)。5万个座位只需要50000 / 8 = 6250字节,约6KB内存。前端加载座位图时,后端直接返回Bitmap的字节数组,前端按位解析,渲染效率极高。座位状态变更时,用SETBIT命令更新对应位,O(1)复杂度。
不过Bitmap方案有一个限制,它只能表达“空闲/占用”两种状态,无法区分锁定和已售。我加了一个辅助的Hash结构来记录锁定中的座位集合,字段名是座位ID,值存的是锁定过期时间,查询时先看Bitmap有没有被占,再查Hash判断是锁定还是已售。两套数据结构配合,既快又准确。
4.3 压测场景与瓶颈分析
我用JMeter搭了压测场景,模拟5000个虚拟用户同时抢购一场热度很高的球赛门票。第一轮压测结果很不理想:TPS只有400,平均响应时间2.3秒,数据库连接池被打满,大量请求超时。
通过分析慢SQL日志,我发现问题主要集中在座位列表查询上——用户刷新选座页面时,后端要一次性查出整场座位状态,这个SQL没有走索引,全表扫描了5万行。优化方案是把“座位状态全量查询”改成“按区域查询”,用户默认只加载当前区域的座位,切换区域时再加载下一个区域,同时把区域座位数据缓存到Redis。优化后TPS直接干到2100,平均响应时间降到180毫秒。
压测中的另一个瓶颈是座位锁定SQL的锁等待。大量并发UPDATE同一行座位时,InnoDB的行锁会让其他线程排队等待,锁等待时间一长,数据库连接就被占满。我的优化是给座位锁定的SQL加了超时时间innodb_lock_wait_timeout = 5,同时在前端加了“5秒内只能提交一次选座请求”的限制,从源头减少了并发冲突。
4.4 高可用与容灾设计
虽然这个系统是项目级别的规模,但高可用设计不能少,至少要把思路理清楚。我做了三个层面的容灾。
第一个是Nginx层配置了多台后端实例的负载均衡,单台实例宕机后自动摘除,不影响整体服务。第二个是Redis和MySQL都做了主从复制,主库出问题时手动切到从库,这里要注意:座位状态变更属于强一致操作,如果主从切换可能丢几条更新,所以切换后要做一次全量对账——把订单表里的座位和座位表里的状态比对一遍,把不一致的修掉。第三个是订单数据每天做全量备份,支付流水实施双写,一份写MySQL,一份写到日志文件,万一库数据损坏还能从日志恢复。
容灾设计这块我的体会是:不要为了高可用而高可用,先想清楚系统的恢复时间目标(RTO)和数据恢复点目标(RPO)。对票务系统来说,支付数据绝对不能丢,座位状态可以接受短暂不一致但必须能对账修复,所以备份策略的重心应该放在支付流水和订单数据上。
5. 常见问题与排查技巧实录
5.1 超卖、重复支付、座位冲突三座大山
这三个问题我全踩过,每次都是血泪教训,分别说一下。
超卖:第一次压测就超卖了,原因是我用了最不该用的“先查后改”模式——先SELECT座位状态,再UPDATE改状态。两个并发请求同时查到座位空闲,同时执行UPDATE,结果都返回成功。这个问题拿行锁或者乐观锁都能解决,我用的是条件UPDATE,SQL里直接带seat_status = 0条件,更新行数为0就说明被别人抢了。
重复支付:用户支付时网络抖动,他点了两遍支付按钮,结果支付渠道回调了两笔成功的支付通知。我最初处理回调的代码没有做幂等,导致订单被支付了两次,退款逻辑还出了bug。后续引入了payment_record表,回调先往这张表插入流水,插入失败说明流水号重复,直接返回成功忽略。
座位冲突:这个问题出现的场景比较隐蔽——用户A锁定了座位但没支付,用户B在后台用“重置座位”功能把这个座位强制释放了,然后用户A支付成功,结果一个座位对应了两笔有效订单。解决方法是释放座位前必须检查该座位关联的订单状态,只有待支付且已过期的订单才能强制释放。
5.2 RedisTemplate 的 increment() 报错问题
热词里提到的“RedisTemplate的increment()报错不是integer or out of range”这个坑,我在预扣库存的时候也遇到了,场景是同一个场次的库存字段,有时候用increment(1),有时候用decrement(1),代码内部存储类型不统一,报错信息就是ERR value is not an integer or out of range。
排查后发现原因有两个:一是RedisTemplate默认的ValueSerializer是JDK序列化,存进去的值带了类型前缀,导致Redis服务端拿到手发现不是纯数字;二是同一个key的值被覆盖过,比如有人在管理后台手动改了这个key的值为一个字符串,再执行increment()就报错。解决方法是统一使用StringRedisTemplate操作计数类key,所有数值操作都走字符串序列化,同时给库存key设置独立的命名前缀如stock:session:{id},避免和其他类型数据混用。
5.3 线程等待与异步任务并行化
订单创建成功后,我需要同时做几件事:发短信通知、发站内信、更新统计报表、推送微信模板消息。如果这些串行执行,每个多花200毫秒,整体接口就慢了。后来我用线程池做了并行化,试过Future和CountDownLatch两种方式。
批量锁定座位这个场景用的是CountDownLatch,虽然用了多个线程去锁定多个座位,但主线程必须等所有座位锁定完成后,才能判断是否所有座位都锁成功了,否则可能出现锁了A座和B座,但C座被抢了,订单还是要整体回滚。此时主线程调latch.await(3, TimeUnit.SECONDS),最多等3秒,超时还没完成就按失败处理,把已经锁定的座位释放还原。异步通知那边则直接用线程池execute,不关心执行结果,所有通知类任务都放在队列里慢慢消费,不能让通知拖慢主流程响应。
5.4 排查工具与日志分析
线上问题排查,我用的最多的工具是Arthas和jstack。有一次系统卡顿,jstack抓线程快照后发现有大量线程阻塞在数据库连接获取上,瞬间定位到是连接池耗尽;有一次接口响应偶尔变慢,用Arthas的trace命令看方法执行耗时,发现是Redis连接没有复用,每次查询都新建连接。
日志这块我强烈建议从一开始就引入traceId。我在拦截器里为每个请求生成一个唯一标识,放进MDC上下文中,日志框架自动打印。这样排查问题时,只需要把用户报错的订单号或座位ID关联到请求入口,就能通过traceId把一条完整链路的所有日志串起来。没有traceId之前,面对几万行并发日志根本无从下手,有了之后排查效率提升了一个量级。
压测后的慢SQL分析,我的习惯是开MySQL慢查询日志,设置long_query_time = 0.5,压测结束后直接捞慢SQL看执行计划。绝大多数慢查询都是索引没建好或者SQL写法让索引失效,比如在索引列上使用函数、隐式类型转换,这些通过EXPLAIN一查一个准。
结尾:一点过来人的体会
做完这个系统,我最深的体会是:票务系统的核心从来不是花哨的技术,而是把“座位唯一性”和“订单一致性”这两件事用最简单可靠的方式做扎实。很多人一开始总想着上各种高深的中间件,结果基础的事务控制和状态机都没做好,最后被各种隐蔽的并发bug反复折磨。我的建议是先把最朴素的方案跑通——单库、SQL条件更新、唯一索引、状态机,这些看似基础的东西才是系统的承重墙,等规模确实撑不住了,再照着压测数据做针对性的缓存和架构升级。做项目时多留个心眼,把踩过的坑记下来,这些实实在在的教训比任何八股文都更能帮你应对下一次挑战。