
我们直接用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 核心表结构我先设计核心的表字段不是越多越好够用就好。下表是电影表字段名类型说明idbigint主键film_namevarchar(100)电影名称cover_urlvarchar(255)海报地址directorvarchar(50)导演actorsvarchar(255)主演durationint时长分钟release_datedate上映日期statustinyint状态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_idUPDATE 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判断业务成功与否非常清爽。这个小设计贯穿整个项目一天下来能省下一大堆繁琐的重复代码。