
简介这是一套基于微服务架构的火车售票系统完整源码面向具备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 还是 RPCtrain-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: 4MBdeadline设 800ms 是因为扣库存操作在压测中 P99 在 300ms 左右留一倍余量。max-inbound-message-size限制 4MB防止大报文打爆内存。如果超过 deadlinegRPC 会抛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, fticket:{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 ) ENGINEInnoDB; -- 余票表热数据按日期分表 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) ) ENGINEInnoDB;余票表按日期分表比如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:MaxGCPauseMillis200控制 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(ftrain:{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(ftrain:{train_id}, 60, NULL) return None redis.setex(ftrain:{train_id}, 3600, json.dumps(train)) return train5.5 压测时线程池被打满导致服务雪崩现象压测 QPS 刚到 5000服务响应时间从 50ms 飙到 5s然后大量超时。原因Tomcat 默认线程池 200gRPC 线程池默认也是 200。下游服务响应变慢时线程被阻塞新请求排队队列满了就拒绝引发雪崩。解决给每个下游调用设独立线程池配合熔断降级。用 Sentinel 或 Hystrix 做熔断当失败率超过 50% 时直接返回降级结果不再调用下游。线程池大小根据压测结果调我一般设核心线程数 QPS * P99响应时间 / 1000。比如 QPS 5000P99 200ms核心线程数就是 1000。6. 压测调优与容量规划把系统推到极限再收回来压测不是跑个 JMeter 看 QPS 数字就完事。我一般分三步基准测试、瓶颈定位、容量规划。基准测试用单接口压比如余票查询逐步加并发记录 QPS 和响应时间。当响应时间开始非线性增长时那个点就是当前配置的极限。train-12306-system的余票查询在 4 核 8G 的容器里单实例大概能扛 3000 QPSP99 在 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 调到 1000maxConnections从 8192 调到 20000。这两个参数在application.yml里配server: tomcat: threads: max: 500 min-spare: 50 accept-count: 1000 max-connections: 20000max-threads设 500 是因为压测发现 200 不够用但设太大上下文切换开销也大。accept-count是等待队列长度设 1000 能缓冲突发流量。max-connections是最大连接数设 20000 防止连接被拒绝。最后说一个我自己的习惯每次上线前用wrk或JMeter跑一遍核心链路把 QPS、P99、错误率三个指标记在文档里。下次改代码后对比如果 P99 涨了 20% 以上必须查原因。这个习惯帮我提前发现了无数次性能退化。希望帮到你。本文还有配套的精品资源点击获取