1. 项目整体设计与技术选型
1.1 需求分析与功能模块拆解
影院购票系统的第一件事,不是撸代码,而是把需求彻底拆清楚。我当时迭代了三轮需求才跑通完整业务流程,这里直接把最终版的功能矩阵摆出来,后面所有设计都是围绕这张表展开的。
- 用户端:注册登录、电影浏览与详情查看、场次选择、在线选座、订单支付、订单查询与退票、个人中心与评价。
- 管理端:影片信息管理、影厅与座位维护、排片计划、订单管理、退票审核、每日票房统计与热门影片分析。
- 公共能力:短信通知(可选)、优惠券、积分体系(扩展)。
这里面最折磨人的不是 CRUD,而是三个核心场景:选座的并发一致性、订单支付后的状态流转、排片冲突检测。这三个点直接决定了系统能不能扛住高峰期。
设计原则我锁定三条:前后端分离、无状态认证、数据库只放最终结果。实时热点数据(比如座位状态、热门影片缓存)全部交给 Redis,给数据库减负。
1.2 技术栈选型:为什么是 Spring Boot + Vue 前后端分离
选型之前,我对比过传统 JSP 单体方案和 Spring Boot 前后端分离方案。如果是学生作业或者小团队内部项目,JSP 加 Bootstrap 确实更快;但考虑到后期要接小程序端、外卖平台合作入口,API 化的后端明显更合适。
最终我确定的技术栈是这样一套:
- 后端框架:Spring Boot 2.7.x + MyBatis-Plus + Spring Security + JWT
- 缓存与分布式锁:Redis 5.x + Redisson
- 数据库:MySQL 8.x(InnoDB 引擎)
- 前端:Vue 3 + Element Plus + Axios + Vite
- 接口文档:Swagger/Knife4j
- 部署:Docker Compose(Nginx + 后端容器 + MySQL + Redis)
选 Spring Boot 的原因很直接:自动装配把大量配置从 XML 里解放出来,内嵌 Tomcat 让部署只需要一个 jar 包。相比 SSM 时代的各种 XML 配置,Spring Boot 在工程化效率上确实好太多。MyBatis-Plus 则让单表 CRUD 几乎不用写 SQL,复杂的多表关联查询仍然手写 SQL,取长补短。
1.3 数据库设计:从表结构看业务逻辑
数据库表我设计了 10 张核心表,这里说几个重点:
user:用户表,存储账号密码加密串(BCrypt)、手机号、角色标识(USER/ADMIN)。movie:影片表,包含片名、海报 URL、导演、主演、时长、上映日期、状态(热映/即将上映/下架)。hall:影厅表,记录影厅名称、座位行数、座位列数、座位总数。session:排片表,关联电影和影厅,包含放映时间、结束时间、票价、余票量。结束时间由电影时长自动计算,用于排片冲突检测。hall_seat:影厅座位表,为每个影厅生成物理座位记录,行列坐标唯一。orders:订单表,包含订单号、用户 ID、场次 ID、总价、状态(待支付/已支付/已出票/已退票/已取消)。order_seat:订单座位关联表,记录订单锁定了哪些座位。
这里有个设计细节:为什么不把座位直接存在订单表里?因为一个订单可以买多张票,而且后续需要根据座位维度做退票和排座校验,拆成关联表查询效率和扩展性都更好。为了避免订单表无限膨胀,超过 30 分钟的待支付订单由定时任务统一关单,状态置为已取消并释放座位。
数据库层面我不建议建物理外键,所有关联关系都用业务字段维护。线上高并发场景下,物理外键的约束校验会拉长事务时间,而且分库分表时外键约束直接成为障碍。用逻辑外键,靠代码保证一致性,这是我在项目里踩出来的经验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排片管理与冲突检测
2.1 排片计划的核心逻辑
排片管理是整个系统业务复杂度最高的模块,因为一个影厅不能在同一个时间段放两部影片。我设计了 session 表来承载排片信息,管理人员选择电影、影厅、日期和开始时间后,后端需要自动检测冲突。
冲突检测的 SQL 逻辑是这样的:查找同一个影厅、同一天、且时间上有重叠的排片记录。
sql复制SELECT COUNT(*) FROM session
WHERE hall_id = #{hallId}
AND show_date = #{showDate}
AND status = 1
AND (
(start_time < #{endTime} AND end_time > #{startTime})
)
两个时间段判断重叠的条件是:startTime < 已有记录.endTime 并且 endTime > 已有记录.startTime。这个条件比简单的时间先后判断要严谨得多,覆盖了包含、交叉、相邻等所有场景。
2.2 排片时间的自动计算
电影时长信息在 movie 表里维护,排片时只需要选择开始时间,结束时间由 start_time + duration + 20分钟清洁时间 自动算出。这 20 分钟是为了给影厅保洁和进场留出缓冲,避免后一场观众入场时前一场还没散场。
自动计算结束时间之后,还要做两个校验:
- 结束时间不能超过影厅当天营业时间(比如 23:59)。
- 两场排片之间必须有至少 10 分钟间隔。
这个间隔校验和冲突检测是两套逻辑,冲突检测保证不重叠,间隔校验保证体验。实际运营中,热门大片排片密集,间隔可以放宽到 5 分钟,冷门电影间隔需要拉长到 30 分钟以上,所以我把间隔做成影厅级别的配置字段,而不是硬编码。
2.3 排片状态与前端展示的联动
排片的状态字段我设计了四个:待上映、售票中、已结束、已取消。用户端只能看到 售票中 状态的场次,管理员端可以看到全部。
前端展示排片时,按日期分组、按影厅筛选取数据,这里有一个性能问题:如果直接查询数据库,影片列表、场次列表、座位图三块数据需要多次请求。我后来加了 Redis 缓存接口,key 设计为 session:list:{movieId}:{date},缓存 600 秒,热门影片的场次列表基本不会再压到数据库上。
3. 在线选座与并发防超卖
3.1 座位状态存储:Redis 位图方案
在线选座是购票系统的核心体验,也是并发问题最集中的地方。一个影厅 100 个座位,如果每个用户选座时都查 MySQL,高峰期每秒几十个请求,数据库压力很大,而且座位状态的实时一致性很难保证。
我最终采用 Redis 位图存储座位状态。每个场次用一条 Redis 位图记录:
bash复制# 场次 1024 的座位状态位图,偏移量 0-99 对应 100 个座位
SETBIT session:seats:1024 0 0
SETBIT session:seats:1024 1 1
位图中 0 表示可售,1 表示已售或锁定。Redis 的 SETBIT 指令和 GETBIT 指令都是 O(1) 复杂度,100 个座位的状态查询内存消耗极小。初始化时,用 BITCOUNT 可以快速统计余票数量。
3.2 选座的分布式锁与乐观锁
用户提交选座请求时,需要同时锁座位和锁订单。我的方案是分段锁:按座位 ID 加分布式锁,而不是锁整个场次,这样用户 A 选 1 号座、用户 B 选 2 号座互不阻塞,只有抢同一个座位的请求才会冲突。
选座的核心代码逻辑:
java复制public boolean lockSeat(Long sessionId, Long seatId, Long userId) {
String lockKey = "seat:lock:" + sessionId + ":" + seatId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 等待 3 秒,自动释放 10 秒
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
return false;
}
// 检查座位是否已被占用
Boolean sold = redisTemplate.opsForValue()
.getBit("session:seats:" + sessionId, seatId.intValue());
if (Boolean.TRUE.equals(sold)) {
return false;
}
// 标记座位锁定
redisTemplate.opsForValue()
.setBit("session:seats:" + sessionId, seatId.intValue(), true);
// 创建订单(待支付状态)
createPendingOrder(sessionId, seatId, userId);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
lock.unlock();
}
}
这里用一个重要设计:先锁 Redis 位图,后写 MySQL 订单。Redis 操作成功后,MySQL 写入失败或者订单超时,需要有一个事务性保障。我的办法是:Redis 座位状态标记了占位,但库存扣减并不直接操作 MySQL,而是通过订单状态驱动座位释放。
注意:分布式锁的释放一定要放在 finally 中。另外
tryLock的等待时间不要太长,用户不可能忍受 3 秒以上无响应,我们实际设置为 2 秒等待、8 秒自动释放。
3.3 订单超时与座位释放
用户选了座位但 30 分钟内没支付,座位必须释放。这个功能我用 Spring Boot 自带的 @Scheduled 定时任务实现,每 30 秒扫描一次待支付订单,发现超时就将订单状态改为已取消,同时把 Redis 位图中的对应座位重置为 0。
java复制@Scheduled(cron = "0/30 * * * * ?")
public void timeoutOrderHandler() {
Date timeout = new Date(System.currentTimeMillis() - 30 * 60 * 1000);
List<Orders> expiredOrders = orderMapper.selectTimeoutOrders(timeout);
for (Orders order : expiredOrders) {
// 更新订单状态为已取消
orderMapper.updateStatus(order.getId(), OrderStatus.CANCELLED);
// 释放 Redis 座位
List<Long> seatIds = orderSeatMapper.selectSeatIdsByOrderId(order.getId());
for (Long seatId : seatIds) {
redisTemplate.opsForValue()
.setBit("session:seats:" + order.getSessionId(), seatId.intValue(), false);
}
}
}
定时任务方案有个隐患:如果服务重启或者任务执行失败,超时订单不会被清理。我在实际项目中加了一层补偿:用户查询订单列表时,如果发现待支付订单超过 30 分钟,顺手触发一次校验和释放。这种"被动清理"是最后一层兜底,确保不会出现座位被永久占用的极端情况。
4. 订单支付与状态流转
4.1 订单状态机设计
订单状态如果只用数据库字段存一个数字,代码里到处判断状态会越来越混乱。我引入了状态机概念,明确每个状态的流转条件和路径。
| 当前状态 | 触发事件 | 目标状态 | 说明 |
|---|---|---|---|
| 待支付 | 用户支付成功 | 已支付 | 同步锁定座位 |
| 待支付 | 超时未支付 | 已取消 | 释放座位 |
| 待支付 | 用户主动取消 | 已取消 | 释放座位 |
| 已支付 | 系统出票 | 已出票 | 生成电子票信息 |
| 已出票 | 用户申请退票 | 退票中 | 进入人工审核或自动审核 |
| 退票中 | 审核通过 | 已退票 | 原路退款,释放座位 |
| 退票中 | 审核拒绝 | 已出票 | 恢复为有效票 |
状态机的好处是,业务逻辑不会因为后续增加功能(比如改签)而变得不可维护。我在代码里用枚举 + 一个状态转换校验方法实现,状态更新时自动检查是否合法流转。
4.2 支付回调的幂等处理
对接微信支付时,最头疼的问题是回调通知可能重复发送。微信支付官方建议商户在收到回调时,先验签,再处理业务逻辑,并且要保证对同一笔订单的重复回调不产生重复的更新。
我的幂等处理方案很简单:使用 orderId 作为唯一维度,更新订单状态时加一个条件判断。
java复制public boolean paySuccess(String orderNo, String transactionId) {
// 乐观锁方式更新:只有当前状态为待支付时才能更新为已支付
int result = orderMapper.updateStatusIfPending(
orderNo, OrderStatus.PAID, OrderStatus.PENDING_PAY);
if (result == 1) {
// 更新成功,生成票务信息
ticketService.generateTickets(orderNo);
return true;
}
// 更新失败说明订单已被处理过,直接返回成功,避免重复通知
return true;
}
这个方式比"先查再更新"的写法更可靠,因为它把判断合并在一条 SQL 里,天然避免了并发下的竞态条件。
重大教训:生产环境里遇到过一次回调处理超时导致用户付款却显示未支付。排查后发现是回调处理函数里做了太多事情:发短信通知、更新统计、推送微信模板消息,这些耗时操作不应该放在回调链路里。正确做法是:回调里只做验签和订单状态更新,其他通知一律通过 MQ 异步处理。
4.3 票号生成与座位信息记录
用户支付成功后,需要生成电子票号。票号我采用 日期 + 场次ID + 订单序号 的组合方式,例如 T202401151024001,保证唯一性的同时方便人工排查。
由于一张订单可能包含多个座位,票号生成放在订单维度,每张票关联一个座位。生成后维护一份 ticket 表,记录订单号、场次 ID、座位行列、二维码内容。二维码内容就是票号,前端用 QRCode.js 生成二维码图片。
5. 数据统计与报表模块
5.1 日票房统计的思路
影院管理员最关心的是每天卖了多少票、收入多少、哪些影片贡献最大。我实现了日票房统计接口,按日期和影片维度聚合订单数据。
sql复制SELECT
movie_name,
COUNT(DISTINCT order_id) AS order_count,
SUM(ticket_count) AS ticket_sold,
SUM(amount) AS total_amount
FROM orders
WHERE status IN ('PAID', 'ISSUED')
AND pay_time >= #{startTime}
AND pay_time < #{endTime}
GROUP BY movie_id
ORDER BY total_amount DESC;
统计查询在数据量大时会很慢,尤其是在月底拉全月数据时。我做了两层优化:
- 建了
daily_statistics表,每天凌晨定时任务对前一天的数据做预聚合,用户查看历史统计时直接读表。 - 实时查询只覆盖当天,数据量可控,索引建在
pay_time和status上。
5.2 热门影片与上座率计算
上座率 = 售出座位数 / 影厅座位总数 × 100%。这个指标在排片决策中很有用,电影快下映时如果上座率依然高,可以适当增加排片场次。
上座率的计算需要同时拿到 session 表的座位总数和 orders 表已售座位数。我的实现是:Redis 中维护 session:sales:{sessionId} 计数器,每次订单支付成功时 INCR,统计时直接从 Redis 读取,不查询数据库。
bash复制# 场次 1024 已售座位数
DECR/INCR session:sales:1024
经验提示:Redis 计数器在服务重启后需要从 MySQL 恢复初始值。我写了一个
RebuildSessionCacheRunner,实现CommandLineRunner接口,应用启动时扫描当天和下一天所有有效场次,重新初始化座位位图和销售计数。
6. 项目实战踩坑与调试经验
6.1 跨域问题的完整解决过程
前后端分离开发,最经典的问题是前端请求接口时出现跨域错误。使用 Vue 开发时,本地地址是 http://localhost:5173,后端是 http://localhost:8080,两者端口不同,浏览器会拦截跨域请求。
我的解决方式分两步:
第一,后端写一个跨域过滤器,允许指定来源访问:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("http://localhost:*", "http://127.0.0.1:*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
第二,生产环境前端打包后由 Nginx 同源部署,通过 /api 前缀反向代理到后端容器,这样就绕过了跨域问题。开发环境用 Vite 的 proxy 配置来做代理转发,效果一样。
踩坑提示:
allowedOrigins和allowCredentials(true)不能同时使用*,否则会报错。Spring Boot 2.4 之后推荐用allowedOriginPatterns。
6.2 本地开发时 Spring Boot 版本匹配口诀
做课程设计或者个人项目时,Spring Boot 版本不是越新越好。我发现很多同学直接下载最新的 Spring Boot 3.x,然后发现 JDK 8 不兼容,或者 MyBatis-Plus 版本对不上,各种报错。
我自己用的稳定组合是:
- JDK 1.8 + Spring Boot 2.7.18 + MyBatis-Plus 3.5.3 + Redis 客户端 Lettuce
- 如果项目必须用 JDK 17,再考虑 Spring Boot 3.x
版本匹配的核心原则:Spring Boot 2.x 对应 JDK 8/11/17,Spring Boot 3.x 最低要求 JDK 17。如果没有特殊需求,直接选 2.7.x 最省心,因为资料最多、踩过的坑都有答案。
6.3 数据库连接池与事务超时
开发阶段可能感觉不到连接池配置的重要性,但一到测试阶段并发一上来,就会遇到 Connection is not available, request timed out 这样的错误。
我把 HikariCP 连接池单独拎出来调优过:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
事务方面,选座和创建订单这两个操作必须放到同一个事务里,否则会出现座位锁定了但订单没建成的脏数据。@Transactional 注解默认只回滚运行时异常,遇到 Exception 类型的非运行时异常需要显式指定 rollbackFor = Exception.class,这一点经常被忽略。
血泪教训:我一开始写
@Transactional没有加 rollbackFor,前台上传的 json 解析异常属于受检异常,事务不会回滚,导致出现悬空座位,找了好久才发现是这个细节。
7. 性能优化与部署上线
7.1 首页接口聚合优化
影院售票系统的首页要展示正在热映电影、即将上映电影、今日推荐场次,如果前端分别调三个接口,首页加载要 1 秒以上。优化方案是后端聚合为一个 homePageData 接口,返回整个首页所需数据,Redis 中缓存 5 分钟,key 为 home:page:data。
Redis 缓存刷新策略:新电影上架、场次变动、热门影片状态变化时,主动删除对应缓存,让下次请求重新查询数据库。这里用 Redis 的 DEL 就能解决问题,不需要复杂的双删策略,因为压力不大。
7.2 MySQL 慢查询排查
开发过程中,我开启过 MySQL 慢查询日志,发现一个隐藏的性能问题:order_seat 表查询座位状态时,由于没有索引,全表扫描要 200ms 以上。后来给外键字段 order_id 和 seat_id 加了联合索引,查询直接降到 10ms 以内。
sql复制ALTER TABLE order_seat ADD INDEX idx_order_seat (order_id, seat_id);
7.3 Docker Compose 一键部署
本地开发没问题之后,我把整个项目打包成 Docker 镜像,用 Docker Compose 一键启动整套环境。
yaml复制version: "3.8"
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: cinema_db
ports:
- "3306:3306"
volumes:
- ./mysql/conf:/etc/mysql/conf.d
- ./mysql/data:/var/lib/mysql
networks:
- cinema_net
redis:
image: redis:7.0
ports:
- "6379:6379"
networks:
- cinema_net
backend:
build: ./backend
ports:
- "8080:8080"
depends_on:
- mysql
- redis
environment:
DB_HOST: mysql
REDIS_HOST: redis
networks:
- cinema_net
nginx:
image: nginx:1.24
ports:
- "80:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d
- ./frontend/dist:/usr/share/nginx/html
depends_on:
- backend
networks:
- cinema_net
networks:
cinema_net:
driver: bridge
部署时注意 MySQL 容器初始化 SQL 脚本位置,docker-entrypoint-initdb.d 目录下挂载 init.sql,容器首次启动时会自动执行建库建表脚本,省去手动导数据的麻烦。
提示:如果是课程设计,建议把 Dockerfile 和 docker-compose.yml 一并提交到项目仓库里。答辩时直接演示
docker-compose up -d一条命令拉起完整环境,比在答辩现场手动启动 MySQL、Redis、后端、前端四套服务要稳得多。
8. 常见问题与排查技巧速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 前端访问后端接口报 403 | 跨域配置缺失或 JWT 过滤器拦截了预检请求 | 在过滤器链中放行 OPTIONS 请求;配置 CorsConfig 允许前端来源 |
| 用户提交订单后座位显示未锁定 | Redis 座位位图未初始化 | 检查服务启动时是否执行 initSessionSeats 初始化任务,确保所有有效场次的座位位图都已创建 |
| 同一座位被两个用户同时选中 | 分布式锁未生效 | 检查是否用同一 Redis 实例;确认锁 key 是否包含 sessionId 和 seatId |
| 支付回调发生死锁 | 回调里同时更新订单和座位,出现并发锁竞争 | 回调中先更新订单,再操作 Redis;避免嵌套事务 |
| 后台管理系统列表页加载很慢 | 分页查询未走索引 | 检查 WHERE 条件字段是否单独建索引;对大表用慢查询日志定位 |
| 定时任务跑完后订单状态没变 | 任务执行时抛异常被吞掉 | 在定时任务方法体里打印日志,捕获异常并告警 |
| 上传电影海报后前端不显示 | 静态资源被 Spring Security 拦截 | 在 Security 配置中放行 /upload/** 和 /static/** 路径 |
| 数据库连接经常超时 | 连接池参数不合理 | 增大 maximum-pool-size;检查是否存在连接泄漏,事务未关闭 |
