Spring Boot电影订票系统:选座下单与防超卖实战

发布时间:2026/11/2 6:41:42
Spring Boot电影订票系统:选座下单与防超卖实战

简介:围绕Java与Spring Boot构建电影在线订票选座系统的技术文档,面向具备一定Java基础、希望学习或从事电影票务平台设计开发的程序员与创业者,重点梳理普通用户、会员与管理员三类角色的功能规划及系统落地思路。文档以IntelliJ IDEA、Maven、Spring Data JPA、Thymeleaf与MySQL为主线,串联功能清单、运行截图、项目结构解析、数据库配置、编译运行流程与核心代码示例,并补充演示入口、改进方向及安全与异常处理注意事项。包内共1个docx文件,约34KB,目录紧凑完整,便于按功能模块、技术介绍、开发步骤、代码示例和项目总结逐项查阅。已有315人学习,适合需要快速厘清订票选座业务边界、对照控制器与实体设计、理解Spring Boot项目组织方式的读者作为方案参考与开发线索。

1. 从一份能跑起来的源码说起:这套电影订票系统到底给了什么

电影在线订票系统这类题目,在课程设计和外包原型里出镜率极高,但真正解压就能跑的没几个——多数压缩包打开是一堆 Controller 堆砌,数据库脚本缺失,前端模板路径写死,跑起来首页直接白屏。这份基于 Java 与 Spring Boot 的订票系统源码相对完整:后端 Spring Boot + Spring Data JPA,前端 Thymeleaf 服务端渲染,数据落 MySQL,开发工具指定 IntelliJ IDEA,普通用户、会员、管理员三种角色的功能都铺开了,影片浏览、场次选座、订单支付、论坛留言、后台管理一路串下来。它适合两类人:一类是要交课程设计、需要能演示能答辩的;另一类是想拿一套带订单和选座逻辑的 Spring Boot 骨架,改造成自己业务原型的。下文按「技术选型为什么这么定 → 环境怎么对齐 → 代码骨架怎么落 → 哪些坑会翻车」的顺序拆。

2. 技术栈拆解:Spring Boot + JPA + Thymeleaf 为什么扛得住订票业务

订票系统的核心不是页面多漂亮,而是「一场电影一百个座位,两个人同时点同一个座,只能有一个人成功」。这个约束决定了技术选型的下限:必须有可靠的事务、必须有数据库层面的唯一性兜底。Spring Boot 在这套源码里的角色是「把事务、连接池、模板引擎、静态资源全部装配好」,让开发者只关心业务;Spring Data JPA 负责把实体映射成表,省掉大量手写 DAO;Thymeleaf 负责把 Model 里的场次列表渲染成 HTML,不需要额外起一个前端工程。有人会问为什么不上 Vue 前后端分离——从交付角度看,单 jar 包丢到服务器上java -jar就能访问,对答辩演示和小型部署更省事;从代码阅读角度看,模板和 Controller 一一对应,改一个页面不用同时开两个工程。代价是交互体验不如 SPA,但对一个订票演示系统来说完全够用。

2.1 一次「查场次→选座→下单」的请求链路

先把链路走通,后面所有配置才有意义。用户在影片详情页点「购票」,浏览器发出GET /movie/12/screenings,请求进入 DispatcherServlet,按路径匹配到ScreeningController,Controller 调ScreeningService,Service 通过ScreeningRepository拿到该影片未来三天的场次,塞进Model,返回逻辑视图名screening/list,Thymeleaf 引擎找到templates/screening/list.html渲染成 HTML 回给浏览器。下单是另一条链路:POST /order/submit携带场次 ID 和座位号,Service 层在@Transactional里先锁座、再生成订单、最后扣减余票,三步要么全成要么全滚。

@Controller public class ScreeningController { private final ScreeningService screeningService; // 构造器注入,不用 @Autowired 字段注入:单测时能直接 new,不依赖容器 public ScreeningController(ScreeningService screeningService) { this.screeningService = screeningService; } @GetMapping("/movie/{movieId}/screenings") public String list(@PathVariable Long movieId, @RequestParam(defaultValue = "3") int days, Model model) { // 只查未来 days 天内的场次,历史场次不进列表,避免全表扫描 List<ScreeningVO> screenings = screeningService.listUpcoming(movieId, days); model.addAttribute("screenings", screenings); model.addAttribute("movieId", movieId); return "screening/list"; // 对应 src/main/resources/templates/screening/list.html } }

这段代码有三个值得说的地方。第一,注入方式用构造器而非字段注入,Spring 官方推荐,好处是依赖关系显式、单元测试不用启动容器。第二,days给了默认值 3,前端不传也不会 400,这种防御性默认值在演示环境里能省掉很多「点进去报错」的尴尬。第三,返回的是视图名不是@ResponseBody的 JSON,因为这条链路服务于 Thymeleaf 页面;如果同一套 Service 还要给移动端用,正确做法是再加一个@RestController暴露/api/movie/{id}/screenings,复用同一个 Service,而不是在页面 Controller 上硬加produces。

分层边界要提前定死,否则代码会烂得很快。我的习惯是:Controller 只做三件事——接参数、做基础校验(非空、格式)、决定返回视图还是 JSON;Service 管事务边界和业务规则(余票够不够、会员折扣怎么算、订单状态能不能流转);Repository 只写查询,不写业务判断。常见翻车是把if (seat.getStatus() == 1)这种判断写在 Controller 里,等要做「同一场次限购 4 张」时发现逻辑散在三个地方,改一处漏两处。

2.2 五张核心表怎么设计

订票业务的表不用多,五张就能撑起来:用户、影片、场次、场次座位、订单。多出来的论坛、留言、公告属于内容模块,跟交易主链路解耦,加不加不影响下单。

表名关键字段说明
sys_userid、username、password、role、points、statusrole 取 USER / MEMBER / ADMIN,status 控制禁用
movieid、title、type_id、duration、release_date、poster影片基础信息,type_id 关联影片类型
screeningid、movie_id、hall_id、start_time、price、stock一个场次一行,stock 是剩余可售座位数
screening_seatid、screening_id、seat_no、status、order_id每场次每个座位一行,status 0 可售 1 锁定 2 已售
ordersid、order_no、user_id、screening_id、seat_nos、amount、statusstatus 0 待支付 1 已支付 2 已取消 3 已退款

screening_seat是整套系统的命门。做法是场次创建时按影厅座位图批量插入 N 行(比如 8 排 × 12 列 = 96 行),而不是下单时才插。原因很直接:预先插行,才能在这张表上建UNIQUE KEY uk_screening_seat (screening_id, seat_no),让数据库替你做并发互斥。下单时用一条UPDATE ... SET status = 1, order_id = ? WHERE screening_id = ? AND seat_no = ? AND status = 0,受影响行数为 0 就说明被人抢了,直接抛异常回滚。索引方面,screening表建idx_movie_start (movie_id, start_time),列表页按影片查未来场次走这个索引;orders表建idx_user_created (user_id, created_at),个人中心「我的订单」按时间倒序翻页靠它。

还有个容易忽略的点:金额字段别用double。票价 39.9 元用 double 存,两张票算出来 79.80000000000001,订单页显示出来就是事故。统一用DECIMAL(10,2),Java 侧用BigDecimal,做乘法时指定setScale(2, RoundingMode.HALF_UP)。会员折扣同理,折扣率用BigDecimal("0.90")而不是0.9的 double 字面量。

2.3 环境准备:JDK、Maven、MySQL 版本必须对齐

版本不对齐是新手第一道坎,而且报错信息往往驴唇不对马嘴。核心判断只有一条:看pom.xml里spring-boot-starter-parent的版本。Spring Boot 3.x 要求 JDK 17 起,包名从javax.*换成了jakarta.*;Spring Boot 2.7.x 及以下用 JDK 8 也能跑,包名还是javax.*。如果你拿 JDK 8 去编译一个 3.x 的工程,报的错是「找不到 javax.persistence.Entity」,看着像缺依赖,实际是版本代沟。

# 三个版本一次性看清,别猜 java -version # 期望 17 或 21;若为 1.8 则只能跑 Spring Boot 2.7.x mvn -v # 期望 3.6+,同时确认 Maven 用的是哪个 JDK mysql --version # 期望 8.0.x,5.7 也能用但驱动配置略有差异

Maven 这里有个隐形坑:mvn -v输出的 Java version 和你终端里java -version可能不是同一个。系统里装了多个 JDK 时,Maven 读的是JAVA_HOME,IDE 读的是项目 SDK,两边不一致就会出现「命令行编译过了、IDEA 里一片红」。解决办法是在 IDEA 的Project Structure → Project SDK和Settings → Build Tools → Maven → Runner → JRE两处都指向同一个 JDK,命令行侧确认JAVA_HOME指向同一目录。MySQL 8 的连接串必须带时区参数,否则启动时报The server time zone value 'xxx' is unrecognized,这个在下一章配置项里展开。

3. 在 IntelliJ IDEA 里把它跑起来:配置、建库、启动三步

拿到源码包,先别急着点 Run。正确顺序是:解压 → IDEA 用Open打开含pom.xml的根目录(不是File → New → Project)→ 等 Maven 依赖下载完 → 改配置 → 建库 → 启动。用 Open 打开的好处是 IDEA 会自动识别为 Maven 工程并生成模块结构;用 New Project 打开再手动导,经常出现src/main/java没被标成 Sources Root,类上全是红波浪线。

3.1 application.properties 里必须改的六个参数

配置文件在src/main/resources/application.properties。这份源码默认值大概率对不上你的机器,逐条过一遍比启动后看报错快得多。

# 1. 数据源:库名、时区、编码三件套,缺一个都可能连不上或中文变问号 spring.datasource.url=jdbc:mysql://localhost:3306/movie_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true spring.datasource.username=root spring.datasource.password=你的密码 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # 2. JPA 行为:首次跑用 update 自动建表,交付前改成 none 并交给 SQL 脚本 spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true # 3. 模板缓存:开发期必须关,否则改了 HTML 刷新还是老页面 spring.thymeleaf.cache=false spring.thymeleaf.encoding=UTF-8 # 4. 端口:8080 常被占用,改成 8081 省事 server.port=8080 server.servlet.encoding.charset=UTF-8 server.servlet.encoding.force=true # 5. 文件上传:海报和资源文件走本地磁盘,路径按自己系统改 spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=20MB app.upload.path=D:/data/movie-ticket/upload/ # 6. 会话超时:演示时别让人坐着坐着被踢出去 server.servlet.session.timeout=60m

逐条解释关键项。连接串里的serverTimezone=Asia/Shanghai解决时区识别失败;useSSL=false是本地开发省掉证书告警;allowPublicKeyRetrieval=true针对 MySQL 8 默认的caching_sha2_password认证插件,不加会报Public Key Retrieval is not allowed。ddl-auto=update在开发期很方便,实体加个字段重启就自动加列,但它永远不会删列、也不会改列类型,所以交付前必须切成none,用独立 SQL 脚本管表结构——否则你在开发机上跑得好好的,换台机器建出来的表可能少索引。spring.thymeleaf.cache=false这条血泪经验值得单拎出来:开着缓存调试页面,改十遍 HTML 刷新都不变,很多人会怀疑是不是模板路径写错了,其实是缓存。

3.2 建库与初始化数据:schema.sql 与 data.sql 的加载顺序

建库一条命令就够,字符集必须显式指定,不然默认 latin1 存中文全是问号。

CREATE DATABASE IF NOT EXISTS movie_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

接下来是建表和种子数据。Spring Boot 支持在启动时自动执行src/main/resources/schema.sql和data.sql,但有两个前提:spring.jpa.hibernate.ddl-auto设成none(否则 Hibernate 先建表,脚本再执行CREATE TABLE就冲突),以及spring.sql.init.mode=always。加载顺序是 schema 先、data 后,这一点由框架保证,不用手动控制。

-- schema.sql 片段:场次座位表,唯一索引是防超卖的底牌 CREATE TABLE IF NOT EXISTS screening_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, screening_id BIGINT NOT NULL, seat_no VARCHAR(8) NOT NULL COMMENT '如 A01、B12', status TINYINT NOT NULL DEFAULT 0 COMMENT '0可售 1锁定 2已售', order_id BIGINT DEFAULT NULL, UNIQUE KEY uk_screening_seat (screening_id, seat_no), KEY idx_screening_status (screening_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- data.sql 片段:至少塞一个管理员和几部影片,否则登录页进不去后台 INSERT INTO sys_user (username, password, role, status) VALUES ('admin', '$2a$10$替换成BCrypt密文', 'ADMIN', 1); INSERT INTO movie (title, type_id, duration, release_date) VALUES ('示例影片一', 1, 118, '2024-06-01'), ('示例影片二', 2, 96, '2024-07-12');

密码这里别图省事存明文。源码里如果用的是 MD5,建议直接换成 BCrypt——Spring Security 的BCryptPasswordEncoder引一个依赖就能用,encode()生成密文,matches()校验,加盐逻辑内置,比手写「MD5(密码+固定盐)」安全得多。种子数据里的密文可以用一段临时测试代码生成后粘进去,注意每次encode()结果都不同,这是正常的,盐随机。另外提醒一句:data.sql每次启动都会执行,插入语句要么加INSERT IGNORE,要么先DELETE,否则第二次启动主键冲突直接起不来。

3.3 编译、启动与接口自测

配置和脚本都就位后,走命令行编译一次,比在 IDEA 里点 Run 更能暴露问题——IDEA 有时会用缓存的编译产物掩盖依赖缺失。

# 跳过测试打包,第一次跑先确保能出 jar mvn -DskipTests clean package # 前台启动,日志直接打到终端,方便看初始化脚本有没有执行 java -jar target/movie-ticket-0.0.1-SNAPSHOT.jar

启动日志里重点看三行:Tomcat started on port(s): 8080说明 Web 容器起来了;Initialized JPA EntityManagerFactory说明数据源和实体映射没问题;如果有Executing SQL script from URL [schema.sql],说明初始化脚本被执行了。三行齐了再去浏览器。

# 先测不需要登录的接口,排除「登录逻辑写错」的干扰 curl -s http://localhost:8080/api/movies/ | head -c 500 # 再测一个带路径参数的 curl -s http://localhost:8080/api/movies/1

返回 JSON 说明后端通了,再开浏览器访问http://localhost:8080/看首页模板渲染。如果接口通、页面 500,问题基本锁定在 Thymeleaf 模板——看日志里TemplateInputException后面的路径,十有八九是return "movie/list"和实际文件templates/movie/list.html对不上,或者模板里引用了 Model 中不存在的属性。

4. 核心业务实现:注册登录、影片查询、选座下单的代码骨架

功能列表看着长,真正吃功夫的是三段:认证、查询、下单。其余论坛、公告、留言本质是 CRUD,照着任意一张表抄一遍就行。下面三段代码给的是骨架和边界判断,具体字段按你实际实体类对齐。

4.1 注册登录:Controller 只做参数校验,认证逻辑下沉到 Service

认证这块最常见的错误是把「查用户、比密码、写 Session」三件事全塞进 Controller,导致后面加会员积分、加登录失败次数限制时无处下手。正确切法是 Controller 收参数并校验格式,Service 做认证并返回结果对象,Session 的写入放在 Controller(因为它属于 Web 层职责)。

@RestController @RequestMapping("/api/auth") public class AuthController { private final UserService userService; private final PasswordEncoder passwordEncoder; public AuthController(UserService userService, PasswordEncoder passwordEncoder) { this.userService = userService; this.passwordEncoder = passwordEncoder; } @PostMapping("/register") public ResponseEntity<?> register(@RequestBody @Valid RegisterRequest req) { // 用户名重复的判断放 Service,Controller 不碰 Repository userService.register(req.getUsername(), req.getPassword(), req.getPhone()); return ResponseEntity.ok(Map.of("code", 0, "msg", "注册成功")); } @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest req, HttpSession session) { User user = userService.authenticate(req.getUsername(), req.getPassword()); if (user == null) { // 统一返回「用户名或密码错误」,不区分是哪个错,避免被枚举账号 return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("code", 401, "msg", "用户名或密码错误")); } session.setAttribute("LOGIN_USER", user); return ResponseEntity.ok(Map.of("code", 0, "role", user.getRole().name())); } }

@Valid配合 DTO 上的@NotBlank、@Size(min = 6)做格式校验,校验失败会抛MethodArgumentNotValidException,用@RestControllerAdvice统一捕获转成 400 响应,比在每个方法里写 if 判断干净。Service 层的authenticate逻辑很短:按用户名查用户(Optional拿不到就返回 null),用passwordEncoder.matches(明文, 密文)比对,再判断status是否为启用。这里有个安全细节——即使用户名不存在,也要走一次matches的空比对,否则响应时间差能被用来探测账号是否存在,演示系统无所谓,真上线就得注意。

会员功能在这套源码里体现为角色和积分两个字段。折扣计算放在订单 Service 里,不放在入口处:BigDecimal amount = price.multiply(BigDecimal.valueOf(seatCount)),会员再multiply(new BigDecimal("0.90")),最后setScale(2, RoundingMode.HALF_UP)。积分规则建议单独抽一个PointService,下单成功后按实付金额累加,退款时扣回,别把加减积分写在订单状态变更的 if 分支里——后面加「积分兑换」时你会发现找不到唯一的真相来源。

4.2 影片与场次查询:分页、条件筛选与 DTO 裁剪

影片列表页要分页,这是必须的。全量findAll()在演示数据只有十条时看不出问题,一旦导入几百部影片,页面加载直接卡死。Spring Data JPA 的分页接口很省事,Pageable从 Controller 传进来,PageRequest.of(page, size, Sort.by("releaseDate").descending())构造。

@GetMapping("/api/movies") public Page<MovieListVO> list(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "12") int size, @RequestParam(required = false) Long typeId, @RequestParam(required = false) String keyword) { // 页码防负数,否则 PageRequest 直接抛 IllegalArgumentException int safePage = Math.max(page, 0); int safeSize = Math.min(Math.max(size, 1), 50); // 上限 50,防止有人传 99999 拖垮库 return movieService.pageQuery(typeId, keyword, PageRequest.of(safePage, safeSize)); }

两个参数细节。safeSize加了 50 的上限,这是对外接口的基本防御——不加的话,一个size=999999的请求就能让服务端内存飙起来。keyword模糊查询在 Service 里拼%keyword%,要注意别在 JPQL 里直接字符串拼接,用参数绑定like :kw,否则就是注入的入口。DTO 裁剪同样重要:Movie实体里有description(剧情简介,几千字)和poster(图片二进制或长路径),列表页根本不需要,直接返回实体会让响应体从几 KB 涨到几百 KB。做法是定义MovieListVO,只含id、title、poster、duration、releaseDate,用构造器或 MapStruct 转换。

场次查询有一个时间边界必须处理干净。listUpcoming(movieId, days)里比较的是start_time与当前时间,这里start_time用LocalDateTime类型,数据库列用DATETIME,两边一致。如果实体用LocalDateTime、数据库列却是TIMESTAMP,MySQL 驱动会按连接串时区做转换,结果就是查出来的场次时间差 8 小时,看起来「今天的场次全都不见了」。这个坑在下一章展开。

4.3 选座下单:唯一索引 + 乐观锁挡住超卖

这是整套系统唯一真正有并发风险的地方。思路分两层:第一层是应用层校验,下单前查一次座位状态,给用户友好提示;第二层是数据库层兜底,靠条件更新 + 唯一索引保证绝不会超卖。只做第一层等于没做,因为「查」和「改」之间有时间窗。

@Service public class OrderService { private final ScreeningSeatRepository seatRepository; private final OrderRepository orderRepository; private final ScreeningRepository screeningRepository; @Transactional(rollbackFor = Exception.class) public Order createOrder(Long userId, Long screeningId, List<String> seatNos) { // 1. 先按 seat_no 排序,多线程锁同一批座位时顺序一致,降低死锁概率 List<String> sortedSeats = seatNos.stream().sorted().toList(); // 2. 条件更新:只有 status=0 的行会被改,返回受影响行数 for (String seatNo : sortedSeats) { int updated = seatRepository.lockSeat(screeningId, seatNo); if (updated == 0) { // 抛运行时异常触发回滚,已锁定的座位一并释放 throw new BizException("座位 " + seatNo + " 已被抢占,请重新选择"); } } // 3. 扣减场次余票,同样用条件更新防扣成负数 int stockChanged = screeningRepository.decreaseStock(screeningId, sortedSeats.size()); if (stockChanged == 0) { throw new BizException("余票不足"); } // 4. 生成订单,金额在 Service 里算,不信任前端传值 Order order = buildOrder(userId, screeningId, sortedSeats); orderRepository.save(order); seatRepository.bindOrder(screeningId, sortedSeats, order.getId()); return order; } }

对应的 Repository 方法用@Modifying+@Query写条件更新:

@Modifying @Query("update ScreeningSeat s set s.status = 1 " + "where s.screeningId = :sid and s.seatNo = :seatNo and s.status = 0") int lockSeat(@Param("sid") Long screeningId, @Param("seatNo") String seatNo);

几个参数和边界必须说清楚。@Transactional(rollbackFor = Exception.class)显式指定回滚异常类型,因为 Spring 默认只对RuntimeException和Error回滚,抛受检异常时事务会照常提交——这是「操作失败了但数据脏了」的头号原因。座位号排序是为了避免两个请求分别按[A01, A02]和[A02, A01]顺序加锁导致死锁。lockSeat返回 0 时立刻抛异常,让前面已经改掉的行随事务回滚,不会留下「锁了一半」的脏状态。余票扣减用update screening set stock = stock - :n where id = :sid and stock >= :n,where里的stock >= :n保证了不会扣成负数。

真实秒杀场景还会加一层乐观锁版本号或 Redis 预扣,但订单表这个体量,条件更新 + 唯一索引已经完全够用。唯一索引uk_screening_seat在这里的作用是最后一道闸:哪怕有人绕过应用层直接写库,同一场次的同一个座位也无法插入两条记录。取消订单时反向操作——status从 2 改回 0、order_id置空、余票加回,整个过程同样要在同一个事务里完成。

5. 避坑与排查:从启动白页到座位超卖的六个翻车点

下面六条都是这套技术组合里高频出现的故障,按「现象 → 原因 → 解决」写,看到相似症状可以直接对号入座。

5.1 启动期:连接、端口、模板与脚本

现象一:启动即退出,日志里一串Failed to configure a DataSource: 'url' attribute is not specified。原因通常有两个:配置写在application.yml里但项目实际加载的是application.properties(或者反过来),以及配置项缩进错位导致spring.datasource.url没被识别。解决方式是先确认target/classes下到底打包了哪个配置文件,再检查缩进——.properties用=且行首不能有空格,.yml用冒号加空格且层级靠缩进。改完mvn clean重新打包,别只点 IDEA 的重新运行,旧资源可能还在。

现象二:Web server failed to start. Port 8080 was already in use。原因是上一个进程没退干净,或者本机装了别的服务占了 8080。解决方式是先查占用进程再决定杀掉还是换端口:Windows 下netstat -ano | findstr :8080找到 PID 后taskkill /PID xxx /F,Linux 下lsof -i:8080。更省事的做法是直接把server.port改成 8081,但要同步改前端模板里写死的请求地址,否则页面上的 AJAX 全 404。

现象三:接口curl能返回 JSON,浏览器打开首页却是 Whitelabel Error Page 或空白。原因基本锁定在 Thymeleaf 模板。三种典型:return "movie/list"但文件实际叫movieList.html;模板里th:each="m : ${movies}"而 Model 里塞的键名是movieList;模板文件放在了src/main/resources/templates/之外的位置。解决方式是看日志里TemplateInputException或TemplateProcessingException给出的完整模板路径,逐字比对文件名和 Model 键名。开发期记得把spring.thymeleaf.cache设为false,否则改完模板不生效,会误判成路径错误。

现象四:data.sql执行报Duplicate entry '1' for key 'PRIMARY',第二次启动直接失败。原因是初始化脚本用了裸INSERT,第一次启动插入了主键 1,第二次启动再插同一条。解决方式三选一:改成INSERT IGNORE INTO;脚本开头加DELETE FROM 表名或TRUNCATE;或者干脆把spring.sql.init.mode设成never,手动执行一次脚本。我的习惯是开发期用INSERT IGNORE,交付时切成never,把脚本单独放进db/目录交给部署的人。

5.2 运行期:时区、事务、懒加载与并发

现象五:场次列表查不到「今天」的场次,数据库里明明有数据;或者订单创建时间总比实际早/晚 8 小时。原因是时区在三处不一致:JVM 默认时区、JDBC 连接串的serverTimezone、MySQL 服务端的time_zone。三者只要有一个不是Asia/Shanghai,LocalDateTime的读写就会偏移。排查顺序是先SELECT NOW()看数据库当前时间对不对,再看连接串有没有带serverTimezone=Asia/Shanghai,最后确认实体字段类型和数据库列类型匹配——LocalDateTime对DATETIME,别对TIMESTAMP(后者会随时区转换)。解决方式是三处统一,并在启动类里加一句TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"))兜底,别依赖服务器默认值。

现象六:Service 方法上明明加了@Transactional,下单失败后座位还是被锁住了。原因有两个常见版本。一是异常被catch住了没往外抛,Spring 感知不到失败,事务照常提交;二是抛的是受检异常(Exception子类但非RuntimeException),默认不回滚。解决方式是:要么在 catch 里处理完再throw new BizException(...)抛运行时异常,要么在注解上写@Transactional(rollbackFor = Exception.class)。还有第三种更隐蔽的情况——同类内部方法互相调用(this.methodB()),走的是原始对象不是代理对象,事务注解直接失效。这种要么把方法拆到另一个 Service,要么注入自身代理。

现象七:两个人同时抢同一个座位,居然都下单成功了。原因几乎都是「只做了查询校验,没做条件更新」。典型错误代码是先findByScreeningIdAndSeatNo查状态,判断status == 0然后setStatus(1)再save()——查和改之间那个时间窗,足够另一个请求插进来。解决方式是改成UPDATE ... WHERE status = 0并检查受影响行数,同时确认uk_screening_seat唯一索引真的建上了(用SHOW INDEX FROM screening_seat查)。验证方法也简单:写个 20 线程的CountDownLatch测试,同时打同一个座位,最后统计成功数,必须是 1。

现象八:影片详情页偶发LazyInitializationException: could not initialize proxy - no Session。原因是实体里配了@OneToMany(fetch = FetchType.LAZY)的关联(比如影片关联场次列表),在模板渲染时事务已经结束、Session 已关闭,Thymeleaf 遍历集合就炸。解决方式是在 Service 层就把需要的数据查出来转成 DTO/VO,模板只碰扁平对象;或者用@EntityGraph在查询时一次性 join fetch 出来。别图省事改成FetchType.EAGER,那会在列表页引发 N+1,十条影片变成十一条 SQL,数据量一大直接拖死。

6. 二次开发与验收:把演示站改成能交付的原型

源码能跑只是起点。真要拿去用,先把「演示态」和「交付态」的开关切干净:ddl-auto从update改none,show-sql关掉,thymeleaf.cache打开,数据库连接串从本地改成环境变量注入(${DB_URL}这种写法,别把生产密码提交进仓库)。这一套切完再走一遍完整流程,能提前发现一堆「只在本机能跑」的问题。

交付前我会固定跑一张自检表,八项全过才算收工:

检查项通过标准常见不合格表现
冷启动空库执行 schema + data 后能起依赖开发机已有的表结构
登录鉴权未登录访问后台跳登录页直接能进管理员页面
并发选座20 线程抢同座只成功 1 个多人同时锁同一座成功
金额计算会员折扣后两位小数正确double 误差出现 79.80000001
分页边界page=-1、size=9999 不报 500直接抛 IllegalArgumentException
中文编码影片名、评论存进库再取出不乱码页面显示问号或方块
取消订单座位释放、余票加回、状态一致座位永久锁定,余票不减不回
异常提示失败有明确文案,不吐堆栈页面直接显示 SQLException

二次开发的方向,我的建议是先补「场次管理」这块后台能力——很多同类源码只做前台订票,管理员加场次还得手动改数据库,这是最影响可用性的缺口。加一个场次新增页,提交时按影厅座位模板批量生成screening_seat行,这一步做完,系统才算能自己运转。再往后是支付对接和退款流程,但那涉及外部接口,演示系统里用「模拟支付」按钮改状态即可,别硬接真渠道。

有个习惯我保持了挺久:每次改完实体类字段,不会只重启看页面,而是额外执行一次mvn clean package再java -jar跑一遍。之前吃过一次亏——IDEA 增量编译把旧的 class 留在target里,页面上新加的字段一直显示空,排查了快两个小时才发现是编译产物没清干净。从那以后我每次改完实体或配置,都强制走一遍「clean → package → jar 启动 → 打开无痕窗口验证」,不再信 IDE 的热部署。希望帮到你。

本文还有配套的精品资源,点击获取