ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/10/3 9:49:37 拓冰建站 浏览量
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 PageResultScheduleVO querySchedule(String fromStation, String toStation, String date) { // 第一步根据起终点模糊匹配线路 LambdaQueryWrapperLine lineWrapper new LambdaQueryWrapper(); lineWrapper.eq(Line::getStartStation, fromStation) .eq(Line::getEndStation, toStation); ListLine lines lineMapper.selectList(lineWrapper); // 第二步根据线路查当天班次 LambdaQueryWrapperSchedule 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表操作更直观。选座的时序是这样前端加载座位图未占用的座位显示为可选状态。旅客点击某个座位前端发起预占请求。后端开启事务检查该座位是否空闲若空闲则插入seat_occupancy记录状态设为locked同时扣减schedule.remaining_seats。前端进入确认订单页面给旅客一个支付倒计时我设的是10分钟。支付成功后更新订单状态和座位状态为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 座位图的初始化与维护每个班次生成时需要同步初始化座位占用记录。这里说的初始化不是插入所有座位为空闲记录而是插入所有座位为未占用状态的基础数据。我考虑过两种方案方案Aseat_occupancy表只为已锁定/已售出的座位插入记录空闲座位不落库。优点表数据量小缺点前端要展示整个座位图时必须知道座位的完整布局而座位数和布局存在vehicle表里。方案B生成班次时为每个座位都插入一条记录状态为idle/locked/sold。优点查询简单直接按状态筛选缺点数据量会比较大——假设一天50个班次、每班30个座位一个月就是4.5万条一年50万条左右。我最终选了方案B。原因是县城客运的场景下一个班次的座位数就二三十个数据量完全可控而且方案B在展示座位图时不需要在代码里硬编码座位布局逻辑直接查表即可。查询性能方面给(schedule_id, status)建联合索引单个班次的查询毫无压力。4.3 停班、调班时的数据一致性调度端还有一个麻烦的操作某班次因故停运。比如车辆坏了、天气恶劣、客源不足临时合并班次。这个操作如果做得不严谨会直接导致已购票旅客到站发现没车。我的处理流程分三步停班前先查询该班次所有sold状态的订单。自动给这些订单触发退票流程并给旅客端标记班次停运票款原路退回。只有全部订单处理完成后才允许把班次状态置为cancelled。因为毕设项目一般没有短信通知和站内信体系我在旅客端做了一个订单异常提醒列表旅客登录后可以查看自己订单的异常状态。实际项目如果要落地这里对接阿里云短信或者微信公众号模板消息会更合适——给旅客发一条您购买的XX班次因故停运票款已自动退回请留意查收的短信体验会好非常多。5. 前端与部署Vue打包融入SpringBoot的实操细节5.1 Vue前端如何打包进SpringBoot很多人在前端联调完成后会问前后端分离的项目怎么部署到一个服务上最省事的办法是把Vue构建后的dist目录打进SpringBoot的静态资源路径里。具体操作在Vue项目的vue.config.js里设置publicPath: ./避免打包后的资源路径是绝对路径导致部署后找不到文件。执行npm run build生成dist目录。把dist目录下的所有文件复制到SpringBoot项目src/main/resources/static/目录下或者放进templates/。重新打包SpringBoot项目一个jar包就同时包含了后端接口和前端页面。这里有个坑要提醒如果你的前端用了前端路由比如vue-router的history模式刷新页面时可能会出现404。解决办法是在SpringBoot里加一个转发规则把非接口路径的请求转发到index.htmlController 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连接串里一定要加上serverTimezoneAsia/Shanghai否则插入时间字段时会报时区异常。这是老生常谈了但真有不少人栽在这spring: datasource: url: jdbc:mysql://localhost:3306/bus_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 1234566. 部署上线前必须过的几道关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.activeprod logs/run.log 21 这里着重要说--spring.profiles.activeprod。它的作用是切换生产环境配置把数据库连接、端口等敏感信息和开发环境隔离。我在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也成功扣减了一次最终导致余票数量比实际售出数量少了。修复方案把隔离级别调整为READ COMMITTED这样B的UPDATE ... WHERE remaining_seats 0会基于当前已提交数据来判断避免快照读带来的判断误差。把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语句的当前读和快照读差异以后换个场景还会踩同样的坑。做系统开发最值钱的不是敲了多少行代码而是踩了多少坑之后沉淀下来的判断力。希望这篇记录能帮你少走几段弯路。