ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot影院售票系统实战:从数据库设计到并发锁座全解析

Spring Boot影院售票系统实战:从数据库设计到并发锁座全解析 1. 为什么选影院售票管理系统做实战项目——选题思路与需求拆解每年到毕业设计季Spring Boot 相关的选题总会被翻来覆去地选图书馆管理系统、宿舍管理系统、校园二手交易平台说实话已经有点审美疲劳了。我当初选影院售票管理系统一个很直接的原因是它看起来简单但真正拆解下来业务链条比绝大多数管理系统都要完整从用户端看电影、选座、下单、支付到管理端排片、定价、统计票房这一套流程做下来前中后端的技术点全都能覆盖到。做一次这样的项目比做三个普通的 CRUD 管理后台学到的都要多。先把这个项目当成一个真实的产品来思考而不是当成一个应付答辩的作业。影院售票的核心参与者有两类人一类是普通用户也就是买票看电影的观众另一类是影院管理员负责维护电影信息、安排场次、管理影厅和座位。用户侧的功能大概是注册登录、浏览电影列表、查看电影详情和场次、选择座位、下单支付、查看订单管理侧则是电影信息的增删改查、影厅和座位管理、场次排片、订单管理和基础的数据统计。这里有一个很关键的认知重构这个项目真正的难点不在管理而在售票本身。售票意味着有库存、有并发、有状态流转——座位就是库存一个场次的座位总数是有限的两个用户同时买同一场次的同一排座位必须只有一个人能成功订单状态从待付款到已支付到已出票到已使用每一步都有边界条件。传统的增删改查管理系统里根本不会遇到这些问题。所以我在做这个项目的时候刻意把重心放在了影院这个具体场景上而不是套一个通用的XX管理系统模板。这也是答辩时最容易拉开差距的地方别人在讲登录注册怎么实现你可以讲并发锁座和订单状态机怎么设计。拆解完需求接下来就是落地。我的建议是不管你要不要用这份源码都先自己把模块画一遍明确每个模块的输入输出和依赖关系。我当初画的模块划分是这样的用户模块注册、登录、个人信息、购票记录电影模块电影信息维护、上下架状态、影片分类影厅模块影厅信息、座位布局定义几排几座、哪些座位是特殊座位场次模块电影和影厅的组合、放映时间、票价、座位状态快照订单模块选座、锁座、下单、支付回调、出票、取消、退款统计模块票房汇总、热门电影排行、场次上座率这六个模块之间是有清晰调用链路的电影 影厅 - 场次 - 选座 - 订单 - 支付 - 统计。把这个链路理顺了后面写代码脑子里就是一条线不会东一榔头西一棒。2. 技术栈选型与项目骨架搭建——从空目录到跑起来技术选型这件事我在踩过几次坑之后得出的经验是不要追求新技术要追求你讲得清楚的技术。Spring Boot 作为主框架是没得跑的生态成熟、资料多面试官和答辩老师都认出了问题也最容易找到解决方案。但我见过有人选 Spring Cloud 那一套微服务全家桶来做毕业设计最后连服务注册发现都没讲明白反而给自己挖坑。我这里用的组合比较常规也是大多数毕设项目的合理选择技术组件选型说明后端框架Spring Boot 2.7.x稳定资料多兼容性好ORM 框架MyBatis-Plus单表 CRUD 省事分页好用数据库MySQL 8.0业务数据存储缓存Redis缓存热点数据、分布式锁选座鉴权JWT Spring Security无状态登录认证接口文档Spring Doc OpenAPI方便演示和答辩时展示接口前端Vue 3 Element Plus前后端分离效果直观选 Spring Boot 2.7 而不是 3.x不是 3.x 不好是 2.7 的坑我都踩平了遇到问题能一眼看出来。比如 Spring Boot 3 里 javax 包全换成 jakarta 了网上的旧教程很多直接不能用如果你前期参考资料主要靠博客2.7 会让你少折腾很多无谓的兼容性问题。项目骨架我用 Maven 来构建目录结构按职责分包尽量不搞那种一层 Controller 一层 Service 一层 Mapper 就完事的三层死板结构。我的建议是按业务模块分包com.example.cinema ├── common // 通用类统一返回体、全局异常、工具类 ├── config // 配置类Redis、JWT、CORS、Swagger ├── security // Spring Security 与 JWT 过滤器链 ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // MyBatis-Plus 的数据访问层 ├── entity // 数据库实体 ├── dto // 前端传入参数 ├── vo // 返回给前端的视图对象 └── enums // 状态枚举订单状态、场次状态、座位状态这个分法最核心的好处是每个包职责一目了然答辩老师问你这个项目的代码结构是怎样的你直接能把这一套框架说得清清楚楚。而不是说controller 接受请求service 处理业务mapper 操作数据库这种教科书标准答案。基础配置里最容易被忽略的几个点端口与上下文路径。开发环境我用的 8080但为了后期部署方便在application.yml里把上下文路径设置为/api所有接口统一前缀。这样前端代理的时候不用单独处理多个前缀也能避免和其他服务冲突。数据库连接池参数。不要直接用默认值。我在application.yml里配置了 HikariCP 的连接池大小和超时时间。影院售票的流量模型有明显的峰值——一场热映电影的票放出来可能短时间内几百个人同时抢连接池太小会直接报connection is not available。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/cinema_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000初始化数据。项目跑起来之后第一件事就是要有数据可看否则页面空空荡荡演示效果很差。我在schema.sql里一次性准备了 10 部电影、4 个影厅、每个影厅若干场次的种子数据连座位状态的初始快照都生成好了。种子数据的意义不只是演示方便它还能帮你测试查询接口的响应速度——数据量少的时候性能问题根本暴露不出来。日期时间处理。这是一个超级容易出错的地方。MySQL 连接串里指定serverTimezoneAsia/Shanghai否则 Java 的 LocalDateTime 和数据库交互时会有 8 小时时差。Jackson 序列化时我统一格式为yyyy-MM-dd HH:mm:ss并且设置spring.jackson.time-zone: GMT8。前后端联调时时间字段的格式错乱问题浪费过我很长时间这个必须一开始就定好规矩。3. 数据库设计——影院业务的表关系与字段细节数据库是这个项目的底盘表设计得不好后面写业务代码会处处别扭。我讲讲我的核心表设计思路以及几个容易想错的点。先说整体表结构。我总共设计了 7 张核心表user用户表movie电影表cinema_hall影厅表session场次表避免用schedule这种关键词seat座位表seat_hold座位锁定表关键设计orders订单表movie表除了常规的电影名、导演、主演、类型、片长之外有几个字段特别容易被忽略。一是status状态字段表示影片是即将上映热映中还是已下架管理员上下架电影靠这个字段二是poster_url海报地址前端展示没海报的列表页面会非常难看三是release_date上映日期可以用来做新片的排序筛选。cinema_hall影厅表里的关键设计是座位信息。我的做法是影厅表里只存row_count排数和column_count列数以及seat_layout这样一个 JSON 字段来标记特殊座位——比如情侣座连座、VIP 座、无障碍座位。不在影厅表里逐行存座位是因为座位本身不是静态数据它随着场次变化会有不同的状态。座位的物理状态和场次状态要分开看待这个放到后面讲。session场次表是连接电影和影厅的桥梁字段包括movie_id、hall_id、start_time、end_time、price、status。这里有两个坑要提醒一下第一排片时间冲突检测。同一个影厅同一时间段不能排两场电影这个逻辑不复杂但很容易在插入时忽略。SQL 可以这样判断SELECT COUNT(*) FROM session WHERE hall_id ? AND start_time #{newEndTime} AND end_time #{newStartTime}这个新片开始时间小于旧片结束时间并且旧片开始时间小于新片结束时间的判断表达的就是两个区间重叠不管是完全包含还是部分重叠都能识别出来。第二影片时长和排片时间的联动。end_time不是管理员手动填的而是根据movie.duration加上两头的间隔时间比如片前广告 15 分钟、散场清理 10 分钟自动计算出来的。这样既保证了数据一致性也减少管理员操作负担。座位部分是这套设计里最有含金量的。我把座位状态设计中关键的两张表拆开看seat表维护的是座位的静态信息hall_id、row_num、column_num、seat_type、name比如 3 排 6 座。seat_hold表则是场次维度的座位锁定记录session_id、seat_id、user_id、status锁定中 / 已购买 / 已释放、hold_expire_time。为什么要有单独的锁定表因为座位状态不是一个简单的空闲/占用二值状态。用户 A 选了一个座位但还没付款这个座位既不能算已售出也不能给别人选——它处于一个中间态锁定中。如果 A 超时没付款座位要能自动释放让其他用户可以重新选。这个状态管理如果写在 seat 表上会搞得很混乱单独用seat_hold表记录场次下的座位占用关系字段清晰出问题时也好排查。orders订单表的状态流转是另一个重点。我设置了订单状态枚举PENDING_PAYMENT待支付、PAID已支付、CANCELLED已取消、REFUNDED已退款、FINISHED已使用。这里要特别注意的是待支付状态必须有超时机制否则不付款的订单会一直占着座位。我的方案是创建的订单如果 15 分钟未支付订单状态改为取消同时释放该订单关联的所有座位锁定记录。顺序上有个细节不能先取消订单再释放座位也不能先释放座位再取消订单——这两个操作必须处于一个事务里要么都成功要么都失败。后面写代码时我用Transactional保证。4. 核心业务实现——从登录鉴权到并发选座售卖的完整链路骨架和数据结构定了核心的代码实现其实就是在填肉。我按从前往后的顺序逐个讲我实现的功能和踩过的坑。4.1 JWT 登录鉴权和用户角色管理登录模块我使用的是 JWT 做无状态认证Spring Security 负责过滤器链和权限控制。之所以不用传统的 Session 方案主要是因为前后端分离架构下Session 需要处理跨域携带 Cookie 和 CSRF 防护这一堆事情JWT 直接放在请求头里简单直接也方便前端在拦截器里统一处理。JWT 生成逻辑用的是io.jsonwebtoken库。我的工具类代码大致如下public String generateToken(Integer userId, String role) { Date now new Date(); Date expireDate new Date(now.getTime() EXPIRE_TIME); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }安全这边有个关键点Spring Security 的过滤链要放行登录、注册、电影列表、场次查询这些接口但下单和订单查询必须走鉴权。我初期写代码时图省事直接放行了所有接口后来发现用户未登录也能随便查别人的订单这种教训属于典型的答辩时被当场揭穿的案例大家一定要吸取。4.2 电影与场次查询的缓存设计电影信息和场次查询是最明显的高频读接口首页一秒几百次查询都打在 MySQL 上虽然数据量小的时候不一定会出问题但既然用了 Redis就该把它用在该用的地方。我做了两级缓存策略电影列表这类变化频率低的数据在 Redis 里缓存 30 分钟某部电影的场次列表缓存 10 分钟。管理员在后台修改电影信息或新增场次时主动删除对应的缓存 key保证用户端及时看到变化。用到的注解方式很简单Cacheable(value movieList, key all) public ListMovieVO getLatestMovieList() { // 查询数据库 } CacheEvict(value movieList, key all) public void updateMovie(Movie movie) { // 更新数据库 }这个缓存设计的难点不在怎么写而在什么时候应该清缓存。我的经验是读多写少的数据才适合做缓存。如果管理员频繁修改电影信息那缓存命中率就很低还容易出脏数据。对于场次而言新增场次排片动作一天没几次但用户查场次的量非常大缓存带来的收益非常明显。4.3 选座与下单——并发控制和事务管理选座和下单是这个系统的灵魂也是最容易写崩的地方。我踩过最狠的一个坑是两个用户同时选了同一个座位的最后一排同一位置结果两个人都下单成功了直到取票时才发现重复售票。这在真实影院是不可想象的事故。问题出在锁的位置。我最初的做法是先检查座位状态如果空闲就插入订单整个流程没有加锁。但检查和插入之间有时间窗口两个并发请求都通过了检查随后都执行了插入座位就卖超了。解决方案有两种思路一个是在数据库层面做一个是在应用层做。我的最终实现是两者结合数据库层面座位锁定表seat_hold对(session_id, seat_id)建立唯一索引。这样即使业务代码层面出了并发漏洞数据库也会拒绝插入第二条相同组合的记录这是一道兜底防线。应用层面在用户选座时利用 Redis 分布式锁或数据库悲观锁来保证同一个座位的处理是串行的。我用的 Redis 分布式锁比较简单key 设计成seat:lock:{sessionId}:{seatId}加锁的超时时间设为 3 秒如果获取不到锁说明这个座位正在被别人处理直接返回该座位已被选择。代码层面的核心逻辑长这样Transactional public boolean selectSeat(Integer sessionId, Integer seatId, Integer userId) { String lockKey seat:lock: sessionId : seatId; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(3)); if (!locked) { return false; } try { int result seatHoldMapper.insertIfAvailable(sessionId, seatId, userId); return result 0; } finally { redisTemplate.delete(lockKey); } }事务这里有一个每人都要踩的坑分布式锁不能放在事务方法内部。因为方法执行完提交事务需要时间如果你在Transactional方法内部获取锁、释放锁锁释放了但事务还没提交另一个线程就能拿到锁去查数据库的座位状态——此时它还看不到前一个事务提交前的数据于是又出现数据不一致。正确做法是分布式锁放在事务的外层调用代码里保证释放锁时事务已经提交了。订单超时处理我是用 Spring 的Scheduled定时任务每一分钟扫描一次状态为待支付且创建时间超过 15 分钟的订单批量取消并释放座位。用定时任务而不是消息队列延迟消息是因为毕设项目里引入 RocketMQ 或 RabbitMQ 会让复杂度上升一个量级这种重武器用在这里反而讲不清楚。4.4 订单状态机和支付模拟支付模块是做毕设时最头疼的因为接真实支付需要商户号还要审核根本行不通。我的处理方式是把支付设计成模拟支付 沙箱回调前端点击立即支付时调用后端/api/pay/mock接口后端记录支付信息把订单状态从待支付更新为已支付并生成座位编码类似电影票的取票码为了演示效果接口里人为加了一个 1 秒的休眠模拟支付请求的网络延迟这里的关键是即使模拟支付也要把支付成功回写订单的逻辑做得像真的。我单独设计了payment_record表记录每一次支付动作字段包括支付单号、订单号、支付金额、支付时间、回调状态。这样答辩时老师问支付怎么做的你可以理直气壮地回答因为个人开发者无法申请商户号我实现的是支付网关的模拟版但回调通知、签名校验、幂等处理这些核心逻辑都是完整的。幂等处理是支付里最容易漏掉的事。支付回调有可能被触发多次如果每次都把订单状态改成已支付没问题但如果还附带加积分、发优惠券之类的动作就会重复。我的方案是在支付回调入口用order_id payment_no做幂等判断如果这笔支付流水已经处理过了直接返回成功标识不做二次业务处理。5. 前端联调与接口设计中的实战坑——跨域、Token、时间格式整个系统我做的是前后端分离Vue 3 Element Plus 搭管理后台和用户端页面后端做好接口后跟前端联调的过程中出现了很多不太起眼但极其折磨人的问题。5.1 统一响应体从第一个接口开始就要定好接口返回的统一结构这是我的强烈建议。我的返回体是{ code: 200, message: success, data: { list: [], total: 100 } }前端 axios 响应拦截器统一判断code 200才走业务逻辑否则弹出 message 提示。这个规则前期不定好后期每个接口返回格式五花八门前端联调时写一堆 if else改起来能让人崩溃。5.2 三大联调坑跨域问题。前后端如果部署在不同端口浏览器就会拦截跨域请求。后端配置一个 CORS 全局配置Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }注意addAllowedOriginPattern(*)和addAllowedOrigin()的差异。后者在携带凭证时浏览器会拒绝带通配符的 origin前者则没问题。Token 失效和 401 处理。JWT 过期后前端请求返回 401axios 响应拦截器要统一跳转到登录页并把用户信息清掉。这里坑在于如果响应拦截器不做处理用户看到的只是控制台报错页面上所有操作突然不响应非常莫名其妙。时间格式问题。前端展示场次时间时出现过2024-01-01T12:00:00这种 ISO 格式直接展示给用户看完全不符合习惯。后端用JsonFormat(pattern yyyy-MM-dd HH:mm:ss)标注日期字段前端就不用再写一堆格式化函数了。5.3 座位的可视化展示影厅选座界面是影院售票系统的门面也是答辩时老师最可能截图或让现场演示的页面。展示逻辑是从后端接口拿到该场次的座位列表和已锁定/已售出状态按排和列渲染成网格。我这里说一下前端实现的思路每个座位是一个可点击的 div状态用背景色区分空闲灰色、选中红色、锁定中/售出灰色不可点。点击座位后前端临时保存选中的座位集合点击确认选座并下单时把这组座位 ID 传给后端。这个交互流程有一个需要特别注意的点前端选座到真正下单之间有时间延迟所以后端必须在上一步的座位锁定逻辑里考虑多座位的情况——用户一次选 5 个座位后端要保证这 5 个座位要么全部锁定成功要么全部失败不能出现只锁了 2 个的情况。我在 service 里用一个 for 循环加锁锁不上的话就把前面已锁的座位全部释放掉最后统一返回结果。6. 从调试到部署——那些实际运行中才会遇见的坑代码写完不等于项目完结运行调试和部署阶段藏着的坑比想象中多。MyBatis-Plus 分页插件不生效。这是初学阶段高频问题。分页查询返回的 total 一直是 0或者列表数据正常但 total 是总数、page 没效果。原因基本是缺少分页插件配置Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }没有这个配置Page对象拿到的只是全量数据内存分页的结果SQL 里根本没有 limit数据量一大页面直接卡死。LocalDateTime 的 JSON 序列化问题。如果你用 Spring Boot 2.x 默认的 JacksonLocalDateTime 返回给前端会是一串数字时间戳而不是格式化字符串。上面提到的JsonFormat注解能解决但更全局的做法是配置一个 Jackson 的 ObjectMapper 定制类统一处理日期格式。Redis 和 MySQL 的数据一致性。我在场次列表上加了缓存但如果管理员修改场次时间或价格后没有及时删缓存用户端会看到旧数据。为了保险我加了一个简单的版本号机制表结构里冗余一个version字段每次查询先把版本号放入缓存 key 的一部分如果数据库版本号和缓存 key 中的不一致说明数据被更新过重新查库并刷新缓存。这个机制比设置过期时间可靠得多也不复杂。部署到云服务器时的内存占用。Spring Boot 应用 Redis MySQL 同时跑在一台 2G 内存的服务器上内存经常爆掉。后面发现 Spring Boot 默认的 JVM 堆内存设置过大我用java -jar -Xms256m -Xmx512m限制了 JVM 内存系统瞬间稳定下来。部署演示给老师看的时候最好先把服务提前十分钟启动完毕避免现场启动时因为初始化慢导致白屏等一下的尴尬。定时任务的调度。我的订单超时取消任务用的是Scheduled(fixedRate 60000)本机一点问题没有。但部署后发现定时任务在并发场景下会重复执行——如果部署了多实例虽然毕设基本不会定时任务就会重复执行需要引入分布式锁或把定时任务抽成单独服务。虽然毕设用单机部署躲过了这个问题但我在答辩时特意提了这一点老师会觉得你想过生产环境的事而不是只做个 demo。7. 演示脚本与答辩准备——把项目讲出让老师听懂代码全部搞定、系统跑通之后很多人就觉得大功告成结果演示和答辩环节翻车。其实把项目做成什么样和最终拿到什么样的评价中间的讲述非常关键。我的做法是准备了一套演示脚本按照一个完整用户购票流程来走注册一个账户 - 浏览热映电影 - 点进电影详情查看场次 - 选座 - 确认下单 - 模拟支付 - 查看订单。整个过程不到两分钟但把系统的所有核心功能串联在一起老师跟着一条线走下来对系统全貌会有很清晰的理解。这套脚本不能临时想一定要提前走三遍以上。演示时的节奏控制也有讲究。选座和支付这种交互性强、能看到系统响应的步骤放慢一点电影管理、影厅管理这种标准的 CRUD 功能可以快速带过。老师最关心的一定是并发卖票时怎么保证座位不超卖订单状态怎么流转你主动把代码切到锁座位那段演示两三个并发请求效果比你磨磨蹭蹭讲半天登录注册好得多。答辩问答准备上我把自己逼着回答了十几个问题核心的几个是为什么选 Spring Boot——快速搭建、生态成熟、集成第三方库方便分布式锁用的什么为什么不用数据库悲观锁——Redis 锁性能更好数据库行锁在长事务里占用时间过长座位释放机制怎么做的——定时任务扫描超时未支付订单事务里更新订单状态并删除座位锁定记录如果某个场次突然取消了已售出的票怎么处理——把订单状态批量改为退款中座位释放模拟退款数据量在什么量级下会出问题——这个问题其实很开放我用缓存和分页回答了当前抽象级别下的吞吐能力一个特别有用的技巧是在讲项目时把技术难点和业务背景结合起来。比如讲锁座时先说清楚真实影院里卖票的规则是什么再说我在系统里怎么用 Redis 锁 数据库唯一索引把这个规则实现出来。老师听到的是你在解决一个真实问题而不是背了一段 JWT 原理。8. 最后分享几条实操心得项目做完整整花了我大概三周每天两三个小时其中一半时间花在了前后端联调上。如果回到第一天我会提醒自己几件事第一开始写代码前把接口文档先写个大概。我最初是边写后端边写前端接口字段经常改导致前端代码反复返工。后面把接口文档在代码注释里写清楚联调顺畅很多。用 Spring Doc 这种自动生成工具也行但核心是先定契约再各自开发。第二Git 提交要勤快。这个我不用多说有一次我改了一整天的代码因为一个低级错误导致项目启动不了回退时发现已经没有可回退的提交点那种绝望感希望大家不要经历。第三写测试数据时顺手把电影的图片资源也准备好。我第一次演示时页面上全是裂开的图片体验很差直接拉低了整体效果。后面的做法是去公开的图片素材站找了一批电影海报图按规则命名放到静态资源目录下页面效果瞬间好了很多。第四所有页面上的中文文案、时间格式、状态命名要统一。比如待支付和未支付不要在同一个系统里同时出现前端一个页面写确认订单另一个页面写提交订单这种细节虽然不影响功能但在老师心里会加分或减分。这个项目做完之后我对 Spring Boot 的理解完全上了一个台阶尤其是事务、并发、缓存这些东西看十遍理论不如自己在项目里踩一遍坑。如果你也在做类似的系统或者准备用这个题目做毕业设计希望这篇博文能帮你在起步时少走一些弯路。项目源码相关的完整代码结构和关键业务代码片段我后续也会整理成更细的拆解文章有需要的话可以持续关注。
返回列表