☰
火车票订票系统实战:从压缩包到高并发防超卖与订单状态机
2026/10/10 6:30:02 网站建设 项目流程

简介:这是一份基于C++实现的火车票订票系统项目源码,面向正在学习C++面向对象编程、数据结构与文件操作的高校学生及自学者,可用于课程设计、实训作业或编程练习参考。系统覆盖查询火车信息、增加火车信息(含重复校验)、打印车票、订票(按到达城市判断余票并同步更新总票数)、修改与删除订票信息,以及将火车信息和订票人数据分别保存到文件与硬盘等完整业务逻辑。压缩包共70个文件,约21.78MB,包含C语言源文件与头文件、Visual Studio工程文件(sln、vcxproj、filters)、编译中间产物(obj、pdb、ipch、tlog)以及dat数据文件和可执行程序,工程结构完整,可直接打开运行调试。目前已有1319人学习下载,适合通过阅读源码理解订票流程设计、文件持久化与增删改查的实现思路,并在此基础上进行功能扩展与二次开发。

1. 火车票订票系统.zip:一个压缩包背后,藏着多少能跑通的工程决策

拿到「火车票订票系统.zip」这个标题,很多人第一反应是去搜源码、找现成项目,但真正做过的人会先问一句:这个压缩包里到底该有什么?是只有几张 JSP 页面加一个 SQL 脚本,还是一套能扛住并发、能讲清楚库存扣减逻辑的完整工程?我见过太多人把这类项目当成课程作业来交,结果面试时被追问「余票怎么扣、订单超时怎么处理、两个人同时买最后一张票怎么办」就卡住了。这个标题真正指向的,是一套典型的高并发票务交易系统的最小可运行实现,核心不在界面多漂亮,而在库存一致性、订单状态机、限流与防超卖这三件事能不能讲明白、跑得通。它适合两类人:一是想拿一个完整项目串起后端工程能力的学习者,二是需要快速搭一套票务类业务原型的一线开发者。接下来我不讲空泛架构,只讲这个压缩包解压之后,你应该看到什么、先跑什么、哪些参数一动就翻车。

2. 先让压缩包跑起来:环境、依赖与最小启动路径

2.1 解压后先看目录结构,别急着改代码

一个能跑的火车票订票系统,目录结构通常不会太复杂,但每个目录的存在都有理由。我一般拿到压缩包后先做三件事:看根目录有没有README或pom.xml/package.json,看有没有sql目录,看配置文件里数据库连接指向哪里。常见结构大致如下:

train-ticket-system/ ├── backend/ # 服务端代码 │ ├── src/main/java/ # 业务逻辑 │ ├── src/main/resources/ │ │ ├── application.yml # 数据库、Redis、端口配置 │ │ └── mapper/ # MyBatis 映射文件 │ └── pom.xml ├── frontend/ # 前端页面或静态资源 ├── sql/ │ ├── schema.sql # 建表语句 │ └── init_data.sql # 车次、座位、用户初始数据 └── docker-compose.yml # 可选,一键起 MySQL + Redis

如果你拿到的压缩包里没有sql目录,或者schema.sql里只有用户表没有车次和座位表,那这套代码大概率跑不起来,需要自己补表结构。这一步别偷懒,表结构决定了后面库存扣减能不能做。

2.2 用 Docker 起 MySQL 和 Redis 的最小命令

票务系统离不开两个中间件:MySQL 存订单和车次,Redis 做余票缓存和分布式锁。手动装环境容易把时间耗在配置上,我一般直接用 Docker 起:

# 启动 MySQL,映射 3306,设置 root 密码 docker run -d --name ticket-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=ticket_db \ mysql:8.0 --default-authentication-plugin=mysql_native_password # 启动 Redis,映射 6379 docker run -d --name ticket-redis \ -p 6379:6379 \ redis:7.0 redis-server --appendonly yes

这两条命令做完,先把sql/schema.sql导入:

docker exec -i ticket-mysql mysql -uroot -proot123 ticket_db < sql/schema.sql docker exec -i ticket-mysql mysql -uroot -proot123 ticket_db < sql/init_data.sql

逻辑说明:MySQL 用mysql_native_password是为了兼容老版本 JDBC 驱动,很多压缩包里的驱动版本偏低,不设这个参数会报Client does not support authentication protocol。Redis 开appendonly yes是为了重启后余票缓存不丢,虽然缓存可以重建,但调试阶段少一次重建就少一次玄学问题。

参数说明:MYSQL_DATABASE=ticket_db要和application.yml里的库名一致;端口如果本机已占用,把-p 3306:3306改成-p 3307:3306,同时改配置文件。Redis 默认无密码,如果压缩包里配了密码,启动时加--requirepass。

2.3 改配置、编译、启动的三步操作

配置文件里通常有四个地方必须核对:数据库 URL、数据库账号密码、Redis 地址、服务端口。以application.yml为例:

spring: datasource: url: jdbc:mysql://localhost:3306/ticket_db?useSSL=false&serverTimezone=Asia/Shanghai username: root password: root123 redis: host: localhost port: 6379 database: 0 server: port: 8080

改完之后编译启动:

cd backend mvn clean package -DskipTests java -jar target/ticket-system-0.0.1-SNAPSHOT.jar

逻辑说明:-DskipTests是因为很多压缩包里的测试用例依赖外部环境,第一次跑先跳过,等主流程通了再回头跑测试。serverTimezone=Asia/Shanghai不加会报时区错误,这是血泪经验,尤其是订单时间字段用timestamp的时候。

参数说明:server.port如果被占用,改成 8081 或 9090,前端请求地址也要同步改。Redis 的database建议用 0,如果本机还有其他项目共用 Redis,改成 1 或 2 避免 key 冲突。

3. 余票扣减与订单状态机:票务系统最核心的两段逻辑

3.1 为什么不能直接 update 减库存

新手最容易写出的扣减逻辑是这样的:

-- 错误示范:先查再减,并发下必然超卖 SELECT remaining FROM ticket WHERE train_no = 'G1001' AND seat_type = '二等座'; UPDATE ticket SET remaining = remaining - 1 WHERE train_no = 'G1001' AND seat_type = '二等座';

这段 SQL 在单线程下没问题,但两个人同时查到余票为 1,都执行 update,最后卖出两张票,库存变成 -1。这就是典型的超卖。正确做法是把判断和扣减合并成一条原子操作:

UPDATE ticket SET remaining = remaining - 1 WHERE train_no = 'G1001' AND seat_type = '二等座' AND remaining > 0;

然后在代码里判断受影响行数:

int affected = ticketMapper.decreaseStock(trainNo, seatType); if (affected == 0) { throw new BizException("余票不足,下单失败"); }

逻辑说明:remaining > 0放在 WHERE 里,数据库层面保证只有余票大于零才会扣减,受影响行数为 0 就说明没抢到。这是最朴素也最可靠的防超卖手段,不需要分布式锁就能扛住一定并发。

参数说明:train_no和seat_type要建联合索引,否则这条 update 会锁全表。索引语句:

ALTER TABLE ticket ADD INDEX idx_train_seat (train_no, seat_type);

3.2 订单状态机:从待支付到已完成的四个状态

订单不能只有一个「已下单」状态,否则超时未支付、用户取消、支付成功这些情况没法区分。我一般用四个状态:

状态码状态名说明
0待支付下单成功,库存已扣,等待付款
1已支付付款完成,等待出票
2已取消用户主动取消或超时未支付
3已完成出票成功,订单终结

状态流转必须单向:0 → 1 → 3,或者 0 → 2。不允许从 2 回到 1,也不允许跳过 1 直接到 3。代码里用枚举加判断:

public enum OrderStatus { PENDING(0), PAID(1), CANCELLED(2), FINISHED(3); private final int code; OrderStatus(int code) { this.code = code; } public int getCode() { return code; } } // 支付回调里校验状态 if (order.getStatus() != OrderStatus.PENDING.getCode()) { throw new BizException("订单状态异常,无法支付"); } order.setStatus(OrderStatus.PAID.getCode());

逻辑说明:状态校验放在更新之前,防止重复支付回调把已取消订单改成已支付。很多压缩包里没有这层校验,支付回调一进来就直接改状态,测试时看不出问题,上线后重复回调就翻车。

参数说明:状态码用整数存数据库,别用字符串,查询和索引效率更高。如果压缩包里已经用了字符串,至少加个CHECK约束或者应用层枚举校验。

3.3 超时未支付怎么自动取消并回滚库存

订单创建后一般给 15 分钟支付时间,超时未支付要自动取消并加回库存。常见做法有两种:定时任务扫表,或者 Redis 过期键监听。我一般用定时任务,简单可控:

@Scheduled(fixedDelay = 60000) // 每分钟执行一次 public void cancelTimeoutOrders() { // 查出 15 分钟前创建且仍为待支付的订单 List<Order> timeoutOrders = orderMapper.selectTimeoutOrders( OrderStatus.PENDING.getCode(), LocalDateTime.now().minusMinutes(15) ); for (Order order : timeoutOrders) { // 先改状态,再回滚库存,顺序不能反 int updated = orderMapper.cancelOrder(order.getId(), OrderStatus.PENDING.getCode()); if (updated > 0) { ticketMapper.increaseStock(order.getTrainNo(), order.getSeatType()); } } }

逻辑说明:先改状态再回滚库存,用updated > 0保证只有一个线程能取消成功,避免重复回滚。如果先回滚库存再改状态,两个定时任务同时跑到同一订单,库存会被加两次。

参数说明:fixedDelay = 60000是一分钟一次,订单量大可以改成 30 秒。minusMinutes(15)是支付超时时间,和前端倒计时保持一致。回滚库存的 SQL 就是UPDATE ticket SET remaining = remaining + 1 WHERE ...,别忘了加索引条件。

4. 避坑与排查:票务系统最容易翻车的五个地方

4.1 余票缓存和数据库不一致

现象:Redis 里显示有票,下单却提示余票不足;或者数据库里没票了,Redis 还能查到。

原因:扣减时只更新了数据库,没有同步 Redis;或者先更新 Redis 再更新数据库,中间失败导致两边不一致。

解决:我一般用「数据库为准,Redis 只做查询加速」的策略。扣减时先走数据库原子 update,成功后再删 Redis 缓存(不是更新),下次查询自然回源到数据库重建缓存。删缓存比更新缓存安全,因为更新可能写错值,删了最多多查一次库。

4.2 订单重复提交

现象:用户快速点两次提交,生成两笔待支付订单,扣了两次库存。

原因:前端没防抖,后端没幂等。

解决:后端用「用户 ID + 车次 + 座位类型 + 乘车日期」做唯一键,插入订单时捕获唯一键冲突,冲突就返回已有订单。或者用 Redis 分布式锁,锁 key 用同样的组合,锁住 3 秒,第二次请求直接拒绝。

4.3 定时任务扫表扫出全表锁

现象:订单表数据量一大,取消超时订单的定时任务越来越慢,甚至把数据库连接池占满。

原因:SELECT * FROM orders WHERE status = 0 AND create_time < ?没有索引,每次全表扫描。

解决:给status和create_time建联合索引:

ALTER TABLE orders ADD INDEX idx_status_time (status, create_time);

另外定时任务每次限制条数,比如LIMIT 500,分批处理,别一次查几万条。

4.4 支付回调验签被绕过

现象:测试时直接调回调接口就能把订单改成已支付。

原因:回调接口没有校验签名,或者签名密钥硬编码在前端。

解决:签名校验必须在服务端做,密钥放配置文件或环境变量,回调参数里带sign,服务端用同样规则算一遍比对。压缩包里如果回调接口是裸的,自己补上验签逻辑,这是上线前的底线。

4.5 车次查询接口被刷

现象:查票接口 QPS 一高,数据库 CPU 直接拉满。

原因:每次查询都走数据库,没有缓存,也没有限流。

解决:查票结果放 Redis,key 用train_no + date,过期时间 5 分钟。同时加一层限流,用 Guava RateLimiter 或 Redis 计数器,单 IP 每秒最多 10 次查询。限流阈值根据实际车次数量调整,别一刀切。

5. 从能跑到能讲:用压测和日志验证你的票务系统

5.1 用 JMeter 或 wrk 压一下下单接口

系统跑起来只是第一步,能不能扛住并发才是票务系统的分水岭。我一般用 wrk 快速压一下下单接口:

# 10 个线程,100 个连接,持续 30 秒,压下单接口 wrk -t10 -c100 -d30s -s post.lua http://localhost:8080/api/order/create

post.lua里构造请求体:

wrk.method = "POST" wrk.body = '{"trainNo":"G1001","seatType":"二等座","userId":1001}' wrk.headers["Content-Type"] = "application/json"

压完之后看三件事:一是余票有没有变成负数,二是订单数是不是等于扣减的库存数,三是响应时间 P99 有没有超过 500ms。如果余票为负,说明扣减逻辑还有并发漏洞;如果订单数对不上,说明有请求扣了库存但没生成订单,事务没回滚。

5.2 日志里必须打出的四个关键字段

排查票务问题时,日志比调试器管用。我一般要求下单链路的日志里必须包含:userId、trainNo、seatType、orderId。下单开始时打一条 info,扣减库存后打一条 info,订单创建后打一条 info,任何异常打 error 并带上前面四个字段。这样出问题时直接 grep 一个 userId 或 orderId,整条链路一目了然。

log.info("下单开始 userId={} trainNo={} seatType={}", userId, trainNo, seatType); // ... 扣减库存 log.info("库存扣减成功 userId={} trainNo={} remaining={}", userId, trainNo, remaining); // ... 创建订单 log.info("订单创建成功 userId={} orderId={}", userId, orderId);

5.3 一个我常用的验证技巧:手动改库存再压测

想验证防超卖逻辑是否真的可靠,我会手动把某车次余票改成 1,然后用 wrk 发 100 个并发下单请求。预期结果是:只有 1 个请求成功,99 个返回余票不足,数据库里remaining最终为 0,订单表里只有 1 笔待支付订单。如果成功数大于 1,说明扣减逻辑有问题;如果成功数为 0,说明库存判断条件写错了。这个测试比看代码直观得多,建议每次改完扣减逻辑都跑一遍。

最后说个我自己的习惯:拿到任何票务类压缩包,先不看业务代码,先把schema.sql里的索引建全,再把扣减库存的那条 SQL 找出来看有没有remaining > 0条件。这两件事做完,系统能不能扛住并发心里就有底了。希望帮到你。

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

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

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

立即咨询