☰
mini 12306 高并发购票系统实战:Spring Cloud 微服务与库存一致性设计
2026/10/7 13:33:04 网站建设 项目流程

简介:这是一套基于Spring Cloud微服务架构的迷你版12306火车购票系统完整源码,面向具备Java与Spring Boot基础、希望深入理解微服务拆分与前后端分离开发的学习者与开发者。项目将购票业务拆分为train-common、admin、train-business等独立服务,借助Eureka、Ribbon、Feign、Hystrix实现服务注册发现、负载均衡与熔断降级,前端采用Vue.js组件化构建单页应用,后端以MySQL存储票务与用户数据,并配合Nginx完成静态资源服务与反向代理。资源包共286个文件,约2.32MB,其中Java源码169个、Vue组件34个、XML配置24个、JavaScript文件16个,另含SQL脚本、YAML与properties配置、HTTP接口测试文件及Markdown说明文档,覆盖后端服务、前端页面、数据库与部署配置各环节。目前已有344人学习下载。读者可据此掌握微服务通信、订单与支付处理、座位选择等核心逻辑,并参考其模块划分与目录组织,作为课程设计、毕业设计或微服务练手的实践案例。

1. 从一张春运余票表说起:mini 12306 到底在解决什么

春运抢票那几天,后台最怕看到的不是流量峰值,而是同一张票被两个请求同时锁定。12306 这类系统的核心难点从来不是页面好不好看,而是高并发下的库存一致性与订单状态流转。mini 12306 火车购票系统就是把这个命题压缩成一个可跑通、可拆解的教学级项目:车次查询、余票扣减、下单、支付回调、订单超时释放,一条链路走完。它适合两类人——想拿一个完整 Spring Cloud 微服务项目练手的后端开发者,以及需要课程设计或简历项目但不想只写 CRUD 的学生。源码本身不复杂,复杂的是你怎么把分布式事务、缓存一致性、限流这些词真正落到代码里,而不是停在八股文层面。

2. 拆开 mini 12306:微服务怎么切、库存怎么扣

2.1 服务拆分不是越细越好

拿到这个标题,第一反应往往是「微服务嘛,拆就完了」。但 mini 12306 的体量如果拆成十几个服务,光是服务间调用和配置管理就能把新手劝退。我一般会按业务边界切成四个核心服务,再加两个基础组件:

服务/组件职责关键依赖
gateway统一入口、路由、鉴权Spring Cloud Gateway
train-service车次、站点、余票查询MySQL + Redis
order-service下单、订单状态机MySQL + RocketMQ
user-service登录、乘客管理MySQL + JWT
nacos注册中心 + 配置中心—
sentinel限流、熔断接入 gateway 和 order-service

这个粒度是我踩过坑之后定下来的。早期把余票扣减放在 order-service 里,结果查询和扣减抢同一把锁,QPS 一上来直接雪崩。后来把余票的读写都收进 train-service,order-service 只负责发消息和落订单,职责才清晰。

2.2 余票扣减:先扣 Redis 还是先扣 MySQL

这是整个项目最容易被面试官追问的点。常见做法有两种:

方案 A:Redis 预扣 + MySQL 异步落库。查询走 Redis,下单时用 Lua 脚本原子扣减 Redis 库存,扣成功再发消息让 order-service 落订单,MySQL 库存由消费者异步更新。

方案 B:直接 MySQL 行锁扣减。UPDATE ticket SET stock = stock - 1 WHERE train_id = ? AND stock > 0,靠数据库保证一致性。

mini 项目我建议先用方案 B 跑通,再演进到方案 A。原因很直接:方案 B 你能亲眼看到锁竞争和超卖是怎么发生的,方案 A 的坑(缓存与数据库不一致、消息重复消费)在没有一定经验时很难排查。

方案 B 的核心 SQL 和判断逻辑:

-- 扣减余票,stock > 0 是防超卖的关键条件 UPDATE ticket_stock SET stock = stock - 1, update_time = NOW() WHERE train_id = #{trainId} AND travel_date = #{travelDate} AND stock > 0;

执行后检查affectedRows,等于 0 说明库存不足,直接返回失败,不要再去查一次再判断,那中间有时间窗口。

// OrderService.java 片段 int affected = ticketStockMapper.deductStock(trainId, travelDate); if (affected == 0) { throw new BizException("余票不足"); } // 扣减成功后再创建订单,订单初始状态为待支付 Order order = buildOrder(userId, trainId, travelDate); orderMapper.insert(order);

逻辑说明:affectedRows是判断扣减是否成功的唯一依据,不要用「先 select 再 update」的写法,那在高并发下必然超卖。参数上travel_date必须进 WHERE 条件,因为同一车次不同日期的库存是独立的,漏掉这个条件会导致跨天扣减。

2.3 订单状态机:别让订单卡在中间态

订单有五个状态:待支付、已支付、已取消、已出票、已退款。状态流转必须单向,禁止从已取消跳回已支付。我一般会在 order-service 里用一个枚举加状态转移表来控制:

public enum OrderStatus { PENDING_PAY, PAID, CANCELLED, TICKETED, REFUNDED; } // 允许的状态转移 private static final Map<OrderStatus, Set<OrderStatus>> TRANSFER = Map.of( PENDING_PAY, Set.of(PAID, CANCELLED), PAID, Set.of(TICKETED, REFUNDED), TICKETED, Set.of(REFUNDED), CANCELLED, Set.of(), REFUNDED, Set.of() );

每次更新订单前先校验TRANSFER.get(current).contains(target),不合法就抛异常。这个表看起来简单,但能挡掉大部分因为消息重复消费导致的脏状态。

2.4 超时未支付释放库存

用户下单后 15 分钟不支付,库存要还回去。常见做法是 RocketMQ 延时消息或者 Redis 过期监听。mini 项目里我用延时消息,因为 Redis 过期监听在集群下不可靠,这个坑后面会细说。

// 下单成功后发送延时消息,30 分钟级别 Message msg = new Message("order-topic", "TIMEOUT", orderId.getBytes()); msg.setDelayTimeLevel(16); // RocketMQ 固定延时级别,对应 30 分钟 producer.send(msg);

消费端收到消息后先查订单状态,如果还是 PENDING_PAY 就取消订单并回补库存。注意:回补库存也要用stock = stock + 1的原子操作,并且要判断订单是否真的被取消成功,避免重复回补。

3. 用 Spring Cloud 把服务串起来:注册、配置、网关三件套

3.1 Nacos 注册与配置的最小可用配置

Nacos 同时做注册中心和配置中心,省掉 Eureka + Config 两套。train-service 的bootstrap.yml:

spring: application: name: train-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 config: server-addr: 127.0.0.1:8848 file-extension: yaml group: DEFAULT_GROUP

逻辑说明:bootstrap.yml比application.yml先加载,Nacos 配置要放在 bootstrap 里才能生效。file-extension决定去 Nacos 拉train-service.yaml这个 Data ID。参数上group默认 DEFAULT_GROUP,多环境时用 namespace 隔离,不要用 group 隔离环境,那会让配置管理变乱。

3.2 Gateway 路由与 Sentinel 限流接入

网关是所有流量的入口,路由配置和限流规则都在这里:

spring: cloud: gateway: routes: - id: train-service uri: lb://train-service predicates: - Path=/api/train/** filters: - StripPrefix=1

lb://表示走负载均衡,StripPrefix=1去掉第一层路径再转发。限流用 Sentinel 的网关适配:

// 在 Gateway 启动类或配置类中注册 Sentinel 网关过滤器 @Bean @Order(Ordered.HIGHEST_PRECEDENCE) public GlobalFilter sentinelGatewayFilter() { return new SentinelGatewayFilter(); }

然后在 Sentinel 控制台给/api/train/query配 QPS 阈值。这里有个容易翻车的点:Sentinel 默认的限流返回是 429 加一段英文,前端体验很差,需要自定义BlockRequestHandler返回统一 JSON。

3.3 OpenFeign 调用与超时设置

order-service 要调 train-service 扣库存,用 OpenFeign:

@FeignClient(name = "train-service", fallback = TrainServiceFallback.class) public interface TrainClient { @PostMapping("/stock/deduct") Result<Void> deduct(@RequestBody DeductDTO dto); }

超时配置必须显式设置,默认值在压测时会导致大量请求堆积:

feign: client: config: default: connectTimeout: 2000 readTimeout: 5000

connectTimeout是建立连接的时间,readTimeout是等待响应的时间。扣库存这种操作 readTimeout 不要设太大,否则线程池很快被占满。fallback 里返回失败并记录日志,不要让异常直接抛到用户层。

4. 避坑与排查:那些让我加班到凌晨的坑

4.1 坑一:Redis 过期监听释放库存,集群下丢事件

现象:单机测试时订单超时释放库存正常,一上集群就有订单卡在待支付,库存永远不回来。

原因:Redis 的 key 过期事件是广播机制,集群模式下每个节点只通知自己处理的过期 key,客户端如果只连了一个节点就会漏掉事件。而且过期事件不保证送达,Redis 官方文档明确说不适合做可靠消息。

解决:换成 RocketMQ 延时消息,或者用定时任务扫表兜底。我现在的做法是两者都上:延时消息做实时释放,定时任务每 5 分钟扫一次超时订单做补偿。补偿逻辑要幂等,靠订单状态判断。

4.2 坑二:Feign 调用超时导致库存扣了但订单没建

现象:压测时出现库存少了但订单表里没有对应记录,用户投诉「票没了但我没订单」。

原因:order-service 调 train-service 扣库存成功,但返回时网络超时,order-service 认为失败没有创建订单,库存却已经扣了。

解决:扣库存接口要做幂等,用orderId作为去重键。train-service 收到请求先查这个 orderId 是否已经扣过,扣过就直接返回成功。同时 order-service 在超时后要主动查询扣减结果,而不是直接判定失败。

4.3 坑三:Nacos 配置刷新导致数据源重建

现象:在 Nacos 控制台改了一个无关配置,服务日志里出现数据源重新初始化,连接池短暂不可用。

原因:@RefreshScope加在了整个 DataSource 配置类上,任何配置变更都会触发 Bean 重建。

解决:只把需要动态刷新的配置项单独放到一个@RefreshScope的 Bean 里,数据源、线程池这类重资源不要加 RefreshScope。这个坑很隐蔽,因为平时不出问题,一出就是生产事故。

4.4 坑四:Sentinel 规则丢失

现象:重启服务后 Sentinel 控制台配的限流规则全没了。

原因:Sentinel 默认把规则存在内存里,应用重启就丢。

解决:接入 Nacos 做规则持久化,用sentinel-datasource-nacos依赖,在配置里指定规则存在哪个 Data ID。规则推送到 Nacos 后,应用启动时自动拉取。

4.5 坑五:MySQL 库存扣减没加唯一索引导致重复扣

现象:同一订单号出现两条扣减记录。

原因:扣减表没有对order_id建唯一索引,重试时插入了重复数据。

解决:ALTER TABLE ticket_stock_log ADD UNIQUE KEY uk_order (order_id);,靠数据库兜底。应用层的幂等判断可能因为并发而失效,唯一索引是最后一道防线。

5. 压测与验证:怎么确认这套 mini 12306 真的扛得住

5.1 用 JMeter 做阶梯压测

验证库存一致性最直接的办法是压测。我用 JMeter 做阶梯加压:100 并发跑 1 分钟,200 并发跑 1 分钟,500 并发跑 2 分钟。重点看三个指标:扣减成功数、订单创建数、库存剩余数。三者必须满足:扣减成功数 = 订单创建数,库存初始值 - 扣减成功数 = 库存剩余值。任何一个对不上,就说明有一致性问题。

压测脚本里把trainId和travelDate参数化,模拟不同车次。同一车次的请求要带不同的userId,否则会被幂等逻辑挡掉,测不出真实并发。

5.2 用日志和链路追踪定位问题

单靠看日志排查分布式问题效率很低。我在项目里接了 SkyWalking,每个请求的调用链一目了然。扣库存慢是慢在 MySQL 行锁还是 Feign 超时,看链路耗时分布就知道。如果不想引入 SkyWalking,至少在 Feign 拦截器里打印traceId,把一次下单涉及的所有服务日志串起来。

// Feign 请求拦截器,传递 traceId public class TraceInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { String traceId = MDC.get("traceId"); if (traceId != null) { template.header("X-Trace-Id", traceId); } } }

5.3 一个我常用的验证技巧:故意制造失败

系统正常时看不出问题,要主动制造异常。我会在扣库存后、创建订单前手动抛一个异常,看库存有没有回滚、消息有没有重发、订单状态有没有卡住。这个「破坏性测试」比正常压测更能暴露问题。还有一招是把 MySQL 主库停掉几秒,看服务是快速失败还是线程池被拖垮。快速失败靠的是合理的超时和熔断配置,线程池拖垮说明超时设太大了。

这套 mini 12306 我前后改了三版,第一版超卖,第二版库存对不上,第三版才把幂等和补偿做全。每次翻车都让我更理解「分布式一致性不是靠一个注解解决的,是靠一层层兜底堆出来的」。如果你也在做类似的项目,建议先把单机的扣减逻辑写对,再往上加缓存和消息,顺序反了会很难排查。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询