微服务架构火车售票系统实战:从服务拆分到防超卖与分布式事务

发布时间:2026/10/5 10:47:44
微服务架构火车售票系统实战:从服务拆分到防超卖与分布式事务 简介这是一套基于微服务架构的火车售票系统完整源码面向具备Java与Spring Cloud基础的开发者、课程设计或毕业设计人群用于学习分布式购票业务的服务拆分与协作实现。压缩包共319个文件约1.07MB以187个Java源文件为核心配合35个Vue前端组件、22个XML配置、19个JavaScript脚本及9个YAML、7个properties配置另有SQL建表脚本、HTTP接口测试用例与少量图片、图标资源覆盖后端服务、前端页面与部署配置的完整链路。资源围绕用户注册登录、车次浏览、座位选择与订单确认等购票流程展开可帮助读者理解微服务拆分、接口调用与数据库集成方式并借助接口测试文件快速验证服务连通性。目前已有123人学习适合作为分布式系统入门与实战参考。1. 微服务架构火车售票系统从单体到分布式的真实落地路径每年春运抢票高峰12306 那种瞬时百万级并发查询、余票扣减不能超卖的场景是很多后端工程师想动手复现的经典命题。train-12306-system这类项目标题背后核心诉求其实很明确用微服务架构把车次查询、余票库存、订单、支付、用户这几块拆开让每个服务独立部署、独立扩容同时保证下单不超卖、支付不掉单。它适合已经写过 Spring Boot 单体应用、想往分布式方向走一步的开发者也适合想拿一个完整业务闭环练手服务治理、分布式事务、缓存一致性的人。这篇笔记不讲空泛的架构图而是按我实际搭这类系统的顺序把服务怎么拆、库存怎么扣、坑在哪一条条讲清楚。2. 服务拆分与选型train-12306-system 的边界怎么划2.1 为什么按业务能力拆而不是按技术层拆很多人第一次做微服务习惯按 controller、service、dao 分层拆成三个服务结果每个服务都要连同一个库改一个字段三处联动比单体还难维护。火车售票系统的正确拆法是按业务能力划边界用户服务管账号和乘车人车次服务管车站、列车、时刻表库存服务管每个车次每个区间的余票订单服务管下单和状态流转支付服务管支付单和回调。这样拆的好处是每个服务有自己独立的数据库库存服务的库压力最大可以单独加机器车次服务读多写少可以上多级缓存。拆分的判断标准是「事务边界」和「变更频率」。余票扣减和订单创建必须在一个业务动作里完成但它们分属两个服务这就引出了后面要讲的分布式事务问题。而车次基础数据可能一周才变一次和每秒都在变的库存放在一起就是灾难。我一般会先把领域模型画出来标出哪些实体是强一致的、哪些可以最终一致再决定服务边界。2.2 技术栈选型与最小可跑骨架常见做法是 Spring Cloud Alibaba 全家桶Nacos 做注册中心和配置中心OpenFeign 做服务调用Sentinel 做限流熔断Seata 处理分布式事务Gateway 做统一入口。数据库 MySQL 8缓存 Redis消息队列 RocketMQ 或 RabbitMQ 处理异步削峰。下面是一个库存服务的最小骨架重点是它独立持有库存库不和其他服务共享表。// InventoryServiceApplication.java SpringBootApplication EnableDiscoveryClient MapperScan(com.train.inventory.mapper) public class InventoryServiceApplication { public static void main(String[] args) { SpringApplication.run(InventoryServiceApplication.class, args); } }# application.yml 关键配置 spring: datasource: url: jdbc:mysql://localhost:3306/train_inventory?useSSLfalse username: inventory_user password: ${DB_PASSWORD} redis: host: localhost port: 6379 cloud: nacos: discovery: server-addr: localhost:8848这段配置的逻辑是库存服务只连自己的train_inventory库通过 Nacos 注册自己其他服务通过服务名调用它。参数上要注意server-addr指向注册中心而不是数据库密码用环境变量注入不要硬编码。启动顺序必须是 Nacos 先起否则服务注册会失败重试。骨架跑通后再往里填业务不要一上来就把所有服务写完那样出问题根本定位不到是哪个环节。2.3 数据库分库与表结构的关键字段库存表的设计直接决定能不能防超卖。核心表ticket_stock至少要有train_no车次、depart_station、arrive_station、travel_date、seat_type、total_count、sold_count、version这几个字段。区间票的难点在于同一趟车不同区间的库存是重叠的比如北京到上海的车北京到济南和济南到上海共享同一批座位。常见做法是把座位按区间段建模或者用「库存扣减记录」的方式记录每一段的占用。CREATE TABLE ticket_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(16) NOT NULL, depart_station VARCHAR(32) NOT NULL, arrive_station VARCHAR(32) NOT NULL, travel_date DATE NOT NULL, seat_type TINYINT NOT NULL, total_count INT NOT NULL, sold_count INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_train_seat (train_no, depart_station, arrive_station, travel_date, seat_type) );version字段是乐观锁用的扣减时带上版本号比对防止并发覆盖。唯一索引保证同一车次同一区间同一天同一席别只有一条记录避免重复插入。这里有个容易忽略的点sold_count不要用total_count - sold_count在应用层算直接查出来判断因为并发下应用层算出来的值可能已经过期。3. 余票扣减与防超卖库存服务的核心实现3.1 乐观锁扣减与 Redis 预扣的双层设计防超卖是售票系统的命门。纯数据库乐观锁能保证不超卖但高并发下大量请求失败重试数据库压力扛不住。我一般用两层Redis 做预扣减挡掉绝大部分无效请求数据库做最终一致性扣减。流程是用户下单先扣 Redis 库存扣成功再异步落库Redis 库存不足直接返回无票根本不碰数据库。// Redis 预扣减Lua 脚本保证原子性 public boolean preDeduct(String stockKey, int num) { String lua local stock redis.call(get, KEYS[1]) if not stock then return -1 end if tonumber(stock) tonumber(ARGV[1]) then return 0 end redis.call(decrby, KEYS[1], ARGV[1]) return 1; Long result redisTemplate.execute( new DefaultRedisScript(lua, Long.class), Collections.singletonList(stockKey), String.valueOf(num)); return result ! null result 1; }Lua 脚本在 Redis 里是原子执行的get和decrby之间不会有其他请求插进来这是防超卖的关键。返回值设计成 -1 表示 key 不存在、0 表示库存不足、1 表示扣减成功调用方根据返回值区分是缓存没预热还是真没票。参数stockKey的命名建议用stock:{trainNo}:{date}:{depart}:{arrive}:{seatType}冒号分隔便于排查。3.2 数据库最终扣减与对账补偿Redis 扣成功后发一条消息到 MQ库存消费者拿到消息做数据库扣减。数据库这层用乐观锁兜底即使 Redis 出问题数据库也不会超卖。// 数据库乐观锁扣减 Transactional public boolean deductInDb(Long stockId, int num) { TicketStock stock stockMapper.selectById(stockId); if (stock.getTotalCount() - stock.getSoldCount() num) { return false; } int affected stockMapper.deductWithVersion( stockId, num, stock.getVersion()); if (affected 0) { throw new RetryableException(版本冲突重试); } return true; }!-- deductWithVersion 的 SQL -- update iddeductWithVersion UPDATE ticket_stock SET sold_count sold_count #{num}, version version 1 WHERE id #{stockId} AND version #{version} AND total_count - sold_count #{num} /updateSQL 里total_count - sold_count num这个条件放在 WHERE 里和版本号一起判断双保险。affected 0说明要么版本被别的请求改了要么库存不够抛可重试异常让上层重试。这里要注意重试次数不能无限一般 3 次还失败就返回下单失败同时把 Redis 预扣的库存加回去否则会出现 Redis 扣了但数据库没扣、库存凭空消失的情况。对账补偿就是定时任务扫 Redis 和数据库的差异以数据库为准修正 Redis。3.3 区间票库存的建模取舍区间票是火车售票最绕的地方。北京到广州的车卖出去一张北京到长沙的票长沙到广州这段就少了一个座位。常见做法有两种一是按最小区间段拆分库存比如把线路拆成若干站间段每段独立计数下单时扣减途经的所有段二是记录每个座位的占用区间查询时动态计算。前者实现简单但库存粒度粗后者精确但复杂度高。我一般先用第一种跑通因为大部分场景下用户对「同区间有票」的感知就够了精确到座位是后期优化。4. 分布式事务与订单状态机下单链路怎么保证不丢单4.1 Seata AT 模式在订单-库存链路中的落地下单要同时创建订单和扣库存跨服务了就得处理分布式事务。Seata 的 AT 模式对业务侵入小适合这种场景。订单服务作为发起方通过 Feign 调库存服务Seata 自动生成 undo log任一环节失败全局回滚。GlobalTransactional(name create-order, rollbackFor Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { // 1. 本地创建订单 Order order orderService.saveOrder(dto); // 2. 远程扣库存失败会触发全局回滚 boolean deducted inventoryFeign.deduct( dto.getTrainNo(), dto.getTravelDate(), dto.getDepart(), dto.getArrive(), dto.getSeatType(), dto.getCount()); if (!deducted) { throw new BusinessException(库存不足); } // 3. 更新订单状态为待支付 orderService.updateStatus(order.getId(), OrderStatus.UNPAID); return convert(order); }GlobalTransactional注解开启全局事务rollbackFor指定哪些异常触发回滚。注意 Feign 调用要配好超时时间默认超时太长会把事务挂住一般设 3 秒。Seata 的 undo log 表会自动建在业务库里别手动删。这个模式有个坑如果库存服务扣减成功但返回响应超时Seata 会回滚但库存可能已经扣了所以库存服务的扣减接口必须做幂等用订单号做去重键。4.2 订单状态机与超时未支付释放订单状态不能随便改要用状态机约束流转待支付 → 已支付 → 已出票待支付 → 已取消已支付 → 已退款。每个状态变更都要校验前置状态防止并发下状态错乱。public enum OrderStatus { UNPAID(0), PAID(1), TICKETED(2), CANCELLED(3), REFUNDED(4); // 合法流转表 private static final MapOrderStatus, SetOrderStatus TRANSITIONS Map.of( UNPAID, Set.of(PAID, CANCELLED), PAID, Set.of(TICKETED, REFUNDED), TICKETED, Set.of(REFUNDED), CANCELLED, Set.of(), REFUNDED, Set.of() ); public boolean canTransferTo(OrderStatus target) { return TRANSITIONS.getOrDefault(this, Set.of()).contains(target); } }超时未支付释放是必须的否则库存被占死。常见做法是用 RocketMQ 的延迟消息下单时发一条延迟 15 分钟的消息消费者收到后检查订单状态还是待支付就取消并回补库存。延迟消息的精度不是秒级但对 15 分钟这个量级足够。回补库存时要同时回补 Redis 和数据库顺序是先数据库后 Redis因为数据库是权威数据。4.3 支付回调的幂等处理支付回调可能重复推送必须幂等。用支付单号做唯一键回调进来先查是否已处理已处理直接返回成功。public String handlePayCallback(PayCallbackDTO dto) { // 幂等已处理的回调直接返回 if (payRecordMapper.existsByPayNo(dto.getPayNo())) { return success; } // 校验签名防止伪造回调 if (!signUtil.verify(dto)) { return sign_error; } // 更新支付单和订单状态 payService.markPaid(dto.getPayNo()); orderService.updateStatus(dto.getOrderId(), OrderStatus.PAID); return success; }existsByPayNo查的是支付流水表payNo上有唯一索引并发重复回调时数据库会拦掉第二条。签名校验不能省否则别人构造个请求就能把订单改成已支付。返回给支付平台的字符串要按平台约定一般是success或OK返回错了平台会一直重推。5. 避坑与排查train-12306-system 上线前必须过的坎5.1 缓存与数据库不一致导致余票显示错误现象是用户看到有票点进去下单提示无票或者反过来明明没票却显示有票。原因是 Redis 和数据库更新不同步比如扣减时先更数据库后删缓存中间有请求读到旧缓存。解决办法是扣减走「先数据库后缓存」的删除策略查询时如果缓存没有就回源数据库并回填回填用setnx防止并发覆盖。更稳的做法是库存变更走 MQ 异步刷缓存保证顺序。5.2 Feign 调用超时引发分布式事务悬挂现象是订单创建失败但库存被扣了或者订单显示待支付但库存没扣。原因是 Feign 默认超时时间太长Seata 全局事务等不到响应就回滚了但库存服务实际执行成功了。解决是给 Feign 配合理的超时连接 1 秒、读取 3 秒库存扣减接口做幂等Seata 的 undo log 定期清理。排查时看 Seata 的undo_log表和业务表数据是否一致不一致的基本就是悬挂。5.3 乐观锁重试风暴打垮数据库现象是高峰期数据库 CPU 飙满大量扣减请求失败重试。原因是乐观锁冲突率高重试又加剧冲突。解决是重试加退避比如第一次立即重试、第二次等 50ms、第三次等 200ms超过三次直接失败。更好的做法是把冲突挡在 Redis 层Redis 预扣减成功后数据库冲突概率就低了。如果还是高考虑把库存按车次分片不同车次的扣减落到不同库。5.4 延迟消息重复消费导致库存多回补现象是订单取消后库存回补了两次余票比实际多。原因是 RocketMQ 延迟消息在消费者重启或超时后会重投。解决是回补库存前先查订单状态只有状态确实是「已取消」且未回补过才执行回补记录用订单号做唯一键。另外消费者要手动 ACK处理成功再确认失败进死信队列人工处理。5.5 Nacos 配置变更未生效导致限流规则丢失现象是 Sentinel 限流规则改了但没生效或者服务重启后规则没了。原因是规则存在 Nacos 但没配持久化或者配置的 dataId 和 group 对不上。解决是把 Sentinel 规则推到 Nacos 配置中心配好spring.cloud.sentinel.datasource指向 NacosdataId 用服务名-sentinel这种规范命名。改完配置后看 Sentinel 控制台是否同步不同步就检查 Nacos 的监听是否注册成功。6. 压测验证与容量估算这套架构到底能扛多少6.1 用 JMeter 模拟抢票峰值验证防超卖最直接的办法是压测。用 JMeter 起 500 个线程每个线程循环 20 次调下单接口目标车次库存设 100看最终成功订单数是不是正好 100。脚本里要带随机用户 ID 和乘车人避免被幂等拦掉。压测前把 Redis 库存预热好数据库清空压完对比sold_count和实际订单数。# JMeter 命令行压测 jmeter -n -t order_stress.jmx -l result.jtl -e -o report/ # 压测后核对库存 mysql -e SELECT train_no, total_count, sold_count FROM ticket_stock WHERE train_noG1234 mysql -e SELECT COUNT(*) FROM t_order WHERE train_noG1234 AND status IN (0,1,2)两个查询的结果必须一致sold_count等于有效订单数多一个少一个都说明有 bug。-n是无 GUI 模式-l输出结果文件-e -o生成 HTML 报告。压测时观察 Redis 的 QPS 和 MySQL 的慢查询如果 MySQL 出现大量行锁等待说明乐观锁冲突太高要调 Redis 预扣的比例。6.2 容量估算与扩容触发线单台 4 核 8G 的库存服务Redis 预扣能扛 8000 QPS 左右数据库扣减受限于行锁大概 2000 TPS。按春运峰值 10 万 QPS 算Redis 层要 13 台左右数据库要分库。实际不会真按峰值配一般按峰值的 1.5 倍留余量再配合限流把超出部分挡掉。扩容触发线看两个指标Redis 的 CPU 超过 70% 加节点MySQL 的活跃连接数超过最大连接数的 80% 考虑分库。我一般会在 Gateway 层配全局限流按服务维度设阈值库存服务阈值设得比订单服务低因为它是瓶颈。6.3 一个我踩过的坑压测数据没清理导致误判有次压测完发现库存对不上查了半天以为是并发 bug最后发现是上一轮压测的订单没清sold_count是累计值。后来我养成了习惯每次压测前跑一个清理脚本把订单表、库存表、Redis 库存全部重置到初始状态压测脚本里带上批次号压完按批次号核对。这个习惯帮我省了很多「玄学」排查时间。做这类系统数据状态的干净比代码逻辑更值得先确认希望帮到你。本文还有配套的精品资源点击获取