Spring Boot在线电影购票系统实战:从数据库设计到并发锁与部署

发布时间:2026/10/11 15:12:35
Spring Boot在线电影购票系统实战:从数据库设计到并发锁与部署
最近帮人调试了一套 Spring Boot 在线电影购票系统源码、数据库、部署文档一套下来前前后后踩了不少坑也理顺了不少细节。先说结论这项目技术栈不新但胜在业务闭环完整——从用户注册、选电影、看场次、选座位、下单、模拟支付到后台管理、订单处理、数据统计一个不落。对于正在准备毕业设计的同学或者想完整走一遍 Web 全栈开发流程的自学者来说是非常合适的练手项目。它不是那种只有一个 CRUD 的“空壳系统”而是真的有业务逻辑、有并发场景、有前后端交互的完整系统。这篇文章我会从技术选型、数据库设计、核心业务实现、实操部署到故障排查把我实际跑通这套系统时验证过的思路和遇到的坑全部整理出来。不吹不黑尽量还原“一个从业者拿到这套源码后该怎么分析、怎么落地”的真实过程。文章会比较长建议收藏后按章节阅读需要动手复现时直接跟着第四部分和第五部分来。1. 项目全景一套在线电影购票系统到底包含什么1.1 技术选型底层逻辑为什么是 Spring Boot先说框架。基于 Spring Boot 做在线电影购票系统有一个非常现实的原因它把过去 SSH、SSM 时代需要手工配置的一大堆东西全部收敛了。比如内嵌 Tomcat、自动配置数据源、Starter 机制管理依赖这些特性大幅减少了环境搭建成本。对于课程设计、毕业设计这类交付周期短、需要快速出效果的项目这种“约定大于配置”的框架几乎是唯一解。Spring Boot 整合 MyBatis 或 MyBatis Plus 都很顺手。实际项目中我建议直接用 MyBatis Plus因为在单表 CRUD、分页查询、条件构造器这几块它能省掉大量手写 XML 的重复劳动。这套购票系统的用户管理、电影管理、订单列表这类接口基本上用 BaseMapper 和 QueryWrapper 就能覆盖 80% 的查询场景开发效率非常明显。版本方面我实测比较稳的是 Spring Boot 2.7.x 配 JDK 8 或 11MyBatis Plus 3.5.x。别一上来就追 Spring Boot 3.x虽然它很新但很多依赖的兼容性、资料数量、教程示例都还是 2.x 时代更成熟毕设项目追求的是“稳定跑通”而不是“版本最新”。如果你用的是 JDK 17Spring Boot 3.x 也可以但遇到问题时排查成本会高一些。1.2 功能模块拆解用户端与管理端的职责划分这套系统一般会拆成两个端用户端和管理端。很多同学拿到源码后第一件事就是到处点结果发现有的页面进不去因为没搞清角色权限。我第一次调试时也在这里绕了一下先看数据库里的表结构和初始化数据再对页面功能这才把两端的边界理清楚。用户端主要提供这些功能注册登录用户名、密码、手机号注册登录后使用 Token 维持会话。电影展示首页展示热映电影、即将上映支持按类型筛选、按关键词搜索。电影详情查看电影简介、导演、主演、上映日期、海报、评价列表。选座购票根据场次选择座位座位状态可视化空闲、已选、已售出。下单支付生成订单进入模拟支付流程支付后出票。订单管理查看历史订单、订单状态支持在限定条件下退票。个人中心个人资料维护、余额查询部分实现里会做虚拟钱包。管理端功能相对更集中管理员登录独立账号体系或有专门的管理员表。电影管理电影信息的增删改查海报上传上下架操作。场次管理为电影排场指定影厅、放映时间、票价。影厅与座位管理维护影厅信息、座位行列数、座位状态。订单管理查看所有用户订单处理退款申请。用户管理查看注册用户列表禁用或删除用户。数据统计按日/按月统计订单量、销售额或统计电影票房排行。这个功能矩阵从用户视角到管理员视角是完整的。对开发者的价值在于你能在一套项目里同时练习“面向用户的交互设计”和“面向管理员的列表管理与数据统计”这两类完全不同的开发模式。角色核心功能对应数据库核心表用户注册、登录、浏览电影、选座、下单、支付、退票user, film, schedule, seat, orders管理员电影/场次/影厅管理、订单/用户管理、数据统计film, schedule, hall, seat, orders, admin1.3 业务流程闭环从选片到购票成功的完整链路想读懂这套系统的代码最有效的方式不是从 controller 层往下读而是先把一条完整的购票链路走通。我建议按下面的顺序去梳理比直接读代码高效得多用户打开首页查看热映电影列表。点击某部电影进入详情页查看简介和评价。选择放映场次系统展示影厅的座位图。点击空闲座位进行选中前端临时记录座位信息。确认下单后端创建订单并锁定座位。跳转支付页面调用模拟支付接口完成支付。支付成功后端回调更新订单状态和座位状态为已售出。用户在我的订单中查看已购票记录需要时可以申请退票。这里最值得关注的不是第 1 步到第 8 步的顺序而是“创建订单”和“锁定座位”这两个动作之间的关联设计。购票不是简单的插入一条订单记录它同时要保证“同一个座位不会被两个人买走”。这就是整个系统里技术含量最高的地方也是后面我在第三部分重点展开的内容。这套流程看似普通但它覆盖了电商类项目里最经典的几个业务场景商品展示与检索、库存状态并发控制、订单状态机、支付回调的幂等性处理。做完这个项目再去写其他涉及库存、订单、支付的业务系统你会发现很多套路都是通用的。2. 数据库设计业务需求如何落到表结构上2.1 核心表结构与设计理由数据库是这套系统建得比较规整的部分。正常情况下应该包含这么几张核心表用户表user、管理员表admin、电影表film、影厅表hall、场次表schedule、座位表seat、订单表orders、订单座位关联表order_seat、评价表comment。以电影表为例通常会有这些字段字段名类型含义idbigint主键自增film_namevarchar(100)电影名称film_postervarchar(255)海报图片路径directorvarchar(50)导演actorsvarchar(200)主演film_typevarchar(50)类型如“动作/冒险”durationint时长单位为分钟release_datedate上映日期film_statustinyint0-下架 1-上映中 2-即将上映film_desctext简介create_timedatetime创建时间update_timedatetime更新时间这里有几个设计上的细节值得说一下。海报字段用的是图片路径而不是直接把图片文件放进数据库通常是上传到本地磁盘或对象存储后数据库只保存 URL 或相对路径页面加载时再拼接完整地址。这个做法跟“数据库存文件流”相比查询更快、数据库体积更可控、前后端联调也更方便。统一加上 create_time 和 update_time 是一个很值得养成的习惯。不管什么表只要业务数据需要追溯这两个字段基本是必备的。MyBatis Plus 里可以用自动填充功能TableField(fill FieldFill.INSERT)来减少重复代码。2.2 场次与座位的模型设计一个经典的“一对多”和“多对多”组合场次和座位的设计是这个数据库最需要想清楚的地方我第一次梳理时在这里花了最多时间。先说场次表。一部电影会在同一个影厅的多个时间段放映所以电影和场次是“一对多”关系。场次表的核心字段是场次对应电影movie_id、对应影厅hall_id、放映时间show_time、票价price。有了这张表用户端才能渲染出“某部电影在什么时间、什么厅、多少钱”的列表。座位设计有两种常见思路。第一种方案是给每个影厅生成固定数量的座位记录每条记录包含影厅 ID、行号、列号。座位的“是否被占”状态直接放在这张表里。这种方案实现简单但有一个明显的缺陷同一个影厅对应多个场次时座位状态是“全局共享”的根本无法区分是哪个场次被占了。第二种方案是把座位的物理属性和场次的临时状态拆开seat 表只记录影厅里有哪些座位比如 hall_id、row_number、col_number不存状态。schedule_seat 表或直接在 schedule 下扩展字段关联场次 ID 和座位 ID再额外存一个状态字段0 表示空闲、1 表示锁定、2 表示已售出。第二种方案明显更能反映真实影院场景。因为同一个影厅在不同场次里同一个座位的占用情况是完全独立的状态字段必须绑定在场次上。这套系统里采用的正是类似的设计读源码时你会看到 schedule_id、seat_id、status 这类组合字段这就是它的由来。订单和座位之间也需要一张关联表。一个订单可以买多个座位比如情侣座、连坐一张订单对应多个座位的映射如果直接塞进订单表设计上会出现多值字段查询、统计都不方便。拆出 order_seat 关联表每个座位一条记录既支持一个订单多个座位也便于后续做退票时按座位粒度处理。2.3 索引设计与状态字段约定数据量小的时候索引优势不明显但作为一个规范项目索引设计还是建议预留。常用的索引有这么几个电影表的 film_name 字段上加普通索引或前缀索引支持搜索时的模糊查询加速。场次表的 movie_id 上建索引因为“查某部电影的所有场次”是高频操作。订单表的 user_id 上建索引因为“查某个用户的所有订单”是用户端高频操作。订单表的 order_no 上建唯一索引订单号必须全局唯一这是幂等处理的基础。schedule_seat 这样的关联表在 schedule_id seat_id 上建联合唯一索引防止同一条记录重复插入。状态字段建议统一用 tinyint 而不是 varchar 或 char。原因很简单整数占用空间更小比较速度更快也不容易因为大小写不一致引发问题。项目里通常约定订单状态 0-待支付 1-已支付 2-已完成已使用 3-已退票 4-已取消座位状态 0-空闲 1-锁定 2-已售出。订单号的生成也是个小坑。不要用数据库自增 ID 直接当订单号否则容易暴露业务量也不适合跨系统使用。我实践下来最稳妥的做法是时间戳 随机数组合或者直接用雪花算法。如果只是这个项目内部用用yyyyMMddHHmmss 四位随机数生成的字符串就够用再配合数据库唯一索引兜底基本不会撞号。3. 核心业务实现从用户登录到购票成功3.1 登录鉴权JWT 如何支撑前后端交互这套系统的登录鉴权是一个能看出代码功底的地方。如果是单体应用 Thymeleaf 模板渲染传统方案是 Session 配合 Cookie用起来简单但前后端若打算分离就不太方便。如果项目里前端页面通过 Ajax 调接口或者整套系统准备拆成前后端分离再部署那 JWT 是更合适的选择。JWT 的原理可以概括为三段式头部、载荷、签名。头部指定加密算法放一个 triple 音频载荷它不是加密的是 Base64 编码的因此不要往里面放密码这类敏感信息。签名部分用服务端密钥对前两段加密每次请求时由拦截器校验签名有效性实现无状态鉴权。实际项目里大概这么组织用户提交用户名和密码后端比对成功后生成一个 JWT Token通常把用户 ID、用户名、角色放在载荷里设置过期时间常见的是 2 小时或 24 小时看需求定。前端把 Token 存到 localStorage 或内存中每次请求在 Header 带上Authorization: Bearer token。后端做一个拦截器HandlerInterceptor或过滤器拦截需要登录才能访问的路径校验 Token 是否有效、是否过期。关键代码如下伪代码Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BusinessException(未登录或Token缺失); } String realToken token.substring(7); try { Claims claims JwtUtil.parseToken(realToken); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { throw new BusinessException(Token无效或已过期); } } }这里有一个非常容易踩的坑拦截器放行路径的匹配。很多同学第一次跑通项目后发现电影列表页能打开但点进详情页就 401或者登录接口本身报“未登录”原因就是拦截器把所有 /api/** 都拦了但没有放行 /api/user/login 和 /api/user/register。需要特别注意放行这些公开路径同时确认静态资源/static/、/upload/没有被拦截器拦截否则页面上图片全部加载不出来。3.2 电影列表与搜索条件组合查询的常规打法用户端首页会同时展示“热映电影”和“即将上映”两类内容。这两个列表本质上是同一个查询接口只是入参不同。用 MyBatis Plus 的 QueryWrapper 来做这个查询非常舒服Override public IPageFilm getFilmList(int page, int size, Integer filmStatus, String keyword, String filmType) { PageFilm pageParam new Page(page, size); LambdaQueryWrapperFilm wrapper new LambdaQueryWrapper(); // 按状态筛选1-上映中 2-即将上映 if (filmStatus ! null) { wrapper.eq(Film::getFilmStatus, filmStatus); } // 按关键词模糊搜索电影名 if (StringUtils.hasText(keyword)) { wrapper.like(Film::getFilmName, keyword); } // 按类型筛选 if (StringUtils.hasText(filmType)) { wrapper.eq(Film::getFilmType, filmType); } // 默认按上映日期降序 wrapper.orderByDesc(Film::getReleaseDate); return filmMapper.selectPage(pageParam, wrapper); }这段代码里值得关注的是 LambdaQueryWrapper 的链式写法它比手写 XML 拼接 SQL 安全得多——没有字符串拼接注入问题编译期还能检查字段名。分页直接用 MyBatis Plus 的分页插件配置一个 MybatisPlusInterceptor 添加 PaginationInnerInterceptor 即可。一个小提示模糊搜索时like直接用到电影名上如果数据量达到百万级别这种写法会导致全表扫描需要引入全文索引或 Elasticsearch。但这是毕设项目的正常范围不需要过度设计开发时先保证功能可用把优化思路写在文档里。3.3 选座与下单并发场景下的座位锁这是整个项目里最核心、也是最有技术含量的一个点。购票系统天然面临“超卖”问题同一个座位两个人同时点下单如果代码没有并发控制就会出现两个人都创建了订单座位被卖出去两次的情况。解决思路通常有三种乐观锁在座位状态表加 version 字段更新时比较 version 是否一致不一致则更新失败回滚。悲观锁事务内先SELECT * FROM schedule_seat WHERE id ? FOR UPDATE把座位行锁住再执行状态判断与更新。Redis 分布式锁更适合分布式部署。本地单体项目用数据库锁足够。我调试时验证过对这个项目来说最直接可靠的是“数据库悲观锁 事务”的组合。核心逻辑用伪代码表达是这样Transactional(rollbackFor Exception.class) public void createOrder(CreateOrderRequest request) { // 1. 锁定座位行悲观锁 ScheduleSeat seat scheduleSeatMapper.selectForUpdate(request.getScheduleId(), request.getSeatId()); // 2. 检查座位状态只有空闲才允许下单 if (seat.getStatus() ! 0) { throw new BusinessException(该座位已被锁定或售出); } // 3. 座位状态改为锁定 seat.setStatus(1); scheduleSeatMapper.updateById(seat); // 4. 生成订单并插入订单表 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setScheduleId(request.getScheduleId()); order.setTotalPrice(...); order.setStatus(0); // 待支付 orderMapper.insert(order); // 5. 插入订单座位关联记录 orderSeatMapper.insert(...); }这段代码的精髓就在第一步的selectForUpdate。它会把对应的座位记录行锁住第二个用户的事务必须等第一个事务提交或回滚后才能读到这条数据。当它读到的时候座位状态已经变成锁定或售出了自然无法重复下单。事务的粒度也要注意。创建订单、锁定座位、插入关联记录必须在一个事务里完成。如果拆成两个方法、各开各的事务锁就会提前释放并发问题又回来了。在代码里最稳妥的做法是让这个业务方法自身具备事务能力不要在同一个类内部通过 this 调用加了事务注解的另一个方法否则事务注解失效。还有一点锁定的座位如果用户一直不支付怎么办所以需要设计一个自动超时机制常见方案是“定时任务扫描超过 15 分钟仍处于待支付状态的订单把对应座位状态改回空闲订单改为已取消”。在项目中这个功能往往用一个定时任务线程池模拟或者直接用数据库事件。这块代码有可能在源码里没写完整如果你要二次开发优先补上这个功能业务才完整。3.4 支付回调模拟如何设计一个不闹心的“伪支付”真实支付要对接支付宝或微信支付需要商户资质毕设项目通常不会真的去对接。这套系统里用的是模拟支付也就是前端弹一个“确认支付”页面点击后后端直接把订单状态从“待支付”改成“已支付”。表面上看逻辑很简单但它内部有一个容易被忽略的点幂等性。支付回调可能会因为网络超时而重复请求也可能因为用户在多个页面重复点击“确认支付”按钮而触发多次。如果不做处理第二次回调会把订单再更新一次如果代码里写成“先置为已支付再把座位状态改成已售出”重复回调虽然不致命但会造成不必要的日志和状态覆盖风险甚至如果它在退票后又回调会把已退票的订单重新置为已支付。解决办法是支付更新前先查一次订单状态只有“待支付”状态才允许流转到“已支付”其他状态直接拒绝并返回“已处理”。代码大致是order.setStatus(1); // 已支付 int rows orderMapper.updateStatus(order.getId(), 0, 1); if (rows 0) { throw new BusinessException(订单状态已变更请勿重复操作); }updateStatus的 SQL 在 WHERE 条件里带上旧状态 0意思是“只有当当前状态是 0 时才更新为 1”。更新影响行数为 0 就说明已经不是待支付状态了直接拒绝。这个写法比“先查再改”更稳因为它把“比对与更新”合并成了一个原子操作天然防并发。类似的思路在库存扣减中也大量使用。支付成功的同时座位状态也要从“锁定”改成“已售出”。这个动作同样建议放在同一个事务里避免出现“钱付了但座位不是我的”这种脏数据。3.5 管理端实现要点模板渲染还是前后端分离管理端有两种常见实现方式。一种是用 Thymeleaf 模板渲染服务端直接把数据渲染到 HTML 页面这种方式代码直观、适合单体项目。另一种是只提供 REST API管理前端单独用 Vue 或 Bootstrap Ajax 调用接口。具体看源码里的结构。我在实际调试中见过比较多的是“用户端页面 管理端页面都用 Thymeleaf 模板但动态数据通过 Ajax 调接口返回 JSON”。这种混合模式的好处是既能利用模板渲染静态框架又能保持接口逻辑清晰。需要注意排查的是Ajax 调接口时一定要带 Token否则会跳到登录失效页。管理端还有一个细节值得关注海报上传。实现上一般是/upload/**目录映射到本地磁盘目录上传成功后返回 URL 给前端。很多同学会把上传目录埋没在某个配置项里结果部署后用域名访问图片死活加载不出来。排查时先确认 Spring Boot 有没有配置资源映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadDir /); } }没有这段配置哪怕图片上传成功访问 URL 也是 404。这个坑虽然不起眼但遇到一次能卡半小时。4. 实操部署从源码到本地跑通全流程4.1 环境准备与安装配置清单拿到源码后不要着急点运行先把环境对齐了再看代码。我按自己踩过的版本组合给你一份参考软件推荐版本说明JDK1.8 或 11Spring Boot 2.x 都支持别直接上 17 除非你确认项目兼容Maven3.6.3 及以上建议配好阿里云镜像不然依赖下载能急死人MySQL5.7 或 8.08.0 要注意驱动和时区配置IDEIDEA 2021 / Eclipse最稳的是 IDEA社区版够用数据库工具Navicat 或 MySQL Workbench导入 SQL、查看表结构用前端工具无特需如果用 Vue 就配 Node.js 16纯模板则不需要内存方面开发机建议 8G 以上。Spring Boot 单应用启动一般 512M 内存足够但如果同时跑前端 dev server、IDEA、Maven 等多个进程8G 以下会比较吃力。第一次启动前建议先mvn clean install编译一遍确认所有依赖能正常下载。如果卡在下载多半是中央仓库访问慢可以在 Maven 的settings.xml里配阿里云镜像mirror idaliyunmaven/id mirrorOf*/mirrorOf name阿里云公共仓库/name urlhttps://maven.aliyun.com/repository/public/url /mirror4.2 数据库初始化SQL 脚本导入与常见坑数据库这块是整个部署最容易卡壳的地方之一。拿到 SQL 脚本后用可视化工具或命令行导入注意以下几点第一建数据库时字符集必须选 utf8mb4而不是 uft8。utf8mb4 才是完整的 UTF-8 编码能存表情符号而且避免中文字符在某些排序规则下报错。命令行导入时可以这样mysql -u root -p -e CREATE DATABASE IF NOT EXISTS movie_cinema DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; mysql -u root -p movie_cinema movie_cinema.sql第二如果没有 sql 文件而是直接给了数据库目录注意用对应版本的 MySQL 恢复跨版本恢复可能出现兼容问题。第三确认数据库中有初始化数据尤其是管理员账号和演示电影数据。我见过有同学导完 SQL 后没有管理员账号卡在登录页面排查半天最后发现是初始化脚本被跳过了一部分。打开表看一眼 admin 表有没有数据有数据的直接用文档里的初始账号登录。第四MySQL 8.0 的驱动类名和 5.x 不一样。Spring Boot 2.x 配合 MySQL 8.0 时JDBC URL 和驱动类是spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/movie_cinema?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 你的密码如果你用的是 MySQL 5.7driver-class-name 可以继续用com.mysql.jdbc.Driver但即使写成 8.x 的驱动类兼容性也没问题。关键是不能漏掉serverTimezoneAsia/Shanghai否则会出现“数据库连接成功但时间相差 8 小时”的诡异现象。4.3 项目配置与启动步骤配置基本集中在application.yml或application.properties里。除了数据源还有几个配置要一并检查服务端口默认 8080如果你本机 8080 被占了改成 8081。文件上传路径检查上传目录是否存在不存在要先创建。JWT 密钥看源码里写死的还是从配置读取用写死的也能跑但生产环境不能这么干。Redis 配置如果项目里用了缓存确认 Redis 服务已启动否则启动时可能报错。配置完成后启动有几种方式IDEA 中直接运行 Main 启动类的 main 方法。这种方式最常用适合开发调试。Maven 命令行方式mvn spring-boot:run打包后运行mvn clean package -DskipTests java -jar target/movie-cinema-0.0.1-SNAPSHOT.jar打包运行时如果遇到端口被占用可以先查端口占用进程也可以用--server.port8081临时指定端口。启动成功的标志是控制台输出 Tomcat started 之类的日志。然后浏览器访问http://localhost:8080/如果能打开首页说明环境基本通了。接着按文档提供的管理员账号登录后台添加一部电影上传一张海报发布一个场次跑一遍最简流程确认功能正常再继续研究代码。4.4 前后端联调注意事项如果你打算把前端部分独立出去比如换成 Vue 工程还需要处理跨域问题。Spring Boot 后端默认不允许跨域需要在后端加一个全局跨域配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)时allowedOrigins不能用*要用allowedOriginPatterns(*)这是一个小坑可以直接照上面配置。如果前端跑的端口是 3000、5173 或 8081后端要跨域访问这个配置基本必加。5. 高频故障排查与真实踩坑记录5.1 启动阶段问题速查表现象原因解决办法端口被占用Tomcat 启动失败8080 被其他进程占用换端口或查占用进程并 kill数据库连接失败报 Access denied账号或密码错误 / 数据库不存在检查 application.yml 配置用工具测试连接依赖下载超时或卡死Maven 中央仓库访问慢换阿里云镜像或本地仓库手工补依赖启动后中文乱码控制台或页面编码问题控制台设置 UTF-8页面加 meta charsetutf-8MySQL 连接报 Public Key Retrieval 错误MySQL 8.0 连接时证书交互问题JDBC URL 后加 allowPublicKeyRetrievaltrue登录成功但静态资源 404拦截器拦截了 /static 路径放行 /static/、/upload/等资源路径首页轮播图 / 海报加载不出上传目录未映射配置 addResourceHandlers 映射 /upload/**5.2 业务运行阶段的隐蔽问题除了启动阶段的问题运行时还有一些隐蔽的问题我第一次调试时在时间显示上栽过跟头。数据库存的 Datetime 是正常的但接口返回 JSON 到前端后就少了 8 小时。这个问题的根源在于数据库连接时没有指定时区或 Jackson 序列化时默认用了 UTC。解决办法有两个任选其一在 JDBC URL 里加serverTimezoneAsia/Shanghai。在 Spring Boot 配置里指定 Jackson 的时区。spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8另一个容易忽略的问题是 Hibernate 或 MyBatis 二级缓存相关的。如果项目开启了缓存修改了电影数据后页面显示的还是旧数据。排查时先检查是不是缓存没有刷新。很多项目写代码时在 Service 层顺手加了本地缓存但没做缓存清理策略于是出现“数据库改了页面没变”的情况。再一个常见的坑是 SQL 中带了保留字。某些表和字段名如果用到了 MySQL 保留字比如order、desc、group需要在 SQL 里加反引号。如果运行时出现某些接口报 SQL 语法错误的优先检查字段名是不是保留字必要时改字段名或加反引号处理。5.3 并发测试中的隐患如何验证锁是否真实生效很多同学的代码里写了Transactional也用FOR UPDATE锁了座位但心里没底。要验证锁是否真实有效最直接的方式是模拟并发抢座。用一个简单的 Java 程序开 20 个线程同时去抢同一个座位记录成功和失败的数量// 伪代码模拟20个用户同时抢同一个座位 public static void main(String[] args) throws Exception { ExecutorService pool Executors.newFixedThreadPool(20); CountDownLatch latch new CountDownLatch(1); for (int i 0; i 20; i) { pool.submit(() - { latch.await(); // 等待所有线程就绪后同时发起 try { orderService.createOrder(...); successCount.incrementAndGet(); } catch (Exception e) { failCount.incrementAndGet(); } }); } latch.countDown(); }这里有一个非常容易犯的错如果用 JUnit 跑多线程代码里又开了Transactional测试事务那么事务会被测试框架回滚导致锁的行为和真实环境不一致。并发验证最好在真实启动的服务下进行或者写成独立测试类、用真实数据库。我测试时发现如果锁逻辑没写对20 个并发请求里会有多个成功日志里能看到多个 insert 订单记录锁写对了则只有一个成功其余全部抛出“座位已被购买”的业务异常。另外前端选座时还有一个常见的体验问题用户停留在选座页面前选了一个座位但迟迟不提交座位会一直处于锁定状态其他人根本选不了。如果没有超时释放机制时间久了座位池就被耗尽了。所以定时释放锁定的功能不能省它既是技术需求也是业务需求。5.4 源码二次开发的建议方向如果你打算在这个项目基础上继续做拓展我有几个方向可以参考按实现成本从低到高排序使用 Redis 缓存电影列表和首页推荐数据减少数据库压力。加一个缓存层并不复杂但能显著提升接口响应速度也是找工作时可以讲的技术亮点。引入支付宝沙箱或微信支付沙箱把模拟支付换成真实支付链路。这个能极大提升项目的完整度但需要注意沙箱环境配置和回调验签逻辑。增加更细粒度的影厅管理支持不同影厅不同座位布局比如 IMAX 厅、VIP 厅。做一个简单的管理端数据看板用 ECharts 画票房趋势图、热门电影 Top10。增加推荐系统根据用户历史购票记录推荐同类电影。起步可以先用简单的标签匹配不必上机器学习。如果这个项目是用来做毕业设计我的建议是不要在技术栈上刻意堆新东西而是把现有业务做好做透把“为什么用悲观锁”“为什么设计成两张座位表”“为什么支付回调要幂等”这几个问题想清楚答辩时能给导师讲明白比任何花哨的功能都加分。6. 尾巴里的几句大实话这套项目我前后调试过完整的一遍最初以为无非是个普通 CRUD 系统真正跑起来才发现购票这类业务对库存一致性、状态机流转的要求远比比想象中高。订单状态怎么流转、座位锁怎么加、超时订单怎么处理、支付回调怎么保证不重复——这些才是这个项目里最有含金量的地方也是很多只会写增删改查的应届生面试时最容易卡壳的点。如果你拿着这套源码做毕设或练手我的建议很简单先别急着写代码先把数据库画清楚、把订单状态机画清楚。尤其是座位锁定和订单状态这两块你愿意花一小时在白纸上把它们画明白后面敲代码会顺畅非常多。这套项目最大价值不是给你一份能跑通的代码而是让你在一套真实业务场景里体会“一个电商系统是怎么从零到一组织起来的”。最后再分享一个实用技巧调试时如果遇到前后端交互的问题别着急翻代码先打开浏览器开发者工具看 Network 面板里的请求和响应。这个项目接口路径设计得还算规范按模块拆得清楚大部分问题看一眼请求路径和报错信息基本就能定位了。多利用浏览器开发者工具你能省出大量盲目打断点的时间。