SpringBoot+Vue影院购票系统:从数据库设计到并发选座实战
经常有人问我想练手一个完整的全栈项目该做什么既要能体现业务深度又不能大到一做就是半年。我第一个想到的就是影院购票系统它比纯 CRUD 多了一道并发选座的门槛又比电商平台简单得多非常适合用来打通从数据库表设计、后端事务控制到前端交互、部署上线的全流程。今天要分享的这套“洋州影院购票管理系统”就是用 SpringBootVue 搭起的前后端分离项目持久层用 MyBatis数据库用 MySQL。名字里的“洋州”是虚构影院品牌纯当项目代号用。文章会从业务链路、数据库设计、后端选座事务、前端座位图、部署踩坑几个角度完整过一遍给正在找项目做、或者准备拿这类题目做练手工程的人一个可复现的模板。1. 购票系统的业务链路与选型逻辑1.1 前台购票和后台管理分别要解决什么问题很多同学看到“管理系统”四个字第一反应是“无非就是增删改查”。但影院购票系统不是这么简单。它其实是前台交易系统和后台管理系统的合体两条线交错在一起才是完整的业务闭环。前台购票链路大概是这样的用户注册登录拿到 JWT token。浏览电影列表查看影片详情、海报、时长。选择一个场次看到场次时间、影厅、票价、剩余座位。进入选座页面座位图按影厅布局渲染已售和锁定座位置灰。选中座位后提交订单后端锁住对应座位生成待支付订单。用户完成模拟支付支付回调更新订单状态座位从“锁定”变成“已售”。用户可以到订单中心查看自己的订单和座位信息。后台管理端则是另一条线影厅管理维护影厅的行列数定义哪些座位是通道或维修位。排片管理给电影分配影厅和场次新增场次时同步生成该场次的全部座位记录。订单管理查看所有订单、处理退款、核对座位状态。基础数据维护电影上下架、场次改价。这两条线里最能体现水平的是“同一场次同一座位两个用户同时点选座怎么保证只有一个人能抢到”。这其实是整个系统的核心考点。所谓的“管理”管理的是座位状态、订单状态以及它们之间的流转关系。1.2 为什么坚持用 SpringBoot MyBatis MySQL技术选型上我见过不少人纠结是 MyBatis 还是 JPA用不用 MyBatis-Plus。这套项目我最终选了 SpringBoot MyBatis MySQL原因很直接这个场景里最关键的“锁座”操作需要一条完全可控的 UPDATE 语句原生 MyBatis 写 XML 最直白。三套方案在实际中的差别大概是这样的方案优点缺点适合场景MyBatisSQL 完全可控复杂条件更新一眼能看懂简单 CRUD 也要写 XML略繁琐本项目这种有并发要求的交易系统MyBatis-Plus开箱即用单表 CRUD 非常快并发更新、条件更新的细节容易被封装隐藏后台管理、CMS 一类系统Spring Data JPA实体关系映射清晰批量条件更新、行锁写起来绕反直觉内部工具、纯业务处理系统SpringBoot 就不用多解释了内嵌 Tomcat配置简化起步快。MySQL 则是免费可靠事务支持成熟在很多小型生产项目里都是第一选择。前端 Vue3 Element Plus 组件生态完善座位图这种高频交互页面用组件写起来效率高也容易维护。这套组合本身不追求新潮但足够稳适合做完整全栈项目的起点。2. 数据库设计四层模型和两张状态表2.1 座位基础表、场次座位表为什么要分开影院的数据模型可以拆成四层电影层、影厅层、场次层、订单层。最容易出错的是座位建模。第一种思路是在影厅表里存一个 JSON 二维数组表示“第几排第几列有没有座位”。新增场次直接把模板拷贝过去。这种方案对前端渲染确实方便但一旦要做“该场次哪些座位已售”、“锁定 5 排 3 座”、“统计余座”这些操作就得在 JSON 里做字符串处理没法走索引性能也难保证。更别提以后想给座位分普通区、VIP 区定不同价格这种结构根本扛不住。我强烈推荐第二种方案独立seat表存影厅的基础座位再用一张session_seat中间表关联场次和座位并在中间表上保存状态。新增场次时把影厅的全部座位复制到session_seat。这样“锁座”本质上是更新中间表某几行的状态SQL 清晰、索引明确、后续扩展空间也大。排片时初始化场次座位的逻辑大概是这样的Transactional public void publishSession(PublishSessionRequest req) { ShowSession session new ShowSession(); session.setFilmId(req.getFilmId()); session.setHallId(req.getHallId()); session.setStartTime(req.getStartTime()); session.setEndTime(req.getEndTime()); session.setPrice(req.getPrice()); sessionMapper.insert(session); ListSeat seats seatMapper.selectByHallId(req.getHallId()); for (Seat seat : seats) { sessionSeatMapper.insert(session.getId(), seat.getId()); } }这个复制动作很关键。如果漏了选座页就会空白一片。实际项目里我甚至会在发布场次后加一个校验统计session_seat数量是否等于影厅seat数量不一致直接报错回滚。2.2 订单状态流转如何驱动座位状态变化座位状态我用 tinyint0可用任何用户都可以选。1锁定已被某个未支付订单占用。2已售支付完成不可再选。3不可售影厅边界、维修位。订单状态也对应设计0待支付。1已支付。2已取消。3已退款。4支付中用户已调起支付但还没收到回调。两个状态机之间的对应关系很明确锁定座位对应待支付订单座位从锁定变已售说明订单已经支付成功座位从锁定回到可用说明订单被取消或超时释放。一个订单买多个座位用order_seat中间表关联而不是在订单表里塞一个座位 ID 数组。原因很简单以后要支持退单个座位、统计某场次卖了多少座一张中间表都能用 SQL 解决。2.3 核心建表脚本与几个字段设计细节下面是这套项目核心表的精简版建表 SQL。-- 电影表 CREATE TABLE film ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, duration INT NOT NULL COMMENT 片长(分钟), poster VARCHAR(255), status TINYINT DEFAULT 1 COMMENT 1上映 0下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 影厅表 CREATE TABLE cinema_hall ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, row_count INT NOT NULL, col_count INT NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 座位基础表 CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, hall_id BIGINT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, UNIQUE KEY uk_hall_seat (hall_id, row_no, col_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 场次表 CREATE TABLE show_session ( 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, price DECIMAL(10,2) NOT NULL, KEY idx_film_time (film_id, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 场次座位表新场次生成后把影厅所有座位拷贝到这里 CREATE TABLE session_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0可用 1锁定 2已售 3不可售, lock_user_id BIGINT, lock_time DATETIME, UNIQUE KEY uk_session_seat (session_id, seat_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, session_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退款 4支付中, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME, UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id), KEY idx_status_time (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单座位关联表 CREATE TABLE order_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, session_seat_id BIGINT NOT NULL, UNIQUE KEY uk_order_seat (order_id, session_seat_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;额外说三个字段层面的经验金额一律用DECIMAL(10,2)不要用 float 或 double避免浮点误差影响支付。时间字段统一DATETIME连接串里固定serverTimezoneAsia/Shanghai否则后面会出现整张表时间差 8 小时的玄学问题。表之间我基本不建物理外键全部用逻辑外键加索引。原因很简单高并发下外键约束会带来额外的锁开销代码层保证一致性更灵活也方便以后拆库。3. 并发选座实现一次 UPDATE 解决超卖3.1 选座下单事务的完整代码这一章是整个项目含金量最高的地方。选座接口要保证用户提交的座位要么全部锁定成功要么全部失败不能出现锁了一半然后报错的状态。我的实现思路是三步走锁定场次主记录串行化、条件更新座位、生成订单。代码大概长这样Override Transactional(rollbackFor Exception.class) public CreateOrderResult createOrder(Long sessionId, ListLong sessionSeatIds, Long userId) { // 1. 锁定场次记录让同一场次的下单请求排队处理 ShowSession session showSessionMapper.selectByIdForUpdate(sessionId); if (session null) { throw new BusinessException(场次不存在); } if (session.getStartTime().before(new Date())) { throw new BusinessException(本场已开演停止售票); } // 2. 条件更新只有 status0 的座位才会被锁住 int lockedCount sessionSeatMapper.lockBatch(sessionId, sessionSeatIds, userId); if (lockedCount ! sessionSeatIds.size()) { throw new BusinessException(所选座位已被其他人锁定请重新选择); } // 3. 座位锁定成功生成待支付订单 String orderNo generateOrderNo(); Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setSessionId(sessionId); order.setTotalAmount(session.getPrice().multiply(BigDecimal.valueOf(sessionSeatIds.size()))); order.setStatus(0); order.setCreateTime(new Date()); orderMapper.insert(order); for (Long sessionSeatId : sessionSeatIds) { OrderSeat os new OrderSeat(); os.setOrderId(order.getId()); os.setSessionSeatId(sessionSeatId); orderSeatMapper.insert(os); } return new CreateOrderResult(order.getId(), orderNo); }对应的 Mapper XML 是这把锁的核心update idlockBatch UPDATE session_seat SET status 1, lock_user_id #{userId}, lock_time NOW() WHERE session_id #{sessionId} AND status 0 AND id IN foreach collectionsessionSeatIds itemsid open( separator, close) #{sid} /foreach /update这里有个细节前端传来的座位 ID 我统一用的是session_seat.id不是seat.id。因为座位图接口返回的本来就是场次座位记录用主键做更新和关联最直接避免再绕一次场次影厅转换。3.2 条件更新为什么能扛住并发很多教程会教“先查座位状态如果是 0再 update 成 1”。这种先查后改的写法在并发下必炸。两个请求可能同时查到 status0然后先后执行 update最终两个订单都以为自己锁座成功。我这里的做法是把“查状态”和“改状态”合并成一条带 WHERE 条件的 UPDATE。数据库行锁会保证同一时刻只有一个事务能更新某一行。第二个事务执行同一条 UPDATE 时第一次的执行已经提交那一行的 status 已经变成 1第二个事务的 WHERE 条件status 0匹配不到受影响行数为 0直接走失败分支。也就是说这场“抢座”的胜负不是靠 Java 代码判断的而是靠 InnoDB 的行锁和 UPDATE 的返回行数决定的。多用户同时抢最后一个座位也没关系库底层的行锁会排队谁先提交谁赢。也许有人会问为什么不用 Redis 分布式锁答案很简单引入 Redis 要多维护一个组件而且在这种场景里数据库本身已经提供了足够强的保证。先用SELECT ... FOR UPDATE把场次记录锁住同一场次的下单请求就完全串行了再配合条件更新双保险。只有当单场并发量到了每秒几千单、数据库成为瓶颈时才值得把座位预扣缓存到 Redis 去抗流量。3.3 10 分钟未支付自动释放座位用户锁了座但不支付座位不能一直占着。我用 Spring 自带的定时任务每分钟扫一次超时未支付订单。关键逻辑是按订单维度驱动而不是直接扫座位Scheduled(cron 0 * * * * ?) public void cancelExpiredOrders() { Date expireTime new Date(System.currentTimeMillis() - 10 * 60 * 1000L); ListOrders expiredOrders orderMapper.selectByStatusAndCreateTimeBefore(0, expireTime); for (Orders order : expiredOrders) { // 取消订单 orderMapper.updateStatus(order.getId(), 2); // 释放座位回滚为可用 ListLong sessionSeatIds orderSeatMapper.selectSessionSeatIdsByOrderId(order.getId()); sessionSeatMapper.releaseBySessionSeatIds(sessionSeatIds); } }releaseBySessionSeatIds也是一条条件更新update idreleaseBySessionSeatIds UPDATE session_seat SET status 0, lock_user_id NULL, lock_time NULL WHERE status 1 AND id IN foreach collectionlist itemsid open( separator, close) #{sid} /foreach /update注意 WHERE 里也带status 1防止某些极端情况下把已支付座位给释放掉。这种“更新条件里永远带上旧状态”的习惯写到哪都能少出事。定时任务还有一个隐藏边界需要处理用户已经点了支付但回调还没到这时候 10 分钟超时触发订单被取消用户却扣款成功了。我在实际项目里会把用户点击支付那一刻的订单状态改成 4支付中定时任务只取消状态为 0 的订单。支付中订单留给支付回调去处理如果回调超时再由对账任务去第三方平台查单。这一层完全够用也能跟面试官讲清楚为什么订单状态机里要多一个“支付中”。3.4 支付回调的幂等处理真实第三方支付的流程是用户支付成功后支付平台服务器主动请求我们的回调接口。这个接口必须幂等因为支付平台可能因为网络原因多次发送回调。我这里的实现是PostMapping(/api/pay/notify) ResponseBody public String notify(RequestBody PayNotifyVO vo) { if (!payService.verifySign(vo)) { return failure; } payService.handlePaySuccess(vo.getOrderNo(), vo.getPaidAmount()); return success; }Service 层里最关键的是先查状态再决定是否继续Transactional public void handlePaySuccess(String orderNo, BigDecimal paidAmount) { Orders order orderMapper.selectByOrderNo(orderNo); if (order null) { throw new BusinessException(订单不存在); } // 幂等订单已经是终态重复回调直接忽略 if (order.getStatus() ! 0 order.getStatus() ! 4) { return; } orderMapper.updateStatus(orderNo, 1, new Date()); ListLong sessionSeatIds orderSeatMapper.selectSessionSeatIdsByOrderId(order.getId()); sessionSeatMapper.markSoldBatch(sessionSeatIds); }本地联调的时候如果接不了真实支付最简单的方法是写一个模拟支付页点击“确认支付”后直接请求本地的一个回调接口同时把订单状态从 0 改成 4再调用handlePaySuccess。这样整条链路跟真实支付行为是一致的以后接真实渠道只需要换回调地址和验签逻辑。4. 前端 Vue 项目座位图、登录态与联调细节4.1 座位图渲染二维布局和一维数组的配合后端返回的座位图接口很简单给当前场次的行数、列数、以及该场次所有座位的列表。每一行座位的rowNo和colNo其实就是它在二维布局里的坐标。前端用 CSS Grid 渲染最省事。只要确定列数Grid 会自动把一维数组里的座位按顺序排进对应格子。核心代码script setup import { ref } from vue import { loadSessionSeats, createOrder } from /api/order const props defineProps({ sessionId: { type: Number, required: true } }) const cols ref(0) const seatList ref([]) const selectedIds ref([]) async function loadSeats() { const { data } await loadSessionSeats(props.sessionId) cols.value data.cols seatList.value data.seats } function toggleSeat(seat) { if (seat.status ! 0) return const index selectedIds.value.indexOf(seat.id) if (index 0) { selectedIds.value.splice(index, 1) } else { selectedIds.value.push(seat.id) } } async function submit() { if (!selectedIds.value.length) return const { data } await createOrder(props.sessionId, selectedIds.value) window.location.href /pay/${data.orderNo} } loadSeats() /script template div classseat-map :style{ gridTemplateColumns: repeat(${cols}, 36px) } div v-forseat in seatList :keyseat.id classseat :class{ selected: selectedIds.includes(seat.id), sold: seat.status 2, locked: seat.status 1 } clicktoggleSeat(seat) {{ seat.rowNo }}-{{ seat.colNo }} /div /div /template样式上可用座位用浅色边框自己选中的用主题色高亮已锁和已售的直接置灰。前端选中状态只是“愿望单”最终能不能锁座成功以接口返回为准。所以createOrder如果返回座位已被锁定的错误要立刻重新调用loadSeats()刷新座位图。4.2 axios 封装与登录态所有接口请求统一走一个 axios 实例好处是拦截器里集中处理 token 和错误。核心封装import axios from axios const service axios.create({ baseURL: /api }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) service.interceptors.response.use(resp { if (resp.data.code 40100) { localStorage.removeItem(token) window.location.href /login return Promise.reject(new Error(请先登录)) } return resp }) export default service登录成功后把后端返回的 token 存进 localStorage路由守卫里判断没有 token 就跳登录页。管理端接口再做一层角色校验user表里用 role 区分普通用户和管理员拦截器里检查 JWT 里的角色字段不是管理员直接拒绝。4.3 Long 精度丢失一次必现的联调事故前端的“大坑”通常不在页面而在数据精度。Java 的 Long 类型最大能到 2^63但 JavaScript 的 Number 安全整数上限是 2^53-1。MyBatis-Plus 默认的雪花 ID 一上来就是 19 位前端拿到之后精度已经丢了用这个 ID 去查订单、查详情后端查不到表现就是“明明数据存在接口却一直返回失败”。本地数据量小的时候自增 ID 可能只有几位不容易出现。但只要你把主键生成策略换成雪花 ID或者数据量上来之后 ID 位数变长这个坑必现。解决办法有两个。一是在实体类字段上加注解JsonSerialize(using ToStringSerializer.class) private Long id;二是全局配置让所有 Long 序列化成字符串Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder - builder .serializerByType(Long.class, ToStringSerializer.instance) .serializerByType(Long.TYPE, ToStringSerializer.instance); }我倾向第二种省得每个字段都加注解。但要注意如果代码里有 Long 类型的时间戳字段也会被转成字符串前端如果直接拿它做日期计算要重新 parse。所以更精细的做法是只对主键字段用注解时间字段保持数字。4.4 开发环境的跨域代理前后端分离项目最常见的第一个报错就是跨域。开发阶段最简单的处理是在 Vite 配置里开代理让前端请求“看起来”是同源的server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }后端可以不配任何 CORS 配置因为生产环境我们也会用 Nginx 做同样的事情。前后端联调时统一用/api前缀不要在前端代码里写死 IP这样环境切换只改代理配置或 Nginx不用动代码。5. 打包部署与六个常见故障排查5.1 从零启动到本地跑通的完整步骤如果你想把这套系统拉到本地跑起来步骤按这个顺序来最顺安装 JDK 8、Maven 3.6、Node 16、MySQL 8。执行建表 SQL创建cinema_db数据库。修改后端的application.yml把数据库账号密码换成自己的。后端启动mvn spring-boot:run。前端启动先npm install再npm run dev。浏览器访问http://localhost:5173。后端配置里有两个容易忽略的点。第一个是数据库连接串一定要写这样的完整格式spring: datasource: url: jdbc:mysql://localhost:3306/cinema_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true第二个是 MyBatis 的map-underscore-to-camel-case。没开这个配置create_time映射不到createTime返回的对象字段全是 null前端拿到的就是一堆空数据排查起来特别容易懵。5.2 Nginx 与后端进程的部署姿势生产环境我一般用 Nginx 托管前端静态文件同时把/api路径反向代理到后端服务。先在前端项目里执行npm run build把生成的dist目录传到服务器上后端打成一个 jar 包跑起来。Nginx 配置长这样server { listen 80; 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; } location / { try_files $uri $uri/ /index.html; } }最后这个try_files很关键。前端用的如果是 history 模式路由刷新/order/list这种页面时Nginx 找不到对应文件会直接 404。加上try_files ... /index.html之后所有前端路由都回退到index.html由 Vue Router 自己处理。5.3 常见故障速查表这套项目从开发到部署我真正花时间排查的问题大概能归纳成下面几类现象原因处理方式登录接口报数据库连接失败MySQL 没启动或账号密码错误先 ping 库确认服务和凭据接口返回字段全是 nullMyBatis 没开驼峰映射开启map-underscore-to-camel-case时间全部差 8 小时数据库连接串没指定时区连接串加serverTimezoneAsia/Shanghai前端请求接口 404代理路径没统一前缀统一用/api检查 Nginx 代理刷新页面就 404history 模式没回退Nginx 加try_files新增场次后选座页空白排片时没生成session_seat检查排片接口补上座位拷贝逻辑这里我要单独强调一下“新增场次后选座页空白”这个坑。它不会报错但就是查不到数据。原因百分百是排片时漏了给场次生成座位记录。我见过不止一个同学把这个 bug 带到联调现场在前后端反复找问题最后发现场次表里压根没有座位数据。所以第二章排片代码里那个循环拷贝座位的步骤千万别省。回头看这套系统我最大的体会是项目难度不取决于代码量而是取决于状态流转和并发边界。影院购票系统的核心就是“座位状态条件更新 订单状态机”这两条线把它俩理清楚业务代码写起来会顺很多。写代码之前我建议先把所有状态枚举和流转关系列出来哪怕手写在纸上都行它能帮你省掉一大半的调试时间。后面我计划在这套项目的基础上把排片冲突校验、退票流程和座位分区定价补完等攒到新的经验再回来分享。