基于SpringBoot的汽车票网上预订系统设计与实现
今年帮一个师弟搞定了他的毕业设计,就是标题里这个“基于SpringBoot的汽车票网上预订系统”。做完之后我最大的感受是:汽车票预订这个场景,看起来是个普通的增删改查项目,但真正把订单、余票、支付这些环节串起来之后,才发现它比想象中有意思得多。尤其是一些细节——比如余票扣减的并发问题、班次日期跨天的处理、座位分配策略——每一个都能单独写一篇排查实录。
这篇博文我就把整个系统的设计思路、表结构方案、核心功能实现、还有那些我踩过的坑一次性梳理出来。项目用的是当前非常主流的 SpringBoot + MyBatis-Plus + MySQL + Redis 这套组合,既适合做毕业设计参考,也能给想自己动手写一个完整Web系统的朋友当作落地模板。不管你是刚学完 Java Web 的新手,还是准备从单体 CRUD 往真实业务场景进阶的开发者,这篇文章都能帮你省下不少走弯路的时间。
1. 项目整体设计与技术选型思路
1.1 核心需求拆解:这不是一个简单的CRUD项目
很多人在做类似系统时有个误区:把汽车票预订理解为“车次管理 + 订单表 + 购物车式下单”,结果做到一半才发现,真正复杂的是余票、座位、支付状态、退票这四件事互相咬合的关系。
我在动手之前先把需求拆成了这样几个层面:
- 用户端:注册登录、车次查询、在线订票、支付模拟、订单查询与退票
- 管理端:车次维护、班次信息发布、订单管理、基础数据(站点、车型)维护
- 核心业务逻辑:余票库存控制、座位分配、订单状态流转、防超卖
- 支撑性需求:数据统计、日志记录、异常处理、权限控制
拆完需求你会发现,这不是传统意义上的“管理系统”,而是一个带库存、带状态机、并发写操作频繁的业务系统。因此技术选型也要围绕这三个特点来展开。
1.2 技术栈选的不是“最新”,而是“最稳”
选型这块我推荐一个非常成熟稳定的方案:SpringBoot 2.7.x + MyBatis-Plus 3.5.x + MySQL 8.0 + Redis 6.x + Vue 2/3(前端视自己情况定)。为什么这么选?
第一,SpringBoot 2.7.x 是 3.x 之前最稳定的版本,市面上绝大多数参考资料、毕设论文、教程都基于这一版本。网上那些“SpringBoot从2.x升级到3.5.x”的帖子看得人脑壳疼,SpringBoot 3 全面拥抱 Jakarta EE,很多老版本的 starter 和配置类要跟着换包名,对新手来说没太大必要。
第二,MyBatis-Plus 是真的省事。车次查询、订单分页、条件构造器这些操作,用 MyBatis-Plus 的 LambdaQueryWrapper 几行代码搞定,不需要手写大量 XML 映射文件。你如果有用“MyBatis分页插件”的经验,会发现 MyBatis-Plus 内置了分页插件,配置一个 MybatisPlusInterceptor 就能直接用。
第三,Redis 用在汽车票系统里非常合适。车次余票是个典型的“读多写少”数据,热点班次会被大量并发查询,加一层缓存能显著降低数据库压力。同时分布式锁防超卖也得靠 Redis 来实现。
1.3 项目结构分层:按业务边界划分,而不是按数据表划分
很多同学喜欢按照 controller / service / dao 三层来做,这没错,但我在这个项目里做了更细的模块划分,按业务边界区分:
code复制com.example.busticket
├── controller // 接口层,只做参数校验和响应封装
├── service // 业务逻辑层,核心业务都在这层
├── mapper // 数据访问层
├── entity // 数据库实体类
├── dto // 前端交互数据对象
├── vo // 视图对象,展示层专用
├── config // 配置类(Redis、分页、跨域等)
├── common // 公共类(统一返回结果、异常处理、常量)
├── utils // 工具类(日期处理、订单号生成)
└── task // 定时任务(取消过期订单、释放座位)
分模块的关键价值在于:当项目从“能跑”走向“要维护”时,你不会因为一个订单状态字段改了七八个文件而崩溃。比如订单状态的枚举我就单独放到 common/enums 里,服务层和接口层共用,避免魔法数字散落在代码各个角落。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库表结构设计:整个项目的“地基”
2.1 核心表清单与关系说明
汽车票系统的表结构我梳理下来共 7 张核心表:
| 表名 | 说明 | 关键字段 |
|---|---|---|
| user | 用户表 | id, username, password(bcrypt), phone |
| station | 车站表 | id, station_name, city, province |
| schedule | 班次表 | id, bus_no, route, start_station_id, end_station_id, depart_time, arrive_time, total_seats, price |
| seat | 座位表 | id, schedule_id, seat_no, status |
| order | 订单表 | id, order_no, user_id, schedule_id, seat_ids, status, create_time, pay_time |
| payments | 支付表 | id, order_id, pay_no, amount, pay_status, pay_channel |
| refund | 退票表 | id, order_id, refund_amount, refund_time, status |
这里最关键的是 schedule(班次表)、seat(座位表)、order(订单表)三者的关系。seat 表的存在看起来多余,很多简化版设计直接在 schedule 表里存一个 left_seats 字段,每次下单就减一。但这样做有两个致命问题:一是你无法记录“到底哪个座位被买了”,二是并发情况下 left_seats 负数也只是时间问题。
2.2 班次表设计细节:日期与时间的拆分
班次表我强烈建议把“发车日期”和“发车时间”分开存储。有的草率设计直接用 depart_datetime 一个 DateTime 字段,结果做“按日期查班次”时必须写 BETWEEN '2025-06-01 00:00:00' AND '2025-06-01 23:59:59' 这种区间条件,还要担心索引失效。
我的方案是:
depart_date(DATE类型):发车日期depart_time(TIME类型):发车时刻arrive_time(TIME类型):到达时刻
这样前端传一个日期字符串,后端直接 eq(Schedule::getDepartDate, date) 就能命中索引,查询效率高,代码也干净。
2.3 订单表状态机设计:状态字段要能完整表达业务流转
订单状态我设计了 5 种,并且在代码里用枚举管理:
- 0:待支付(下单后锁定座位,15分钟内需完成支付)
- 1:已支付(出票成功)
- 2:已退票
- 3:已取消(超时未支付自动取消,或用户主动取消)
- 4:已检票
为什么不用简单的“未支付/已支付”两个状态?因为超时取消和主动取消对座位释放的处理逻辑是一致的,但它们是两个不同的业务动作,涉及日志和状态流转路径。如果状态机不够精细,后期做数据统计时会发现“取消率”永远算不准。
2.4 索引设计:别图省事,索引少一个查询慢十倍
这个项目里我加了 5 个核心索引,实测效果非常明显:
sql复制KEY idx_schedule_date (depart_date, start_station_id),
KEY idx_order_user (user_id, status),
KEY idx_order_no (order_no),
KEY idx_seat_schedule (schedule_id, status),
KEY idx_pay_order (order_id)
其中 idx_order_no 用了唯一索引,因为订单号全局唯一,后续支付回调要按订单号反查订单,没有唯一索引在极端情况下可能出现重复订单。别小看这些细节,我当时在本地测试 50 万条订单数据时,没有 idx_order_user 索引的查询要 800ms,加上之后直接降到 12ms。
3. 核心功能实现与关键代码拆解
3.1 车次查询:缓存加数据库兜底的双层策略
车次查询是访问量最大的接口,尤其节假日热门线路。我的实现逻辑是:
先查 Redis 缓存,如果命中直接返回;缓存没有则查数据库,再写入缓存并设置过期时间。缓存的 key 设计为 schedule:list:{departDate}:{startStation}:{endStation},过期时间设为 30 分钟。
java复制public List<ScheduleVO> searchSchedules(String departDate, Long startStationId, Long endStationId) {
String cacheKey = String.format("schedule:list:%s:%d:%d", departDate, startStationId, endStationId);
String cached = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.hasText(cached)) {
return JSON.parseArray(cached, ScheduleVO.class);
}
// 数据库查询,只查未发班次
List<ScheduleVO> list = scheduleMapper.selectScheduleList(departDate, startStationId, endStationId);
if (list != null && !list.isEmpty()) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(list), 30, TimeUnit.MINUTES);
}
return list;
}
这里有个细节我必须多说一句:缓存回填时要判断集合是否为空。如果某个冷门线路当天确实没有班次,你把这个空列表也缓存了,那管理员在后台上新班次后,用户看到的数据可能会有最长 30 分钟的延迟。我的做法是空列表缓存 2 分钟,或者干脆不缓存空结果,避免这种“幽灵空数据”。
3.2 防超卖:别用同步锁,用 Redis Lua 脚本
防超卖是整个系统里最值得写进代码注释的核心逻辑。我一开始图省事,直接在 synchronized 同步块里做“查余票、减库存”,结果用 JMeter 一压测,500 并发下单直接崩了一半。后来换成分布式锁,再换成 Redis Lua 脚本,才真正稳住。
Lua 脚本的核心优势在于原子性。它是 Redis 内置的脚本机制,整个脚本在 Redis 端执行期间,不会被其他命令打断。我用 Lua 在 Redis 里维护一个“余票计数器”,下单时原子性地减一,如果结果大于等于 0 才允许继续生成订单:
lua复制local left = redis.call('GET', KEYS[1])
if not left then
return -1
end
if tonumber(left) <= 0 then
return 0
end
redis.call('DECR', KEYS[1])
return 1
对应的 Java 调用:
java复制private static final DefaultRedisScript<Long> DECREMENT_SCRIPT = new DefaultRedisScript<>(
"local left = redis.call('GET', KEYS[1]) " +
"if not left then return -1 end " +
"if tonumber(left) <= 0 then return 0 end " +
"redis.call('DECR', KEYS[1]) " +
"return 1", Long.class);
public boolean tryLockSeat(Long scheduleId) {
String key = "schedule:left:" + scheduleId;
Long result = redisTemplate.execute(DECREMENT_SCRIPT, Collections.singletonList(key));
return result != null && result == 1L;
}
注意,这里只是“锁定了一张余票”,数据库层面的订单记录和座位状态更新还需要在事务里完成。如果事务回滚,必须把 Redis 里的余票数加回来。这个补偿逻辑放在 catch 块里执行,别漏。
3.3 下单流程完整时序:从订单生成到座位分配
下单流程是整个系统的中枢,我梳理一下完整链路,以免你写到一半迷失方向:
- 用户传参:班次ID、乘车人列表
- 校验班次状态:未发班、未停运
- 调用 Redis Lua 脚本扣减余票
- 生成订单号,插入订单表,状态为待支付
- 批量插入 seat 占用记录(锁定座位)
- 设置订单 15 分钟过期,启动延时检查任务
- 返回订单号,前端跳转支付页面
其中订单号生成我用的是 时间戳 + 随机数 + 用户ID后四位,形如 2025060115300018234567001。不用数据库自增ID当订单号,是因为自增ID容易暴露系统一天的订单量,而且后续对接支付时,外部流水号用业务订单号更安全。
座位分配的思路是:按照座位表里 schedule_id 相同的所有座位,取 status = 0(未售出)的前 N 个,先到先得、顺序分配。这个策略简单可靠,也不涉及复杂的选座交互。如果你想做“在线选座”,那就是把分配主动权交还给用户,在点击座位时用同一把余票锁锁住单个座位,逻辑会再绕一点。
3.4 定时任务取消超时订单:别等到用户来查订单才发现过期
超时未支付订单如果不处理,座位就会一直被占用,车永远卖不满。我用 Spring Boot 内置的 @Scheduled 每分钟扫一次“待支付 + 创建时间超过15分钟”的订单:
java复制@Scheduled(fixedRate = 60000)
public void cancelExpiredOrders() {
List<Order> expiredOrders = orderMapper.selectExpiredPendingOrders(15);
for (Order order : expiredOrders) {
// 开启新事务处理单个订单
cancelOrder(order.getId(), "超时未支付");
}
}
但这里有个很典型的问题:如果一张订单锁了 3 个座位,取消订单时只把订单状态改成“已取消”而不释放座位的状态,那这 3 个座位就永久“消失”了。所以取消订单的方法里,必须同时做三件事:更新订单状态、把座位状态改回未售出、把 Redis 里的余票数加回来。
而且注意,这一步和用户主动取消是同一套逻辑,我已经把 cancelOrder(orderId, reason) 抽成了公共方法,避免两处代码行为不一致。
3.5 支付与回调:模拟支付一定要做的严谨
因为是毕设或练习项目,很多同学不做真实支付,用一个“模拟支付”按钮直接改订单状态。这里我强烈建议你模拟一个支付回调接口,而不是在支付页面直接更新数据库,原因是:你日后如果想接入微信/支付宝,回调机制才是真实业务里的标准做法。
我的实现是:
- 提交支付请求后,生成一条 payments 记录,状态为“支付中”
- 调用模拟支付网关,传入订单号与金额
- 网关“异步回调”
/api/payment/notify,传入订单号、支付流水号、支付结果 - 后端验签(模拟场景用固定密钥)+ 校验金额 + 更新支付状态 + 更新订单状态为已支付
java复制@PostMapping("/api/payment/notify")
public String handlePaymentNotify(@RequestBody PayNotifyRequest request) {
// 1. 校验签名(模拟)
if (!SignatureUtils.verify(request.getSign())) {
return "fail";
}
// 2. 根据支付流水号查询支付记录
Payments payment = paymentMapper.selectByPayNo(request.getPayNo());
if (payment == null || !payment.getAmount().equals(request.getAmount())) {
return "fail";
}
// 3. 幂等处理:重复回调时不重复更新
if ("SUCCESS".equals(payment.getPayStatus())) {
return "success";
}
// 4. 更新支付状态和订单状态
payment.setPayStatus("SUCCESS");
payment.setPayTime(LocalDateTime.now());
paymentMapper.updateById(payment);
orderService.markPaid(payment.getOrderId());
return "success";
}
这里“幂等”两个字是重点。支付回调可能会因为网络抖动被网关重发几次,如果你的接口没有做幂等,第二三次回调就会把订单状态从“已支付”改成别的什么,或者重复加积分、重复通知。第一次处理成功后再收到同样的回调,直接返回成功,不再重复执行业务逻辑。
3.6 管理端数据看板:查多表不如建统计SQL
管理端的订单统计、销售看板,一开始我图方便直接在业务代码里遍历订单列表然后内存计算。订单量少时看不出问题,但一旦有几万条订单,页面响应直接飙到秒级。
后来我把统计逻辑全部下沉到 SQL 里,用聚合函数一次查出来:
sql复制SELECT DATE(create_time) AS stat_date,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM `order`
WHERE create_time >= #{startDate}
GROUP BY DATE(create_time)
ORDER BY stat_date DESC
像“今日售出票数”“热门线路TOP10”这两类统计,也尽量用一条 SQL 解决。MySQL 处理这种 GROUP BY 聚合的效率,远高于你从业务层一条条取数据再计算。只有遇到那种非常复杂的报表需求,我才会考虑额外建一张统计中间表,用定时任务每小时刷新一次。
4. 开发过程中踩过的坑与排查实录
4.1 IDEA 里创建 SpringBoot 项目用不了 JDK 1.8
这是个高频问题。Spring Boot 3.x 官方要求 JDK 17 以上,如果你在 IDEA 里新建项目时 Server URL 选的是默认的 https://start.spring.io,然后本地只有 JDK 1.8,IDEA 会直接提示无法创建或编译失败。
我的做法是:手动把 Server URL 改成 https://start.aliyun.com,然后在 Spring Boot 版本里选择 2.7.18,JDK 选择 1.8。阿里云的 starter 仓库保留了老版本的完整依赖信息,对国内开发者下载速度也快得多。
如果你已经在用 SpringBoot 3.x 却发现一堆依赖适配问题,最省事的办法不是一个个换依赖,而是直接用 2.7.x 重新初始化项目。
4.2 分页插件不生效:别忘了配置拦截器
用 MyBatis-Plus 分页时,很多人导入了 pagination 依赖,但 selectPage 还是返回全量数据。原因很简单:MyBatis-Plus 3.5.x 之后的分页插件需要显式声明为 Spring Bean,不然拦截器根本没注册进去。
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
这个配置类我建议放在 config 包下面统一管理。顺带一提,连表查询时如果你写了自定义 SQL,分页插件也是可以生效的,国主需要保证 Mapper 接口方法传入 Page 参数并且返回类型是 IPage<T>。
4.3 前端请求跨域:后端配置和前端代理二选一
开发阶段用 Vue CLI 的 proxy 配置代理最省事,但上线部署时通常前后端分离在两个端口跑,跨域问题就会冒出来。我统一在后端加了一个全局 CorsFilter,白名单里配置前端地址,这样前后端接口联调时干干净净,不用每个接口都加 @CrossOrigin 注解。
java复制@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
但注意,addAllowedOrigin("*") 和 allowCredentials(true) 在旧版本 Spring 里是冲突的,用 addAllowedOriginPattern("*") 可以绕过这个限制。这个小坑我当时卡了半小时。
4.4 事务内部锁失效:同一个类里调用方法要小心
在一个 Service 里写了一个 createOrder(),方法内部调用了另一个方法 deductSeat(),deductSeat 上加了 @Transactional(propagation = Propagation.REQUIRES_NEW)。结果测试时发现,deductSeat 里抛出异常后,createOrder 里已插入的订单居然没有回滚。
这是 Spring AOP 代理机制的经典坑:同类内部调用 this.deductSeat() 时,走的是对象直接调用,不是代理对象调用,@Transactional 断言完全失效。解决办法是把需要独立事务的方法拆到另一个 Service 类里,或者自己注入自身代理对象 @Autowired private OrderService self; 然后 self.deductSeat()。这种问题调试起来挺隐蔽的,但理解了 AOP 代理原理就很清楚了。
4.5 高并发压测下数据不一致:务必要做压测再看数据
我在本地模拟 300 个用户同时购买最后一个座位时,一开始出现了两个问题:一是订单表里出现两条相同 seat_no 的记录;二是 Redis 余票扣成了负数。这两个问题本质上都是没有做原子性控制导致的。
解决方案就是我前面说的 Redis Lua 脚本扣减余票 + 数据库 seat 表插入座位占用的唯一索引兜底。双保险之后,我用 Jmeter 并发跑了一轮:500 个请求,200 个抢 3 个座位,最终只有 3 个成功订单,其余全部返回“余票不足”,数据库和缓存数据完全一致。
5. 项目中的几个关键优化点
5.1 订单列表查询加索引后还是慢?试试强制走覆盖索引
订单查询是管理端最频繁的操作。如果查询条件组合很多,比如“状态 + 日期 + 用户手机号”,单独的复合索引可能依然不够理想。我在 order 表上建了一个 (status, create_time) 的组合索引,并确保 SELECT 的字段都在索引里,这样 MySQL 可以通过覆盖索引直接返回结果,避免回表。
SQL 里看执行计划,如果出现 Using index condition 而不是 Using where; Using filesort,基本说明索引已经命中。别小看这个细节,5 万条订单后,一次分页查询从 300ms 降到了 20ms 以下。
5.2 定时任务扫表别一次梭哈:合理设置分批处理
cancelExpiredOrders 如果每分钟全量扫表,数据量大后会拖垮数据库。后来我改成一次只处理前 200 条待取消订单,扫完就停,下个周期继续。数据库压力小很多,而且延迟完全可接受。
同理,像报表统计这种不需要实时的任务,我都建议在凌晨低峰期跑。用 @Scheduled(cron = "0 30 2 * * ?") 在凌晨两点半生成前一天的统计快照,页面展示直接查快照表,体验和效率都稳。
5.3 前端接口统一响应体:少写很多重复判断
后端接口统一返回 Result<T>,格式固定为 { code, message, data }。前端在 axios 响应拦截器里对 code 统一判断,code !== 200 就直接弹错误提示,不用在每一个页面重复写状态判断。这个习惯虽然和技术栈无关,但能明显提升前后端联调效率。
6. 项目扩展思路:让系统从“毕设”走向“可用”
如果你有余力,下面几个方向很值得继续完善:
- 接入真实地图API,展示汽车线路走向与站点间距离,辅助用户决策
- 增加会员积分体系,下单送积分,积分抵扣车票金额,提升用户粘性
- 对接真实微信支付或支付宝沙箱环境,了解真实支付流程和异步回调的健壮性要求
- 管理端引入图形化座位图标,实现在线选座,进一步增强购票体验
- 用 WebSocket 做超时订单的实时通知,用户订单快过期时在页面上收到提醒
其中我重点推荐“在线选座”和“真实沙箱支付”这两个方向。前者让系统的复杂度上一个台阶,涉及座位表与订单表更细粒度的一致性控制;后者让你接触到支付回调幂等、签名验证、异常订单处理这些课本上不会细讲的工程问题。对找工作面试来说,这两个点都是很棒的加分经历。
最后分享一个实际经验:做这类系统,先把核心表和订单状态机画清楚再写代码。我见过很多人边写代码边加表,到最后状态字段到处都是魔法数字,逻辑根本理不清。用半天时间把表和状态流转想透,后面能省下好几天排查的时间。汽车票预订系统虽然听起来平平无奇,但库存、并发、状态机、支付回调的坑走一遍,你对 Web 业务系统的理解会有一个质的提升。
