ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

Spring Boot+Vue火车票订票系统实战:搞定并发防超卖与订单管理 简介这是一套面向计算机专业本科生的毕业设计级SpringBootVue火车票订票系统完整源码聚焦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-web、spring-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-PlusDynamic SQL写起来方便分页插件直接能用前端状态管理用PiniaVue 3官方推荐的状态管理库比Vuex 2的语法更简洁路由用Vue Router 4HTTP请求用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。后端要做的事情是找到所有经过这两个站的车次过滤出出发站到到达站方向正确的车次然后计算每个车次在目标区间的余票数。第一次实现时我直接写了这样的SQLSELECT 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分钟未支付订单要自动取消同时释放座位。这个功能有两个经典实现方案。方案ASpring Schedule定时扫描。每30秒扫一次orders表把所有创建时间超过15分钟且状态为PENDING_PAYMENT的订单捞出来批量执行取消逻辑。实现简单但存在3个问题一是扫描全表性能差订单量大时SQL越来越慢二是存在时间窗口误差订单可能在实际超时后最长30秒才被取消这个误差还能接受三是如果应用重启扫描任务会中断恢复后需要手动触发补偿。方案BRedis ZSET延迟队列。下单时把订单号写入一个Redis ZSETscore设置为当前时间戳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(); SetString 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.title在afterEach钩子里设置体验比写死标题好。4.2 车次列表页的筛选与余票展示车次列表页是整个系统信息量最大的页面。每张车次卡片要展示车次号、出发/到达时间、历时、座位等级及每个等级对应的余票数量和票价。用Element Plus的el-table来实现表格列分别对应这些信息。余票展示要考虑一个状态颜色的问题余票充足显示绿色余票紧张低于10张显示橙色无票置灰。这个规则在Element Plus里用标签的type属性控制el-tag :typegetStockColor(scope.row.firstClassStock) sizesmall {{ scope.row.firstClassStock 0 ? scope.row.firstClassStock 张 : 无票 }} /el-tag车次列表数据来自后端接口GET /api/train/search返回的字段包括trainNo、fromTime、toTime、duration、firstClassStock、secondClassStock、price等。注意后端返回的时间字段是字符串格式08:30前端直接展示即可不需要用dayjs额外转换。实时刷新策略车次列表页我用了setInterval每30秒调用一次查询接口因为热门车次的余票变化很快用户看到的余票数要尽量接近真实值。但要注意两个细节一是在beforeUnmount钩子里清除定时器否则路由跳转后定时器还在会造成内存泄漏和多余的HTTP请求二是轮询时用if (document.visibilityState visible)判断页面是否可见不可见时跳过请求。4.3 选座与订单确认页的防重复提交订单确认页比列表页复杂很多用户要从列表页选中的车次进入确认乘车人、选择座位等级、输入乘车人证件号然后提交订单。这里的确认动作涉及支付必须防止用户重复点击导致重复下单。我的做法有两道防护第一道按钮加loading状态点击提交后立即置灰并显示提交中...禁用点击。第二道前端生成一个唯一的requestId用uuid库生成提交订单时携带这个ID后端用Redis的SETNX命令做幂等控制——同一个requestId只能成功创建一次订单。即使前端因为网络原因超时后用户又点了一次第二次请求也会被后端识别为重复请求直接返回第一次的订单号。选座交互座位选择器是用户直接可视化选座还是系统自动分配真实12306是选座优先但不保证一定选到也就是用户选择靠窗或过道系统在分配时尽可能满足。我在前端实现了座位等级选择不提供具体座位号选择因为一旦允许用户手动选具体座位锁座的复杂度和崩溃概率会大大增加。用户在订单确认页只选择一等座或二等座具体的座位号由后端分配。这样做对新手更友好也降低了前端和后端的协作复杂度。4.4 Axios封装与登录态管理既然做前后端分离前端的每一个请求都要带着登录凭证后端才能识别用户身份。我用JWTJSON 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:5173Vite默认端口后端跑在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: GMT8配置之后后端返回的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脚本我把建表和初始化数据脚本放在这个目录新环境上电即用后端通过容器名mysql和redis访问数据库和缓存不需要写IP地址Docker内建的DNS解析会自动对应到容器IP。后端镜像的核心DockerfileFROM 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配置按天滚动配合ELK或Loki做集中检索。排查线上问题看不到日志等于盲人摸象。MySQL慢查询监控。在my.cnf中打开慢查询日志设置long_query_time 1定期分析超过1秒的SQL加索引或者重构查询方式。缓存过期策略调整。热点车次的余票缓存如果过期时间太长Redis和数据库的同步就有延迟如果太短Redis又频繁穿透到数据库。我最终设定为5分钟过期配合每30秒的主动更新任务。HTTPS加密。部署到公网必须上HTTPS用Lets Encrypt的免费证书就能搞定Nginx配置证书路径全站跳转HTTPS。涉及用户登录和支付的系统明文HTTP是绝对不能接受的。消息队列异步化。短信通知、邮件通知、订单日志写入这些非核心操作可以投递到RabbitMQ或Kafka异步处理降低请求延迟也避免这些辅助操作影响了主流程的性能。当前项目规模不大同步执行还能撑住但一旦用户量上来这个优化会非常明显。整套系统从开发到部署走完我最大的体会是一个看起来简单的订票系统真正做起来横跨了前后端开发、数据库设计、并发控制、缓存策略、容器化部署等好几个知识面。这也是为什么我建议学习项目选这个题材——麻雀虽小五脏俱全做完一遍你对Web全栈开发的理解会上一个台阶。如果你也在做类似的系统最后分享一个我觉得很有用的习惯每次修改完库存相关代码都跑一遍JMeter的并发测试再提交。超卖类bug在正常手动测试时几乎不可能复现只有并发压测才看得见提前发现省下的排查时间不是一两个小时是一整天。本文还有配套的精品资源点击获取
返回列表