news 2026/10/4 14:57:19

微服务架构火车售票系统实战:服务拆分与余票扣减并发控制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微服务架构火车售票系统实战:服务拆分与余票扣减并发控制

简介:这是一套基于微服务架构的火车售票系统完整源码,面向具备Java与Spring Cloud基础的开发者、课程设计或毕业设计人群,用于学习分布式购票业务的服务拆分与协作。资源包共319个文件,约1.07MB,以187个Java源文件为核心,配合35个Vue前端组件、22个XML配置、19个JavaScript脚本,以及YAML、properties、SQL、JSON等配置与数据文件,另有少量PNG、HTML、HTTP测试脚本和JAR依赖,覆盖后端服务、前端页面、数据库脚本与接口调试等模块。系统围绕用户注册登录、车次浏览、座位选择与订单确认等购票流程展开,内容预览中可见成员、乘客、车站、订单确认等接口测试文件,便于理解各微服务的职责边界与调用关系。目前已有123人学习,适合作为微服务入门到进阶的实战参考,帮助读者掌握服务注册、配置管理、前后端分离与接口联调的完整思路。

1. 微服务架构火车售票系统:从单体到分布式的真实落地路径

每年春运,12306 的峰值 QPS 能冲到几十万,这个量级放在任何技术团队面前都是一道硬菜。train-12306-system这个项目标题背后,指向的是一个典型的微服务架构实战场景:把车次查询、余票扣减、订单生成、支付回调、用户认证这些模块拆成独立服务,各自部署、各自扩容。很多人第一次接触微服务架构,就是拿火车售票系统练手,因为它天然具备高并发、强一致性、读写分离这几个硬骨头。这篇文章不讲空泛的架构图,而是从服务拆分、数据库设计、接口联调到压测调优,把一条能跑通的路径摊开讲。适合已经写过 Spring Boot 单体应用、想往分布式方向迈一步的后端开发者,也适合正在做毕业设计或技术选型评估的工程师。

2. 服务拆分与通信选型:为什么不是拆得越细越好

2.1 火车售票场景下的服务边界怎么划

拿到train-12306-system这个需求,第一反应往往是按业务模块拆:用户服务、车次服务、订单服务、支付服务、通知服务。这个拆法没错,但不够。火车售票有一个特殊点——余票查询和余票扣减的读写比例极度悬殊,查询请求可能是扣减请求的几百倍。如果把它们放在同一个服务里,查询流量会把扣减逻辑拖垮。

我一般会这样划边界:

服务名职责数据存储扩容策略
train-query-service车次查询、余票展示Redis + 只读副本水平扩展,无状态
ticket-inventory-service余票扣减、库存回滚MySQL 主库 + 分布式锁垂直扩展优先
order-service订单创建、状态流转MySQL 分库分表按用户 ID 分片
user-service注册、登录、乘车人管理MySQL + Redis Session水平扩展
payment-service支付回调、对账MySQL + 消息队列独立部署

拆分的核心原则是:写操作收敛,读操作分散。余票扣减必须串行化,所以独立成一个服务,用分布式锁或数据库行锁保证一致性。查询服务可以随便扩,加机器就行。

2.2 同步调用还是异步消息:订单创建链路的取舍

订单创建涉及扣库存、生成订单、发通知三个步骤。新手容易写成同步链式调用:order-service 调 inventory-service 扣库存,成功了再写订单,再调 notify-service 发短信。这条链路一旦 notify-service 超时,整个订单创建就失败了。

常见做法是引入消息队列做最终一致性。扣库存成功后,往 MQ 发一条order.created事件,order-service 和 notify-service 各自消费。代码大概长这样:

// inventory-service 扣减库存后发送事件 @Transactional public void deductStock(Long trainId, Long userId, int count) { // 1. 数据库扣减,带乐观锁版本号 int affected = ticketMapper.deduct(trainId, count, version); if (affected == 0) { throw new BizException("余票不足或并发冲突"); } // 2. 发送消息到 RocketMQ,事务消息保证本地事务与消息发送一致 rocketMQTemplate.sendMessageInTransaction( "order-topic", MessageBuilder.withPayload(new OrderEvent(trainId, userId, count)).build(), null ); }

逻辑说明:先做数据库扣减,利用version字段做乐观锁,避免超卖。扣减成功后通过事务消息投递事件,RocketMQ 的事务消息机制会回调检查本地事务是否成功,只有成功才真正投递。参数方面,trainId和userId组合可以作为分片键,count一般限制在 1 到 5 之间,防止黄牛批量刷票。

注意:事务消息不是万能的,如果 MQ 本身挂了,本地事务会回滚,但已经扣减的库存需要靠定时对账任务补偿。我一般会加一个inventory_log表记录每次扣减,每 5 分钟跑一次对账。

2.3 服务间通信的协议选择与超时设置

微服务之间用 HTTP 还是 RPC?train-12306-system这种内部调用密集的系统,我倾向 gRPC。HTTP/1.1 的队头阻塞在高并发下很致命,gRPC 基于 HTTP/2 多路复用,序列化用 Protobuf,体积比 JSON 小一半以上。

但 gRPC 有个坑:默认没有超时。你不设 deadline,一个慢请求能把调用方线程池占满。我一般这样配:

# application.yml 中 gRPC 客户端配置 grpc: client: inventory-service: address: discovery:///ticket-inventory-service negotiation-type: plaintext deadline: 800ms max-inbound-message-size: 4MB

deadline设 800ms 是因为扣库存操作在压测中 P99 在 300ms 左右,留一倍余量。max-inbound-message-size限制 4MB,防止大报文打爆内存。如果超过 deadline,gRPC 会抛DEADLINE_EXCEEDED,调用方需要捕获并做降级处理,比如返回“系统繁忙,请重试”。

3. 余票扣减的并发控制:从超卖到分布式锁的踩坑记录

3.1 数据库乐观锁与 Redis 预扣减的配合

余票扣减是火车售票系统最核心也最容易翻车的地方。假设一列火车有 100 张票,1000 个人同时抢,怎么保证不超卖?

最朴素的做法是UPDATE ticket SET count = count - 1 WHERE train_id = ? AND count > 0。这条 SQL 在 MySQL 里是原子操作,不会超卖。但问题是并发一高,行锁竞争激烈,大量请求排队,响应时间飙升。

我一般用 Redis 做预扣减,数据库做最终落盘。流程是:

# Redis 预扣减 Lua 脚本,保证原子性 lua_script = """ local key = KEYS[1] local count = tonumber(ARGV[1]) local stock = tonumber(redis.call('GET', key) or '0') if stock >= count then redis.call('DECRBY', key, count) return 1 else return 0 end """ # 调用示例 result = redis.eval(lua_script, 1, f"ticket:{train_id}", count) if result == 1: # 预扣成功,发消息异步落库 mq.send("ticket-deduct", {"train_id": train_id, "count": count}) else: raise BizException("余票不足")

逻辑说明:Lua 脚本在 Redis 中单线程执行,GET和DECRBY之间不会有其他命令插入,保证了原子性。预扣成功后发消息到 MQ,由消费者慢慢写数据库。这样数据库的压力从“实时扣减”变成“异步落盘”,吞吐量能提升一个数量级。

参数方面,Redis 的ticket:{train_id}这个 key 需要设置过期时间,一般是发车后 24 小时。否则 Redis 内存会被历史车次占满。我一般用EXPIRE设 172800 秒。

3.2 分布式锁在订单创建中的正确用法

预扣减解决了库存竞争,但订单创建还需要防止同一个用户重复下单。比如用户点了两次提交,或者网络重试导致重复请求。这时候需要分布式锁。

Redisson 的RLock是常用方案:

@Autowired private RedissonClient redissonClient; public Order createOrder(Long userId, Long trainId) { String lockKey = "order:lock:" + userId + ":" + trainId; RLock lock = redissonClient.getLock(lockKey); try { // 尝试加锁,最多等 3 秒,锁持有 10 秒后自动释放 boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS); if (!acquired) { throw new BizException("操作过于频繁,请稍后重试"); } // 检查是否已有未支付订单 Order exist = orderMapper.findUnpaid(userId, trainId); if (exist != null) { return exist; } // 创建新订单 return doCreateOrder(userId, trainId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException("系统繁忙"); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }

逻辑说明:tryLock(3, 10, TimeUnit.SECONDS)表示最多等待 3 秒获取锁,拿到锁后 10 秒自动释放。为什么要设自动释放?防止服务宕机后锁一直不释放,导致死锁。isHeldByCurrentThread()判断是防止误删别人的锁。

注意:锁的粒度是userId + trainId,不是userId。如果只锁用户,同一用户买不同车次也会被阻塞,体验很差。锁的 key 一定要设过期时间,这是血泪教训——曾经有一次没设过期,服务重启后所有订单创建全部卡死。

3.3 库存回滚与对账补偿机制

预扣减成功但订单超时未支付,库存需要回滚。常见做法是订单创建时写一条inventory_deduct记录,状态为“已扣减”。支付超时后,定时任务扫描超时订单,把状态改为“已回滚”,同时往 Redis 里INCRBY加回库存。

-- 定时任务:每 30 秒扫描一次超时未支付订单 SELECT order_id, train_id, count FROM orders WHERE status = 'UNPAID' AND create_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE) LIMIT 100;

拿到超时订单后,先更新订单状态为CANCELLED,再执行 Redis 回滚。这里有个坑:如果 Redis 回滚失败怎么办?我的做法是记录一条rollback_failed日志,由对账任务每 5 分钟重试一次。对账任务还会对比 Redis 库存和数据库库存,不一致时以数据库为准,强制覆盖 Redis。

4. 数据库设计与分库分表:订单表撑不住的时候怎么办

4.1 车次表与余票表的冷热分离

train-12306-system的数据有一个明显特征:车次信息是冷数据,一旦确定基本不变;余票是热数据,每秒都在变。如果把两者放同一张表,每次查余票都要扫车次信息,效率很低。

我一般拆成两张表:

-- 车次基础表,冷数据,变化极少 CREATE TABLE train_info ( train_id BIGINT PRIMARY KEY, train_no VARCHAR(16) NOT NULL, start_station VARCHAR(32), end_station VARCHAR(32), depart_time DATETIME, arrive_time DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; -- 余票表,热数据,按日期分表 CREATE TABLE ticket_stock_20250101 ( id BIGINT AUTO_INCREMENT PRIMARY KEY, train_id BIGINT NOT NULL, seat_type TINYINT COMMENT '1-商务座 2-一等座 3-二等座', total_count INT NOT NULL, remain_count INT NOT NULL, version INT DEFAULT 0, UNIQUE KEY uk_train_seat (train_id, seat_type) ) ENGINE=InnoDB;

余票表按日期分表,比如ticket_stock_20250101、ticket_stock_20250102。查询时根据乘车日期路由到对应表。这样单表数据量可控,历史表可以归档到冷存储。

4.2 订单表分库分表的时机与路由策略

订单表是增长最快的。一天 100 万订单,一年就是 3.6 亿行。MySQL 单表超过 2000 万行,查询性能断崖式下跌。所以订单表必须分库分表。

分片键选什么?常见的有user_id和order_id。如果选order_id,用户查自己的订单需要扫所有分片,不行。选user_id,同一个用户的订单落在同一个库,查询方便,但可能出现热点用户(比如黄牛)导致单库压力过大。

我一般用user_id做分片键,配合一致性哈希。分片数根据预估数据量定,比如 4 库 16 表,总共 64 个分片。路由算法:

public class OrderShardingRouter { private static final int DB_COUNT = 4; private static final int TABLE_COUNT = 16; public static String route(Long userId) { // 先对 user_id 做哈希,再取模 int hash = Math.abs(userId.hashCode()); int dbIndex = hash % DB_COUNT; int tableIndex = (hash / DB_COUNT) % TABLE_COUNT; return String.format("db_%d.order_%d", dbIndex, tableIndex); } }

逻辑说明:hash % DB_COUNT决定库,(hash / DB_COUNT) % TABLE_COUNT决定表。这样同一个用户的订单始终落在同一个分片,查询时直接路由,不用广播。

注意:分库分表后,跨分片的查询(比如按车次统计订单量)会变得很麻烦。我一般用 Elasticsearch 做二级索引,把订单数据同步到 ES,复杂查询走 ES,简单查询走 MySQL。

4.3 读写分离与主从延迟的应对

火车售票系统读多写少,读写分离是标配。主库负责写,从库负责读。但主从同步有延迟,刚创建的订单可能查不到。

应对策略有三个:

第一,写后立即读的场景强制走主库。比如用户支付成功后跳转到订单详情页,这个查询走主库。

第二,用半同步复制。MySQL 的半同步复制保证至少一个从库收到 binlog 后才返回成功,延迟从秒级降到毫秒级。

第三,前端做补偿。订单创建成功后,前端先展示“订单处理中”,等 1 秒后再查详情。这个 1 秒就是留给主从同步的窗口。

# Spring 动态数据源配置 spring: datasource: master: url: jdbc:mysql://master-host:3306/train username: write_user slave: url: jdbc:mysql://slave-host:3306/train username: read_user

配合@Transactional(readOnly = true)注解,Spring 会自动路由到从库。但要注意,readOnly = true只在事务内生效,非事务查询默认走主库。

5. 避坑与排查:那些让我半夜爬起来改配置的问题

5.1 服务注册中心心跳超时导致实例被误摘

现象:服务正常运行,但注册中心显示实例下线,流量被摘除,接口 502。

原因:服务注册心跳间隔默认 30 秒,健康检查超时默认 90 秒。如果服务 GC 停顿超过 90 秒,注册中心会认为实例挂了,把它摘掉。等 GC 结束,实例又注册回来,但期间流量已经丢了。

解决:把心跳间隔调到 10 秒,超时调到 30 秒。同时给 JVM 加-XX:+UseG1GC -XX:MaxGCPauseMillis=200,控制 GC 停顿。如果还是频繁超时,检查是不是内存泄漏,用jmap -histo:live看对象分布。

5.2 分布式锁释放失败导致死锁

现象:某个用户的订单创建一直卡住,日志显示“等待锁超时”。

原因:lock.unlock()没有放在finally块里,或者isHeldByCurrentThread()判断缺失,导致 A 线程释放了 B 线程的锁。更隐蔽的是 Redis 主从切换:主节点加了锁,还没同步到从节点就挂了,从节点升主后锁丢失,另一个线程又能加锁。

解决:用 Redisson 的RLock,它内部用 Lua 脚本保证释放锁的原子性。如果对一致性要求极高,可以用 RedLock 算法,但 RedLock 本身也有争议,我一般只在金融级场景用。普通场景用 Redisson 就够了,关键是finally里必须释放。

5.3 消息队列重复消费导致库存多扣

现象:用户只下了一单,但库存扣了两次。

原因:MQ 的 at-least-once 语义保证消息至少消费一次,网络抖动或消费者重启会导致重复投递。如果消费逻辑没有幂等性,就会重复扣减。

解决:给每条消息带一个唯一messageId,消费者处理前先查 Redis 或数据库,看这个messageId是否已处理。处理成功后写入messageId记录,设置过期时间(比如 24 小时)。代码示例:

public void onMessage(Message message) { String msgId = message.getProperty("messageId"); // SETNX 原子操作,返回 true 表示第一次处理 Boolean isFirst = redis.opsForValue().setIfAbsent("msg:" + msgId, "1", 24, TimeUnit.HOURS); if (Boolean.FALSE.equals(isFirst)) { return; // 重复消息,直接丢弃 } // 处理业务逻辑 doDeduct(message); }

5.4 缓存穿透导致数据库被打爆

现象:大量请求查询不存在的车次,Redis 没有命中,全部打到 MySQL,数据库 CPU 飙到 100%。

原因:恶意攻击或爬虫构造不存在的train_id,缓存和数据库都没有数据,每次请求都穿透到数据库。

解决:布隆过滤器加空值缓存。布隆过滤器把所有存在的train_id哈希到一个位数组,查询前先过布隆过滤器,不存在直接返回。空值缓存是把查询结果为空的 key 也写入 Redis,值设为NULL,过期时间设短一点,比如 60 秒。

def get_train(train_id): # 1. 布隆过滤器判断 if not bloom_filter.contains(train_id): return None # 2. 查 Redis cache = redis.get(f"train:{train_id}") if cache is not None: return None if cache == "NULL" else json.loads(cache) # 3. 查数据库 train = db.query(train_id) if train is None: redis.setex(f"train:{train_id}", 60, "NULL") return None redis.setex(f"train:{train_id}", 3600, json.dumps(train)) return train

5.5 压测时线程池被打满导致服务雪崩

现象:压测 QPS 刚到 5000,服务响应时间从 50ms 飙到 5s,然后大量超时。

原因:Tomcat 默认线程池 200,gRPC 线程池默认也是 200。下游服务响应变慢时,线程被阻塞,新请求排队,队列满了就拒绝,引发雪崩。

解决:给每个下游调用设独立线程池,配合熔断降级。用 Sentinel 或 Hystrix 做熔断,当失败率超过 50% 时直接返回降级结果,不再调用下游。线程池大小根据压测结果调,我一般设核心线程数 = QPS * P99响应时间 / 1000。比如 QPS 5000,P99 200ms,核心线程数就是 1000。

6. 压测调优与容量规划:把系统推到极限再收回来

压测不是跑个 JMeter 看 QPS 数字就完事。我一般分三步:基准测试、瓶颈定位、容量规划。

基准测试用单接口压,比如余票查询,逐步加并发,记录 QPS 和响应时间。当响应时间开始非线性增长时,那个点就是当前配置的极限。train-12306-system的余票查询在 4 核 8G 的容器里,单实例大概能扛 3000 QPS,P99 在 80ms 左右。

瓶颈定位用arthas的trace命令看耗时分布。常见瓶颈有三个:数据库慢查询、Redis 大 key、GC 停顿。数据库慢查询用EXPLAIN看执行计划,缺索引就加索引。Redis 大 key 用redis-cli --bigkeys扫,超过 10KB 的 key 要拆。GC 停顿用-Xlog:gc*打日志,看 Full GC 频率。

容量规划按峰值 QPS 的 1.5 倍准备机器。比如预估峰值 10 万 QPS,单实例 3000 QPS,那至少需要 50 个实例。但要注意,实例不是越多越好,注册中心、配置中心、数据库连接池都有上限。我一般会留 20% 的 buffer,然后做全链路压测验证。

一个具体的调优技巧:把 Tomcat 的acceptCount从默认 100 调到 1000,maxConnections从 8192 调到 20000。这两个参数在application.yml里配:

server: tomcat: threads: max: 500 min-spare: 50 accept-count: 1000 max-connections: 20000

max-threads设 500 是因为压测发现 200 不够用,但设太大上下文切换开销也大。accept-count是等待队列长度,设 1000 能缓冲突发流量。max-connections是最大连接数,设 20000 防止连接被拒绝。

最后说一个我自己的习惯:每次上线前,用wrk或JMeter跑一遍核心链路,把 QPS、P99、错误率三个指标记在文档里。下次改代码后对比,如果 P99 涨了 20% 以上,必须查原因。这个习惯帮我提前发现了无数次性能退化。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 14:56:08

Claude Code 安装配置全攻略:从零上手到本地模型接入

1. 为什么我最终把主力开发工具换成了 Claude Code先说结论&#xff1a;Claude Code 不是那种"装完就完事"的插件&#xff0c;它是一个跑在终端里的 AI 编程代理&#xff0c;能直接读写你本地的文件、执行命令、跑测试、改配置。我用了大概三个月&#xff0c;从最初抱…

作者头像 李华
网站建设 2026/10/4 14:51:05

插件加载失败排查指南:从did not activate到依赖体检全流程

最近“plugins”这个词的热度又上来了&#xff0c;而且围观群众里哀嚎一片。热搜词底下跟着的不是教程&#xff0c;是一串串让人血压升高的报错&#xff0c;比如failed to load plugins web boot: 2 entries did not activate linxin666/dsh-p&#xff0c;比如harness failed t…

作者头像 李华
网站建设 2026/10/4 14:50:30

Claude Code 2.1.287 mods机制解析:TypeScript扩展与sec-default安全策略

1. 从 2.1.287 这个版本号说起&#xff1a;mods 机制到底改了什么Claude Code 的版本迭代节奏一直很快&#xff0c;2.1.287 这个版本在社区里被讨论得比较多&#xff0c;核心原因就是它把mods这套扩展机制往前推了一大步。所谓 mods&#xff0c;你可以理解成给 CLI 工具做"…

作者头像 李华
网站建设 2026/10/4 14:50:05

Cursor零代码开发流程:数据库生成到 TaoToken 统一 Key 接入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/4 14:49:07

微信小程序中如何使用less:从配置到生效的完整实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华