我们直接用Spring Boot上手一个"在线电影购票系统",整个过程我会从项目结构的设计思路讲起,一直拆到具体的表结构、接口实现、座位锁定的坑,以及订单超时怎么处理这些细节。
1. 项目整体设计与技术选型
1.1 业务功能范围
在线电影购票系统,本质上是一个典型的电商交易平台,但比普通商品交易多了几个特殊逻辑:场次与座位绑定、座位临时锁定、订单超时释放。核心功能划分如下:
- 用户端:注册登录、电影浏览、场次查询、在线选座、下单支付、订单查询、取消订单。
- 管理端:电影信息管理、影厅管理、场次排片、订单管理、数据统计。
做一个这样的系统,最核心的不是CRUD写得多快,而是业务状态的流转设计。比如一个座位,从"可选"到"锁定"到"已售出",中间经历哪些状态、谁负责释放、超时怎么处理,这都是一开始就要想清楚的问题。
对于学习型项目,技术选型可以不用特别花哨,但必须具备代表性。我的方案如下表:
| 模块 | 技术选型 | 说明 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.x | 生态成熟,快速构建 |
| 持久层 | MyBatis-Plus | 减少SQL编写量,内置分页插件 |
| 数据库 | MySQL 8.x | 存储业务数据 |
| 缓存 | Redis | 分布式锁、首页缓存、Token管理 |
| 前端 | Vue 3 + Element Plus | 前后端分离开发 |
| 接口文档 | Knife4j | 自动生成在线API文档 |
| 认证方式 | JWT | 无状态登录。安全可控 |
这个组合的最大好处是:每一层都有明确的代表技术,覆盖了大多数Java后端岗位要求的技能点。另外,它们之间的整合方案网上资料很多,遇到问题容易查到解决方案。
1.2 模块划分与项目结构
后端工程采用标准的Maven多模块设计,虽然单模块也能跑,但多模块可以让职责边界更清晰,也方便以后的扩展。我实际的包结构大致如下:
movie-ticket/ ├── film-service # 电影模块:影片、影厅、场次 ├── order-service # 订单模块:选座、下单、支付、超时处理 ├── user-service # 用户模块:注册、登录 └── common-module # 公共模块:统一返回、异常处理、工具类如果觉得多模块工程繁琐,单体应用把包分开写也是可以的,关键是在代码里把逻辑隔离好。项目的核心业务几乎全部围绕"场次"和"座位"进行,所以这两个模块的代码一定要保持足够的自由度,避免后续要加功能时无从下手。
1.3 整体流程拆解
用户购票的路径很清晰,但每个环节都要考虑异常情况:
用户选电影 -> 查看排片场次 -> 选择座位 -> 创建订单 -> 座位锁定 -> 支付 -> 出票从开发角度,这里最需要抠细节的是"选择座位"和"创建订单"两步。座位是有限的共享资源,两个人同时选同一个座位,就只能有一个人成功,这是典型的并发问题。所以我在设计的时候,把座位状态作为一个独立的维度来管理,不用订单状态去反推座位状态,这样逻辑会清晰很多。
2. 数据库设计:一切业务的基石
2.1 核心表结构
我先设计核心的表,字段不是越多越好,够用就好。下表是电影表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| film_name | varchar(100) | 电影名称 |
| cover_url | varchar(255) | 海报地址 |
| director | varchar(50) | 导演 |
| actors | varchar(255) | 主演 |
| duration | int | 时长(分钟) |
| release_date | date | 上映日期 |
| status | tinyint | 状态:1上映中,0已下架 |
影厅表和常规场景差不多,重点是座位数目和排数、列数的配置。场次表需要关联影厅,存储开始时间和结束时间。如果有特殊定价策略,比如早场半价、节假日调价,可以扩展一个price_factor字段,这里先不展开。
座位表要单独说明一下,它是整个系统的容量瓶颈所在。我的设计是:
CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, hall_id BIGINT NOT NULL COMMENT '影厅ID', seat_row VARCHAR(10) NOT NULL COMMENT '排号', seat_col VARCHAR(10) NOT NULL COMMENT '列号', seat_type TINYINT DEFAULT 0 COMMENT '0普通座 1情侣座 2残疾人座', UNIQUE KEY uk_hall_row_col (hall_id, seat_row, seat_col) );座位是物理存在的,不会随场次改变,因此它只和影厅绑定。每个场次的座位状态则由另一张表记录,也就是"场次座位表"。这个表才是判断座位是否能被选的关键:
CREATE TABLE schedule_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL COMMENT '场次ID', seat_id BIGINT NOT NULL COMMENT '座位ID', status TINYINT DEFAULT 0 COMMENT '0可选 1锁定 2已售出 3故障', order_id BIGINT DEFAULT NULL COMMENT '锁定/售出的订单ID', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_schedule_seat (schedule_id, seat_id) );2.2 状态设计的目的
很多新手会把座位状态直接写入orders表,用订单的字段去判断座位是否被占,表面看没问题,但一旦涉及"锁定中"这种中间状态,查询就会变得蹩脚。单独用schedule_seat表,状态一目了然,业务操作也直接:选座时改状态,取消时改回来,不需要在订单表里加额外的状态位。
订单表和常见的电商订单表差异不大,核心字段包含订单编号、用户ID、场次ID、总金额、状态、创建时间、支付时间、取消时间。值得一提的是一次多买几个座位的情况,我建议订单表只存总价,座位关系通过order_id关联schedule_seat表,一对多由子表维护。
订单状态我定义为:
| 状态值 | 含义 | 说明 |
|---|---|---|
| 0 | 待支付 | 下单成功,锁定座位 |
| 1 | 已支付 | 支付成功,完成出票 |
| 2 | 已取消 | 用户主动取消或超时取消 |
| 3 | 已退款 | 支付后申请退款 |
2.3 索引设计的细节
索引这块很多人容易忽略,但等到数据量上来再改Schema就很痛苦了。我的建议是最少保证以下几组索引:
- schedule_seat表:(schedule_id, status)联合索引,查某个场次的可选座位会非常快。
- orders表:(user_id, create_time)联合索引,用户查询历史订单常用。
- schedule表:(film_id, show_date)联合索引,查某天某部电影的场次。
索引不是越多越好,但上面这几个是根据实际查询场景反推出来的,属于"命中刚需"。加索引之后,查询速度从全表扫描变成索引扫描,体感非常明显。
3. 核心功能模块实现
3.1 用户注册登录与JWT认证
用户模块本身不复杂,但有几个点需要注意。密码存储一定不能明文,我使用的是BCryptPasswordEncoder,每次加密的盐值不同,即使两个用户密码相同,密文也不一样,防止彩虹表攻击。
注册流程里建议加入"确认密码"、"手机号格式校验"、"用户名唯一性检查",这些都是前端后端都要做一遍的。后端校验是最后的防线,前端校验只是为了体验。
登录成功后签发JWT,我设置的有效期是24小时。JWT里只放userId和userName,不存敏感信息。请求拦截器里解析Token,把userId放入ThreadLocal,方便后续业务使用。为了避免Token被窃取,我们要求前端在请求头携带Authorization字段,并且必须使用HTTPS协议部署。
3.2 电影列表与场次查询
电影列表接口支持分页和筛选,筛选条件一般有"正在上映"、"即将上映"、"按类型"等。这个接口比较简单,但查询量可能很大,首页可以加入Redis缓存。我用Redis缓存首页正在上映的电影列表,key设计为film:now_showing:page:1,缓存时间为10分钟。电影排片变动不频繁,10分钟完全够用。
查看场次时,需要返回影厅信息、放映时间、还有座位图。这里有一个性能优化点:查询场次详情时,不要把座位表全部返回,只返回"不可选座位"的集合即可。前端拿到不可选列表后,把所有座位坐标画出来,可选座位自然就出来了。这样传输的数据量会小很多。
3.3 在线选座与座位锁定
在线选座是系统的核心体验,也是唯一有并发写冲突的地方。最初我用的方式是"先查再卖",也就是查询座位状态为可选,然后创建订单,再更新座位状态。但两个用户同时查到可选,就可能出现超卖。
这里一定要用数据库层面的原子操作来保证,不能靠应用层判断。我用的方案是条件更新:
UPDATE schedule_seat SET status = 1, order_id = #{orderId} WHERE schedule_id = #{scheduleId} AND seat_id = #{seatId} AND status = 0如果更新的影响行数为1,说明抢座成功;如果为0,说明座位已经被别人锁定或售出。这个方案简单有效,不需要引入分布式锁,在单库场景下完全可以扛住并发。
多用户的并发问题解决后,还有事务问题。创建订单和锁定座位必须在同一个事务里完成,否则会出现订单没建上但座位锁了的尴尬情况。我用@Transactional包裹整个方法,任何一步失败都会整体回滚。
3.4 订单超时与座位释放
订单超时处理是系统的隐藏难点。用户锁定座位但迟迟不支付,座位不能一直被占着。业界常见的方案有三种:
| 方案 | 实现思路 | 优缺点 |
|---|---|---|
| 定时任务扫描 | 定期扫描超过5分钟未支付的订单并取消 | 实现简单,但存在延迟,有扫描压力 |
| Redis延迟队列 | 下单后写入带过期时间的Key,过期后监听触发回调 | 实时性好,但需要额外开发,Redis版本需支持Key过期事件 |
| RocketMQ延迟消息 | 使用消息中间件的延迟等级消息 | 扩展性强,但引入了额外中间件 |
在学生项目或学习项目中,定时任务扫描是首选。我使用Spring自带的@Scheduled注解,每30秒执行一次,查询创建时间超过5分钟且状态为待支付的订单,批量取消并释放座位。这个方案实现成本低,逻辑也清晰。唯一要注意的是定时任务的执行时间要避开高峰期,并且要做好日志记录,方便观察执行情况。
如果要追求准实时,可以在下单时把订单号写入Redis,设置过期时间为5分钟,监听Redis Key过期事件。这种方法实现起来也不难,但需要考虑机器宕机导致的事件丢失,所以支付成功后要主动删掉Redis Key,同时定时任务兜底扫描。
3.5 支付模块的模拟实现
真实的支付流程一般要对接微信支付或支付宝,接入流程较长,还不一定有商户号。学习项目可以直接做一个模拟支付接口,把支付时的操作全部走通:查询订单 -> 校验订单归属 -> 校验订单状态 -> 修改状态 -> 更新座位状态为已售。
这里有一个易错点:支付成功后更新座位状态,不能把"锁定中"的座位全部改成"已售出",而是要根据订单号更新。如果没有带order_id条件,很可能把别人的座位也改了。所以在释放或售出的SQL里,必须同时带上order_id:
UPDATE schedule_seat SET status = 2 WHERE schedule_id = #{scheduleId} AND order_id = #{orderId}3.6 前端页面设计与交互
前端使用Vue 3和Element Plus,页面包括首页、电影详情页、选座页、订单确认页、个人中心。我最满意的部分在选座的交互,这是整个项目里最考验前端功力的模块:
- 座位图用CSS Grid布局,根据影厅行列数据动态生成。
- 已售和锁定座位渲染为灰色不可点击。
- 可选座位点击后高亮为绿色,再点取消。
- 底部实时显示已选座位列表、总价格。
前端选座时把座位ID暂存在本地变量中,提交订单时一次性传给后端。这是标准的做法,不是因为"懒得多写请求",而是为了减少座位被锁定的时间。如果每点一个座位就调一次接口,用户只要犹豫30秒,第一个座位就被超时释放了,体验非常差。
4. 性能优化与安全性加固
4.1 缓存策略
这个系统的热点数据集中在电影列表和场次信息,我把它们都做了缓存。
- 电影列表缓存粒度按分页来,key为
film:list:{current}:{size},缓存的value是JSON字符串。 - 场次信息按场次ID缓存,key为
schedule:{scheduleId}。 - 用户会话信息用JWT本身的无状态特性,不存Redis,减少存储压力。
Redis虽然快,但也要注意缓存穿透和雪崩风险。我的处理方案是:查询数据库之前先用布隆过滤器拦截明显不存在的ID,同时给缓存设置随机过期时间,避免同一时间大量Key同时失效。
4.2 防刷与限流
购票场景天然存在黄牛风险,所以我在接口层加了一个简单限流:每个用户每分钟最多调用选座接口20次。实现方式是使用Redis的INCR命令,每次都重置过期时间为60秒,如果计数超过阈值就拒绝请求。
String key = "rate:selectSeat:" + userId; Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1) { redisTemplate.expire(key, 60, TimeUnit.SECONDS); } if (count > 20) { throw new BusinessException("操作过于频繁,请稍后再试"); }这个方案简单粗暴但有效。在实际商业系统中,还会结合IP限流、设备指纹识别,逻辑会更复杂,但核心思想都是一样的:在资源消耗前把风险拦截住。
4.3 SQL注入与XSS防护
持久层使用MyBatis-Plus后,常规的SQL注入风险已经很低,因为参数都是预编译的。但我还是会在编写自定义SQL时保持警惕,凡是拼接条件的地方一律用#{},不用${}。前端展示用户输入的字段,比如昵称、评论,需要做HTML标签转义,避免存储型XSS。
另外,所有管理端接口都要校验管理员身份。我使用拦截器统一处理:请求URL以/admin/开头时,解析JWT,如果角色不是ADMIN则直接返回403。这种统一处理的方案比在每个Controller里写重复的判断代码要优雅得多。
4.4 日志与异常处理
我在项目中统一使用了全局异常处理器@RestControllerAdvice,业务异常会返回固定的错误码和提示信息,未知异常返回"系统繁忙"并记录完整堆栈到日志文件。日志中打印的关键信息包括:请求路径、请求参数、用户ID、异常类名。生产环境排查问题时,这几项缺一不可。
还有一个小技巧:在创建订单和支付回调的方法中,我会额外打印业务日志,包括订单号、座位ID列表、当前状态。线上出现问题的时候,这些日志能直接定位到具体环节,避免在茫茫代码里瞎猜。
5. 项目部署与常见问题排查
5.1 本地开发环境搭建
开发环境我使用的是Docker Compose来管理MySQL和Redis,好处是环境一致性好,换电脑也能一键起服务。下面是我常用的docker-compose.yml片段:
version: '3' services: mysql: image: mysql:8.0 container_name: ticket-mysql environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: movie_ticket ports: - "3306:3306" volumes: - ./mysql-data:/var/lib/mysql redis: image: redis:7.0 container_name: ticket-redis ports: - "6379:6379"后端服务启动前修改application.yml里的数据库连接配置,前端项目npm install之后npm run dev,本地就能跑起来了。为了方便联调,我在后端配置了CORS跨域,允许localhost:5173的请求。
5.2 常见问题与解决思路
我在开发和测试过程中遇到过不少问题,挑几个有代表性的列出来:
问题一:两个请求同时锁定了同一个座位
这个问题的原因是第一版代码采用了"先查询后更新"模式。解决办法就是上面说到的条件更新SQL,把查询和更新合并成一个原子操作。这是并发问题里最常见的思路:与其防止并发发生,不如让并发发生时只有一个能成功。
问题二:订单超时但座位没有释放
这个情况多半是定时任务的执行时间设置太长,或者SQL更新条件写错。排查时先看日志确认定时任务有没有执行,再手动执行一次释放SQL看看影响行数。如果影响行数为0,检查订单状态字段是否匹配。
问题三:前端选座后提交订单报"座位不可选"
这个大概率是因为用户在页面停留太久,订单已经被超时取消,座位被释放后又被别人选了。前端拿到错误码后要做友好提示,引导用户返回重新选座。另外可以把下单前的二次确认做成弹窗,提醒用户确认场次和时间,减少因犹豫产生的此类问题。
问题四:支付成功但订单还是待支付状态
先检查支付接口有没有走到Service层的成功分支,再查有没有事务加在正确的方法上。一个经典小坑是事务方法内部调用了this.xxx(),导致事务注解失效。解决办法是使用注入的Bean来调用,或者把事务方法体抽到另一个类中。
5.3 测试与演示数据
为了快速演示项目效果,我在resources目录下准备了一个data.sql,包含10部电影、3个影厅、若干场次和座位数据。启动项目时Spring Boot会自动执行SQL,插入初始数据。这样不管是自己演示还是给别人看效果,都不用一步步在后台创建数据。
演示数据的编写也有讲究:场次时间必须合理,不能上午10点排一场,10点10分又排一场。我一般会在22点到24点生成数据,影厅只做部分排片,这样时间上看起来自然很多。
6. 扩展与改进思路
项目做到这个程度已经可以完整演示,但如果想拿去作为毕业设计或面试作品,可以进一步扩展以下功能:
- 电影评论与评分模块
- 电影详情页的预告片播放
- 优惠券系统与积分商城
- 基于Redis的分布式Session
- 使用消息队列削峰填谷,应对抢票高峰
- 引入Elasticsearch做电影搜索,支持模糊匹配和拼音搜索
- 座位分区定价,比如前两排半价、IMAX厅加价
这些扩展方向都是"加分项",但要注意不要在毫无保留地加需求时把自己带进坑里。比如引入Elasticsearch后,又要处理数据同步问题;引入消息队列后,又要处理消息可靠性。每引入一个组件都意味着系统复杂度的增加,务必量力而行。
根据我的实战经验,把一个项目做好做深,比做很多个项目却每个都是半成品要强得多。在线电影购票系统的业务闭环很完整,非常适合用来训练从需求分析到系统设计再到编码实现的全流程能力。如果正在准备暑期实习或者校招,把这样一个项目吃透,面试官问到项目细节时也会让你有话可说。
最后提一个细节:接口统一返回结构别忽略,我在common模块里定义了全局的Result对象,包含code、message、data三个字段。所有接口都返回这个结构,前端拿到之后按code判断业务成功与否,非常清爽。这个小设计,贯穿整个项目,一天下来能省下一大堆繁琐的重复代码。