“票卖完了”“座位被占了”“订单超时没付上钱”——这些电影购票系统里天天都在发生的场景,背后其实是锁座、订单状态流转、库存一致性这一整套逻辑在撑着。我手上的这个基于SpringBoot + Vue的在线电影购票系统(源码+数据库+文档),前后断断续续做了大半年,中间推倒重来过一次。它不是那种只能跑通Demo的教学项目,而是能真正支撑单场次几百人并发选座的完整系统,包含完整的用户端购票流程、后台管理、座位状态机、订单超时机制,以及配套的数据库脚本和部署文档。这篇文章把这套系统从技术选型、数据库设计、后端核心链路到前端交互、部署运维的完整过程拆开讲一遍,适合正在做毕业设计、想入行前后端分离开发、或者准备上手真实电商类项目的开发者参考。
1. 为什么选SpringBoot + Vue这套组合:从需求反推技术选型
1.1 需求拆解:购票系统到底在解决什么问题
做技术选型之前,先把需求盘清楚。在线电影购票系统表面上无非是看电影、选场次、买票,但把它拆成具体功能点,复杂度立刻上来了:
- 用户端需要浏览电影列表、查看详情、选择场次、选择座位、生成订单、在线支付、查看订单状态
- 后台需要管理电影信息、场次排期、座位价格、订单审核与退款
- 核心难点在于座位状态和订单状态的强一致性,同一场次同一座位只能被一个人锁定,且锁座后必须在规定时间内完成支付
- 还有一套完整的权限体系,用户端和后台管理员需要不同的角色权限
这套需求摆在面前,技术选型的思路就很清楚了。前端需要处理复杂的选座交互,Vue这类组件化框架天然合适;后端要快速搭建RESTful API并且处理订单这类事务型业务,SpringBoot的生态成熟度和开发效率都是第一梯队;数据库层面,订单、场次、座位之间存在明确的关系约束,MySQL这种关系型数据库最稳妥。
1.2 为什么不选其他组合
很多人上来会纠结要不要用微服务、要不要上Redis和消息队列。以这个项目体量来说,微服务完全没必要——它只会增加部署复杂度和调用链问题,对购票这种核心逻辑毫无帮助。Redis倒是值得引入,我后面会详细讲它在锁座中的应用,但即使不用Redis,纯靠数据库事务也能实现基本的座位锁定,只是并发上限会低一些。
前端框架里有人推荐React或者更轻量的原生JS,Vue的优势在于模板语法直观、生态里有现成的组件库(我用的是Element UI),对于一个需要快速实现"选座界面+管理后台"两个端口的项目来说,开发效率是最高的。
1.3 整体架构设计
这套系统的架构比较常规但是层次清晰:
- 前后端彻底分离,Vue前端通过Axios调用SpringBoot提供的Restful API
- 后端采用典型的Controller、Service、Mapper三层结构
- 认证方案用的是JWT,用户登录后拿到Token,请求时在Header中携带
- 数据库用MySQL,连接池用Druid,权限控制用Spring Security配合JWT实现
整个过程从技术选型到架构确定,前后花了不到一周时间。真正耗时间的是后面数据库表结构设计和锁座逻辑的实现,这两块才是系统能不能扛住并发、逻辑是否严密的关键。
提示:如果你的项目也想做前后端分离,建议从一开始就定好接口规范,前端不要直接操作后端返回的原始数据结构,而是统一经过一层封装。我这套系统的后端接口统一返回
{ code, message, data }格式,前端配合axios拦截器处理,后面联调会少掉一大半的沟通成本。
2. 数据库设计:从排片规则到座位状态的核心表结构
2.1 数据模型的整体逻辑
购票系统的数据库设计是整个项目的地基,表与表之间的关系搞不清楚,后面写业务逻辑全是坑。我的核心设计思路是:用户、电影、场次、订单、座位这五个核心实体必须拆清楚。
直接说结论吧,我把表拆成了这些核心表:
| 表名 | 核心字段 | 作用 |
|---|---|---|
| user | id, username, password, phone, role | 用户与管理员共用一张表,用role区分 |
| movie | id, title, director, actors, duration, poster, status | 电影基本信息与上下架状态 |
| session | id, movie_id, hall_id, show_time, end_time, base_price | 电影场次,关联具体的放映厅 |
| hall | id, name, row_count, col_count | 放映厅信息,座位矩阵规格 |
| seat | id, hall_id, row_no, col_no, seat_type | 座位主数据,每个座位一条记录 |
| session_seat | id, session_id, seat_id, status, order_id | 场次与座位的状态关系表,锁定/已售/空闲 |
| orders | id, order_no, user_id, session_id, total_price, status, create_time, expire_time | 订单主表 |
| order_item | id, order_id, session_seat_id, price | 订单明细,一个订单关联多个座位 |
这个设计里最关键的是session_seat这张表。很多人第一次做购票系统会直接在seat表里加状态字段,这是错误的设计——因为同一个座位在不同场次的状态是相互独立的,今天19:00的场次座位空闲,不代表明天21:00的场次也空闲。所以必须把"座位"和"场次"两个维度拆出来,用中间表记录每个座位在具体场次下的状态。
2.2 座位矩阵与排片规则的落库方式
放映厅的座位矩阵是这个项目里最微妙的表结构设计。一个放映厅有多少排、每排多少座,这些信息在后台创建厅时就要定下来,座位数据不是手动一条条插入的,而是在创建放映厅时通过程序自动生成。
我在创建放映厅的Service里写了一段批量插入逻辑:根据row_count和col_count双重循环,为每个座位生成row_no和col_no,同时标记座位类型。普通厅默认全部为普通座,IMAX厅可以在后台手动把中间的座位改为VIP座,VIP座在价格上会有加成系数。
排片这块有个很实用的细节:场次时间不能冲突。一个放映厅在同一时间只能跑一部电影,我在插入场次时做了时间段的交叠校验,插入前先查一下这个厅在目标时间段内是否已有未删除的场次,如果有就拒绝。
-- 校验排片时间冲突的核心SQL SELECT COUNT(*) FROM session WHERE hall_id = #{hallId} AND is_delete = 0 AND ((show_time <= #{endTime} AND end_time >= #{showTime}))这个SQL也顺便解决了跨天排片的问题——只要场次结束时间记录的是真实结束时刻而不是时长,凌晨的场次也能正确判断冲突。
2.3 订单表的状态字段设计思路
订单表的状态字段,我这套系统用了整数状态码,分别对应:待支付、已支付、已取消、已退款、已完成。这里有一个非常容易忽略的点:订单创建时间和过期时间一定要分开存。
我在orders表里同时放了create_time和expire_time,过期时间等于创建时间加15分钟。为什么单独存而不在查询时用create_time + 15分钟去算?因为定时任务清理过期订单时,直接用expire_time < NOW()去查,走索引的效率会比函数计算高得多,而且逻辑上更清晰。
另外订单号不建议用数据库自增ID直接暴露给用户,我用了时间戳加随机数拼了一个20位的订单号,既保证唯一性,也避免用户通过订单号推测出平台的订单总量。
3. 后端核心:购票链路中的锁座与订单一致性实现
3.1 用户选座的完整业务链路
整个系统的核心链路就一条:用户选定座位、系统锁座、用户支付、系统确认出票。这条链路在设计上最怕出现的情况是两个人同时抢同一个座位,导致超卖。我的锁座策略分了两层,第一层在生成订单时立即锁定座位,第二层在支付回调时再次校验座位状态。
生成订单的接口是这个系统里最核心的一段代码,它的完整逻辑如下:
- 前端传入场次ID和座位ID列表
- 后端根据场次ID查出所有场次座位记录
- 校验这些座位当前是否全部为"空闲"状态,任何一个不是空闲就整体拒绝
- 全部空闲则批量把状态改为"锁定",并生成订单记录,状态为"待支付"
- 返回订单号和过期时间给前端
这段逻辑看起来简单,但这里的关键是:状态校验和状态修改必须在一个数据库事务中原子完成。我的Service方法上加上了@Transactional注解,通过事务保证从查状态到改状态之间不会插入其他并发请求。
3.2 用乐观锁兜底:版本号字段与更新条件
光靠事务还不够。两个并发请求同时查到同一批座位都是空闲状态,如果都去执行UPDATE,依然会超卖。这里我用了乐观锁的思路:session_seat表加了一个version字段,更新状态的SQL强制带上版本号条件:
UPDATE session_seat SET status = 2, order_id = #{orderId}, version = version + 1, update_time = NOW() WHERE id = #{id} AND status = 0 AND version = #{oldVersion}如果更新语句影响的行数为0,说明座位状态已经被别人改过,直接抛出业务异常,提示用户"该座位已被锁定"。这套方案在不需要引入复杂分布式锁的情况下,用数据库自身的行锁能力保证了并发安全,对于单机部署的购票系统来说完全够用。
注意:这里有一个很隐蔽的坑,如果事务隔离级别是默认的REPEATABLE READ,要留意快照读和当前读的区别。上面的UPDATE语句是当前读,每次都会读取最新版本,所以并发场景下能正确感知状态变化。但如果你先SELECT再判断再UPDATE,SELECT是快照读,可能读到旧数据。所以我的建议是:UPDATE语句自己带条件判断,不要单独做一次"先查再改"。
3.3 订单超时自动取消的定时任务设计
15分钟不支付,订单要自动取消,座位要释放。这个功能看起来简单,但定时任务的实现方式很影响系统稳定性。我的方案是:用一个Spring的@Scheduled注解定时任务,每1分钟扫描一次是否有过期的待支付订单。
每次扫描将过期订单状态批量改为"已取消",同时把订单关联的session_seat记录释放回空闲状态。这里要注意扫描条件和批量更新的顺序:先找出过期的订单ID集合,再批量释放座位,两个操作必须保证要么全部成功要么全部失败,所以我同样用了一个事务方法包裹。
这个定时任务在生产环境跑下来有一个值得优化的点:全表扫描过期订单在大数据量下会很慢。后来的优化方案是给orders表的status和expire_time建了联合索引,扫描速度明显提升。如果你的数据量更大,还可以考虑用一张独立的"待处理订单表",把待支付订单单独存,定时任务只扫这张小表。
3.4 支付回调与状态机的严谨流转
支付环节我没有接真实的支付网关(涉及商户资质和复杂回调),而是做了一个模拟支付的接口:前端跳转到模拟支付页,点击确认支付后,前端调用后端支付确认接口,后端校验订单状态并执行出票逻辑。
这个模拟设计并不影响系统主体逻辑的完整性,因为我把支付回调的核心——订单状态从"待支付"变为"已支付"以及座位状态从"已锁定"变为"已售出"——完整的实现了。真实接入微信或支付宝支付时,只需要在支付网关异步回调里调用同一个"支付成功处理"Service方法即可。
这里我踩过一个很经典的坑:支付成功处理接口没有做幂等校验。模拟支付时两次点击,订单状态就会从"已支付"被改成"已退款",座位状态也跟着错乱。修复方案是:在支付确认入口先检查订单当前状态,只有"待支付"状态才能继续流转到"已支付",其他状态一律拒绝并返回提示。
4. Vue前端关键页面:选座交互与支付流程的细节处理
4.1 前端项目结构与技术栈选择
前端部分我用的是Vue 2加Vue Router加Vuex,UI组件库选了Element UI。项目结构按照页面维度拆分成前台用户端和后台管理端两套布局,路由层面用动态路由配合路由守卫做权限控制。
技术栈这块,Vue 2和Vue 3的差异有些人会纠结。如果你的目的是快速完成一个可用系统,Vue 2的生态确实更成熟,Element UI对Vue 2的支持也最稳定。如果是从学习角度出发且不在乎老代码兼容,Vue 3配合Element Plus也是完全可以的。我这个项目因为要同时兼顾稳定交付和学习参考,最终用的是Vue 2版本。
项目目录结构大致如下:
src/views/user/用户端所有页面,包括电影列表、电影详情、场次选择、选座、订单确认、支付src/views/admin/后台管理页面,包括电影管理、场次管理、订单管理src/components/公共组件,重点是SeatMap.vue选座组件src/api/所有Axios请求模块,按业务模块拆分src/utils/JWT处理、金额格式化等工具函数
4.2 选座组件的实现原理与交互细节
SeatMap.vue是整个前端最有技术含量的部分。它的核心逻辑是把后端返回的座位列表渲染成一个座位矩阵,并根据座位状态渲染不同的颜色和点击行为。
从接口拿到的数据是扁平的座位列表,每一条包含rowNo、colNo、status。前端在computed属性里把列表转换成二维数组:外层遍历行号,内层遍历列号,这样渲染出来的效果就是一个和放映厅实际布局一致的座位矩阵。
座位状态我用三种视觉样式区分:灰色是已售出(不可点击),绿色是可选(点击选中),蓝色是已选中(再次点击取消)。这里有个交互细节要特别处理:点击座位时需要实时累加已选座位的数量和总价,总价计算逻辑不仅仅看座位类型,还要结合场次的base_price,VIP座要在基础票价上加价。
用户选中座位后点击"立即购买",前端把场次ID和座位ID列表一起POST到后端创建订单接口。创建成功后跳转到订单确认页,展示订单详情和15分钟倒计时。倒计时结束后如果没有支付,前端会轮询后端获取订单最新状态,发现订单变更为"已取消"后自动跳转回选座页并刷新座位状态。
4.3 路由守卫与权限控制的实现
前后端分离的权限控制,前端靠路由守卫,后端靠JWT拦截。我的前端在路由配置里给后台管理的路由加了一个meta: { roles: ['ADMIN'] }标记,路由守卫在跳转时先从Vuex里取用户信息,判断角色是否匹配,不匹配就跳转到首页并提示"无权访问"。
还有一个细节是Axios请求拦截器。每次请求前从localStorage取Token,拼到请求头里。后端接口如果返回401状态码,前端就清掉本地Token并跳回登录页。如果返回业务错误码(比如座位已被锁定),前端就弹出Message提示并刷新座位状态。
这套交互流程走通之后,用户端的核心体验就完整了。当然,我在开发过程中也走了一些弯路,最典型的是选座接口的轮询时机——一开始只在倒计时结束的时候查一次订单状态,后来发现用户停留页面时间过长也会导致状态过期,于是改成了倒计时期间每5秒轮询一次,并在组件销毁时清理掉定时器。
5. 数据库脚本、源码组织与配套文档的整理规范
5.1 源码结构如何组织才能让别人一看就懂
一个项目的交付质量,很大程度体现在代码组织上。我见过很多项目功能实现了,但是源码目录乱得没法看,接手的人想改个功能要翻半天。这个系统我在目录组织上特意花了心思:后端严格按Controller、Service、Mapper、Entity分层,同时每个模块(电影、场次、订单、用户、座位)的代码都放在同一个包下,相关的类一眼就能找到。
以订单模块为例:
OrderController.java对外暴露创建订单、查询订单、支付确认、取消订单等接口OrderService.java承载核心业务逻辑,比如创建订单时的锁座事务OrderMapper.java与数据库交互Order.java实体类OrderStatusEnum.java订单状态枚举
前端源码同理,src/api/目录下按模块拆分了movie.js、session.js、order.js、user.js,组件和页面也按端别分开。这种组织方式最大的好处是:拿到源码的人可以按业务模块去阅读,而不是在一大堆文件里大海捞针。
5.2 数据库脚本设计的三个层次
附带的数据库脚本我设计成三个文件,对应三个层次的交付需求:
第一份是完整建库脚本,包含所有表的建表语句、外键约束、索引、初始数据。初始数据很关键,如果交付的数据库是空表,拿到项目的同学要花很多时间造数据才能把页面跑起来。我预置了10部电影信息、3个放映厅及其全部座位数据、一部分场次和演示订单。
第二份是示例数据脚本,独立于建表脚本,包含一批排列好的演示数据。为什么要单独拆开?因为有人可能想清空数据从零开始测试,也有人想直接看效果,两份脚本分开可以按需选择。
第三份是数据库变更记录,记录了我开发过程中对表结构的每一次修改。比如最初设计的orders表没有expire_time字段,后来是ALTER TABLE加上的;session_seat表最初也没有version字段,是在做并发优化时补上的。这份变更记录对于有二次开发需求的人特别有价值,能清楚了解表结构演进的原因。
5.3 部署文档的"别再让我踩你踩过的坑"
配套的部署文档我按"从零启动到跑通"的顺序写,包含环境要求、数据库初始化、后端配置修改、前端构建、启动顺序、端口说明、常见问题。环境要求部分明确写清楚了JDK版本(1.8)、Maven版本(3.6+)、Node版本(14+)和MySQL版本(5.7+),因为版本不匹配导致的启动失败在技术问答里占了很大比例。
后端配置部分重点说明了application.yml里的数据库连接串、Redis地址(如果有)、JWT密钥等必须修改的项。启动顺序我也写了:先初始化数据库,再启动后端(默认8080端口),最后启动前端开发服务器(默认3000端口),因为后端启动时要连接数据库,如果数据库没准备好会直接启动报错。
部署文档里最有价值的是"常见启动失败问题"这一节,我把自己在开发中遇到的启动类问题都整理进去了:
| 问题 | 原因 | 解决办法 |
|---|---|---|
| 后端启动报数据库连接失败 | 数据库未初始化或连接串错误 | 先执行建库脚本,检查application.yml配置 |
| 前端页面接口全部404 | 前端请求的baseURL与后端端口不一致 | 检查Vue的axios配置,统一改成后端实际端口 |
| 登录后Token无效 | JWT密钥上下文不一致 | 检查application.yml中jwt.secret是否与拦截器使用的一致 |
| 跨域报错 | 前后端分离跨域未配置 | 后端添加CORS配置类,允许前端开发服务器地址 |
6. 实际开发中踩过的坑与并发性能优化记录
6.1 并发抢座测试中暴露的超卖问题与修复
我把系统部署到测试环境后,用JMeter模拟了50个用户同时抢同一个场次的同一个座位,一共跑了3轮,其中有一轮真的出现了超卖——两张订单关联了同一个座位。
定位过程很典型。我把并发测试的日志打开,发现两个请求几乎同时打到了创建订单的Service方法上,两个线程都通过了"座位状态是空闲"的判断,然后都执行了更新座位的SQL。问题就出在我之前提到的"先查再改"模式上:两个请求的SELECT查到的是同一份快照,即使有事务,隔离级别也拦不住这种逻辑上的竞态。
修复方案就是我前面讲的乐观锁版本号机制。在session_seat表增加version字段,UPDATE语句强制携带版本条件,影响行数为0时说明数据已被修改,抛出"座位已被锁定"的业务异常。改完后我重新跑了同样的并发测试,50个并发请求,最终只有1个订单创建成功,其余全部正确返回了座位被锁的提示。这个修复也让我真正理解了为什么电商类项目下单都要走这种带条件的UPDATE而不是先SELECT再UPDATE。
6.2 数据库索引设计:哪个查询慢就优化哪个
项目开发到后期,我发现订单列表页在数据量增大后查询明显变慢。通过EXPLAIN查看执行计划,发现orders表按用户ID查询订单列表时走了全表扫描。
原因很直接:user_id字段没有建索引。我后来对订单表的user_id、status和create_time三个高频查询字段分别建了索引,优化效果立竿见影。同样的道理也用在了session_seat表上,这个表是session_id和seat_id的联合索引,因为所有座位状态查询都依赖这两个字段。
我的建索引经验是:优先给WHERE条件和JOIN条件里的字段建索引,不要盲目给所有字段都建索引——索引太多会影响写入性能,而且占用空间。尤其是订单表这种高频写入的表,索引数量要克制。
6.3 从单机到扩展:如果数据量变大该怎么做
这套系统在单机部署、单MySQL实例下跑日常流量完全够用,但如果某天业务量涨到足够大,有几个瓶颈会先出现:
第一个瓶颈是锁座的并发能力。乐观锁方案在极高并发下会有大量失败请求,体验会变差。那时候就得上Redis分布式锁,把"锁定座位"这个操作放到缓存层完成,用Lua脚本保证原子性,再异步同步到MySQL。这是从"能用"到"抗高并发"的必经之路。
第二个瓶颈是定时任务扫描订单的效率。我之前提到过用独立的待处理订单表来隔离扫描范围,这也是电商系统里常用的"分表分状态"思路。把海量历史订单和少量待处理订单分开,定时任务只处理一小撮数据,效率自然高。
第三个瓶颈是静态资源。电影海报、观影指南这些静态图片如果全部由后端服务提供,带宽和I/O都会成为压力。后续可以把图片服务迁移到对象存储加CDN,前端直接访问CDN地址,后端只存图片路径。
这些优化内容我没有全部在当前版本实现,但在文档的"后续扩展方向"一节里做了详细梳理。遇到有人问"如果量大了怎么办",至少能有一套清晰的演进路线,而不是一脸懵。
6.4 文档和源码交付前的最终检查清单
项目交付前,我列了一个检查清单,每一条都实际执行过:
- 数据库脚本能在空环境下完整执行,不报错,初始数据可用
- 后端代码能通过
mvn clean package正常打包,没有未解决的依赖冲突 - 前端代码能通过
npm run build正常构建,没有控制台报错 - 核心接口写好接口文档,包含请求参数、响应格式、业务错误码说明
- README里写清楚项目简介、技术栈、启动步骤、默认账号密码
- 源码目录中不包含
node_modules、target等构建产物目录 - 前后端联调用的演示账号密码统一写在部署文档中
这套检查清单看起来朴素,但很多项目交付出去后问题不断,往往就卡在这些基础环节上。数据库脚本跑不通的、前端一打包就报错的、默认账号密码没写的,这些问题我在做技术评审的时候见得太多了。一个项目能不能顺利跑起来,很多时候不是架构多先进,而是基础事项有没有做到位。
最后分享一点做这类完整项目的个人体会
这套系统从设计到落地,我最深的感受是:完整项目开发的难点不在于某个接口怎么写、某个页面怎么调,而在于把散落的功能串成一条完整的业务链路,并且保证链路中的每一步在异常情况下都能正确处理。选座时的并发、支付后的状态流转、超时后座位的释放,这些边界场景才是真正考验工程能力的地方。
对准备拿这个项目做毕业设计或者写进简历的同学,我的建议是:不要只满足于把功能跑通,试着去回答这几个问题——为什么session_seat表要单独设计?为什么更新座位状态时要带版本号?为什么订单过期时间要独立存储?把这几个"为什么"搞透了,项目面试环节你基本就稳了。系统源码和配套文档都是现成的,但这篇拆解文章里讲到的每一条设计决策背后的思考,才是这套资源真正的价值所在。