前后端分离的Web电影院购票系统,几乎是SpringBoot+Vue+MyBatis+MySQL这套技术组合里最值得动手的项目之一。表面上看,它不过是一个“电影列表、场次展示、选座下单”的管理系统,但真正把代码写起来就会发现,座位锁定的并发问题、订单状态机的流转、场次与座位的关联建模,每一步都在逼你认真对待数据库事务、接口设计和前端交互。这篇文章按我自己从需求分析、建库建表、后端接口、Vue页面,再到服务器部署的完整顺序来写,重点记录那些常规文档不会提、真正敲代码才会踩到的坑。想用这个项目练手的人,以及正在做类似管理系统的开发者,可以直接照着往下走。
1. 为什么前后端分离的电影院购票系统值得自己敲一遍
1.1 一个小系统把企业开发的常见场景集齐了
电影院购票系统有两个端:用户端面对的是电影信息浏览、场次查询、座位选择、订单支付;管理端面对的是电影排片、影厅维护、场次创建、订单查询。这两个端一拆开,前端负责交互和展示,后端只提供数据接口,开发时可以并行,部署时前端静态页面和后端服务也完全可以分开上线。
关键是它不像普通“班级管理系统”那样只有增删改查。购票这个动作自带两个硬核问题:一是座位不能超卖,二是未支付订单要自动释放座位。这两个问题直接牵扯到数据库行锁、事务边界、定时任务,做完之后你对“后端到底难在哪”会有非常具体的体感。再加上支付状态流转、票码生成、订单历史查询,几乎把一个小型电商系统最核心的骨架都覆盖了。
1.2 选型对比:MyBatis和JPA的取舍
后端用SpringBoot没有悬念,生态成熟、起步快。ORM层面我坚持用MyBatis而不是JPA/Hibernate,原因很简单:这个系统的查询条件很杂——电影列表要按类型、上映状态、关键词动态过滤,座位要批量插入、条件更新,订单要联合票表查询。这些场景用MyBatis的动态SQL写起来非常直接,SQL是自己控制的,出问题也好排查。JPA在简单CRUD上很爽,但遇到稍微拧巴一点的查询,要么拼JPQL,要么写原生SQL,反而绕。
前端选Vue同样务实。Vue的组件化特别适合选座这种“头部信息+可变座位矩阵”的页面,数据驱动视图,选中、锁定、售出状态切换就是改一个状态数组的事。如果用jQuery写选座交互,DOM操作会把人绕晕。MySQL作为数据库也够用,InnoDB支持事务和行级锁,正好承接座位并发控制,完全不需要上更重的数据库。
另外说一句MyBatis-Plus。如果你只是想快速跑通项目,MP确实能省不少样板代码,但学习阶段我更推荐手写MyBatis XML,多写几段动态SQL比用内置方法更有收获。下面所有内容都按手写MyBatis来讲,换成MP也不冲突。
2. 购票核心链路的状态机:订单、座位、支付三方怎么协作
2.1 订单状态机的定义
购票不是一条直线“下单=成功”,中间至少隔了一次支付,还有超时取消、退款这些分支。我先把订单状态定死,后端所有操作都只认这个状态机:
| status | 含义 | 可以流转到 |
|---|---|---|
| 0 | 待支付 | 1(支付)/ 3(超时取消) |
| 1 | 已支付(已出票) | 2(已使用)/ 4(退款) |
| 2 | 已使用 | 无 |
| 3 | 已取消 | 无 |
| 4 | 已退款 | 无 |
实际代码里我不会到处写魔法数字,而是定义一个枚举或常量类。状态流转的核心原则:任何一步更新都必须在UPDATE的WHERE条件里带上当前状态,比如支付时写UPDATE orders SET status=1, pay_time=NOW() WHERE order_no=? AND status=0。这样即使接口被重复调用,第二次因为status已经是1,影响行为0,直接返回“请勿重复操作”。这就是最简单的幂等控制。
2.2 选座并发:数据库行锁才是靠得住的方案
选座是这个项目最值得研究的地方。两个人同时提交同一个座位,前端再花里胡哨也拦不住,因为HTTP请求到了后端就是两个并发事务。
我见过一些新手用Java的synchronized或者一个ConcurrentHashMap来标记座位,其实不太对——单机部署时可能能撑一阵,但一旦扩展到多实例,synchronized就不管用了。正确的姿势是把并发控制交给数据库:
createOrder方法里的核心逻辑是这几步:
- 开启事务(@Transactional)
- 对选中的每个座位执行条件更新:
UPDATE schedule_seat SET status = 1 WHERE schedule_id = #{scheduleId} AND row_no = #{rowNo} AND col_no = #{colNo} AND status = 0;- 如果UPDATE返回的行数不等于选座数量,说明有人抢先占了这个座位,直接抛异常,事务回滚。
- 插入订单记录和票表记录。
- 提交事务,座位状态和订单状态一起生效。
这个UPDATE语句的巧妙之处在于:InnoDB会对命中的索引行加锁,同一个座位的并发更新会串行执行;第二个事务执行同一个UPDATE时,因为status已经不满足为0的条件,更新行数为0,自然就被挡在外面。这比先SELECT再判断的写法更紧凑,也避免了“检查-更新”之间的时间差。
如果你更喜欢显式的锁,也可以用SELECT ... FOR UPDATE先锁住座位行再检查状态,效果类似,但要多一条SQL。我个人推荐条件更新这种写法,逻辑简单且不容易漏锁。
2.3 未支付订单的自动过期
订单创建后如果一直不支付,座位就永远锁着,所以要有过期机制。我设置15分钟有效,用一个Spring定时任务每分钟扫一次:
@Scheduled(cron = "0 */1 * * * ?") @Transactional public void cancelExpiredOrders() { List<Order> expiredOrders = orderMapper.selectExpired(15); for (Order order : expiredOrders) { orderMapper.updateStatus(order.getOrderNo(), 3); scheduleSeatMapper.releaseByOrderId(order.getId()); } }这里的releaseByOrderId把该订单对应的座位状态批量改回0。两条更新在同一个事务里,避免出现“订单取消了但座位还锁着”的中间状态。小流量下这个定时扫描够用,量大以后再换延迟队列或者消息中间件,思路是一样的。
3. 数据库建模:影院、场次、座位、订单四张表的关系与索引
3.1 核心表结构
这个项目我不做太复杂的权限和会员体系,核心表就六张:电影表、影厅表、场次表、场次座位表、订单表、票表。重点说后面三张。
场次表(schedule)是连接电影和影厅的中间表:
CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, film_id BIGINT NOT NULL, hall_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, base_price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, KEY idx_film_time (film_id, start_time), KEY idx_hall_time (hall_id, start_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;场次座位表是选座的依据,也是并发控制的核心:
CREATE TABLE schedule_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, row_no VARCHAR(4) NOT NULL, col_no INT NOT NULL, area TINYINT DEFAULT 0, -- 0普通区 1中间区 2VIP区 price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, -- 0可用 1锁定(待支付) 2已售出 UNIQUE KEY uk_schedule_seat (schedule_id, row_no, col_no), KEY idx_schedule_status (schedule_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单表记录一次购票的整体信息,票表记录每个座位的票码:
CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, expire_time DATETIME NOT NULL, pay_time DATETIME DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_create (user_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;3.2 场次座位数据从哪来
座位不是全局一张表,而是每个场次生成一批独立的座位记录。创建场次的时候,程序按照影厅的rows×cols循环插入schedule_seat,数量就是影厅容量。这个设计的好处是查询简单——选座页只需要WHERE schedule_id=#{}就能把该场次的座位全部拉出来,而且status是当场次的独立状态。
价格方面我用“基础价+区域价差”。base_price放在场次上,某个座位最终价格等于base_price加上所在区域的偏移,比如普通区不加钱,中间区加5元,VIP区加15元。area字段决定了前端坐位图上的颜色分组,也让选座页的底部价格计算有据可依。
3.3 索引不是越多越好,够用就行
这个系统的热点查询就三类:按电影查场次、按场次查座位、按用户查订单。我对应的三个索引分别是schedule的(film_id, start_time)、schedule_seat的(schedule_id, status)加唯一键(schedule_id, row_no, col_no)、orders的(user_id, create_time)和order_no唯一索引。
特别说一下schedule_seat的唯一键,它既保证了同一场次下“第A排3座”这种座位不会重复插入,又在并发更新时提供了一个明确的锁定目标,InnoDB行锁就是靠它定位行。我在本地用EXPLAIN验证过,按schedule_id查询座位是走索引的,没必要再叠加更多字段。索引建多了写入变慢,对这个小项目是负收益。
4. 后端接口分层、MyBatis动态SQL与事务边界
4.1 接口设计和统一返回
后端我按 Controller-Service-Mapper 三层来写,每个接口返回一个统一的Result封装,方便前端axios统一处理:
public class Result<T> { private int code; // 0成功,其他失败 private String msg; private T data; }核心接口大概这么几条:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/film/list | 电影列表,支持分页和条件 |
| GET | /api/film/{id} | 电影详情 |
| GET | /api/schedule/list?filmId= | 某电影的场次列表 |
| GET | /api/schedule/{id}/seats | 某场次的座位图 |
| POST | /api/order/create | 创建订单并锁座 |
| POST | /api/order/{orderNo}/pay | 模拟支付 |
| GET | /api/order/my | 我的订单 |
| POST | /api/admin/schedule | 管理端创建场次 |
用户登录用JWT,登录接口签发token,后续请求在Authorization头里带,后端用一个拦截器解析并把userId塞进ThreadLocal。管理端接口在拦截器里多校验一个角色字段就够了,不需要引入完整的安全框架。
4.2 MyBatis动态SQL的典型场景
第一个典型场景是电影列表的多条件查询。用户可能按类型、关键词、是否上映中筛,每个条件都可空,用动态SQL直接拼:
<select id="listByCondition" resultType="com.example.Film"> SELECT * FROM film <where> <if test="type != null and type != ''"> AND type = #{type} </if> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR director LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="showStatus != null"> AND show_status = #{showStatus} </if> </where> ORDER BY release_date DESC LIMIT #{offset}, #{pageSize} </select>第二个是批量插入座位。创建场次时一次性插入几十上百条座位记录,用foreach拼接,比逐条insert快很多。
第三个就是前面那个条件更新座位的语句。这三个场景基本覆盖了MyBatis最常用的高级写法:动态条件、批量操作、并发条件下的精确更新。
4.3 事务边界:什么时候必须加@Transactional
我总结下来这个项目有三处必须加事务:
一是createOrder,锁座和插订单必须一起成功或一起失败,否则会出现“扣了座位但订单没建”的问题。二是payOrder,更新订单状态和生成票码要在一个事务里。三是cancelExpiredOrders,取消订单和释放座位必须一致。
另外三个经验值:@Transactional默认只对RuntimeException回滚,受检异常要手动指定rollbackFor;同一个类内部方法调用不会走代理,所以事务最好放在Service公开方法上;锁操作和事务必须同生共死,事务提交时锁才释放,中途不能有其他查询插进来读到脏状态。
5. Vue前端:页面路由、选座交互与接口联调
5.1 页面结构和路由设计
前端页面分用户端和管理端两块。用户端有首页电影列表、电影详情、场次列表、选座页、支付页、我的订单;管理端有电影管理、场次管理、订单管理。路由我直接用Vue Router,配合懒加载:
{ path: '/film/:id', name: 'FilmDetail', component: () => import('@/views/FilmDetail.vue') }, { path: '/schedule/:id/seats', name: 'SeatSelect', component: () => import('@/views/SeatSelect.vue'), meta: { requiresAuth: true } }全局守卫里判断token,没有登录就跳登录页。管理端路由额外加一个role字段,守卫里校验角色,防止普通用户进后台页面。
5.2 选座页的实现思路
选座页是前端最复杂的部分。接口返回的是平铺的座位数组,每个元素有row_no、col_no、status、area、price。渲染时我先把数组按排分组,然后一排一排用CSS Grid画出来。
状态映射很直观:status=0显示浅色可点,status=1显示灰色不可点,status=2显示红色已售,用户点选后变成高亮。每次点击维护一个selected集合,底部实时计算总价——遍历selected里每个座位的price累加。提交时把scheduleId和选中的座位坐标传给createOrder接口,成功后跳转支付页。
这里必须提醒一句:选座页展示的座位状态只是后端接口返回的一个快照,两个用户同时打开同一场次,看到的状态可能都是“可选”。真正能不能选上,取决于创建订单时后端的行锁判断。前端做不了这个保证,所以创建订单失败时的处理也很重要——我建议失败后重新拉一次座位图,把被抢的座位标记成已锁,不要让用户以为卡了。
5.3 接口联调的两个必踩坑
第一个是开发环境的跨域。前端跑在Vite的5173端口,后端在8080,浏览器直接请求会跨域。我的做法是用Vite的proxy,在vite.config.js里把/api开头的请求代理到后端:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }配置好后前端代码里统一写/api开头,生产环境再交给Nginx反向代理,代码不用改。
第二个是Long类型精度丢失。这个坑特别阴:数据库主键、订单号如果用了雪花ID那种18位以上的Long,JavaScript的number类型只能安全表示到2的53次方,多出来的尾数会被截断,订单号看起来对,提交查询却永远查不到,非常诡异。解决方法是让Jackson把Long序列化成字符串。我在项目里这么配,一劳永逸:
@Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> builder.serializerByType(Long.class, ToStringSerializer.instance); }如果主键只是自增小ID,这个坑暂时不明显,但养成序列化习惯没坏处。
6. 部署到服务器的完整流程和三次踩坑记录
6.1 打包构建与进程管理
本地跑通只是第一步,真正上线又是一轮折腾。后端打包:
mvn clean package -DskipTests # 得到 target/cinema-backend-1.0.0.jar java -jar cinema-backend-1.0.0.jar --spring.profiles.active=prod &前端打包:
npm run build # 得到 dist 目录,静态文件服务器上我习惯把目录分成 /opt/cinema/backend、/opt/cinema/frontend、/opt/cinema/logs。Java进程用systemd管理,写一个service文件,启动命令指到jar,Restart=on-failure,日志输出到logs目录。这样进程崩了能自动拉起,比裸nohup靠谱。数据库初始化直接用mysql -u root -p cinema < cinemadb.sql导入,上线前把账号密码改成独立的专用账号,别用root裸奔。
6.2 Nginx反向代理配置
前后端分离部署最简单的方案就是Nginx同时管两件事:静态页面交给Nginx,接口反向代理到Java进程。我的核心配置长这样:
server { listen 80; server_name your-domain.com; root /opt/cinema/frontend; index index.html; location / { try_files $uri $uri/ /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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里有个细节:因为后端接口统一带/api前缀,proxy_pass后面不加URI路径,保持原路径转发。如果后端Controller里没写/api,那就要rewrite一下。我自己是坚持所有接口都挂在/api下,省得前后端约定不一致。
另外一个常见误解是“跨域”。开发时Vite代理解决了一次,部署后前端页面和接口同域(都是Nginx转发的),根本不存在跨域问题,反而有些项目里既是代理又是后端手工开CORS,出现两套CORS头互相干扰的情况。
6.3 三次踩坑的完整排查记录
第一个坑:刷新页面404。前端用了history模式,Vue Router管理的路径在刷新时Nginx没有这个物理文件,直接404。一开始我以为是打包问题,翻了半天dist目录才意识到是Nginx找不到对应的index入口。解决办法就是配置里的try_files $uri $uri/ /index.html,让所有未知路径都回退到index.html。
第二个坑:MySQL 8连接报时区错误。本地库好好的,换到服务器上启动就一直报时区相关错误,提示server time zone。原因是JDBC URL里没带时区。改成下面这样就好:
url: jdbc:mysql://127.0.0.1:3306/cinema?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai顺带把连接参数里的UseSSL也处理掉,很多新版驱动默认要SSL,没配置会警告甚至报错。
第三个坑:电影海报传不上来。管理端在线上传大图一直返回503,本地没事。查了一圈是Nginx的默认body大小限制1m,后端Spring Boot默认单文件上传限制也是1m,双端限制任何一边不调都会失败。后端配置里设置 multipart.max-file-size=10MB,Nginx里加 client_max_body_size 10m,重启后就好了。这类“本地没事线上不行”的问题,排查思路就是一层层看限制配置,客户端、网关、后端逐层排除。
7. 压测后的性能心得与后续还能怎么扩展
7.1 实测验证座位不超卖
系统跑通后,我写了个简单的并发测试脚本:两个账号同时请求同一个场次同一个座位的createOrder接口,模拟抢座。结果是两个请求只有一个成功,另一个收到“座位已被选”的提示,数据库里该座位status=1,订单也只有一条。这个结果说明条件更新的行锁方案是生效的。
我顺便用并发工具多开了100个线程循环下单,观察了一下CPU和数据库连接池的占用。瓶颈最后落在MySQL连接上,而不是Java代码。这里有个心得:连接池初始大小不要开得太大,20到50足够,连接过多反而拖垮数据库。出现死锁也不怕,MySQL会主动回滚其中一个事务,应用层捕获异常重试一次就好。
7.2 值得继续做的几个扩展
如果想把项目往上抬一个档次,我个人觉得最值的几个方向:用Redis缓存首页电影列表和热门场次的座位快照,减少数据库压力;把订单超时的定时扫描换成延迟队列,精确到秒级处理;给创建订单接口加一个简单的限流,防止刷单;支付模块可以接第三方支付平台的沙箱环境,把“模拟支付”换成真实流程。每个方向都不难,但加进去之后,这个项目从“练手作业”到“能写进简历的项目”的质感会完全不同。
最后说一点我做完这个项目最有体感的体会:前后端分离的项目,真正难的地方从来不在页面,也不在接口本身,而在那些跨表、跨状态、跨系统的一致性问题上——座位锁了订单没建怎么办、订单付了款票没出怎么办、用户支付成功但接口超时又点了一次怎么办。把这些问题一个一个问题摆到桌面上想清楚,数据库事务、幂等设计、状态机这些概念就不再是面试题了。这套电影院购票系统之所以值得动手,就是因为它能用一个足够小的体量,把这些真实问题都装进来。