☰
从零手写火车票售票系统:领域模型、数据库设计与并发控制实战
2026/10/20 5:34:30 网站建设 项目流程

1. 为什么我要从零手写一个火车票售票系统

很多刚入行的朋友问我,学完Java基础、Servlet、JSP之后,到底该拿什么项目练手。我的答案一直没变过:做一个有真实业务复杂度的系统,而不是增删改查的玩具。火车票售票系统就是这样一个绝佳的练手标的——它看起来只是“查票、下单、付款”,但背后牵扯到余票计算、并发扣减、订单状态机、车次调度、座位分配等一系列硬核问题。你把这个系统啃下来,再去看市面上大部分业务系统,会发现底层逻辑都是相通的。

我自己带过不少新人,也见过太多人卡在“学完框架却写不出东西”的阶段。根本原因不是技术不够,而是没有经历过从需求到表结构、从接口到并发控制的完整闭环。这个系列我打算把整个开发过程拆开揉碎,从零开始,一行一行把核心逻辑写出来。第一篇先不急着敲代码,而是把领域模型、数据库设计、技术选型这三件事讲透。这三件事想不清楚,后面写多少代码都是返工。

这篇文章适合谁看?如果你已经会写Java、懂基本的SQL、用过Tomcat,但没独立做过一个完整的中型项目,那这篇就是为你准备的。如果你已经工作一两年,想回头补一补“为什么表要这样设计”“为什么这里要用乐观锁”,也能从中找到共鸣。我会尽量用大白话把每个决策背后的“为什么”讲清楚,而不是甩一堆结论让你背。

提示:本系列默认你使用JDK 8或11、MySQL 5.7/8.0、Maven构建,前端先用最朴素的JSP+Servlet把流程跑通,后面再谈前后端分离。不要一上来就Spring Cloud全家桶,那是给自己挖坑。

2. 火车票售票系统的领域模型到底长什么样

2.1 先搞清楚“票”到底是什么

很多人一上来就建一张ticket表,字段写个train_id、seat_no、status就完事了。这么干的人,后面一定会被“同一趟车不同区间怎么算余票”这个问题打哭。火车票的本质不是“一张票”,而是一段区间上的座位占用。举个例子:G1234次列车从A站出发,途经B、C,终到D站。一个乘客买A到B的票,另一个乘客买B到D的票,他们完全可以坐在同一个物理座位上——因为区间不重叠。

所以正确的建模思路是:车次(Train)→ 经停站(TrainStop)→ 区间库存(SeatInventory)→ 订单(Order)→ 票(Ticket)。这五层关系理不顺,余票永远算不对。我见过最离谱的设计是把座位和车次直接绑定,结果一趟车只能卖一张全程票,区间复用完全做不了。

具体来说,TrainStop表要记录每一站是第几站(stop_order)、到达时间、出发时间。有了站序,任意两个站之间的区间就能用[from_order, to_order)这个左闭右开区间表示。区间库存表则按“车次+座位类型+日期+起始站序+结束站序”来记录剩余数量。这样查询A到C的余票时,只需要找所有覆盖[A_order, C_order)区间的库存记录,取最小值即可。

2.2 座位类型与车厢的抽象

真实的车票系统里,座位类型远不止“一等座、二等座”。还有商务座、软卧、硬卧、硬座、无座等等。每种类型的座位数量、定价系数、是否支持区间复用都不一样。我的做法是抽一张SeatType字典表,字段包括type_code、type_name、price_rate(相对基准价的系数)、reusable(是否支持区间复用)。无座票的reusable设为false,因为它本质上不占物理座位,但库存逻辑要单独处理。

车厢(Carriage)这一层要不要单独建表?我的建议是初期不建。因为对于售票逻辑来说,你只关心“某种座位类型还剩多少”,不关心具体是第几车厢第几排。等到要做选座功能时,再引入Carriage和Seat表,通过carriage_id关联到车次。这样分层演进,避免一开始就过度设计。

2.3 订单状态机的设计

订单不是简单的“已支付/未支付”。真实场景下,一个订单会经历:待支付 → 已支付 → 已出票 → 已乘车/已退票/已改签。如果只用一个status字段存字符串,后面做状态流转校验时会非常痛苦。我推荐用状态机模式:定义枚举OrderStatus,再写一个OrderStateMachine类,明确哪些状态可以迁移到哪些状态。比如“待支付”只能迁到“已支付”或“已取消”,“已支付”只能迁到“已出票”或“已退款”。

这样做的好处是,所有非法流转在代码层面就被拦截了,不会出现“已退票的订单又被支付了一次”这种脏数据。而且状态机的迁移日志可以单独存一张表,方便对账和排查问题。这个设计我在多个交易类系统里用过,非常稳。

3. 数据库表结构设计与关键字段取舍

3.1 核心表清单与字段说明

下面这张表是我实际项目中反复打磨后的精简版,去掉了一些非核心字段,保留最关键的。你可以直接照着建,也可以根据自己的业务微调。

表名作用关键字段
train车次主表train_no,start_station,end_station,depart_time
train_stop经停站表train_id,station_name,stop_order,arrive_time,leave_time
seat_type座位类型字典type_code,type_name,price_rate,reusable
seat_inventory区间库存train_id,travel_date,seat_type,from_order,to_order,total_count,sold_count
ticket_order订单主表order_no,user_id,train_id,travel_date,from_order,to_order,seat_type,amount,status
ticket票明细order_id,passenger_name,id_card,seat_no,status

这里重点说几个字段的取舍。seat_inventory里的total_count和sold_count是分开存的,而不是只存一个remain_count。为什么?因为余票 = total - sold,分开存可以随时对账,而且做并发扣减时,更新sold_count比更新remain_count更直观——扣减就是sold_count = sold_count + 1,配合WHERE sold_count < total_count就能实现乐观锁。如果只存余票,扣减时还要判断remain_count > 0,逻辑上多一层转换,容易出错。

ticket_order里的from_order和to_order直接存站序,而不是站名。因为站序是整数,比较大小、计算区间覆盖非常快,而且不受站名变更影响。站名只在展示时通过train_stop关联查出来。这个设计在查询余票时优势明显:一条SQL就能筛出所有覆盖目标区间的库存记录。

3.2 索引怎么建才不拖后腿

余票查询是最高频的操作,索引必须围绕它来建。seat_inventory表上,我建了一个联合索引:idx_train_date_type (train_id, travel_date, seat_type)。这样查询“某车次某天某种座位类型的库存”时,能直接走索引,不需要回表。另外,from_order和to_order虽然不放在索引最左,但在WHERE里作为范围条件,配合前面的等值条件,效率依然很高。

订单表ticket_order上,user_id和order_no都要建索引。user_id用于查“我的订单”,order_no用于订单详情和支付回调。注意order_no要加唯一索引,防止重复下单。我见过有人用自增ID当订单号,结果被用户猜出规律,批量查询别人的订单——这是安全事故,不是技术问题。订单号一定要用时间戳+用户ID后四位+随机数这种不可预测的格式。

注意:travel_date用DATE类型而不是DATETIME,因为车票是按天卖的,不需要精确到秒。用DATE能让索引更紧凑,查询也更快。

3.3 库存初始化的时机与方式

库存不是凭空来的。每天凌晨,系统需要为未来30天的车次生成库存记录。这个操作叫“库存初始化”。我的做法是写一个定时任务,遍历所有有效车次,对每个车次的每个区间、每种座位类型,插入一条seat_inventory记录,total_count根据车厢定员和区间复用规则计算。

这里有个坑:区间库存的total_count不是简单等于座位数。因为一个座位可以被多个不重叠区间复用,所以每个区间的total_count都等于该座位类型的总座位数。比如二等座有500个座位,那么A-B区间的total_count是500,B-C区间也是500,A-C区间还是500。但实际卖票时,A-B卖出去100张,B-C卖出去200张,A-C还能卖多少?答案是min(500-100, 500-200) = 300。这个计算逻辑必须在代码里体现,不能靠数据库字段直接减。

4. 技术选型:为什么我不建议一上来就上微服务

4.1 单体架构在这个阶段是优势不是劣势

网上很多教程一讲项目就上Spring Cloud、Nacos、Gateway,好像不用微服务就不够高级。但我要说句实话:对于一个练手项目,单体架构才是最优解。原因很简单——你的核心目标是搞懂业务逻辑和并发控制,而不是服务治理。微服务会引入网络调用、分布式事务、服务发现等一堆额外复杂度,把你的注意力从“余票怎么算”转移到“Feign怎么配”上,得不偿失。

我的建议是:用Spring Boot + MyBatis + MySQL搭一个单体应用,前端用Thymeleaf或JSP都行。先把整个购票流程跑通,把并发问题暴露出来,再考虑拆分。等你把单体版的并发扣减做稳了,再去学微服务,会发现那些分布式锁、消息队列的知识点,本质上和单机锁是相通的。

4.2 持久层选MyBatis还是JPA

这个问题争论很多年了。我的观点很明确:涉及复杂查询和手写SQL优化的场景,MyBatis更合适。火车票系统的余票查询、订单统计、区间覆盖判断,都需要精细控制SQL。JPA的自动生成SQL在简单CRUD上很爽,但遇到“查询覆盖某区间的所有库存记录并取最小值”这种需求,要么写原生SQL,要么用Criteria API拼半天,都不如MyBatis直接写XML来得痛快。

当然,MyBatis的缺点是样板代码多,每个表都要写Mapper接口和XML。但你可以用MyBatis-Plus来减少简单CRUD的重复劳动,复杂查询还是手写XML。这个组合我在多个项目里用过,开发效率和可控性平衡得很好。

4.3 并发控制方案的选择

这是整个系统最核心的技术点。火车票售票的并发场景主要有两个:同一区间多人同时下单和同一订单重复支付。对于前者,我推荐数据库乐观锁 + 重试的方案。具体来说,扣减库存时执行:

UPDATE seat_inventory SET sold_count = sold_count + 1 WHERE train_id = ? AND travel_date = ? AND seat_type = ? AND from_order = ? AND to_order = ? AND sold_count < total_count;

然后判断affectedRows是否等于1。如果等于1,说明扣减成功;如果等于0,说明库存不足或有并发冲突,返回失败让用户重试。这个方案不需要引入Redis或ZooKeeper,对于日订单量几万到几十万的系统完全够用。而且它天然避免了超卖——WHERE sold_count < total_count这个条件在数据库层面就是原子性的。

对于重复支付,用订单号唯一索引 + 状态机校验就够了。支付回调进来时,先根据订单号查订单,如果状态已经是“已支付”,直接返回成功,不做任何处理。如果状态是“待支付”,才执行状态迁移。这样即使支付平台回调多次,也不会重复出票。

提示:乐观锁重试次数不要超过3次。超过3次还失败,说明冲突太激烈,应该直接返回“系统繁忙,请稍后重试”,而不是无限重试拖垮数据库。

5. 从查询余票到下单的完整链路拆解

5.1 余票查询的SQL到底该怎么写

假设用户要查“G1234次列车,从B站到D站,二等座,余票多少”。已知B站的stop_order是2,D站的stop_order是4。那么查询逻辑是:找出所有from_order <= 2且to_order >= 4的库存记录,取(total_count - sold_count)的最小值。

SELECT MIN(total_count - sold_count) AS remain FROM seat_inventory WHERE train_id = ? AND travel_date = ? AND seat_type = '二等座' AND from_order <= 2 AND to_order >= 4;

这条SQL的关键在于from_order <= 2 AND to_order >= 4这个条件。它表示“该库存区间完全覆盖了用户要买的区间”。为什么取最小值?因为如果A-C区间只剩10张,A-D区间只剩5张,那B-D区间最多只能卖5张——受限于最紧的那个区间。这个逻辑用MIN聚合函数实现,简洁且高效。

5.2 下单时的库存扣减顺序

下单不是简单扣一次库存就完事。用户从B到D,可能跨越多个区间。比如B-C区间和C-D区间是分开管理的,那下单时就要同时扣减这两个区间的库存。如果只扣了一个,另一个没扣,就会出现“B-C有票但C-D没票”的脏数据。

我的做法是:把用户要买的区间拆成所有相邻站序对,然后按站序从小到大依次扣减。比如B(2)到D(4),拆成[2,3)和[3,4)两个区间。扣减时用事务包起来,任何一个区间扣减失败,整个事务回滚。这样保证要么全扣成功,要么全不扣。

这里有个性能优化点:扣减顺序要固定。所有并发请求都按站序从小到大扣减,可以避免死锁。如果有的请求从大到小扣,有的从小到大扣,数据库就可能出现循环等待。这个细节很多人忽略,但在高并发下会直接导致大量事务超时。

5.3 订单创建与超时取消

库存扣减成功后,创建订单,状态设为“待支付”,同时记录一个过期时间(比如15分钟)。然后启动一个延迟任务,15分钟后检查订单状态,如果还是“待支付”,就取消订单并回滚库存。

这个延迟任务怎么做?初期可以用数据库轮询:每分钟扫一次ticket_order表,找出status = '待支付' AND create_time < NOW() - INTERVAL 15 MINUTE的订单,逐个取消。这个方案简单可靠,缺点是实时性差一点,但15分钟的容忍度下完全够用。等系统规模大了,再换成消息队列的延迟消息。

取消订单时,库存回滚要按扣减的逆序执行,也就是从大到小回加。这样和扣减顺序相反,进一步降低死锁概率。回加时同样用乐观锁:UPDATE seat_inventory SET sold_count = sold_count - 1 WHERE ... AND sold_count > 0。

6. 那些只有踩过才知道的坑

6.1 区间库存的“重叠扣减”陷阱

我最早做这个系统时,犯过一个低级错误:用户买B到D的票,我只扣了B-C区间的库存,忘了扣C-D。结果系统显示B-D有票,但实际C-D已经卖光了。用户上车后补不了票,投诉一大堆。这个bug的根源是没有把区间拆解逻辑和库存扣减逻辑绑定在一起。后来我强制要求:任何库存操作必须先调用splitInterval(fromOrder, toOrder)方法,拿到所有子区间,再逐个操作。这个方法成了整个系统的“安全阀”。

6.2 并发下的“超卖”是怎么发生的

很多人以为加了WHERE sold_count < total_count就不会超卖。但在读已提交隔离级别下,两个事务可能同时读到sold_count = 499,然后都执行UPDATE ... SET sold_count = 500 WHERE sold_count < 500。第一个事务更新成功,第二个事务因为WHERE条件不满足而失败——这没问题。但如果第二个事务在第一个事务提交前执行UPDATE,它会等待行锁,等第一个事务提交后,它重新读取sold_count,发现已经是500了,WHERE条件不满足,更新失败。所以不会超卖。

真正会超卖的场景是:你先SELECT查余票,然后在应用层判断“余票>0”,再执行UPDATE。这中间有时间差,两个请求可能都查到余票>0,然后都去扣减。避免方法就是把判断和扣减合并到一条SQL里,让数据库来保证原子性。这也是我坚持用UPDATE ... WHERE sold_count < total_count的原因。

6.3 订单号生成的“唯一性”与“可读性”平衡

订单号既要唯一,又要方便用户报给客服查询。我用的是:yyyyMMddHHmmss+ 6位随机数 + 用户ID后4位。比如202405201430251234560001。这个格式的好处是:前14位是时间,客服一看就知道大概什么时候下的单;中间6位随机数保证同一秒内不同用户的订单不冲突;后4位用户ID方便定位用户。总长度24位,不算太长,用户报起来也不费劲。

注意不要用UUID当订单号,太长且没有可读性。也不要用纯自增ID,容易被遍历。这个格式我在生产环境用了很久,没出过重复。

6.4 数据库连接池的配置别照抄

网上很多教程给MySQL连接池配置maxActive=100,但你的数据库max_connections可能只有151。如果应用有多个实例,每个实例都配100,瞬间就把数据库连接打满了。我的经验是:单实例的maxActive不要超过20,然后根据实际压测结果调整。另外,一定要配testWhileIdle=true和validationQuery=SELECT 1,防止连接被数据库主动断开后应用还在用死连接。

还有一个坑:事务方法里不要做远程调用。比如下单时调用支付网关,如果支付网关响应慢,数据库连接会被长时间占用,连接池很快耗尽。正确做法是先扣库存、创建订单,提交事务,然后再异步调用支付。这个顺序调整能显著提升吞吐量。

7. 第一篇的收尾:先把地基打牢再往上盖

写到这里,领域模型、表结构、技术选型、核心链路和常见坑都过了一遍。你可能发现,我几乎没怎么写具体代码——这是故意的。在一个项目里,想清楚“为什么这样设计”比“怎么写代码”重要十倍。表结构错了,后面写再多代码都是补丁摞补丁;并发方案选错了,上线后就是无尽的救火。

下一篇我会开始动手建工程、写实体类、配MyBatis,把余票查询和库存扣减的代码一行一行敲出来。到时候你会发现,今天讲的这些设计决策,在代码里都会一一对应上。如果你跟着这个系列走完,最后拿到的不只是一个能跑的售票系统,而是一套面对复杂业务时如何拆解、如何取舍、如何验证的方法论。这套方法论,比任何框架都值钱。

提示:在开始写代码之前,建议你先把本文的表结构在MySQL里建一遍,手动插入几条测试数据,用SQL把余票查询和库存扣减跑通。这一步花不了多少时间,但能帮你提前发现设计上的漏洞。等代码写起来,你会感谢自己做了这一步。

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

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

立即咨询