Spring Boot电影在线订票系统:选座并发锁与订单事务实战

发布时间:2026/11/6 2:25:36
Spring Boot电影在线订票系统:选座并发锁与订单事务实战

简介:面向具备一定Java基础、希望学习或从事电影票务管理系统开发的人员,这份docx技术文档围绕基于Java与Spring Boot的在线订票选座系统展开,可作为课程设计、毕业项目或企业票务平台需求梳理的参考。文档梳理普通用户、会员与管理员三类角色的功能边界,涵盖注册登录、影片浏览、论坛交流、订单管理、会员折扣与积分、用户及影视信息管理、留言板监督、数据分析和资源管理等模块,并说明IntelliJ IDEA、Maven、Spring Data JPA、Thymeleaf与MySQL的技术组合及编译运行流程。资源包仅含1个docx文件,大小约34KB,内容包括系统实现截图说明、核心代码片段、完整代码示例、开发步骤、未来改进方向与注意事项,便于读者理解数据模型和业务流程逻辑。目前已有315人学习,适合用作方案对照和功能设计参考。

1. 影院排片表还挂在 Excel 上的时候,这套系统解决了什么问题

上个月帮一个做本地影院的朋友看他们那套排片表:销售用 Excel 记场次,前台拿打印的座位图用笔画勾,晚上对账再人工核一遍收款记录。这种流程撑三五场还行,热门片首映当天重座、错单、退票扯皮全来了。这套基于 Java 与 Spring Boot 的电影在线订票系统,走的是 MySQL 存数据、Spring Data JPA 管持久层、Thymeleaf 直出页面、IntelliJ IDEA 加 Maven 构建的路子,普通用户、会员、管理员三种角色各有各的入口。想学 Spring Boot 全栈落地的新手,或者要给自家小影院搭一套轻量票务后台的开发者,拿它当底子都合适。真正难的地方不在页面上有几个按钮,而在于同一张椅子被两个人同时点下去的那一刻,只能卖出去一次。

2. 技术选型拆解:Spring Boot + JPA + Thymeleaf 这套组合为什么够用

一个票务系统到底该用前后端分离还是服务端渲染,是很多人拿到源码后第一个要问的问题。这个项目选择了最朴素的单体结构,Spring Boot 一个进程把 Web 层、业务层、模板渲染全包了,没有 Node 工程、没有打包产物、没有跨域配置。看起来不新潮,但它恰好匹配「小团队维护、部署在内网或一台小云主机上、改动频率以周计」这类真实场景。把这套技术栈拆开看,每个选择背后都有它的理由,也都有它的代价。

2.1 四层分层:controller / service / repository / entity 各自守住什么边界

项目骨架是标准的四层结构。controller只干三件事:接 HTTP 请求、做参数校验、决定返回 JSON 还是跳模板;service承载业务规则和事务边界;repository继承JpaRepository,除了极少数复杂查询外基本不写实现;entity与数据库表一一对应。这个划分听起来像教科书,但它在订票场景里有一条非常硬的约束:事务必须开在 service 层,不能开在 controller 层。

原因很实在。如果 controller 上挂@Transactional,事务的提交时机由 Spring 的 AOP 代理决定,通常是在方法返回之后。而模板渲染、JSON 序列化发生在这之后,一旦渲染阶段抛异常,事务已经提交了,数据回不去。订单落了库、座位标了已售、页面却报 500 —— 这是订票系统最忌讳的黑匣子故障,用户拿着截图来找你,你查日志只能看到一句模板解析失败。

我一般把事务注解写在 service 的公有方法上,方法内部只做纯业务动作,不碰HttpServletRequest、不拼响应体。controller 拿到 service 返回的实体或 DTO 之后,再决定怎么呈现。这样即使视图层炸了,业务数据也保持在一个自洽的状态里。

// controller 层只负责协议转换,不在这里开事务 @Controller @RequestMapping("/movies") public class MoviePageController { private final MovieService movieService; public MoviePageController(MovieService movieService) { this.movieService = movieService; } // 返回视图名,由 Thymeleaf 解析 templates/movie/list.html @GetMapping public String list(@RequestParam(defaultValue = "1") int page, Model model) { // service 内部完成查询、组装、分页,controller 只把结果塞进 Model model.addAttribute("pageData", movieService.pageMovies(page, 12)); return "movie/list"; } }

这段代码里有两个容易忽略的细节。第一是构造器注入而不是@Autowired字段注入,好处是单元测试可以直接 new 出来,也不会有循环依赖时启动失败的玄学问题。第二是@Controller而不是@RestController,这是新手最容易翻车的地方,@RestController会把返回的字符串当成响应体直接写出去,浏览器上你就看到白底黑字一个movie/list。

page参数用@RequestParam(defaultValue = "1")兜底,避免用户手改 URL 传个空值进来导致NumberFormatException。pageData这种命名我在项目里统一用,里面装content、totalPages、currentPage三个字段,模板直接遍历,不在模板里算分页逻辑。

2.2 三种角色的权限模型:USER、MEMBER、ADMIN 怎么落到一张表上

项目正文里把功能分成了用户、会员、管理员三块,但数据模型上它们并不需要三张表,用一张t_user加一个Role枚举就够了。用户注册默认是USER,在个人中心完成充值或开通之后升级成MEMBER,ADMIN由后台或者初始化 SQL 手动指定,不开放注册入口。

这种做法简单,但也埋了一个坑:会员身份是「状态」还是「角色」?如果会员到期了,你是把role改回USER,还是保留MEMBER另外加一个expireAt字段?我倾向于后者。因为会员等级和权限判断是两件事,role决定你能进哪个后台,expireAt决定你能不能享受折扣。混在一起写,等到要加「黄金会员/白银会员」的时候就得重构。

权限拦截这块,项目里用HandlerInterceptor加 Session 就够了,没必要上 Spring Security。一个拦截器校验登录态,一个拦截器校验路径前缀:

@Component public class AdminInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { User user = (User) request.getSession().getAttribute("loginUser"); // 未登录或不是管理员,一律踢回登录页,不做 403 页面 if (user == null || user.getRole() != Role.ADMIN) { response.sendRedirect("/login?redirect=" + request.getRequestURI()); return false; } return true; } }

注册拦截器的时候用addPathPatterns("/admin/**")限定范围,千万别写成/**,否则登录页自己也会被拦住,形成无限重定向,浏览器直接提示「重定向次数过多」。这个现象我见过不止一次,排查的时候看 Network 面板里一长串 302 就明白了。

redirect参数带上原路径是个小细节,用户从收藏夹点进一个需要登录的页面,登录完能回到原来那页,体验差别很大。回跳之前记得校验一下参数,只允许站内相对路径,否则就是个开放重定向漏洞。

2.3 Thymeleaf 服务端渲染:为什么不做前后端分离

这套系统的交互密度其实不高。首页看公告和推荐、列表页翻页、详情页看介绍和评论、选座页点几个座位、订单页确认支付。这类以「读」为主、写操作集中在少数几个流程的场景,服务端渲染的收益很明显:首屏没有白屏等待、不需要维护两套路由、不存在跨域和 Token 刷新那堆事。

代价是选座这种强交互页面写起来会啰嗦一些。纯 Thymeleaf 只能靠表单提交,点一个座位刷新一次页面,体验很糟。项目里实际的做法是混合:页面骨架和初始数据由 Thymeleaf 渲染,座位点击、锁座、倒计时这些用原生 JS 加fetch调/api/**接口。这也解释了为什么代码里同时存在@Controller和@RestController两类控制器 —— 它们服务的是同一个页面的不同阶段。

<!-- templates/movie/list.html 片段:分页条 --> <div class="pager" th:if="${pageData.totalPages > 1}"> <a th:href="@{/movies(page=${pageData.currentPage - 1})}" th:classappend="${pageData.currentPage == 1} ? 'disabled'">上一页</a> <!-- 用 #numbers.sequence 生成页码,避免在模板里写 Java 循环 --> <a th:each="i : ${#numbers.sequence(1, pageData.totalPages)}" th:href="@{/movies(page=${i})}" th:text="${i}" th:classappend="${i == pageData.currentPage} ? 'active'"></a> <a th:href="@{/movies(page=${pageData.currentPage + 1})}" th:classappend="${pageData.currentPage == pageData.totalPages} ? 'disabled'">下一页</a> </div>

@{/movies(page=${i})}是 Thymeleaf 的链接表达式,它会自动补上应用上下文路径,将来把项目部署到/ticket这种子路径下也不会失效,比手写href="/movies?page=1"稳。th:classappend只追加 class 不覆盖原有 class,配合 CSS 里的.disabled { pointer-events: none; }就能实现置灰不可点,不需要额外的th:if判断。

#numbers.sequence在页数很多的时候要截断,比如只显示当前页前后各两页,否则一次渲染几百个<a>标签,页面会明显卡顿。这个项目单页 12 条、总数据量在千级,暂时没做截断,但这是个应该记住的扩展点。

3. 目录与配置:拿到源码先看 pom.xml 和 application.properties 这两处

很多人拿到一份 Spring Boot 源码,第一反应是从main方法点进去一路 F7,结果半小时后迷失在 Spring 的自动配置里。我的习惯是先看三个地方:pom.xml定版本、application.properties定环境、目录结构定职责。这三处看明白,项目 70% 的骨架就清楚了,剩下 30% 才是业务细节。

3.1 目录结构逐层拆:哪些包是骨架,哪些是业务

项目的 Maven 标准结构如下,我在原基础上补全了实际开发中会出现的几个包:

movie-ticket-system/ ├── pom.xml ├── src/main/java/com/example/ticket/ │ ├── TicketApplication.java # 启动类,@SpringBootApplication │ ├── config/ # 拦截器注册、跨域、静态资源映射 │ ├── controller/ # 页面控制器 + API 控制器 │ ├── service/ # 业务逻辑,事务边界所在 │ │ └── impl/ │ ├── repository/ # JpaRepository 接口 │ ├── entity/ # 与表一一对应的实体 │ ├── dto/ # 登录请求、下单请求这类入参 │ ├── common/ # 统一返回体、业务异常、枚举 │ └── util/ # 订单号生成、日期工具 ├── src/main/resources/ │ ├── application.properties │ ├── templates/ # Thymeleaf 模板,按模块分子目录 │ │ ├── movie/ │ │ ├── order/ │ │ └── admin/ │ ├── static/ # css / js / 海报图 │ └── data.sql # 初始化电影、场次、座位数据 └── src/test/java/...

templates下的子目录名和 controller 返回的视图名是强绑定的。return "movie/list"找的是templates/movie/list.html,少一层目录就是 500 错误Error resolving template。static下的资源在模板里用@{/css/app.css}引用,不需要写/static前缀,这是 Spring Boot 默认的静态资源映射规则,写错了就是 404。

data.sql值得单独说一句。一个空的票务系统是没法演示的,你得先有电影、有场次、有座位,才能点进选座页。项目里用data.sql灌初始数据,配合spring.jpa.hibernate.ddl-auto=create在每次启动时重建表,学习阶段很方便。但上线前必须改成none或者用 Flyway 管理,否则某天重启一下,用户的订单全没了,那就不是踩坑是事故了。

3.2 pom.xml 依赖清单:每个 starter 解决什么问题

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <!-- Web MVC + 内嵌 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- JPA + Hibernate,负责实体到表的映射 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- Thymeleaf 模板引擎,hot reload 靠它 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- 参数校验,@NotBlank / @Min 这些注解的运行时依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- MySQL 8 驱动,注意 groupId 是新版 com.mysql --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

三个地方容易出问题。第一,mysql-connector-j在旧版本里叫mysql:mysql-connector-java,Spring Boot 2.7 之后换成了com.mysql:mysql-connector-j,如果你手动抄了一段老配置,Maven 不一定报错,但运行时会提示驱动类找不到。第二,Lombok 声明成optional是对的,它只在编译期生成 getter/setter,不打进最终 jar,但 IntelliJ IDEA 需要装对应插件并开启 Annotation Processing,否则满屏红色波浪线,代码其实能跑。第三,spring-boot-starter-validation不加的话,@Valid注解会被静默忽略,你以为校验生效了,实际什么也没拦。

版本号我只在parent里写一次,其余 starter 全不写版本,交给 Spring Boot 的依赖管理统一裁决。这是避免版本冲突最省事的做法,手动给每个 starter 指定版本,迟早会撞上 Jackson 或者 Hibernate 的不兼容。

3.3 application.properties 关键参数逐行讲

# 服务端口,冲突时改这里,别去改 Tomcat 配置类 server.port=8080 # 数据库连接:三个参数最容易被漏掉 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=change_me spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # ddl-auto:学习阶段用 update,生产必须换成 none spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true # 关闭 open-in-view,避免事务被拖到视图渲染阶段 spring.jpa.open-in-view=false # Thymeleaf:开发期关缓存,改模板不用重启 spring.thymeleaf.cache=false spring.thymeleaf.encoding=UTF-8

characterEncoding=utf8和serverTimezone=Asia/Shanghai这两个参数,缺一个就会出现中文变问号或者时间差 8 小时的现象。allowPublicKeyRetrieval=true是 MySQL 8 默认加密插件下的必需项,不加会报Public Key Retrieval is not allowed,这个问题在新机器上极其常见。

open-in-view=false是我个人的强制项。Spring Boot 默认开启它,好处是 Thymeleaf 模板里访问懒加载关联不会报错,坏处是一个 HTTP 请求从头到尾都占着数据库连接,并发一上来连接池就耗尽。关掉之后就会暴露真实的懒加载问题,逼你在 service 层用join fetch或者 DTO 解决,短痛换长治。

spring.thymeleaf.cache=false只在开发环境开。上线记得改回true,否则每次请求都重新解析模板文件,QPS 能差出一截。

4. 核心链路代码:从注册登录到下单出票的完整实现

业务代码是这份源码里最值得细看的部分。项目正文给了UserController、MovieController这些示例,但那只是骨架,真正决定系统能不能用的,是实体之间的关系设计,以及下单那一刻的并发处理。下面按数据流向从表结构一路讲到订单落库。

4.1 实体建模:五张表撑起从排片到出票的全流程

除开用户、电影这些基础表,票务系统真正的核心是screening(场次)和screening_seat(场次座位)。很多入门项目图省事,只在screening上放一个sold_count字段做库存扣减,结果就是没法选座 —— 用户不知道自己买的是哪个位置,退票也没法精确释放。正确做法是为每个场次预生成座位行,一行代表一个可售单位。

@Entity @Table(name = "t_screening_seat", uniqueConstraints = @UniqueConstraint(columnNames = {"screening_id", "seat_no"})) public class ScreeningSeat { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "screening_id", nullable = false) private Long screeningId; @Column(name = "seat_no", length = 8, nullable = false) private String seatNo; // 例如 "5排8座" @Column(name = "row_index") private Integer rowIndex; @Column(name = "col_index") private Integer colIndex; @Enumerated(EnumType.STRING) @Column(length = 16, nullable = false) private SeatStatus status = SeatStatus.AVAILABLE; @Column(precision = 10, scale = 2) private BigDecimal price; }

uniqueConstraints这一行是兜底。应用层的锁可能因为代码写错而失效,但数据库的唯一约束不会,同一场次同一座位号想插两行,直接抛异常。rowIndex和colIndex是给前端画座位图用的,seatNo是给人看的,两者都要留。

status用@Enumerated(EnumType.STRING)而不是ORDINAL。默认值ORDINAL会把枚举存成 0、1、2,将来你在枚举中间插一个新状态,历史数据的语义就全乱了,这种坑排查起来极其痛苦,因为数据库里看着一切正常。

订单表则记录一次购买行为的快照:

@Entity @Table(name = "t_order", indexes = @Index(columnList = "user_id, status")) public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, length = 32) private String orderNo; // 业务单号,对外展示用 private Long userId; private Long screeningId; @Column(length = 255) private String seatIds; // 逗号分隔,逻辑上是一对多 @Column(precision = 10, scale = 2) private BigDecimal amount; @Enumerated(EnumType.STRING) private OrderStatus status; // UNPAID / PAID / CANCELLED / REFUNDED private LocalDateTime createdAt; private LocalDateTime expireAt; // 未支付超时时间,定时任务扫这个字段 }

seatIds用逗号分隔存字符串,在严格范式下是不推荐的,但订单一旦生成就不该再和座位表产生强关联 —— 座位可能被回收再卖给别人,订单必须保留「当时买了哪几个位置」这个事实。用 JSON 字段或者中间表也行,看团队习惯,我选字符串是因为查问题的时候直接select出来就能看懂。

4.2 注册登录:密码加盐与 Session 落地

原项目的UserService.register里只有一句userRepository.save(user),这个写法能跑通注册,但密码是明文入库的。稍微正经一点的实现都要过一遍 BCrypt。

@Service public class UserServiceImpl implements UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; // BCryptPasswordEncoder public UserServiceImpl(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } @Override @Transactional(rollbackFor = Exception.class) public void register(RegisterRequest req) { // 先查重,数据库唯一索引还会再兜一层 if (userRepository.findByUsername(req.getUsername()).isPresent()) { throw new BizException("用户名已存在"); } User user = new User(); user.setUsername(req.getUsername()); // encode 内部生成随机盐,盐值随密码一起存进同一个字段 user.setPassword(passwordEncoder.encode(req.getPassword())); user.setRole(Role.USER); user.setCreatedAt(LocalDateTime.now()); userRepository.save(user); } @Override public User login(LoginRequest req) { User user = userRepository.findByUsername(req.getUsername()) .orElseThrow(() -> new BizException("用户名或密码错误")); // matches 会用存下来的盐重新算一遍,不要自己写比较逻辑 if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) { throw new BizException("用户名或密码错误"); } return user; } }

两个安全细节。一是用户名不存在和密码错误返回同一句提示,避免被人拿来枚举有效用户名。二是passwordEncoder.matches内部就是从数据库字段里解析盐值,不需要单独存一列 salt,BCrypt 的输出格式已经包含了它。

登录成功后要把用户放进 Session:

@PostMapping("/login") public String doLogin(@Valid LoginRequest req, HttpSession session, Model model) { try { User user = userService.login(req); // 只放必要字段进 session,别把整个实体塞进去 session.setAttribute("loginUser", user); return "redirect:/"; } catch (BizException e) { model.addAttribute("error", e.getMessage()); return "login"; // 回到登录页并带上错误提示 } }

redirect:前缀走的是重定向,避免用户按 F5 重复提交表单。失败时不重定向而是直接返回视图,这样model里的错误信息能在页面上渲染出来。这个细节很多人写反了,重定向之后再取model发现是空的。

4.3 电影与场次查询:分页 + 条件筛选的实际写法

电影列表要支持按类型筛、按关键词搜,用JpaRepository的派生查询方法会写出findByTitleContainingAndTypeIdAndStatus这种几十个字符的方法名,一旦条件变可选就彻底失控。项目里用Specification动态拼条件更合适。

public Page<Movie> pageMovies(int page, int size, Long typeId, String keyword) { Specification<Movie> spec = (root, query, cb) -> { List<Predicate> ps = new ArrayList<>(); // 只在参数非空时追加条件,避免 null 参与比较 if (typeId != null) { ps.add(cb.equal(root.get("typeId"), typeId)); } if (StringUtils.hasText(keyword)) { ps.add(cb.like(root.get("title"), "%" + keyword + "%")); } return cb.and(ps.toArray(new Predicate[0])); }; // 前端页码从 1 开始,JPA 从 0 开始,这里要减 1 Pageable pageable = PageRequest.of(page - 1, size, Sort.by("releaseDate").descending()); return movieRepository.findAll(spec, pageable); }

ps.toArray(new Predicate[0])在条件为空的时候返回一个空数组,cb.and()对空数组的处理是返回恒真条件,正好符合「无条件即全量」的预期。分页的page - 1是必须的转换,忘了就会一直跳过第一页,或者越界抛异常。

排序字段releaseDate要和实体属性名一致,写数据库列名release_date会在启动时或首次查询时报Unable to locate Attribute。

4.4 下单事务:一次购买动作里三个必须原子化的步骤

这是整个系统最关键的一段代码。锁座、计价、改座位状态,三步必须在同一个事务里,而且要解决并发下两个人抢同一个座位的问题。

@Service public class OrderServiceImpl implements OrderService { private final ScreeningSeatRepository seatRepository; private final OrderRepository orderRepository; private final MemberService memberService; @Override @Transactional(rollbackFor = Exception.class) public Order createOrder(Long userId, Long screeningId, List<Long> seatIds) { // 1. 排好序再查,保证所有并发事务按同一顺序获取行锁,避免交叉死锁 List<Long> sorted = seatIds.stream().distinct().sorted().collect(Collectors.toList()); // 2. 悲观写锁把座位行锁住,第二个请求会在这里排队等待 List<ScreeningSeat> seats = seatRepository.lockSeats(screeningId, sorted); if (seats.size() != sorted.size()) { throw new BizException("座位不存在或不属于该场次"); } for (ScreeningSeat s : seats) { if (s.getStatus() != SeatStatus.AVAILABLE) { throw new BizException("座位 " + s.getSeatNo() + " 已被占用,请重选"); } } // 3. 计价:会员折扣在 service 内完成,不暴露给 controller BigDecimal amount = seats.stream() .map(ScreeningSeat::getPrice) .reduce(BigDecimal.ZERO, BigDecimal::add); amount = memberService.applyDiscount(userId, amount); Order order = new Order(); order.setOrderNo(OrderNoGenerator.next()); order.setUserId(userId); order.setScreeningId(screeningId); order.setSeatIds(sorted.stream().map(String::valueOf).collect(Collectors.joining(","))); order.setAmount(amount); order.setStatus(OrderStatus.UNPAID); order.setCreatedAt(LocalDateTime.now()); order.setExpireAt(LocalDateTime.now().plusMinutes(15)); orderRepository.save(order); // 4. 座位状态流转为锁定,支付成功后再置为已售 seats.forEach(s -> s.setStatus(SeatStatus.LOCKED)); seatRepository.saveAll(seats); return order; } }

配套的 repository 方法:

public interface ScreeningSeatRepository extends JpaRepository<ScreeningSeat, Long> { /** * 按场次和座位 id 批量加悲观写锁 * @param screeningId 场次 id,防止跨场次误锁 * @param ids 已排序的座位 id 列表 */ @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select s from ScreeningSeat s " + "where s.screeningId = :sid and s.id in :ids order by s.id") List<ScreeningSeat> lockSeats(@Param("sid") Long screeningId, @Param("ids") List<Long> ids); }

PESSIMISTIC_WRITE在 MySQL 下翻译成SELECT ... FOR UPDATE,行级锁。为什么必须排序:假设事务 A 锁了 5 号再要 8 号,事务 B 锁了 8 号再要 5 号,两边互相等待对方释放,直接死锁。排序之后所有事务的加锁顺序统一,最坏情况是排队,不会死锁。

expireAt是这张订单的「后悔药」。用户锁了座不付款,座位会一直处于LOCKED状态,别人买不了。所以要配一个定时任务,每分钟扫一遍过期未支付的订单,把订单置为CANCELLED、座位退回AVAILABLE:

@Scheduled(fixedDelay = 60_000) @Transactional(rollbackFor = Exception.class) public void releaseExpiredOrders() { List<Order> expired = orderRepository .findByStatusAndExpireAtBefore(OrderStatus.UNPAID, LocalDateTime.now()); for (Order o : expired) { o.setStatus(OrderStatus.CANCELLED); // 解析 seatIds 字符串,批量恢复座位状态 seatRepository.releaseSeats(o.getScreeningId(), parseIds(o.getSeatIds())); } orderRepository.saveAll(expired); }

15 分钟这个时长是可以调的,太短用户还在支付页就被释放了,太长高峰期座位被占着卖不出去。一般影院场景 10 到 15 分钟比较合适,支付渠道回调慢的可以放宽到 20 分钟。

提示:定时任务需要启动类上加@EnableScheduling,忘了加的话这个方法一辈子不会执行,而且没有任何报错。

5. 启动与联调避坑:5 个真实翻车现场的现象、原因与解法

源码跑不起来的原因,八成集中在环境、配置、依赖这三处,跟业务代码关系不大。下面这五个坑,是我在 Spring Boot 项目里反复遇到过的,按踩中概率排序。

5.1 环境与启动阶段:连不上库、起不来服务

坑一:启动报Communications link failure

现象是控制台一片红,HikariPool-1 - Exception during pool initialization,应用直接退出。原因通常有三个:MySQL 服务没起、数据库movie_ticket没建、spring.datasource.url里的端口或主机写错。还有一种隐蔽的情况是密码里有@或#,直接拼在 URL 或者明文写在 properties 里会被解析错。

解决方式是先用命令行客户端连一次,确认账号密码和库名都对,再回来改配置。密码含特殊字符的话,properties 文件里用单引号包起来,或者干脆把密码里那些字符换掉。建库语句记得指定字符集:

CREATE DATABASE movie_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

utf8mb4和utf8的区别在于前者支持完整的四字节字符,电影名里带生僻字或者特殊符号时,用utf8会存成问号。

坑二:端口被占用,Port 8080 was already in use

现象是启动瞬间就退出,日志里明确写了端口冲突。原因可能是上一次的进程没杀干净,也可能是本机跑着别的东西占着 8080。Windows 上用netstat -ano | findstr 8080找到 PID 再taskkill /F /PID xxx,macOS 或 Linux 用lsof -i :8080。

更稳妥的方式是别去杀进程,直接在配置里改端口server.port=8081。团队协作时我会把端口写进application-dev.properties并加到.gitignore,每个人本地用不同的端口,互相不打架。

坑三:IDEA 里满屏红,编译却是通的

现象是@Data、@Slf4j这些 Lombok 注解下面全是红色波浪线,get/set方法提示不存在,但mvn compile完全正常。原因是 IDEA 没开注解处理。解决路径是 Settings → Build → Compiler → Annotation Processors 勾上 Enable annotation processing,同时确认装了 Lombok 插件。

这个坑之所以烦人,是因为它不影响运行,只影响你写代码时的心情和自动补全,很多人忍了很久才发现是个开关的问题。

5.2 运行与渲染阶段:页面 500、数据拿不到

坑四:模板渲染报LazyInitializationException

现象是电影详情页能打开,但一访问关联数据(比如电影的场次列表)就 500,堆栈里是could not initialize proxy - no Session。原因就是我前面提到的open-in-view。这个配置默认是true,如果你手动关掉,或者用了异步线程,Session 在 service 方法返回时就关闭了,模板里再访问懒加载集合就会炸。

解决办法有两个方向。一是查询时就用join fetch把需要的数据一次捞出来:

@Query("select distinct m from Movie m left join fetch m.screenings where m.id = :id") Optional<Movie> findDetailById(@Param("id") Long id);

二是在 service 层就组装成 DTO,模板只碰 DTO 里的普通字段,不碰任何代理对象。我一般选第二种,因为 DTO 能明确表达「这个页面需要哪些数据」,比猜哪些关联被用到了更可控。

坑五:POST 提交中文变乱码,或者表单参数全是 null

现象是新增电影时片名存进库变成问号,或者 controller 里@RequestParam拿到的值是null。前者是编码链路上的问题,检查三处:数据库表字符集、JDBC URL 里的characterEncoding=utf8、页面<meta charset="UTF-8">。三者缺一不可。

后者多半是参数名不匹配。表单里写name="movieTitle",controller 里写@RequestParam String title,Spring 找不到就给你 null,而且不报错。加上@RequestParam(name = "movieTitle")显式绑定,或者干脆用对象接收:

@PostMapping("/admin/movies") public String create(@Valid MovieForm form, BindingResult result, Model model) { // BindingResult 必须紧跟在被校验对象后面,位置错了会直接抛异常 if (result.hasErrors()) { model.addAttribute("errors", result.getAllErrors()); return "admin/movie-form"; } movieService.create(form); return "redirect:/admin/movies"; }

BindingResult的位置是个经典陷阱。它必须紧挨着@Valid修饰的参数,中间插了别的参数,Spring 就不会把它当成校验结果的载体,校验失败直接抛MethodArgumentNotValidException,你的自定义错误页根本走不到。

6. 二次开发改造:给选座页补一套可靠的锁座与释放机制

拿到这份源码,多数人的第一个改造需求是「把选座做得更像真的」。原实现的悲观锁方案在单机、并发量不大的场景完全够用,但它有个明显的短板:锁的粒度是数据库行,用户点一下座位就占一次事务,热门场次开票那一分钟,数据库连接池很容易被打满。我一般会在外面加一层缓存预扣,把压力挡在数据库前面。

思路是引入缓存做第一道闸门,键设计成seat:lock:{screeningId}:{seatId},值为用户 id,过期时间设成和订单超时一致的 15 分钟。用户点座位先走缓存写入,成功了再落库;支付成功删 key 并更新座位状态。这样重复点击、并发抢占在前置层就被拦掉了,数据库只处理真正走到下单的请求。

代价是要处理缓存和数据库的一致性问题。缓存写成功但数据库写失败,会出现「座位上锁了但没订单」的脏状态。我的做法是给缓存 key 的过期时间比订单短一分钟,让它在订单失效之前先自然过期,中间这段窗口靠数据库的唯一约束兜底 —— 万一真插重复了,唯一索引会抛异常,捕获后返回「座位已被占用」即可。

验证改动是否有效,别靠手动点。我会开两个终端,用ab或者 JMeter 对同一个座位 id 并发打 100 个下单请求,然后查数据库里这张座位被成功占用了几次。正确的输出应该是 1 次成功、99 次失败,而不是 2 次甚至更多。这个测试跑通,说明锁真的生效了;跑不通,再怎么改代码都是猜。

验证维度可以按这个表过一遍:

验证项预期结果异常时的排查方向
同座位并发下单仅 1 单成功锁模式、唯一索引是否存在
未支付超时释放15 分钟后座位回到可售定时任务是否启用、时间字段时区
会员折扣计算折后金额与规则一致折扣是否重复叠加、精度舍入
退票后重新上架座位状态与订单状态同步事务是否覆盖两处更新

最后说个我自己的习惯。这套系统第一次部署到测试环境时,我没做压力测试就直接让同事试用,结果开票那一下数据库连接池爆了,页面全白。从那以后我每次改完锁相关的代码,都会先用并发脚本跑一遍再交付,哪怕只改了一行sorted()的调用位置。座位这东西,卖重一次就是一次真实的客诉,没有后悔药可吃。希望这篇拆解能帮到你少走几段弯路。

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