拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot实现县域长途客车售票系统:从数据库设计到部署上线

SpringBoot实现县域长途客车售票系统:从数据库设计到部署上线

我刚把一个县域长途客车售票系统从需求梳理做到上线部署,前后折腾了将近两个月。这里头踩过的坑、总结的经验,我完整记录下来,希望能给正在做类似毕业设计或者实际项目的朋友一些参考。

先交代一下项目背景。我做的这个系统面向的是县域范围内的长途客运场景,核心诉求就一句话:让旅客能在网上查班次、买票、退票,让车站调度员能管理车辆、订单、座位,让管理员能看到运营数据。听起来不复杂,但真正落地的时候,涉及的面比想象中宽得多——班次规划、余票计算、座位锁定、订单超时释放、报表统计,每一个环节都有坑。

1. 项目定位与核心功能拆解

1.1 这个系统到底要解决什么问题

县域长途客运和城市公交、高铁售票有个很大的区别:线路杂、班次密、车辆小、站点多。很多县城到乡镇的线路,一天可能发十几班,但每辆车只有18到30个座位,而且不少乘客是现场买票的,线上售票不能把座位全部占死。这就要求系统必须具备三个核心能力:实时性——余票数量必须跟线下售票联动;灵活性——支持按线路、按日期、按班次多维查询;可控性——运营方要能随时调整班次、停开班次、设置限售。

我最终确定的功能模块分为四块:

  • 旅客端:班次查询、在线购票、订单支付(模拟)、退票、电子票查看。
  • 调度端:线路管理、班次管理、车辆管理、座位分配策略配置。
  • 订单中心:订单创建、支付状态流转、超时未支付自动取消、退票审核。
  • 数据统计:按线路、按日期的售票量统计,营收汇总,班次实载率计算。

这四块功能如果全部铺开做,工作量非常大。对于毕设或者中小型客运企业的实际需求来说,我建议把精力集中在班次查询和订单流转这两个核心链路上,其余做基础版本即可。

1.2 基于SpringBoot选型的理由

技术栈选的是SpringBoot + MyBatis-Plus + MySQL + Vue,这是目前最稳妥的组合。为什么用SpringBoot而不是SSH或者纯Servlet?核心原因是开发效率。SpringBoot的自动装配机制省掉了大量XML配置,内嵌Tomcat让部署变成"一个jar包扔上去就跑",这对时间紧张的毕设项目来说是决定性的优势。

有朋友可能会问:SpringBoot版本怎么选?我看到热搜词里有人提到"springboot版本太高"的问题,这里给个实际建议:别追新。如果你的JDK是1.8,那就老老实实用SpringBoot 2.7.x;如果你用的是JDK 17,那可以上3.x。版本太高带来的麻烦往往是依赖兼容性问题,比如MyBatis-Plus对SpringBoot 3.x的支持有过一段混乱期,搞不好就得自己适配。

2. 数据库设计:县域客运场景下的表结构权衡

2.1 核心表设计与关系梳理

客运售票系统最忌讳的就是把表设计得过于复杂。我见过有人把订单表拆成订单主表、订单明细表、支付流水表、退款流水表四张,这在电商场景是标配,但在县域客运里反而累赘——一张订单就是一个座位一张票,不存在"购物车多商品"的概念。

我最终保留了六张核心表:

表名用途关键字段
line线路表起点、终点、里程、票价、预计耗时
schedule班次表所属线路、发车日期、发车时间、车辆ID、总座位数、限售比例
vehicle车辆表车牌号、座位数、车型
seat_occupancy座位占用表班次ID、座位号、订单ID、状态
ticket_order订单表订单号、班次ID、乘客信息、座位号、支付状态、金额
station站点表站点名称、所属区域、排序权重

这里重点说一下schedule表里的限售比例字段。这是我做需求调研时跟客运站调度员聊出来的关键点。线上售票不能把所有座位都放出去,因为要预留一部分给线下窗口和临时上车的乘客。具体的限售比例可以配置,一般县域线路设在70%到85%之间比较合理。这个字段如果不设计进去,后面运营方会天天找你改需求。

2.2 余票计算方案:不要实时count

余票计算是客运系统的核心难点。很多新手第一反应是:SELECT COUNT(*) FROM seat_occupancy WHERE schedule_id = ? AND status = 'occupied',然后拿总座位数减一下。这个方案在数据量小的时候没问题,但一旦订单量大、并发上来,频繁的count查询会把数据库拖垮。

我的方案是在班次表上直接维护一个余票数字段,每次成功锁定座位就减一,取消或退票就加一。说起来简单,但这里有两个细节必须处理好:

细节一:锁座位和扣余票必须在一个事务里。用MyBatis-Plus的@Transactional注解,先执行UPDATE schedule SET remaining_seats = remaining_seats - 1 WHERE id = ? AND remaining_seats > 0,如果更新影响行数为0,说明没票了,直接抛异常回滚。这个写法同时完成了原子扣减和库存检查,比先查后改安全得多。

细节二:座位占用表和余票字段要保持最终一致。纯靠事务保证一致性是够的,但为了排查问题方便,我写了一个定时任务,每隔五分钟扫描一遍,比对schedule.remaining_seats和seat_occupancy表里实际占用数量,发现不一致就告警并自动修正。这个对账任务帮我在开发阶段抓到了好几个并发bug。

2.3 站点顺序与线路匹配的建模

县域客运有个特点:从A县到B县,中间可能停靠三四个乡镇站点。但长途客车的售票方式和公交车不一样,通常是从起点站买到终点站,中间站点虽然停车但一般不允许中途上下客扰乱售票。不过也有"联程票"的需求,比如从甲镇到丙镇,需要经过乙站中转。

基于这个考虑,站点表设计时必须带上排序权重字段,同一线路下的站点按顺序排列,这样前端展示站点列表时才能正确排序,后台新增站点时也能灵活插入。我在线上表里存的是"起点站ID"和"终点站ID",而不是直接存站点名称字符串,这样后续如果要扩展联程票、站点管理功能,数据模型不用推翻重来。

3. 核心业务链路的前后端协同实现

3.1 班次查询接口的设计思路

旅客购票的第一步是查班次。前端页面需要用户选择"出发城市""到达城市""出发日期",然后系统返回符合条件的班次列表。这个接口看起来简单,但我踩了一个坑:只按线路匹配是不够的。

因为同一线路一天会有多个班次,比如从武胜到重庆,早上7点一班、9点半一班、下午2点一班,它们属于同一条线路line,但在schedule表里是三条不同的记录。所以查询接口要先根据起终点找到line_id,再查这个线路下所有符合条件的schedule。

接口的查询条件我设计为:

public PageResult<ScheduleVO> querySchedule(String fromStation, String toStation, String date) { // 第一步:根据起终点模糊匹配线路 LambdaQueryWrapper<Line> lineWrapper = new LambdaQueryWrapper<>(); lineWrapper.eq(Line::getStartStation, fromStation) .eq(Line::getEndStation, toStation); List<Line> lines = lineMapper.selectList(lineWrapper); // 第二步:根据线路查当天班次 LambdaQueryWrapper<Schedule> scheduleWrapper = new LambdaQueryWrapper<>(); scheduleWrapper.in(Schedule::getLineId, lines.stream().map(Line::getId).collect(Collectors.toList())) .eq(Schedule::getDepartDate, date) .eq(Schedule::getStatus, 1) // 1表示正常运营 .orderByAsc(Schedule::getDepartTime); // 第三步:剩余座位数直接从schedule表取 }

这里有个经验要分享:前端展示的"余票数"不要实时去查seat_occupancy表,直接用schedule.remaining_seats字段就行。前面说的对账任务保证了这个字段的准确性。实时查询在低并发场景下没问题,但会拖慢接口响应时间,而且会让数据库查询逻辑变复杂。

3.2 座位选择与锁定:事务边界很重要

旅客选好班次后进入选座页面,这时候需要展示座位布局。座位布局数据有两种存法:一种是在schedule表里存一个seat_mapJSON字段,描述哪行哪列有座位;另一种固定每个班次生成18个或30个座位记录。我用的第二种方案,因为锁定座位时直接对seat_occupancy表操作更直观。

选座的时序是这样:

  1. 前端加载座位图,未占用的座位显示为可选状态。
  2. 旅客点击某个座位,前端发起预占请求。
  3. 后端开启事务,检查该座位是否空闲,若空闲则插入seat_occupancy记录,状态设为locked,同时扣减schedule.remaining_seats。
  4. 前端进入确认订单页面,给旅客一个支付倒计时(我设的是10分钟)。
  5. 支付成功后更新订单状态和座位状态为sold;超时未支付则释放座位。

这个流程看起来顺理成章,但在实现时有一个常见的并发隐患:两个旅客同时点击同一个座位。如果不加锁,两个人可能都通过"检查座位空闲"这一步,然后都插入成功,导致一个座位被卖两次。解决办法有两个:

  • 在seat_occupancy表的(schedule_id, seat_no)上建唯一索引,插入时谁先成功谁赢,后插入的抛DuplicateKeyException,捕获后提示"座位已被选"。
  • 使用SELECT ... FOR UPDATE悲观锁。

我实际选用的是唯一索引方案,理由很简单:不需要额外的锁管理,数据库层面就帮我们挡住了绝大多数并发冲突。SELECT ... FOR UPDATE在高并发下容易造成锁等待和死锁问题,县域客运的并发量远没到需要悲观锁的程度,用唯一索引靠数据库约束兜底是最稳的。

3.3 订单号生成:业务与并发兼顾

订单号的设计看似小事,但处理不好会让人很头疼。我见过有人直接用数据库自增ID当订单号,这有两个问题:一是容易被猜到,二是多端展示时不好看。我也见过用UUID的,32位字符串又太长,旅客对单号时眼睛都要看花。

我的做法是组装式订单号:日期 + 线路编号 + 随机流水号。具体格式是YYYYMMDD+ 4位线路ID + 4位随机数。落地为:

String orderNo = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + String.format("%04d", schedule.getLineId()) + String.format("%04d", ThreadLocalRandom.current().nextInt(10000));

这个方案在单机部署下基本够用。如果系统要集群部署,订单号可能出现重复,那时候可以在前面加上机器编号或者改用Redis自增序列。对毕设和县域客运企业来说,单机版本就用上面的格式,简单又够用。

3.4 支付模块的取舍:模拟支付还是真实接入

很多同学在支付模块上纠结:要不要接微信支付或者支付宝?我的建议是——看需求。如果这只是一个课程设计或者毕设,接真实支付渠道纯粹是给自己找麻烦:你需要商户号、需要营业执照、需要进行回调地址的公网暴露测试。而如果是实际给客运公司用,支付又确实是核心环节。

折中方案是做模拟支付。我自己实现了一个支付状态机:

待支付 -> 已支付 -> 已出票 -> 已检票 -> 已取消(超时或主动取消) -> 已退票 -> 退款中 -> 已退款

前端提供"模拟支付成功""模拟支付失败""模拟退款"三个按钮,后端对应更新状态并记录支付流水时间。这个方案足以演示完整的业务闭环,也把支付回调的架构预留好了——等真正接入微信支付时,只需要把回调接口里的逻辑挂到支付成功状态变更处,不需要改订单主流程。

4. 调度端的核心逻辑:班次生成与座位管理

4.1 自动生成班次的策略

调度端有一个操作非常高频:每天/每周批量生成班次。如果让调度员手动一条条建班次,系统上线三天就会被人骂死。所以必须提供按模板批量生成的能力。

我的实现思路是增加一张schedule_template模板表,存储线路ID、发车时间、运行的星期(比如周一至周五运行、周末停运)、车辆ID、限售比例。调度员只需在后台维护好模板,系统每天凌晨自动为当天生成对应的班次记录,并初始化该班次的全部空闲座位记录。

这里有个细节容易忽略:生成的班次遇到节假日应该怎么处理?比如五一、国庆期间,客运量会暴增,常规班次远远不够。我的做法是在模板上增加一个"特殊日期类型"字段,系统支持设置节假日专用模板,在特殊日期优先使用专用模板生成班次,这样既保底又灵活。

4.2 座位图的初始化与维护

每个班次生成时,需要同步初始化座位占用记录。这里说的"初始化"不是插入所有座位为"空闲"记录,而是插入所有座位为"未占用"状态的基础数据。我考虑过两种方案:

  • 方案A:seat_occupancy表只为"已锁定/已售出"的座位插入记录,空闲座位不落库。优点:表数据量小;缺点:前端要展示整个座位图时必须知道座位的完整布局,而座位数和布局存在vehicle表里。
  • 方案B:生成班次时为每个座位都插入一条记录,状态为idle/locked/sold。优点:查询简单,直接按状态筛选;缺点:数据量会比较大——假设一天50个班次、每班30个座位,一个月就是4.5万条,一年50万条左右。

我最终选了方案B。原因是县城客运的场景下,一个班次的座位数就二三十个,数据量完全可控,而且方案B在展示座位图时不需要在代码里硬编码座位布局逻辑,直接查表即可。查询性能方面,给(schedule_id, status)建联合索引,单个班次的查询毫无压力。

4.3 停班、调班时的数据一致性

调度端还有一个麻烦的操作:某班次因故停运。比如车辆坏了、天气恶劣、客源不足临时合并班次。这个操作如果做得不严谨,会直接导致已购票旅客到站发现没车。

我的处理流程分三步:

  1. 停班前先查询该班次所有sold状态的订单。
  2. 自动给这些订单触发退票流程,并给旅客端标记"班次停运,票款原路退回"。
  3. 只有全部订单处理完成后,才允许把班次状态置为cancelled。

因为毕设项目一般没有短信通知和站内信体系,我在旅客端做了一个"订单异常提醒"列表,旅客登录后可以查看自己订单的异常状态。实际项目如果要落地,这里对接阿里云短信或者微信公众号模板消息会更合适——给旅客发一条"您购买的XX班次因故停运,票款已自动退回,请留意查收"的短信,体验会好非常多。

5. 前端与部署:Vue打包融入SpringBoot的实操细节

5.1 Vue前端如何打包进SpringBoot

很多人在前端联调完成后会问:前后端分离的项目,怎么部署到一个服务上?最省事的办法是把Vue构建后的dist目录打进SpringBoot的静态资源路径里。

具体操作:

  1. 在Vue项目的vue.config.js里设置publicPath: './',避免打包后的资源路径是绝对路径导致部署后找不到文件。
  2. 执行npm run build,生成dist目录。
  3. 把dist目录下的所有文件复制到SpringBoot项目src/main/resources/static/目录下,或者放进templates/。
  4. 重新打包SpringBoot项目,一个jar包就同时包含了后端接口和前端页面。

这里有个坑要提醒:如果你的前端用了前端路由(比如vue-router的history模式),刷新页面时可能会出现404。解决办法是在SpringBoot里加一个转发规则,把非接口路径的请求转发到index.html:

@Controller public class PageForwardController { @RequestMapping(value = {"/", "/index", "/{path:[^\\.]*}"}) public String forward() { return "forward:/index.html"; } }

这个规则要小心写,正则[^\\.]*的作用是只拦截不带点的路径,这样静态资源(.js、.css、.png)不会被拦截。

5.2 前端接口联调的代理配置

开发阶段前后端分离跑,必然遇到跨域问题。两种解法:一种是在后端写CORS配置类,另一种是前端启devServer代理。我推荐后者,因为不用动后端代码,更贴近生产环境。

在vue.config.js里配置:

module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }

这样前端代码里所有请求都写成/api/xxx的相对路径,开发时由devServer转发到后端,生产打包后由后端自己处理同源请求。两种环境零切换。

5.3 启动端口与配置数据的坑

看热搜词里有人问"IDEA 2026 怎么配置SpringBoot服务启动端口",这里一起说清楚。SpringBoot的端口配置就一行,在application.yml里写:

server: port: 8080

如果你在IDEA里启动多个服务(比如后端和前端devServer同时跑),注意端口不要冲突。Vue devServer默认端口是8080,和SpringBoot默认端口一样,所以要么在后端配置改端口,要么在devServer配置里改端口,二选一。我习惯让后端跑8080,前端devServer跑3000。

还有一个配置细节:MySQL连接串里一定要加上serverTimezone=Asia/Shanghai,否则插入时间字段时会报时区异常。这是老生常谈了,但真有不少人栽在这:

spring: datasource: url: jdbc:mysql://localhost:3306/bus_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456

6. 部署上线前必须过的几道关

6.1 数据初始化与演示数据的准备

毕设答辩和项目演示最尴尬的场景是什么?系统里空荡荡的,随便查个班次就是"暂无数据"。所以在部署前一定要准备一套完整的演示数据,至少包含:

  • 5条以上的线路,覆盖短途、中途、长途不同距离等级。
  • 每条线路至少3个班次,不同发车时间错开。
  • 车辆数据10辆左右,座位数有18座、30座、48座几种型号。
  • 提前生成未来7天的班次和座位数据。

另外我建议写一个DataInitializer类,在项目启动时检测到数据库为空就自动插入演示数据。这样别人拿到你的项目,一跑起来就能看到效果,印象分会高很多。

6.2 单元测试与服务层验证

时间再紧,服务层的核心逻辑也建议写单元测试。我重点测了两个场景:

场景一:座位唯一性并发测试。用线程池模拟20个线程同时抢同一个座位,断言最终只有1个成功。

场景二:超时订单释放测试。创建一个待支付订单,修改它的创建时间为20分钟前,运行定时任务,断言订单状态变为已取消且座位恢复空闲。

这两个测试帮我抓到了两个真实bug,其中一个就是前面说的座位重复售卖。测试代码虽然简单,但价值极大。我把测试类放在src/test/java下,用SpringBootTest拉起完整上下文来跑,基本模拟了真实调用链。

6.3 Linux服务器部署的具体操作

本地跑通之后要部署到服务器,我给出完整的操作清单:

第一步,在服务器上装好JDK和MySQL。JDK版本要和本机一致,否则jar包可能起不来。MySQL装完后建库:

CREATE DATABASE bus_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

第二步,把本地的数据库导出再导入服务器:

mysqldump -u root -p bus_ticket > bus_ticket.sql mysql -u root -p bus_ticket < bus_ticket.sql

第三步,把SpringBoot项目打成jar包,上传到服务器,后台启动:

nohup java -jar bus-ticket-system.jar --spring.profiles.active=prod > logs/run.log 2>&1 &

这里着重要说--spring.profiles.active=prod。它的作用是切换生产环境配置,把数据库连接、端口等敏感信息和开发环境隔离。我在application-dev.yml里写本地连接,在application-prod.yml里写服务器连接,部署时用参数指定环境,这样代码库不用改任何一行。

7. 实际运行中踩到的坑:一场并发压测引发的座位超售

我觉得最有必要拿出来详细说的,是一次真实的线上问题排查过程。系统在我本机跑得好好的,上了服务器后,同事用脚本模拟50个并发用户同时买票,结果出现了余票显示还剩5张,但实际卖出了7张的诡异情况。

排查思路是这样的:

一开始我怀疑是前端重复提交。检查后发现前端按钮确实做了防重复点击处理,而且后端接口也用了分布式锁的简化版——一个synchronized方法块。但压测结果显示锁没有完全生效。

再往下查,问题出在锁的粒度。我用的是synchronized (this),锁的是整个Service对象,这个粒度是够大的,应该不会出问题。但后来我发现,我的Service里有两个方法:createOrder和execLockSeat。createOrder里有事务注解@Transactional,而事务提交是在方法返回之后;execLockSeat里也有事务。问题就出在事务边界和锁边界不一致:

压测时,线程A进入createOrder,加锁,扣减余票,返回,但事务还未提交;线程B在同一时刻进入,成功获取锁,执行UPDATE schedule SET remaining_seats = remaining_seats - 1 WHERE id = ? AND remaining_seats > 0。注意,因为A的事务还没提交,B的这个UPDATE实际是在等待A的数据库行锁释放——它虽然获得了应用层锁,但被数据库层的行锁挡住了。问题在于,我扣减余票用的WHERE remaining_seats > 0条件,在MySQL默认的REPEATABLE READ隔离级别下,B事务读取的是A事务修改前的快照——余票数值可能是旧的,导致B也通过了判断,进入后续流程,然后被数据库行锁阻塞。

等A事务提交后,B获得行锁,但B的UPDATE语句在可重复读隔离级别下基于快照判断remaining_seats > 0,快照值其实是A修改前的值,于是B也成功扣减了一次,最终导致余票数量比实际售出数量少了。

修复方案:

  1. 把隔离级别调整为READ COMMITTED,这样B的UPDATE ... WHERE remaining_seats > 0会基于当前已提交数据来判断,避免快照读带来的判断误差。
  2. 把@Transactional注解移到createOrder方法上,并在方法内部用编程式事务控制锁座位和扣余票的最小事务边界。

压测复测后,超售问题消失。这个坑给了我一个很重要的教训:Spring的事务管理和并发控制交叉时,千万不要想当然。事务要么直接在数据库层面解决并发(用FOR UPDATE),要么在应用层加锁并确保锁覆盖的代码块内不做跨事务的读取判断。

8. 进阶优化:从毕设到可商用系统还差什么

如果你的目标不只是交一份毕设,而是想把这个系统做成真正能运营的版本,下面这几项是我认为最值得投入的方向:

8.1 引入Redis缓存热点数据

班次查询是最高频的接口,每次查库虽然也能扛住,但完全没必要。把未来7天内每条线路的班次和余票信息缓存到Redis里,key设计为schedule:{lineId}:{date},value存班次列表JSON。查询接口先查缓存,缓存未命中再查库并回填。售票扣减余票时同步更新缓存,可以极大降低数据库压力。

8.2 定时任务的分布式化

我前面提到用@Scheduled做订单超时释放和对账任务,单机部署没问题。但系统如果要做成多实例部署,多个实例会同时执行同一个定时任务,造成重复扣减、重复释放。解决方案是引入ShedLock框架或者基于Redis的分布式锁,保证同一时刻只有一个实例执行特定任务。

8.3 报表统计的数据仓库思维

每天营收多少、哪条线路实载率最高、哪些班次经常空驶,这些统计分析如果直接在业务库上跑SQL,会拖慢线上业务。商用版本建议每天凌晨把业务库数据同步到一张统计宽表里,报表查询全部走宽表。县域客运虽然数据量不大,但提前把架构分好,后续扩展会轻松很多。

8.4 电子票与检票端的打通

线上购票完成后,旅客拿什么上车?目前我的版本是让检票员在后台根据订单号或手机号核对。商用版本建议生成包含二维码的电子票,在车门口放一台二维码扫码枪或者让司机用手机扫码验票。这又牵扯到验票端的开发,可以做成小程序,也可以做成本地桌面应用,看客运公司的设备预算决定。

9. 写在最后的一些个人体会

项目做完回头看,县域长途客车售票这个场景,麻雀虽小五脏俱全。它不像电商那样需要极致的性能设计,也不像金融系统那样需要苛刻的容错保证,但对业务逻辑的完整性要求很高——班次、座位、订单、支付、退票、调度,每个环节都必须闭环。作为毕设题目,它既有足够的深度去展示技术能力(数据库设计、并发控制、前后端分离、缓存优化),又不用一头扎进某个大厂中间件里出不来,确实是性价比很高的选题。

最后分享一个我在整个过程中最有价值的一个习惯:每碰到一个bug,不只是修掉,而是把根因弄清楚再记到笔记里。比如那个座位超售的问题,如果我只把remaining_seats > 0改成FOR UPDATE修掉就收工,我永远不会理解MySQL可重复读隔离级别下UPDATE语句的当前读和快照读差异,以后换个场景还会踩同样的坑。

做系统开发,最值钱的不是敲了多少行代码,而是踩了多少坑之后沉淀下来的判断力。希望这篇记录能帮你少走几段弯路。

返回列表