news 2026/9/1 16:11:40

Spring Boot+Vue火车票订票系统实战:搞定并发防超卖与订单管理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot+Vue火车票订票系统实战:搞定并发防超卖与订单管理

简介:这是一套面向计算机专业本科生的毕业设计级SpringBoot+Vue火车票订票系统完整源码,聚焦Web全栈开发实践,解决传统购票流程线上化、交互体验优化与前后端协同落地等典型工程问题。资源包共1197个文件,涵盖85个Java后端业务类(如CheciController、DictionaryServiceImpl)、44个Vue前端组件、297个JS逻辑脚本、97个CSS样式文件及158个SVG图标资源,配合SQL建表语句与YML配置文件,构成模块清晰、接口完备的分离式架构;压缩包大小38.24MB。已有45人学习下载。读者可直接运行调试,获得含用户认证、车次查询、座位锁定、微信/模拟支付、订单全生命周期管理在内的可商用级功能闭环,并通过预览中的Controller层与Entity实体类深入理解RESTful接口设计、MPUtil工具封装及字典动态管理等关键实现细节。

1. 技术选型复盘:为什么Spring Boot和Vue是这个项目最稳妥的组合

火车票订票系统,听起来就是个老生常谈的"管理系统"类项目。但真正动手写之后你会发现,它和其他CRUD项目完全是两个难度级别的东西——涉及余票计算、订单状态流转、并发防超卖、支付回调,任何一个环节处理不好,项目演示的时候就会翻车。我最初接到这个需求时,心里盘算的是:既然要做一个前后端分离的完整闭环项目,那技术选型必须兼顾三件事——开发效率、业务表达力、后续扩展空间,而Spring Boot加Vue正好是这套组合里最成熟的答案。

1.1 前后端分离对这个系统意味着什么

火车票系统的交互天然是"重前端、重异步"的:用户在查询页输入出发地、目的地和日期,车次列表要按余票数和时间动态排序;选中车次后进入订单确认页,座位等级、票价、乘车人信息需要联动校验;提交订单后还要轮询支付结果,刷新订单状态。这种高频的局部刷新和状态同步,如果用传统的服务端模板渲染,每次操作都要整页刷新,体验会非常割裂。

所以前端选了Vue,利用它的响应式数据绑定和组件化能力,把"车次查询、车次筛选、座位选择、订单状态"拆成独立的组件,状态变更时只需要更新对应的视图节点。Vue的响应式机制在这种表单密集、状态多的场景下,比jQuery时代的手动操作DOM要省心得多——你的数据变了,界面自动跟着变,不用再自己写一堆document.getElementById去同步。

后端选Spring Boot的原因更直接:Java生态在事务管理、权限控制、ORM框架这些"业务系统刚需"上太成熟了。订票系统绕不开的一个核心诉求就是数据一致性——用户下单、扣减库存、生成订单,这三步必须在同一个事务里,要么全部成功,要么全部回滚。Spring的声明式事务@Transactional用一行注解就能解决问题,换成其他语言框架,你得自己管理事务边界,一旦并发上来就容易出漏子。Spring Boot在Spring的基础上做了大量的自动配置,内嵌Tomcat,项目启动不需要外置容器,spring-boot-starter-webspring-boot-starter-data-jpa这些starter把依赖给你配好大半,开发效率蹭蹭往上涨。

1.2 版本选择的坑:Spring Boot 3.x 与 Java 17 的前置条件

选Spring Boot版本时我吃了一次亏,这里单独拿出来说,因为热搜词里"springboot版本太高"就是针对这个的。当时我图新,直接用了Spring Boot 3.2.0,结果项目一启动就报错——各种依赖不兼容,网上查资料发现3.x版本要求Java 17及以上,而我的机器上装的是JDK 8。Spring Boot 3.x基于Jakarta EE 9规范,包名从javax.*改成了jakarta.*,很多老项目的代码直接迁移会报编译错误。

如果你是从零开始新写项目,我的建议是:本机JDK版本决定Spring Boot大版本。JDK 8就用Spring Boot 2.7.x,这是2.x的最终版本,稳定且兼容性好;JDK 17及以上就用3.x,性能更好,但需要确认所有依赖都支持。用IDEA新建项目时,如果发现"springboot 3.4.3选项"没出现,大概率是IDEA版本太老,升级IDEA或者在 start.spring.io 上手动生成项目再导入即可。另外Spring Boot 3.x的配置项有些变化,比如spring.redis.*变成了spring.data.redis.*,从2.x迁移过来的人容易在这里踩坑。

Vue这边的版本相对省心,Vue 3 + Vite + Element Plus是当前主流组合。如果完全没接触过Vue 2,直接学Vue 3就行,Composition API配合<script setup>写起来比Options API更顺手,代码逻辑更集中。组件库用Element Plus,表格、表单、日期选择器这些现成组件都有,开发后台管理类页面能省一半时间。

1.3 辅助组件的配套选择

完整的技术栈还包括这些:数据库用了MySQL 8.0,存储过程、函数这些用得少,但InnoDB的行级锁和事务隔离级别对订单系统是刚需;缓存用了Redis,后面讲防超卖的时候会细说它承担了什么角色;ORM选了MyBatis-Plus,Dynamic SQL写起来方便,分页插件直接能用;前端状态管理用Pinia(Vue 3官方推荐的状态管理库,比Vuex 2的语法更简洁);路由用Vue Router 4;HTTP请求用Axios,统一封装拦截器。

这套技术栈组合在一起,最大的好处是任何一个环节遇到问题,都能在GitHub或Stack Overflow上找到大量现成案例。对学习者来说,踩坑不可怕,可怕的是坑太冷门,连搜都搜不到解决方案。

2. 业务模型设计:车次、余票与订单的三角关系怎么梳理

火车票系统最核心的业务逻辑不是"查车次",而是"算余票"和"管订单"。这两个功能背后隐藏着一个所有订票系统都绕不开的难题——区间票的库存计算。你以为卖票就是卖数量,实际上一张票对应的是一个"区间"的占用,不是单纯"卖出去一张少一张"那么简单。

2.1 从数据库表设计看业务边界

我设计了六张核心表,先看整体结构:

表名关键字段用途说明
userid, username, password, id_card, phone用户信息与登录凭证
trainid, train_no, train_type, start_station, end_station车次基础信息
train_stationid, train_id, station_name, station_order, arrive_time, depart_time车次经停站信息
carriageid, train_id, carriage_no, carriage_type, seat_count车厢信息与座位容量
seatid, carriage_id, seat_no, seat_type, status座位原始状态
ordersid, order_no, user_id, train_id, from_station_id, to_station_id, carriage_id, seat_id, price, status, create_time, pay_time订单主表

重点讲一下train_station这个表。一个车次从始发站到终点站,中间会有若干个经停站,每个站点有自己的到达时间和发车时间,还有在整条线路中的顺序号station_order。这个顺序号是余票计算的关键——它决定了区间重叠关系。

举个例子:一趟车从北京出发,经停石家庄、郑州,最终到达武汉。那么"北京到石家庄"、"石家庄到郑州"、"郑州到武汉"是三个相邻区间。用户买"北京到武汉"的票,实际占用的是北京-石家庄、石家庄-郑州、郑州-武汉三个区间的运力;用户买"石家庄到郑州"的票,同样占用石家庄-郑州这个区间。所以在计算余票时,不能简单地"总票数减已售票数",而是要看目标区间与已售区间是否有重叠。

seat表记录的是一趟车每个车厢每个座位的物理状态(可用/已占用),但实际计算余票时我们不会去遍历座位表,那太慢了。真实方案是维护一份按"车次日期+区间"计数的余票列表,下单时锁定区间对应数量。这个方案在后面第3章展开,这里先把表的职责边界理清楚。

2.2 余票计算的两种模式与选择

模式一:总量扣减。最简单,每个车次一个总票数,卖出一张就减一。实现成本极低,但问题明显——它不区分区间。假设北京到武汉总票100张,这100张全部卖成"北京到石家庄"的短途票,那"石家庄到武汉"的旅客就永远买不到票,即使列车后半程空着。现实中的火车票系统不可能这么做。

模式二:区间重叠计数。每个"车次+日期"下,维护一段连续的区间库存。查询余票时,找到所有与目标区间有重叠的已售区间,累加它们占用的座位数,用总座位数减去这个累加值,就是当前区间的余票。下单选座时,则要找到目标区间内全部空闲的座位,标记占用。

这个逻辑用代码表达不难,难点在于高并发下的性能。如果每次查询都去数据库算一遍SUM,车次一多、区间一多,数据库压力直接爆表。实际项目中我的做法是:针对热点车次,把余票数据缓存到Redis,用Redis的Hash结构存每个区间的已售数,查询走缓存,下单时先更新缓存,再异步落库。热点车次的判断逻辑很简单——查询量排前N的车次就是热点。

2.3 订单状态机:从待支付到已出票的完整流转

订单状态是订票系统的第二个核心难点,因为它不是简单的"待支付→已支付"两个状态,而是包含取消、超时关闭、退票等多条路径。我设计了六个状态,流转关系如下:

  • PENDING_PAYMENT(待支付):用户提交订单后,锁定座位,进入待支付状态。系统设置15分钟支付时限。
  • PAID(已支付):用户完成支付,系统收到支付回调后,将订单状态改为已支付,并正式出票。
  • COMPLETED(已完成):车次已发车且订单已使用的状态。
  • CANCELLED(已取消):用户在待支付状态下主动取消,或超时未支付被系统自动取消,座位释放。
  • REFUNDED(已退票):已支付订单用户发起退票,座位释放,款项原路退回。
  • CLOSED(已关闭):已取消或已退票订单的终态,只是把状态固定下来便于查询统计。

状态流转一定要搞清楚两个"释放座位"的节点:未支付取消已支付退票,这两个动作都会把锁定的座位释放回库存。但如果订单已经出票且临近发车(比如发车前30分钟内),退票逻辑就要限制,这是业务规则层面的约束,可以用配置项控制。

还有一个容易被忽视的细节:订单编号要全局唯一且具备一定业务含义。我用的是"日期+随机数"组合,格式类似202501141234560001,前8位是日期,后面是随机序列。这样既方便按日期检索订单,也能在日志里快速定位问题。

3. 后端核心实现:从"查得到"到"订得对"的关键代码与原理

后端代码是整个系统的心脏。这一段我挑最有含金量的几个点展开说:车次查询接口的SQL优化、防止超卖的原子性保证、以及订单超时自动取消的实现。

3.1 车次余票查询:SQL与Redis双轨并行

先看查询接口的逻辑。前端传三个参数:出发站fromStation、到达站toStation、出发日期travelDate。后端要做的事情是:找到所有经过这两个站的车次,过滤出"出发站到到达站方向正确"的车次,然后计算每个车次在目标区间的余票数。

第一次实现时我直接写了这样的SQL:

SELECT t.*, ts_from.station_order AS from_order, ts_to.station_order AS to_order FROM train t JOIN train_station ts_from ON ts_from.train_id = t.id AND ts_from.station_name = #{fromStation} JOIN train_station ts_to ON ts_to.train_id = t.id AND ts_to.station_name = #{toStation} WHERE ts_from.station_order < ts_to.station_order AND t.id IN (SELECT train_id FROM train_daily WHERE travel_date = #{travelDate})

这个SQL本身没问题,能查出所有符合条件的车次。但压测下来发现,单次查询耗时200毫秒以上,QPS稍高就扛不住。原因在于train_station表没有建联合索引,每次都要全表扫描。

优化方案分两步:

第一步,给train_station表加联合索引(train_id, station_name),让JOIN走索引;给train_daily表加索引(travel_date, train_id)

第二步,热点车次的余票直接查Redis。我用了Redis的Hash结构,key设计为train:stock:{trainId}:{date},field是{startStationOrder}:{endStationOrder},value是当前区间已售数量。余票数 = 总座位数 - 已售数量。查询时如果要查"北京到武汉"的余票,只需要把这两个站对应的station_order拼成1:5,去Redis里GET一下,O(1)复杂度搞定。

这里要解释一个关键点:为什么Hash的field是"起始站序:终点站序",而不是站名?因为站名可能是中文,做Redis key的一部分时容易有编码问题;更重要的是,区间重叠判断依赖的是顺序号,用订单号作为field的第二段让代码逻辑更直观。用户买"北京到石家庄"(1:2)时,实际需要锁定的区间是1:2;但如果买"北京到武汉"(1:5),就需要锁定1:2、2:3、3:4、4:5四个field的库存。这个"区间拆分"的逻辑用代码实现很简单,遍历从起始站序到终点站序的所有相邻区间即可。

3.2 防超卖的原子性保证:Redis Lua脚本的落地姿势

这是整个系统里最核心、最容易写崩的地方。我第一次实现下单逻辑时,用的是"先查余票 → 判断有票 → 扣减库存 → 插入订单"这种朴素四步,结果用JMeter模拟100个用户并发抢同一趟车的票,卖出去了120张。原因显而易见——多个请求同时查到余票大于0,同时通过判断,然后一起执行扣减,数据库最终扣减结果变成了负数。

解决思路有两个方向:数据库悲观锁Redis原子操作。数据库方案,SELECT * FROM seat WHERE id = ? FOR UPDATE给行加锁,能保证同一时间只有一个事务在操作这个座位,但数据库锁有性能开销,而且订单事务持有锁的时间越长,其他请求等待越久。Redis方案,利用Redis的单线程特性,把"检查余票+扣减库存"放在一个Lua脚本里原子执行,因为Redis执行Lua脚本时不会插入其他命令,所以不存在并发竞争。

最终我采用了Redis Lua方案,脚本核心逻辑如下:

-- KEYS[1] = 库存key, KEYS[2] = 已售key -- ARGV[1] = 需要扣减的区间列表(用逗号分隔的field) -- ARGV[2] = 本次扣减数量 local fields = cjson.decode(ARGV[1]) for i = 1, #fields do local sold = redis.call('HGET', KEYS[2], fields[i]) if sold and tonumber(sold) + tonumber(ARGV[2]) > tonumber(redis.call('HGET', KEYS[1], fields[i])) then return -1 -- 余票不足 end end for i = 1, #fields do redis.call('HINCRBY', KEYS[2], fields[i], ARGV[2]) end return 1

这段脚本做的事很简单:先遍历所有需要占用的区间,检查每个区间的已售量加上本次购买量是否超过总库存;如果全部通过,就批量累加已售量。因为Lua脚本在Redis中是原子执行的,所以多个请求同时进来时,只有一个能成功。

但这里还有个问题:Redis扣减成功不代表数据库一定成功,如果数据库事务回滚了,Redis里已经扣减的库存就"凭空消失"了,会造成余票不准。我的处理方式是:先更新Redis,再写数据库;如果数据库抛异常,用补偿逻辑将Redis的已售量减回去。实际操作中,我会在订单状态增加一个PENDING_PAYMENT的中间态,Redis扣减成功后先创建订单记录(状态为待支付),如果创建失败,立即执行Lua脚本的逆操作恢复库存。

注意:补偿逻辑必须在catch块中保证执行,并且要记录日志。如果补偿也失败了,Redis和数据库会不一致,需要定期的对账任务去修正——每天凌晨跑一个Job,比对Redis中的已售量与数据库实际订单数,不一致的以数据库为准。

3.3 订单超时自动取消:定时任务与延时队列的取舍

下单后15分钟未支付,订单要自动取消,同时释放座位。这个功能有两个经典实现方案。

方案A:Spring Schedule定时扫描。每30秒扫一次orders表,把所有创建时间超过15分钟且状态为PENDING_PAYMENT的订单捞出来,批量执行取消逻辑。实现简单,但存在3个问题:一是扫描全表性能差,订单量大时SQL越来越慢;二是存在"时间窗口误差",订单可能在实际超时后最长30秒才被取消,这个误差还能接受;三是如果应用重启,扫描任务会中断,恢复后需要手动触发补偿。

方案B:Redis ZSET延迟队列。下单时把订单号写入一个Redis ZSET,score设置为"当前时间戳+15分钟的过期时间"。启动一个后台线程,每隔几秒用ZRANGEBYSCORE取出所有score小于当前时间戳的订单,批量处理。这个方案精准、实时、不依赖数据库扫描,但需要额外维护一个延迟队列的后台逻辑。

实际项目中,我用了方案B,因为火车票的座位资源非常宝贵,越早释放越好。具体实现如下:

// 下单成功后,写入延迟队列 stringRedisTemplate.opsForZSet().add( "order:delay:cancel", orderNo, System.currentTimeMillis() + 15 * 60 * 1000 );

后台用定时线程池每2秒执行一次:

@Scheduled(fixedDelay = 2000) public void checkExpiredOrders() { long now = System.currentTimeMillis(); Set<String> expiredOrders = stringRedisTemplate.opsForZSet() .rangeByScore("order:delay:cancel", 0, now); // 处理每个过期订单:取消订单、释放座位、删除ZSET记录 }

每次处理完一个订单,调用ZREM把它从ZSET中移除,避免重复处理。这个方案的优点是取消时间精确到秒级,而且不会对数据库造成周期性全表扫描压力。

3.4 座位分配:如何尽量让乘客坐在一起

现实中的订票系统还会遇到一个体验问题:两个人一起买票,系统要尽量分配相邻座位。我的seat表里增加了lock_flag字段,用"按区间占用"的方式标记每个座位的占用状态。下单时,先查目标区间内所有空闲座位,如果订单包含多张票,优先分配相邻座位;没有相邻的再随机分配。

这个"分区段座位锁定"略微复杂一点,但这是真实用户的刚需。考虑到本节篇幅,我不展开代码,但建议在做这个功能时,掌握一个核心思路:座位锁定不能只锁一个座位,要按区段锁定。某座位在"北京到石家庄"被占用了,但"石家庄到郑州"它可能还是空闲的,乘客在中途站下车后,这个座位就能被后面的区间复用。

4. Vue前端实现:车次列表、选座与订单确认的组件设计

后端把接口准备好之后,前端的任务是把这些数据变成用户可以流畅操作和理解的界面。火车票系统的前端不是一个"展示型页面",它需要频繁与用户交互、实时刷新、响应各种异步状态。下面分享几个我认为值得单独讲清楚的点。

4.1 前端页面结构与路由设计

我用Vue Router设计了5个路由页面,配合一个主布局组件:

// router/index.js const routes = [ { path: '/', component: () => import('@/views/TrainSearch.vue'), meta: { title: '车票查询' } }, { path: '/train-list', component: () => import('@/views/TrainList.vue'), meta: { title: '选择车次' } }, { path: '/order-confirm', component: () => import('@/views/OrderConfirm.vue'), meta: { title: '填写订单' } }, { path: '/order-list', component: () => import('@/views/OrderList.vue'), meta: { title: '我的订单' } }, { path: '/login', component: () => import('@/views/Login.vue'), meta: { title: '登录' } }, ]

路由守卫这里做了两件事:一是检查登录状态,未登录用户跳转订单相关页面时,先引导到登录页;二是动态更新浏览器标题,用meta.titleafterEach钩子里设置,体验比写死标题好。

4.2 车次列表页的筛选与余票展示

车次列表页是整个系统信息量最大的页面。每张车次卡片要展示车次号、出发/到达时间、历时、座位等级及每个等级对应的余票数量和票价。用Element Plus的el-table来实现,表格列分别对应这些信息。

余票展示要考虑一个"状态颜色"的问题:余票充足显示绿色,余票紧张(低于10张)显示橙色,无票置灰。这个规则在Element Plus里用标签的type属性控制:

<el-tag :type="getStockColor(scope.row.firstClassStock)" size="small"> {{ scope.row.firstClassStock > 0 ? scope.row.firstClassStock + '张' : '无票' }} </el-tag>

车次列表数据来自后端接口GET /api/train/search,返回的字段包括trainNofromTimetoTimedurationfirstClassStocksecondClassStockprice等。注意后端返回的时间字段是字符串格式"08:30",前端直接展示即可,不需要用dayjs额外转换。

实时刷新策略:车次列表页我用了setInterval每30秒调用一次查询接口,因为热门车次的余票变化很快,用户看到的余票数要尽量接近真实值。但要注意两个细节:一是在beforeUnmount钩子里清除定时器,否则路由跳转后定时器还在,会造成内存泄漏和多余的HTTP请求;二是轮询时用if (document.visibilityState === 'visible')判断页面是否可见,不可见时跳过请求。

4.3 选座与订单确认页的防重复提交

订单确认页比列表页复杂很多:用户要从列表页选中的车次进入,确认乘车人、选择座位等级、输入乘车人证件号,然后提交订单。这里的"确认"动作涉及支付,必须防止用户重复点击导致重复下单。

我的做法有两道防护:

第一道:按钮加loading状态,点击提交后立即置灰并显示"提交中...",禁用点击。

第二道:前端生成一个唯一的requestId(用uuid库生成),提交订单时携带这个ID;后端用Redis的SETNX命令做幂等控制——同一个requestId只能成功创建一次订单。即使前端因为网络原因超时后用户又点了一次,第二次请求也会被后端识别为重复请求,直接返回第一次的订单号。

选座交互:座位选择器是用户直接可视化选座还是系统自动分配?真实12306是"选座优先,但不保证一定选到",也就是用户选择"靠窗"或"过道",系统在分配时尽可能满足。我在前端实现了"座位等级选择,不提供具体座位号选择",因为一旦允许用户手动选具体座位,锁座的复杂度和崩溃概率会大大增加。用户在订单确认页只选择"一等座"或"二等座",具体的座位号由后端分配。这样做对新手更友好,也降低了前端和后端的协作复杂度。

4.4 Axios封装与登录态管理

既然做前后端分离,前端的每一个请求都要带着登录凭证,后端才能识别用户身份。我用JWT(JSON Web Token)做认证,登录接口返回token,前端存在localStorage里,每次Axios请求时在请求拦截器中添加Authorization: Bearer <token>头。

Axios封装的核心代码:

// utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) request.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.response?.data?.message || '请求失败') return Promise.reject(error) } )

一个容易忽略的细节是baseURL: '/api',这个/api前缀不是后端接口的前缀,而是为了让Nginx在部署时能识别出"前端请求"并反向代理到后端服务。开发环境下,Vite的代理配置把这个前缀转发到本机的后端端口:

// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

这样前后端联调时,前端代码不需要关心后端的实际域名或端口,部署时也只需要统一改Nginx配置,代码无需改动。

5. 前后端联调与踩坑记录:那些最难排查的隐蔽问题

做项目最花时间的不是写代码,而是调试。我把这个系统从开发到跑通期间遇到的几个最典型的坑列出来,都是我在真实调试中花了好几个小时甚至一两天才定位到的问题。

5.1 跨域问题:为什么浏览器把请求拦下来了

前后端分离项目最常见的第一个拦路虎就是跨域。前端跑在localhost:5173(Vite默认端口),后端跑在localhost:8080,端口不同,浏览器会认为这是一个"跨域请求"。

浏览器跨域拦截的原理是同源策略——协议、域名、端口任何一个不同,都被视为跨域。解决方式我推荐后端用@CrossOrigin注解或全局CORS配置,而不是在前端改,因为前端改只是本地开发有效,部署后还是要后端支持。全局配置如下:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

注意,allowCredentials(true)设为true时,allowedOrigins不能直接用"*",必须用allowedOriginPatterns("*"),否则Spring框架会报错。这是一个非常容易踩的细节坑。

5.2 LocalDateTime序列化:前后端的时间格式之争

后端实体里我用了LocalDateTime类型,数据库直接映射没有问题,但返回给前端时默认序列化成"2025-01-14T10:30:00"这样的ISO格式,前端解析起来很不方便。我期望的是"2025-01-14 10:30:00"。解决办法是在application.yml中全局配置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

配置之后,后端返回的LocalDateTime就会自动格式化为指定格式。但这里还有另一个坑:前端提交时间字符串给后端时,如果后端实体字段是LocalDateTime,反序列化也需要匹配格式。Spring Boot 2.x默认的Jackson可以处理ISO格式,但处理"2025-01-14 10:30:00"这种带空格的格式会报错。需要在日期字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")注解,保证前后端格式一致。

5.3 并发压测后发现:Redis已售量和数据库订单数对不上

这是我花了整整一个下午排查的问题。压测完查看数据,发现Redis记录的已售量大于数据库里实际存在的订单数。跟了几条链路才发现,原因是我在"订单创建失败"的补偿逻辑里有一个致命bug——补偿脚本里用的是HINCRBY -1恢复库存,但恢复的区间数写错了。用户买的是"北京到武汉"(1:5区间),实际占用了4个相邻区间,补偿时只恢复了1个区间,导致大部分区间库存没恢复回来。

这个问题的教训是:只要是涉及库存变动的逻辑,必须保证"加"和"减"的区间集合完全一致。我后来把"拆分区间"封装成一个公共方法,下单和补偿都调用它,确保区间列表复用。同时加了一个对账Job,每小时比对一次Redis和数据库的数据,发现不一致时报警,让开发者介入处理。

5.4 Vue组件间通信:当兄弟组件需要共享余票状态时

在车次列表页,上方的"出发地/目的地/日期"筛选条件是一个组件,下方的车次表格是另一个组件。当用户修改筛选条件,表格需要立刻刷新数据——这就是典型的"兄弟组件通信"场景。

我最初用$emit往父组件抛事件、父组件再调用子组件方法的方式,代码写了一堆,但逻辑绕来绕去非常难维护。后来改用Pinia存储筛选条件和车次列表数据:

// store/trainSearch.js import { defineStore } from 'pinia' import { searchTrains } from '@/api/train' export const useTrainSearchStore = defineStore('trainSearch', { state: () => ({ searchForm: { from: '', to: '', date: '' }, trainList: [] }), actions: { async fetchTrains() { this.trainList = await searchTrains(this.searchForm) } } })

筛选条件的子组件修改searchForm,表格子组件通过storeToRefs复用trainList,数据流清晰且单一。如果多个页面都要用到车次信息(列表页、订单确认页),这种方式也能避免重复请求。

6. 部署上线与后续优化:从能跑到真正可用的进阶之路

项目开发完成不是终点,部署上线才是真正检验质量的开始。这一节我分享部署架构、关键的Nginx配置,以及如果要把它真正推向生产环境,还有哪些必须补上的优化内容。

6.1 Docker Compose一揽子部署方案

部署最忌手动一个个启服务。我写了一个docker-compose.yml,把MySQL、Redis、后端、前端四个服务编排在一起,一条命令搞定启动。

version: '3' services: mysql: image: mysql:8.0 container_name: train-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: train_ticket ports: - "3306:3306" volumes: - ./mysql/init:/docker-entrypoint-initdb.d redis: image: redis:7.0 container_name: train-redis ports: - "6379:6379" backend: build: ./backend container_name: train-backend depends_on: - mysql - redis ports: - "8080:8080" environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/train_ticket SPRING_DATA_REDIS_HOST: redis frontend: build: ./frontend container_name: train-frontend ports: - "80:80" depends_on: - backend

注意几个细节:MySQL容器第一次启动时会自动执行/docker-entrypoint-initdb.d目录下的.sql脚本,我把建表和初始化数据脚本放在这个目录,新环境上电即用;后端通过容器名mysqlredis访问数据库和缓存,不需要写IP地址,Docker内建的DNS解析会自动对应到容器IP。

后端镜像的核心Dockerfile:

FROM openjdk:17-jdk-slim WORKDIR /app COPY target/train-ticket-backend-1.0.0.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]

6.2 Nginx配置的3个关键点

前端镜像内部用Nginx托管打包后的静态文件,同时反向代理后端API。配置如下:

server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; # 后端API反向代理 location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # Vue Router history模式刷新404问题 location / { try_files $uri $uri/ /index.html; } }

第一个关键点是location /api/的代理,它把前端以/api开头的请求转发到后端的8080端口,解决了跨域问题,同时前端代码里的baseURL: '/api'和后端的@RequestMapping("/api/...")约定要对齐。

第二个关键点是try_files $uri $uri/ /index.html——这个配置就是为了解决Vue Router的history模式在刷新页面时出现404的问题。因为Vue是单页应用,前端路由在浏览器端控制,但刷新时浏览器会向服务器请求该URL对应的实际文件,如果没有这段配置,Nginx会直接返回404。配置后,所有找不到的路径都会回退到index.html,由Vue Router接管。

第三个细节是缓存策略。静态资源(js/css/img)会带hash指纹,可以设置长缓存;index.html不缓存,每次请求都拉取最新的。

6.3 生产环境还要做的事

如果这个系统不是毕业设计或练手项目,而是要真的上线服务用户,我还会补上这几件事:

日志的磁盘与检索。当前的日志只是输出到控制台,容器一重启日志就没了。生产环境需要把日志挂载到宿主机目录,用logback配置按天滚动,配合ELKLoki做集中检索。排查线上问题,看不到日志等于盲人摸象。

MySQL慢查询监控。my.cnf中打开慢查询日志,设置long_query_time = 1,定期分析超过1秒的SQL,加索引或者重构查询方式。

缓存过期策略调整。热点车次的余票缓存如果过期时间太长,Redis和数据库的同步就有延迟;如果太短,Redis又频繁穿透到数据库。我最终设定为5分钟过期,配合每30秒的主动更新任务。

HTTPS加密。部署到公网必须上HTTPS,用Let's Encrypt的免费证书就能搞定,Nginx配置证书路径,全站跳转HTTPS。涉及用户登录和支付的系统,明文HTTP是绝对不能接受的。

消息队列异步化。短信通知、邮件通知、订单日志写入这些非核心操作,可以投递到RabbitMQ或Kafka异步处理,降低请求延迟,也避免这些辅助操作影响了主流程的性能。当前项目规模不大,同步执行还能撑住,但一旦用户量上来,这个优化会非常明显。

整套系统从开发到部署走完,我最大的体会是:一个看起来简单的订票系统,真正做起来,横跨了前后端开发、数据库设计、并发控制、缓存策略、容器化部署等好几个知识面。这也是为什么我建议学习项目选这个题材——麻雀虽小,五脏俱全,做完一遍,你对Web全栈开发的理解会上一个台阶。

如果你也在做类似的系统,最后分享一个我觉得很有用的习惯:每次修改完库存相关代码,都跑一遍JMeter的并发测试再提交。超卖类bug在正常手动测试时几乎不可能复现,只有并发压测才看得见,提前发现省下的排查时间不是一两个小时,是一整天。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/1 16:10:13

LangGraph实战:从零构建可控的Agent流程

如果你正在做 Agent 开发&#xff0c;或者准备从 LangChain 进入 Agent 工程化&#xff0c;最近一定频繁听到 LangGraph 这个名字。但真正动手用过之后&#xff0c;很多人会有一种共同的困惑&#xff1a;它不过是一个“画流程图”的框架&#xff0c;为什么社区把它捧得这么高&a…

作者头像 李华
网站建设 2026/9/1 16:09:46

小空间空调选购避坑指南:从制冷量到无外机散热全解析

看到这条空调推荐标题&#xff0c;我第一反应不是心动&#xff0c;而是想把它拆开。短短几十个字里&#xff0c;同时出现了“冷暖两用”和“单冷”&#xff0c;同时出现了“大1匹”和“小1匹”&#xff0c;“嵌入式中央空调”“无外机”“石墨烯”“顶置通用”这些词又堆在一起…

作者头像 李华
网站建设 2026/9/1 16:07:37

Figma设计稿自动化导入Cocos Creator:提升游戏UI开发效率

1. 背景与核心概念 在游戏和UI界面开发中&#xff0c;设计师与工程师的协作效率是项目进度的关键瓶颈之一。设计师通常在 Figma 这类专业设计工具中完成界面、图标、组件的创作&#xff0c;而工程师则需要将这些设计资产&#xff08;Assets&#xff09;手动导出、切割、命名&am…

作者头像 李华
网站建设 2026/9/1 16:05:40

2026/8/27日记:在本地部署docker-desktop客户端,并适用scanner扫描代码

摘要 本文是一篇面向 Windows 开发者的 SonarQube 本地代码质量扫描环境搭建教程。文章从零开始&#xff0c;依次讲解 Docker Desktop 与 WSL2 的安装、国内镜像加速配置、WSL2 内存限制设置、SonarQube 容器启动、JDK 17 环境确认、项目编译、访问令牌生成&#xff0c;以及最…

作者头像 李华
网站建设 2026/9/1 16:03:52

python numpy dft NumPy一条公式秒算整批数据,还在手动代值?你Out了

NumPy&#xff1a;数学与物理数值实验的数组计算基础系列名称&#xff1a;《工程师嘴里的那些词》 文章编号&#xff1a;S-022 作者&#xff1a;wvvx 时间&#xff1a;2026-08-01 欢迎关注、收藏、转发&#xff0c;一起把工程词讲明白。同一条用于抛体运动的公式, 代入一组初速…

作者头像 李华