1. 购票系统的项目边界与前期设计思路
1.1 这个系统到底在解决什么问题
先说结论:演出购票系统是一个典型的前后端分离 + 高并发库存扣减 + 订单状态机综合体,它和普通的管理后台根本不是一回事。很多人把它当 CRUD 去做,数据库表一建、页面一通渲染就算完事,可真到联调和压测阶段,问题全出来了。
我们先还原一下业务流程。用户打开系统,看到演出列表(演唱会、话剧、音乐节),点进详情,选择场次,进入选座页面,锁定座位,提交订单,支付,最后通过订单拿到电子票。后台这边,管理员维护演出信息、场次座位、上架下架,运营能看到售票数据。如果只是写个“平台”,这套东西确实不难;但购票的核心从来不在展示,而在交易链路的正确性和并发场景下的数据一致性。
我在实际做这个项目的时候,最先想清楚的是三件事:一是座位到底怎么建模,二是库存扣减怎么做才不超卖,三是订单超时未支付后座位怎么释放。这三个问题想不清楚,后面全是打补丁。
1.2 技术选型:SpringBoot + Vue 是不是最优解
先说框架。SpringBoot做后端,Vue做前端,这个组合在目前的 Java 全栈项目里,基本就是标准答案。SpringBoot 解决了 Spring 早期 XML 配置地狱的问题,内嵌 Tomcat、自动装配、starter 一键引入依赖,几分钟就能把一个可运行的工程拉起来。Vue 的好处是组件化 + 响应式,选座页面的座位状态变化、购物车式的订单确认、支付倒计时这类交互,用 Vue 写起来很顺手,不像 jQuery 时代那样得手动操作 DOM。
有人会问:那我用 JSP + Servlet 不行吗?可以,但你在面试和实际协作中会非常吃亏。前后端分离意味着前端可以独立部署到 Nginx,后端只提供 JSON 接口,分工明确、扩展方便;而且 Vue 生态里有 Element Plus、Ant Design Vue 这种现成组件库,后台管理页面半天就能搭出来,效率完全不是一个级别。
需要提醒一句:技术选型不要盲目追新。SpringBoot 3.x + Vue 3 的组合没问题,但如果你是参考旧教程,或者要用一些老的轮子,建议选 SpringBoot 2.7.x 和 Vue 2/3 都行。稳定优先于版本新潮,这是我踩过坑后最深的体会。
2. 后端核心模块拆解:表结构、配置与接口落地
2.1 数据库设计:别把“票”设计成一张表
这是整个项目成败的关键,我要多说几句。
很多新手拿到购票系统,第一反应是建一张 ticket 表,带个库存字段,卖一张减一。这个思路对简单商品可以,但对演出场景是错的。原因在于:演出票是有“座位”属性的商品,你要回答的不是“还剩多少张”,而是“剩下的是哪几个座位”。把座位和票混在一张表里,订单关联会变得又乱又难扩展。
我实际用的核心表结构是这样的:
| 表名 | 关键字段 | 作用 |
|---|---|---|
| t_show | id、name、poster、description、status | 演出基本信息 |
| t_session | id、show_id、hall_id、start_time、end_time、price、total_stock、stock | 某一场次,库存挂在场次上 |
| t_seat | id、session_id、row_no、col_no、status(0可用/1锁定/2售出) | 座位维度,绑定场次 |
| t_order | id、order_no、user_id、session_id、total_amount、status(0待支付/1已支付/2已取消/3已退款)、expire_time | 订单主表 |
| t_order_item | id、order_id、seat_id、seat_row、seat_col | 订单和座位的关联 |
| t_user | id、username、password、phone、nickname | 前端登录用户 |
这里有两个设计要点值得展开。
第一个是座位数据放在场次下还是演出下。我选了场次级,即每创建一个场次,就批量生成这个场次对应厅的座位记录。好处是隔离性好,一个场次卖完了不影响另一个场次;坏处是座席多的时候数据量大。不过对中等规模的演出(几千座)完全够用,批量插入很快。
第二个是库存字段要不要冗余。t_session 里的 stock 是冗余字段,因为理论上可以从 t_seat 统计出来。但为什么不直接 count?因为购票接口要判断“还有没有票”,数据库里 count 全表然后判断,性能差;而且座位状态有“锁定”这种中间态,统计口径容易乱。冗余一个 stock 字段,用原子更新扣减,查询和更新都高效。
订单表也要单独说。order_no 尽量用时间戳 + 随机数生成,或者干脆用雪花 ID,别用自增 ID 对外暴露,容易被人遍历出业务量。订单和座位的关系是一对多,一个人可以一次买多张票,所以要有订单明细表。退款、改签的时候,明细表的作用就体现出来了。
2.2 SpringBoot 工程结构与关键配置
工程结构我用的是经典的四层:controller → service → mapper → entity,额外加一个 config 包放配置类,一个 common 包放统一返回和异常处理。这种分层在毕业设计和实际团队里都通用,不要搞花活。
启动类、pom.xml 这些不细说,重点聊几个配置上的坑。
第一个是yml 里的敏感信息。数据库密码、JWT 密钥直接写在 application.yml 里,项目一传到 Git 上就相当于裸奔。我后来用了 Jasypt 做配置项加密,密钥通过环境变量传入。用法很简单,pom 里加依赖,然后用工具类将明文密码加密成密文,yml 里写ENC(xxx)。
spring: datasource: url: jdbc:mysql://localhost:3306/ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: ENC(加密后的密文) redis: host: localhost port: 6379 servlet: multipart: max-file-size: 100MB max-request-size: 100MB mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0第二个是启动时自动建表。用 MyBatis-Plus 的话,可以自己写一个数据库初始化组件,在项目启动时执行建表 SQL。注意 MySQL 的高版本对 utf8mb4、时区要求比较严格,连接串里一定要带serverTimezone=Asia/Shanghai,否则插入时间数据会报错。这个坑我帮别人排查过很多次,每次都是时区问题。
第三个是Java 版本和 SpringBoot 版本匹配。如果你本机是 Java 17,就不要硬去用 SpringBoot 2.x 的老版本,虽然能跑但是会有一些兼容性警告。反之,如果跟着教程用 SpringBoot 2.7.x,装了 Java 11,也不要硬换 Java 17。版本不匹配导致启动失败的案例太多了。
2.3 登录鉴权与接口统一规范
购票系统的接口基本都要求登录后才能操作,尤其是下订单。这个不用搞复杂的 OAuth,JWT 无状态鉴权就够了。
流程很简单:用户登录成功后,后端生成一个 token(包含用户 ID、过期时间),前端存到 localStorage 里,每次请求在 header 里带上Authorization: Bearer <token>,后端拦截器校验 token 合法性,并把用户信息放入 ThreadLocal 或请求上下文。
我这里贴一个拦截器核心逻辑:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录和注册接口 if (request.getRequestURI().contains("/api/user/login") || request.getRequestURI().contains("/api/user/register")) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); // 解析 token,失败会抛异常 Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); return true; } // 未认证统一返回 401 response.setStatus(401); return false; } }还有一点,接口返回结构一定要统一。我定义了一个Result<T>类,包含 code、message、data 三个字段,所有 controller 方法都返回这个结构。这样前端 axios 封装里可以统一处理错误码,不用每个接口单独判断。很多人忽略这个小细节,结果联调时前端被各种嵌套 JSON 结构折磨到崩溃。
2.4 购票核心接口:库存扣减与订单生成
购票接口是整个系统里逻辑最复杂、也是最能说清楚项目含金量的部分。
我先给一个出错版本的设计思路,很多人在这一步就栽了。最直观的想法是:先查库存,大于 0 就执行更新,然后把订单插进去。伪码长这样:
// 错误示例,切勿照抄 int stock = sessionService.getStock(sessionId); if (stock > 0) { sessionService.reduceStock(sessionId); orderService.createOrder(...); }问题在哪里?在并发场景下,“查库存”和“减库存”不是原子操作。两个线程同时查到库存为 1,都认为能买,结果都把库存减了,库存变负数,订单却生成了两条。这就是典型的超卖。
直接能想到的改进是加同步锁或者用数据库乐观锁。同步锁在单机下有效,一旦上多台服务器部署就失效;乐观锁的话,SQL 改成这样:
UPDATE t_session SET stock = stock - 1 WHERE id = ? AND stock > 0这条 SQL 是原子的,数据库层面保证同一条记录只有一个线程能更新成功。返回影响行数如果是 1,说明扣减成功;如果是 0,说明库存卖完了。这个方法简单可靠,是目前绝大多数购票系统的兜底方案。
但纯靠这一条 SQL,还解决不了“选座”场景的问题。因为座位不是数量,而是具体到某个位置。两个人同时选中 A 座下单,库存减了没问题,但 A 座只能属于一个人。所以我在座位表里加了 status 字段,锁座的时候也要用原子的 UPDATE:
UPDATE t_seat SET status = 1 WHERE id = ? AND status = 0更新影响行数为 1,表示这个座位抢到了;为 0,表示这个座位已经被人锁定或售出。这一步执行成功的座位才进入后续创建订单的流程。座位状态的原子化更新,是整个购票系统防超卖的第二道防线。
3. 前端 Vue 项目的实现细节与联调要点
3.1 安装环境与初始化项目
很多人在这一步就卡住了。我见过太多同学从网上抄了一堆命令,然后对着屏幕报错发愣。这里认真说一遍最稳妥的流程。
先装Node.js,版本建议 LTS(长期支持版),比如 18 或 20。装完在终端输入node -v和npm -v,能看到版本号就说明装对了。Node 自带 npm,但国内网络环境下很多时候下载依赖慢甚至失败,建议把 npm 源切成淘宝源:
npm config set registry https://registry.npmmirror.com然后创建 Vue 项目。我用的是官方脚手架 create-vue,它比 Vue CLI 更轻快,默认就是 Vite 构建。执行:
npm create vue@latest它会问你项目名、要不要 TypeScript、要不要 Vue Router、Pinia 等,按需选择。注意,不要一上来就装一堆依赖,等基础项目能跑起来,再按功能慢慢加。
依赖安装我建议用 pnpm,比 npm 快很多,还能省磁盘空间。如果你用 npm,那就在项目目录执行npm install,遇到版本冲突就检查 package.json 里的依赖版本,热门组件库之间的版本打架是最常见的问题。
3.2 路由、状态管理与请求封装
Vue 项目的骨架里,我要认真讲的是三块:路由、状态管理、axios 封装。这三块是前后端分离项目的神经中枢。
路由方面,购票系统页面层级不多,但要注意两点:一是路由守卫,未登录用户不能进入下单页面;二是路由传参,比如从演出详情页跳转到选座页,要带 sessionId,有两种方式:query 参数和 params 参数。query 会出现在 URL 上,刷新不丢失;params 要配合路由配置里动态路径:sessionId,否则刷新后参数会丢失。我实际项目里选座页跳转用的是动态路由 + params,语义更清晰。
// 路由配置 { path: '/select-seat/:sessionId', name: 'SelectSeat', component: () => import('@/views/SelectSeat.vue'), meta: { requiresAuth: true } } // 跳转 router.push({ name: 'SelectSeat', params: { sessionId: row.sessionId } })状态管理用的是 Pinia(Vue 3 推荐)。注意不要把整个用户信息乱塞,只存 token 和用户 ID,其余个人信息如昵称、头像,用的时候再去查。因为登录态下一步要用于 axios 请求头,这样处理最干净。
请求封装是重点。axios 如果直接裸调,每个页面都要处理 token 和错误码,代码会非常冗长。我封装了一个全局实例:
import axios from 'axios' import { useUserStore } from '@/stores/user' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 15000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { // 跳转登录页 } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )走到这一步,前后端接口联调就算打通了一大半。
3.3 选座页面和订单确认的交互细节
选座页是这个项目交互难度最高的页面。核心逻辑是渲染一个二维数组,行是排,列是座位号。每个座位根据后端传过来的状态显示成不同的颜色:可选、已锁、已售。用户点击选中,再次点击取消,前端维护一份“本次选择座位”的数组,最后带着座位 ID 列表去下单。
需要特别说明的是锁定策略:为了体验好一点,我做的方案是用户点击“提交订单”时才去后端锁座,因为用户选座过程中反复点击,如果每个动作都请求后端锁座,数据库压力会很大。如果用户锁座成功后一直没有支付,系统会在超时后自动释放。
这里有个小细节容易被忽略:前端选座数组和后端座位的对应关系。座位 ID 是后端生成的,前端拿到座位数据后要保留 ID,不能只存行列号。否则提交订单时你发现你根本没有座位 ID,只能再查一遍,多一次交互多一份出错概率。
订票数量方面,我在前端限制单笔订单最多买 6 张票。这不是后端硬性规定,而是业务场景需要,演唱会通常有连坐需求,但又不希望你一个人把一个区域买干净。
4. 并发、超卖与订单超时的处理方案
4.1 为什么普通写法一定会超卖
我在上面说了简单的超卖场景,这里再往深一层挖。
假设库存是 5 张票,同时来了 10 个请求。如果采用“查库存-判断-更新”的流程,即使你把判断逻辑写成一整块 synchronized,一旦系统部署成多实例(比如两台服务器,nginx 负载均衡),锁只对单台机器有效,两台机器同时查到库存 5,同时各自减一,结果库存变 3,可是有两个人买到了同一张票。这就是分布式场景下的并发问题。
所以并发控制的本质,是把“检查 + 更新”做成一个原子操作。数据库层面用条件更新可以做到,Redis 层面的 Lua 脚本也可以做到。两者各有利弊,下面展开说。
4.2 基于 Redis 预扣 + 数据库兜底的方案
在实际项目中,我在购票接口上做了一层 Redis 库存预扣,目的不是炫技,而是挡住瞬时高并发对数据库的直接冲击。
思路是这样的:在演出场次上架时,把场次剩余库存同步到 Redis,key 用stock:{sessionId}。购票请求进来,先执行一段 Lua 脚本:
-- 判断库存是否大于 0,是则减一 if redis.call('get', KEYS[1]) and tonumber(redis.call('get', KEYS[1])) > 0 then return redis.call('decr', KEYS[1]) end return -1脚本返回大于等于 0 的值,说明预扣成功,继续走后续创建订单逻辑;返回 -1,说明没票了直接返回。Lua 脚本是原子执行的,Redis 单线程模型保证同一时刻只有一个请求能成功扣减,这里不会出现超卖。
注意,Redis 预扣成功并不代表数据库扣减成功。你在 Redis 扣了,真正落库的时候,数据库的 t_session.stock 仍然要执行那条UPDATE ... WHERE stock > 0的条件更新。也就是说:Redis 是第一道快速校验和流量缓冲,数据库是最终一致性兜底。两道都过了,才真正把订单创建出来。
还有一个关键点:如果用户下单后取消或超时未支付,要把 Redis 和数据库的库存都加回来,否则库存会被慢慢扣光。这会和下面的订单超时处理联在一起做。
4.3 订单超时未支付自动取消
购票系统必须处理“锁座后不支付”的问题,否则座位全被锁死,其他人买不了。
最常见的实现有两种。第一种:定时任务扫描。每隔 30 秒查一次订单表,把超过支付时限(比如 15 分钟)且状态还是待支付的订单,修改为已取消,同时释放关联座位,回补库存。优点是不需要额外中间件,实现简单;缺点是有延迟(最坏情况快 30 秒才释放),数据量大时数据库压力不小。
第二种:RabbitMQ 延迟队列。创建订单时发送一条延迟消息,15 分钟后投递给消费者,消费者去检查订单状态,如果还是待支付,就执行取消和回滚。优点是很精准,几乎无延迟;缺点是要多引入 MQ,对部署环境要求更高。
我在毕设级别和真实小项目中,推荐先用定时任务。因为购票系统压力一般没有大到连 30 秒一次扫描都扛不住,而且定时任务排查问题更容易,不需要理解消息队列的复杂概念。等到有真实并发需求了,再迁移到延迟队列也不迟,接口层面不用大改。
一个实操细节:扫描数据库时,不能一条一条更新,要写一条批量 SQL,比如UPDATE t_order SET status = 2 WHERE status = 0 AND expire_time < NOW()。别忘了同时把座位状态回滚,并且把 Redis 库存也加回去。这三个动作最好放在一个事务里,保证原子性。
5. 常见问题与排查技巧实录
5.1 跨域、端口与联调时的老大难
前后端分离项目第一个拦路虎永远是跨域。Vue 开发服务器默认跑在 5173,后端在 8080,浏览器直接发请求会被 CORS 拦截。最快的解决方案是在 Vite 配置里做代理:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端代码里请求/api/xxx时,开发服务器会把请求转发到http://localhost:8080/api/xxx,浏览器看到的是同源请求,就绕开了 CORS。注意,部署到生产环境时,通常用 Nginx 做同类型代理,而不是在后端开启 CrossOrigin 一放了之。
端口冲突也是高频问题,最常见的是 8080 被占。在 Windows 上用netstat -ano | findstr 8080查到占用进程,然后任务管理器杀掉;简单粗暴,但很有效。
还有一个跟 IDEA 相关的坑:IDEA 创建 SpringBoot 项目时卡住或报超时,通常是因为访问 Spring Initializr 默认地址https://start.spring.io被网络拦住。解决办法是把初始化服务的 URL 改成阿里云镜像:https://start.aliyun.com。改完秒建项目。
5.2 数据一致性相关的诡异问题排查
这里我把实际遇到过的几个诡异问题整理一下,未必每个项目都会遇到,但遇到了真的会卡很久。
现象一:明明库存没扣,页面却显示已售罄。排查方向:Redis 里stock:{id}是不是被扣成了 0?我说过 Redis 预扣和数据库更新是两层,如果 Redis 扣了但后续数据库事务失败了,Redis 没有回滚,就会出现这种不一致。解决思路:在事务回滚的 catch 块里,把 Redis 库存加回来。如果项目里有分布式事务中间件可以用,那就更稳,但学生项目用不上。
现象二:订单已取消,但座位仍然显示已锁定。排查方向:定时任务取消订单的 SQL 是否真的执行了,座位释放逻辑是否在同一个事务里。我见过有人把释放座位的代码写在校验条件之外,结果 SQL 执行了,座位释放逻辑被跳过,这种代码 review 都不容易看出来。
现象三:前端启动后 browser 显示 Network 不可用。这个和网络环境、代理工具有关,但也可能是浏览器安全策略拦截了。只访问本机开发地址时,最简单是清空浏览器缓存和 LocalStorage,再重启开发服务器。定位方法:打开浏览器开发者工具,看一下 Network 面板里具体哪个请求失败了,再顺藤摸瓜。
现象四:演出详情页要播放预告片,video 标签放 m3u8 流播不了。这是 H5 原生视频标签的限制,需要用 hls.js 或 video.js 来解析 m3u8,再喂给 video 标签。这个需求和购票系统本身没有必然关联,但如果你加了演出视频模块,这是绕不开的。
5.3 项目部署与打包时的细节
前端打包是npm run build,产物在 dist 目录。后端是mvn clean package,产物是 jar 包。生产环境我用 Nginx 部署前端,反向代理/api到后端地址:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # history 路由刷新 404 修复 location / { try_files $uri $uri/ /index.html; } }这里最容易踩的坑就是try_files这一行。如果你的 Vue 路由用了 history 模式,刷新/select-seat/123这个地址时 Nginx 会去找该路径下的真实文件,找不到就 404。加上try_files ... /index.html后,所有前端路由都回退到 index.html,由 Vue Router 自己处理。如果你用 hash 模式,则不需要这一行,但 URL 会带#,不太好看。
后端 jar 包启动也别直接用java -jar裸启动,用nohup java -jar app.jar > logs/app.log 2>&1 &拉起来,落日志也方便排查,这是我常用的生产启动方式。
6. 写在最后的个人体会
做这个演出购票系统,最值得的不是你学会了 SpringBoot 几个注解、Vue 的几个生命周期函数,而是你接触到了真实业务系统里最核心的交易与一致性问题。CRUD 练习做一百个,不如把一个购票流程从头到尾跑通畅一次,这中间涉及的数据库设计、并发控制、订单超时处理、前后端联调、部署上线,任何一环出问题都要花时间排查,而这些排查经验才是面试时能讲出来的真东西。
我个人体会最深的一点是:不要在一开始就把方案设计得过于复杂。Redis 预扣、延迟队列、分布式事务,这些名词听起来很高级,但如果你的业务量根本没有到那个程度,它们只会增加你的调试成本。先把最简单可靠的方案跑通——数据库条件更新防超卖、定时任务关单、Nginx 代理部署——然后在面试或项目总结时,把“如果量大了我会怎么演进”的思路讲出来,这才是务实的学习路径。
最后分享一个很小但特别实用的技巧:开发联调阶段,把后端接口的访问日志和前端 axios 的请求日志同时打开,出问题时两侧对照着看,会比对着页面猜来猜去快十倍。购票系统的业务链路长,从选座到支付涉及四五次请求,日志就是你的眼睛。