1. 项目缘起与整体设计思路
1.1 为什么选择火车票售票系统作为练手项目
做Java Web开发的人,迟早都会面临一个尴尬:增删改查写了几百遍,一到真正复杂的业务场景就不知道从哪下手。我当初也是这样,后来决定找一个“业务复杂度足够、但又不至于大到做不完”的项目来练手,最终选了火车票售票系统。
这个选择不是拍脑袋决定的。火车票售票系统天然包含几个在普通管理系统里很难遇到的核心难点:高并发下的余票扣减、座位与车次的复杂关联关系、订单超时释放与状态流转、区间售票的库存计算。你去看市面上大部分Java Web教程,教来教去就是用户管理、商品管理,这些系统的数据模型太扁平了,一张表加几个外键就完事了。但火车票不一样,同一趟车从A站到B站,中间经过C、D、E,每个区间的余票是相互影响的,这个模型设计起来就有意思了。
另外,这个项目的可扩展性极强。你做完基础版本之后,可以往上叠加支付模块、消息通知、分布式锁、缓存优化、分库分表,几乎每一个Java后端的热门技术点都能在这个场景里找到落地的位置。对于想从“会写代码”进阶到“能做系统”的人来说,这是一个非常理想的载体。
这篇文章是整个系列的第一篇,主要聚焦在项目整体架构设计、数据库模型搭建、核心业务逻辑梳理这三块。我不会一上来就贴大段代码,而是先把“为什么这么设计”讲清楚,因为我自己踩过的坑告诉我,前期设计没想明白,后面代码写得越多越痛苦。
1.2 技术选型的取舍与理由
技术栈的选择上,我走的是“稳”字路线。所谓稳,就是每一项技术都是经过大量项目验证的,社区活跃、文档齐全、出问题好查。
后端框架用Spring Boot + Spring MVC + MyBatis。为什么不用JPA?因为火车票系统的查询条件非常灵活,比如“查某天从A到B的所有车次,且余票大于0,且出发时间在某个区间内”,这种动态SQL用MyBatis的XML映射写起来更直观,也更好调优。JPA在处理这种复杂查询时,要么写原生SQL,要么用Specification拼条件,代码可读性反而下降。
数据库选MySQL 8.0,存储引擎用InnoDB。这里有一个关键决策:余票扣减到底放在数据库层还是应用层。我最终选择在数据库层用乐观锁配合版本号来做,原因后面会详细展开。缓存层用Redis,主要承担两个职责:一是缓存车次信息和余票数据,减轻数据库压力;二是做分布式锁,防止同一座位被并发预订。
前端没有用现在流行的Vue或React,而是用了Thymeleaf + Bootstrap。这不是因为我不懂前后端分离,而是这个项目的核心价值在后端业务逻辑,前端只需要能正常展示和交互就行。用Thymeleaf可以少维护一套前端工程,把精力集中在后端。当然,如果你时间充裕,完全可以换成前后端分离的架构,后端提供RESTful API,前端用Vue3 + Element Plus,这个后面可以再开一个系列来讲。
提示:技术选型没有绝对的对错,关键是匹配你的学习目标和项目阶段。如果你是为了面试准备项目经验,建议用主流的前后端分离方案;如果是为了深入理解业务逻辑和并发处理,把后端做扎实更重要。
1.3 系统模块划分与职责边界
整个系统我划分成了五个核心模块,每个模块的职责边界必须清晰,否则后期维护会非常混乱。
用户模块负责注册、登录、实名认证、乘客管理。这里有一个容易忽略的点:乘客和用户是一对多的关系,一个用户可以给多个乘客买票,但乘客信息(姓名、身份证号)是独立的。很多初学者会把乘客信息直接塞到用户表里,这是不对的。
车次模块负责车次信息的维护,包括列车编号、始发站、终点站、途经站点、发车时间、到达时间、座位类型和数量。这个模块的数据通常由管理员在后台维护,普通用户只读。
余票模块是整个系统的核心,负责计算和更新每个车次在每个区间的余票数量。这个模块的设计直接决定了系统能不能正确处理并发请求。
订单模块负责创建订单、查询订单、取消订单、订单状态流转。订单和余票之间是强关联的,创建订单时必须扣减余票,取消订单时必须归还余票。
支付模块在第一版里我先用模拟支付代替,只记录支付状态,不接入真实支付渠道。这样可以把注意力集中在业务逻辑上,等核心流程跑通了再考虑接入。
模块之间的调用关系我遵循一个原则:上层模块可以调用下层模块,同层模块之间通过服务接口调用,不允许跨层直接访问数据库。比如订单模块需要扣减余票,它调用的是余票模块提供的服务接口,而不是直接去操作余票表。这样做的好处是,将来余票模块的实现方式变了(比如从数据库扣减改成Redis扣减),订单模块的代码不需要改动。
2. 数据库模型设计与核心字段解析
2.1 核心表结构设计与字段说明
数据库设计是火车票售票系统的地基,地基没打好,后面写再多代码都是白搭。我一共设计了八张核心表,下面逐一说明。
用户表(t_user)存储用户的基本信息。关键字段包括:用户ID(主键,自增)、用户名(唯一索引)、密码(存储BCrypt加密后的哈希值,绝对不能用明文)、手机号(唯一索引)、真实姓名、身份证号(加密存储)、创建时间和更新时间。这里要特别注意,身份证号属于敏感信息,我在实际项目中是做了AES加密的,查询时用加密后的值去匹配。
乘客表(t_passenger)存储乘客信息,和用户表是多对一关系。关键字段:乘客ID、所属用户ID(外键)、乘客姓名、身份证号(加密)、乘客类型(成人/儿童/学生)、创建时间。乘客类型会影响票价计算,儿童票通常是半价,学生票有特定折扣。
车站表(t_station)存储所有车站信息。关键字段:车站ID、车站名称(唯一索引)、所属城市、车站代码(比如“BJP”代表北京)。车站代码在车次查询时用来做索引,比用中文名称效率高。
列车表(t_train)存储列车的基本信息。关键字段:列车ID、列车编号(比如“G101”)、列车类型(高铁/动车/普快)、座位类型配置。这里我把座位类型单独拆了一张表,因为不同列车的座位类型和数量可能不同。
车次表(t_schedule)这是最核心的表之一,存储具体的车次信息。关键字段:车次ID、列车ID(外键)、出发日期、始发站ID、终点站ID、发车时间、到达时间、状态(正常/停运/晚点)。注意,同一趟列车在不同日期会有不同的车次记录,所以出发日期是必须的字段。
车次经停站表(t_schedule_station)存储每个车次途经的所有站点及其到达和出发时间。关键字段:记录ID、车次ID(外键)、车站ID(外键)、站序(第几站)、到达时间、出发时间、停留时长。这张表决定了区间售票的计算方式。
座位库存表(t_seat_inventory)这是整个系统最核心的表,存储每个车次每个区间每种座位类型的余票数量。关键字段:库存ID、车次ID、出发站ID、到达站ID、座位类型、总数量、已售数量、版本号(用于乐观锁)。这张表的设计直接决定了余票扣减的效率和正确性。
订单表(t_order)存储订单信息。关键字段:订单ID、订单编号(业务主键,唯一索引)、用户ID、车次ID、出发站ID、到达站ID、座位类型、乘客ID、座位号、订单状态(待支付/已支付/已取消/已退票)、订单金额、创建时间、支付截止时间。
2.2 区间售票的库存模型设计
区间售票是火车票系统和普通电商系统最大的区别。普通电商卖的是独立商品,库存减一就是减一。但火车票不一样,一张票对应的是“从A站到B站”这个区间,同一趟车的同一个座位,在A到B区间卖出去之后,B到C区间还可以继续卖。
我举个例子说明。假设一趟车从北京西出发,途经石家庄、郑州东,终点是武汉。座位总数是100个。如果有一个乘客买了北京西到郑州东的票,那么北京西到石家庄、石家庄到郑州东这两个区间的余票都要减一,但郑州东到武汉的余票不变。
这个逻辑如果用一张简单的库存表来存,就会非常麻烦。我的做法是:把每个车次的每个区间都拆成独立的库存记录。以上面的例子来说,北京西到石家庄、北京西到郑州东、北京西到武汉、石家庄到郑州东、石家庄到武汉、郑州东到武汉,一共六个区间,每个区间每种座位类型都有一条独立的库存记录。
这样做的好处是,扣减余票时只需要更新对应的区间记录,逻辑简单清晰。坏处是数据量会膨胀,一个车次如果有N个站点,就会产生N*(N-1)/2个区间记录。但考虑到实际车次的站点数量通常在10到20个之间,这个数据量完全可以接受。
注意:区间库存的初始化必须在车次创建时完成,而且一旦有订单产生,就不能再随意修改区间划分,否则会导致库存数据不一致。
2.3 乐观锁与版本号机制在余票扣减中的应用
余票扣减的并发问题是这个系统最大的技术挑战。假设一趟车只剩一张票,同时有100个人在下单,如果不做并发控制,很可能出现超卖。
我对比了几种方案。悲观锁(SELECT ... FOR UPDATE)可以保证强一致性,但性能太差,同一区间的库存记录会被锁住,其他请求只能排队等待。分布式锁(Redis锁)性能好一些,但实现复杂,还要处理锁超时、锁误删等问题。最终我选择了乐观锁 + 版本号的方案。
具体做法是:在座位库存表中加一个version字段。每次扣减余票时,先查询当前库存和版本号,然后在更新时带上版本号条件:
UPDATE t_seat_inventory SET sold_count = sold_count + 1, version = version + 1 WHERE id = ? AND version = ? AND total_count - sold_count > 0;如果更新影响的行数为0,说明要么版本号变了(有其他请求先更新了),要么余票不足。这时候需要重试或者返回失败。
这个方案的好处是:不需要显式加锁,数据库层面的行锁只在更新那一瞬间生效,并发性能好。坏处是:高并发时重试次数会增多,需要在应用层控制重试次数,比如最多重试3次,超过就返回“系统繁忙,请稍后重试”。
实测下来,在单机MySQL、100并发的情况下,这个方案可以稳定运行,没有出现超卖。当然,如果并发量再高一个数量级,就需要考虑用Redis预扣减库存、异步落库的方案了,那是后续优化的内容。
3. 核心业务逻辑与实操流程拆解
3.1 车次查询与余票展示的完整链路
用户打开购票页面,输入出发站、到达站、出发日期,点击查询,这个看似简单的操作背后其实有一整条链路。
第一步,参数校验。出发站和到达站不能相同,出发日期不能早于今天,这些基础校验必须在Controller层完成,避免无效请求打到数据库。
第二步,查询车次列表。根据出发站、到达站和日期,从车次表关联经停站表,找出所有满足条件的车次。这里有一个细节:用户查的是“从A到B”,但车次可能是从C出发经过A到B再到D,所以查询条件不能是“始发站=A且终点站=B”,而应该是“经停站包含A且经停站包含B,且A的站序小于B的站序”。
这个查询用SQL写出来大概是这样的:
SELECT s.* FROM t_schedule s JOIN t_schedule_station ss1 ON s.id = ss1.schedule_id JOIN t_schedule_station ss2 ON s.id = ss2.schedule_id WHERE ss1.station_id = ? AND ss2.station_id = ? AND ss1.station_order < ss2.station_order AND s.departure_date = ?;第三步,查询余票。拿到车次列表后,对每个车次查询对应区间的余票。这里我用Redis做了一层缓存,key的格式是inventory:{scheduleId}:{fromStationId}:{toStationId}:{seatType},value是剩余数量。缓存过期时间设为5分钟,因为余票变化比较频繁,缓存时间太长会导致用户看到的数据不准确。
第四步,组装返回数据。把车次信息、余票数量、票价等信息组装成前端需要的格式返回。票价的计算规则是:基础票价乘以区间系数,不同座位类型有不同的系数。
实操心得:余票缓存这里有一个坑,就是缓存和数据库的一致性问题。我的做法是,扣减余票时先更新数据库,然后删除对应的缓存key,而不是更新缓存。这样下次查询时会从数据库重新加载,保证数据一致性。这个策略叫“Cache Aside”,是业界最常用的缓存更新模式。
3.2 下单流程与库存扣减的时序控制
下单是整个系统最复杂的环节,涉及多个步骤,每一步都必须保证正确性。
第一步:参数校验与风控检查。校验用户是否登录、乘客信息是否完整、是否重复下单(同一用户同一车次同一乘客不能有未支付的订单)。这里我加了一个简单的风控规则:同一用户1分钟内最多创建3个订单,防止恶意刷单。
第二步:查询余票并锁定。从数据库查询对应区间的库存记录,获取当前版本号。注意,这里查询的时候不加锁,只是读取。
第三步:计算票价。根据座位类型、乘客类型、区间距离计算最终票价。票价计算涉及浮点数运算,我统一用BigDecimal来处理,避免精度丢失。
第四步:扣减库存。用乐观锁的方式更新库存记录,如果更新失败,说明并发冲突,返回重试或失败。扣减成功后,立即删除对应的Redis缓存key。
第五步:创建订单。生成全局唯一的订单编号(我用的是“日期 + 雪花算法ID”的格式),插入订单记录,状态设为“待支付”,同时设置支付截止时间(通常是15分钟后)。
第六步:返回结果。返回订单编号和支付链接给前端。
这六步必须保证原子性,也就是说,如果第四步扣减库存成功了,但第五步创建订单失败了,必须把库存加回去。我的做法是用Spring的@Transactional注解把这几步包在一个事务里,任何一步抛出异常都会回滚。但要注意,Redis的操作不在事务范围内,所以缓存删除失败的情况需要额外处理,我的做法是记录日志,后续通过定时任务补偿。
3.3 订单超时释放与库存归还机制
用户下单后15分钟内没有支付,订单需要自动取消,库存需要归还。这个功能看起来简单,但实现起来有几个坑。
第一个坑:用什么方式触发超时检查。我一开始想用定时任务,每分钟扫描一次订单表,把超时的订单找出来取消。但这样有两个问题:一是时间精度不够,用户可能等了16分钟才被取消;二是订单量大的时候,每分钟全表扫描性能很差。
后来我改用了Redis的过期键监听方案。创建订单时,在Redis里设置一个key,过期时间15分钟。同时监听这个key的过期事件,一旦过期,就触发取消订单的逻辑。这个方案时间精度高,性能也好。但要注意,Redis的过期事件不是100%可靠的,极端情况下可能丢失,所以我还加了一个兜底的定时任务,每5分钟扫描一次,处理那些漏掉的订单。
第二个坑:库存归还的顺序。取消订单时,必须先把订单状态改为“已取消”,然后再归还库存。如果顺序反了,先归还库存再改状态,万一改状态失败,就会出现库存已经归还但订单还是待支付的情况,用户可能会重复支付。
第三个坑:并发取消。用户手动取消和系统自动取消可能同时发生,需要用数据库的行锁或者乐观锁来保证只有一个取消操作生效。我的做法是在更新订单状态时加一个条件:WHERE order_status = '待支付',这样只有第一个取消操作会成功。
4. 开发环境搭建与项目骨架生成
4.1 开发工具与运行环境准备
工欲善其事,必先利其器。这套项目的开发环境我建议这样配置:
- JDK 17:Spring Boot 3.x要求JDK 17以上,而且JDK 17的ZGC垃圾回收器在高并发场景下表现更好。
- Maven 3.9+:依赖管理用Maven就够了,Gradle虽然灵活但学习成本高,没必要。
- MySQL 8.0:注意字符集用utf8mb4,排序规则用utf8mb4_general_ci,支持emoji存储。
- Redis 7.0:用于缓存和分布式锁,安装后用
redis-cli ping测试连通性。 - IntelliJ IDEA:社区版够用,配合Lombok插件可以减少大量样板代码。
环境变量配置这里有一个容易忽略的点:MySQL的时区设置。默认情况下MySQL用的是UTC时区,而我们的应用用的是东八区,如果不设置,插入的时间会差8小时。解决办法是在JDBC连接URL里加上serverTimezone=Asia/Shanghai。
4.2 Spring Boot项目初始化与依赖配置
用Spring Initializr生成项目骨架,选择以下依赖:
- Spring Web
- MyBatis Framework
- MySQL Driver
- Spring Data Redis
- Lombok
- Validation
生成之后,在application.yml里配置数据库连接和Redis连接:
spring: datasource: url: jdbc:mysql://localhost:3306/train_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 timeout: 3000msMyBatis的配置我放在mybatis-config.xml里,主要配置了驼峰命名转换和日志实现:
<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="SLF4J"/> </settings> </configuration>提示:
mapUnderscoreToCamelCase这个配置非常重要,它让数据库的user_name字段自动映射到Java对象的userName属性,省去了大量手动映射的工作。
4.3 项目包结构与分层规范
包结构我遵循的是经典的三层架构 + 模块化的组织方式:
com.train.ticket ├── common // 公共类:统一返回结果、常量、工具类 ├── config // 配置类:Redis配置、MyBatis配置、Web配置 ├── controller // 控制器层:接收请求、参数校验、返回响应 ├── service // 服务层:业务逻辑 │ ├── impl // 服务实现 │ └── dto // 数据传输对象 ├── mapper // 数据访问层:MyBatis Mapper接口 ├── entity // 实体类:与数据库表对应 ├── exception // 异常处理:自定义异常、全局异常处理器 └── util // 工具类:加密、日期处理、ID生成分层规范的核心原则是:Controller层不写业务逻辑,Service层不直接操作HttpServletRequest,Mapper层不写业务判断。每一层只做自己该做的事,这样代码才好维护。
我见过很多初学者把业务逻辑写在Controller里,一个方法几百行,后期改一个需求要通读整个方法,非常痛苦。分层不是为了好看,是为了让每一层的职责单一,修改的时候影响范围可控。
5. 常见问题与排查技巧实录
5.1 数据库连接与事务相关故障排查
问题一:启动时报“Communications link failure”。这个错误通常是MySQL服务没启动,或者连接URL写错了。排查步骤:先用mysql -u root -p在命令行测试能否连接,如果能连,检查URL里的端口号和数据库名是否正确。如果URL里带了useSSL=true但MySQL没配置SSL,也会报这个错,改成useSSL=false即可。
问题二:事务不生效。明明加了@Transactional注解,但异常时数据没有回滚。常见原因有三个:一是方法不是public的,Spring的AOP代理只对public方法生效;二是同类内部方法调用,比如this.methodB(),这样不会走代理,事务不生效;三是异常类型不对,默认只回滚RuntimeException,如果抛的是CheckedException,需要显式指定@Transactional(rollbackFor = Exception.class)。
问题三:乐观锁更新失败率过高。如果发现大量请求返回“系统繁忙”,说明并发冲突太严重。解决办法有两个:一是减少事务持有时间,把不必要的操作移出事务;二是引入Redis预扣减,先在Redis里扣减库存,成功后再异步同步到数据库。
5.2 缓存与数据库一致性问题的处理经验
缓存和数据库的一致性是老生常谈的问题,但在火车票系统里尤其重要,因为余票数据直接影响用户决策。
我采用的策略是先更新数据库,再删除缓存。为什么是删除而不是更新?因为更新缓存的值需要重新计算,如果计算逻辑复杂,更新缓存的成本可能比删除更高。而且在高并发下,更新缓存容易出现脏数据,删除缓存则相对安全。
但删除缓存也有一个经典问题:删除失败怎么办。我的做法是引入消息队列做补偿。更新数据库后,发送一条删除缓存的消息到队列,如果删除失败,消息会重新入队重试。当然,第一版里我简化了,只是记录日志,然后靠缓存的过期时间兜底。
还有一个细节:缓存穿透。如果用户查询一个不存在的车次,每次都会打到数据库。解决办法是缓存空值,即使查询结果为空,也往Redis里写一个空对象,过期时间设短一点,比如1分钟。
5.3 高频问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 启动报端口被占用 | 8080端口已被其他程序使用 | `netstat -ano | findstr 8080` |
| 接口返回中文乱码 | 响应头字符集未设置 | 检查Content-Type头 | 在配置中设置spring.http.encoding.charset=UTF-8 |
| 余票数量为负数 | 并发扣减未加锁 | 查看库存更新SQL | 加乐观锁版本号条件 |
| 订单状态不流转 | 定时任务未执行 | 查看定时任务日志 | 检查@Scheduled注解和线程池配置 |
| Redis连接超时 | Redis服务未启动或网络不通 | redis-cli ping | 检查Redis配置和防火墙 |
实操心得:排查问题时,日志是第一手资料。我建议在关键业务节点都打上日志,比如“开始扣减库存,车次ID={},区间={},当前版本号={}”,这样出问题时能快速定位。但要注意,日志级别用DEBUG,不要用INFO,否则生产环境日志量太大。
6. 后续扩展方向与个人经验体会
第一版的核心功能跑通之后,我实际测试了一下,单机环境下100并发下单没有出现超卖,平均响应时间在200毫秒左右。这个成绩对于练手项目来说已经够用了,但如果要往生产级别靠拢,还有几个方向可以继续深挖。
第一个方向是引入Redis预扣减库存。把余票数据全部放到Redis里,下单时先在Redis里扣减,扣减成功后再异步写入数据库。这样数据库的压力会小很多,并发能力可以提升一个数量级。但要注意处理Redis和数据库的数据一致性问题,以及Redis宕机后的恢复方案。
第二个方向是分库分表。当订单量达到千万级别时,单表查询会越来越慢。可以按用户ID哈希分库,按日期分表。这个改造工作量比较大,建议先把业务逻辑跑通再考虑。
第三个方向是接入真实支付。模拟支付只能验证流程,真实支付涉及回调通知、签名验证、对账等环节,复杂度会高很多。如果要做,建议单独抽一个支付模块,把支付渠道的差异封装起来。
第四个方向是前端重构。用Vue3 + Element Plus重写前端,做成前后端分离的架构。后端只需要提供RESTful API,前端负责页面渲染和交互。这样前后端可以并行开发,效率更高。
我个人在做这个项目的过程中,最大的体会是:不要一上来就追求完美架构。我一开始花了很多时间设计各种设计模式、抽象接口,结果写到后面发现很多抽象根本用不上,反而增加了理解成本。后来我调整了策略,先把核心流程用最直接的方式实现,跑通之后再根据实际需要重构。这样每一步都有反馈,不会陷入“过度设计”的陷阱。
另外,测试驱动开发在这个项目里非常有用。余票扣减、订单状态流转这些逻辑,光靠手动测试很难覆盖所有边界情况。我写了一些单元测试和集成测试,用JUnit 5 + Testcontainers,每次改完代码跑一遍,心里踏实很多。特别是乐观锁的重试逻辑,测试用例模拟了并发场景,帮我发现了几个隐藏的bug。
最后分享一个小技巧:用Postman或者Apifox把常用的接口请求保存成集合,每次改完代码一键跑一遍,比手动一个个点快得多。这个习惯帮我省了大量重复劳动的时间。