Java+Spring Boot电影订票系统:数据模型与并发选座
简介面向具备一定Java基础、希望学习或从事在线票务系统开发的技术人员这份文档完整梳理了一套基于Java与Spring Boot的电影在线订票选座系统的设计与实现过程可作为搭建线上票务平台的参考方案。内容按功能与技术两条主线展开先分别列出普通用户、会员与管理员三类角色的功能清单涵盖注册登录、影片浏览、订单支付、会员积分折扣、用户与影视信息管理等模块再说明以IntelliJ IDEA为IDE、Maven管理依赖、Spring Data JPA操作MySQL、Thymeleaf渲染模板的技术栈与项目结构并给出数据库配置与编译运行步骤。文中附有注册登录、影片查询等核心Controller代码示例、系统运行截图与演示入口以及安全性、数据完整性和后续改进方向便于对照复现业务逻辑。压缩包内为1个docx文件约34KB已有315人学习。1. 电影在线订票系统不是把页面堆上去先看清这套 Java Spring Boot 骨架能干什么很多 Java 练手项目死在第一步把源码拖进 IntelliJ IDEA点绿色三角控制台一片红然后开始怀疑人生。这套基于 Java 与 Spring Boot 的电影在线订票系统价值不在页面有多少个菜单而在于它把「用户—影片—场次—订单」这条主线的数据关系摆得比较正角色权限USER / MEMBER / ADMIN也用枚举落了库。适合两类人一类是 Java 基础过得去、想拿一个完整业务流程练手的开发者另一类是接过小型票务类需求、需要一份能改能扩的底稿的从业者。它不解决高并发但把「先建模型再写接口」的顺序讲明白了这比多几个页面值钱。2. 在 IntelliJ IDEA 里把工程跑起来建库、改配置、启动的三段式拿到源码包之后别急着点启动先把三件事按顺序做完认目录、建库、改配置。顺序反了就会出现「表不存在」「时区不对」「中文乱码」这类看着像玄学、实际全是配置问题的报错。这一章按我平时的落地顺序走一遍新手照着抄能跑起来熟手可以重点看 2.3 的配置项说明那里是最容易翻车的地方。2.1 目录结构与分层职责先对齐标准 Maven 结构下src/main/java放代码src/main/resources放配置和模板这是 Spring Boot 的默认约定别自己改。真正需要你动手认的是包内的分层一般长这样src/main/java/com/example/movieticket ├── MovieTicketSystemApplication.java # 启动类 ├── controller/ # 接口层UserController、MovieController、OrderController ├── service/ # 业务层UserService、MovieService、OrderService ├── repository/ # 数据访问层继承 JpaRepository 的接口 ├── entity/ # 实体User、Movie、Screening、TicketOrder ├── dto/ # 入参出参LoginRequest、OrderSubmitRequest └── config/ # 配置类密码编码器、拦截器、定时任务开关 src/main/resources ├── application.properties # 数据源、JPA、Thymeleaf 配置 ├── templates/ # Thymeleaf 页面index、movie-list、order-confirm └── static/ # 静态资源css、js、海报图分层不是形式主义。Controller 只做参数接收和状态码返回Service 承担事务和业务判断Repository 只负责查询这条线一旦被打乱比如在 Controller 里直接注入 Repository 写库存后面加事务、加缓存、加校验都会变成拆房子。我的习惯是先看entity包有几个类再看repository有几个接口两边数量对得上说明作者是照着模型写的后面改动心里有底。controller包里如果同时存在返回 JSON 的RestController和返回页面的Controller注意别把注解用混。带RestController的方法返回字符串会被当成响应体直接吐给浏览器而不是渲染模板新手在这儿翻车一次能记一辈子。2.2 建库与四条启动命令数据库先建编码必须是 utf8mb4否则影片名里的生僻字和表情符号会直接插入失败。建库语句很简单但字符集和排序规则建议写死-- 建库字符集用 utf8mb4避免中文和特殊符号插入失败 CREATE DATABASE movie_ticket DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; -- 建一个专用账号别用 root 跑业务本地练手可跳过 CREATE USER ticketlocalhost IDENTIFIED BY Ticket2024; GRANT ALL PRIVILEGES ON movie_ticket.* TO ticketlocalhost; FLUSH PRIVILEGES;参数说明utf8mb4而不是utf8是因为 MySQL 的utf8只支持三字节遇到四字节字符会截断utf8mb4_general_ci在本地开发够用如果要做严格的姓名、片名排序可以换utf8mb4_0900_ai_ciMySQL 8.0 起支持。用专用账号是因为后面一旦把连接串写进配置文件提交到仓库泄露的是权限受限的账号不是超级管理员。库建好后回到命令行四条命令走完就算编译通过# 1. 确认 JDK 版本Spring Boot 3.x 要求 17 及以上 java -version # 2. 确认 Maven 可用 mvn -v # 3. 跳过测试编译打包首次会拉依赖耐心等 mvn clean package -DskipTests # 4. 用 Maven 插件直接启动比 java -jar 更适合开发期 mvn spring-boot:run-DskipTests不是偷懒。很多教学项目的测试类里写死了本地数据库连接你在没配库的机器上跑测试必然失败先把主流程跑通测试后面单独修。打包产物在target/目录下形如movieticket-0.0.1-SNAPSHOT.jar用java -jar启动时记得配上外部配置否则它会去找 jar 包内的默认配置。启动成功的标志不是「没有报错」而是控制台出现Started MovieTicketSystemApplication in x.xxx seconds且端口监听成功。如果启动到一半退出先看最后 30 行Spring Boot 的失败原因基本都在那儿不用从头翻。2.3 application.properties 里真正影响成败的六个配置配置文件是整个项目的黑匣子出问题十有八九在这。下面这份是我在本地调试时常用的版本每一项都有存在理由# 1. 数据源时区、编码、SSL 三个参数缺一不可 spring.datasource.urljdbc:mysql://localhost:3306/movie_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue spring.datasource.usernameticket spring.datasource.passwordTicket2024 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver # 2. JPA开发期用 update 自动建表上线前必须换成 none 或 validate spring.jpa.hibernate.ddl-autoupdate spring.jpa.show-sqltrue spring.jpa.properties.hibernate.format_sqltrue spring.jpa.open-in-viewfalse # 3. 模板开发期关缓存改完页面刷新即生效 spring.thymeleaf.cachefalse spring.thymeleaf.encodingUTF-8 # 4. 服务端口与上下文 server.port8080 server.servlet.encoding.charsetUTF-8 server.servlet.encoding.forcetrue逐条说。serverTimezoneAsia/Shanghai不写会报The server time zone value is unrecognized这是 MySQL 8 驱动最常见的拦路虎useSSLfalse只是本地省掉证书握手线上必须开allowPublicKeyRetrievaltrue配合 MySQL 8 的caching_sha2_password认证插件使用不写会报公钥检索失败。ddl-autoupdate是双刃剑。它在启动时对比实体和表结构缺字段就加、缺表就建开发期非常省事但它不会删字段、不会改类型跑久了表结构会和实体悄悄漂移。我的做法是开发期用update交付前用validate跑一遍确认实体和库对得上再切到none。open-in-viewfalse是我强烈建议加的一项。默认开启时Hibernate 的 Session 会一直开到视图渲染结束Thymeleaf 模板里访问懒加载关联不会报错但一旦改成false模板里再碰未加载的关联就是LazyInitializationException。早点暴露比上线暴露好逼着你在 Service 层把数据取干净。spring.thymeleaf.cachefalse只用于开发。上线必须改回true否则每次请求都重新解析模板QPS 上不去还查不出原因。3. 数据模型先立住四张核心表和一条防重售的唯一索引订票系统的复杂度不在页面在「一个座位在同一场次只能被一个人买走」这句话。这句话落到数据库上就是一条唯一索引落到代码上就是一次冲突捕获。模型没设计好后面写再多校验都是补丁摞补丁。3.1 实体字段与角色枚举怎么落用户表最容易被写薄。只有 id、username、password 三个字段的版本跑通登录就没了下文会员积分、观影记录、收藏容量全没地方放。合理的字段集大致是这些Entity Table(name sys_user, uniqueConstraints UniqueConstraint(name uk_username, columnNames username)) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, length 32) private String username; /** 存 BCrypt 哈希长度留 60 以上绝不存明文 */ Column(nullable false, length 100) private String password; Enumerated(EnumType.STRING) Column(nullable false, length 16) private Role role; // USER / MEMBER / ADMIN Column(nullable false) private Integer points 0; // 会员积分普通用户为 0 Column(length 20) private String phone; Column(nullable false, updatable false) private LocalDateTime createTime LocalDateTime.now(); }逻辑说明Enumerated(EnumType.STRING)必须指定 STRING默认的 ORDINAL 会把枚举按序号存成 0、1、2将来在枚举中间插一个值历史数据的角色全错位这类事故修起来要写数据迁移脚本。uniqueConstraints显式命名是为了在捕获唯一键冲突时能通过约束名判断是哪一列撞了而不是笼统地报一个Duplicate entry。参数说明password长度给到 100是因为 BCrypt 输出固定 60 字符留余量给以后换算法points给默认值 0 而不是允许 null聚合查询时少一层判空createTime加updatable false防止更新用户信息时被覆盖。角色枚举单独定义值只有三个别在数据库里存字符串常量满天飞public enum Role { USER, // 普通用户浏览、下单、评论 MEMBER, // 会员折扣、积分、优先预约 ADMIN // 管理员用户管理、影片管理、数据看板 }会员和普通用户共用一张表、靠枚举区分是我更推荐的做法。拆两张表会让登录逻辑分叉订单外键也要分两套最后统计口径都对不上。真要做会员等级加一个level字段比加一张表划算。3.2 场次、座位、订单的关联设计影片和场次必须拆开。一部《影片 A》在 3 号厅 14:00 放一场在 5 号厅 19:30 再放一场价格还不一样这些信息塞进 movie 表就变成一列一列的show_time_1、show_time_2典型的反模式。Entity Table(name screening) public class Screening { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false) private Long movieId; // 关联影片这里用外键 ID 而非对象关联 Column(nullable false, length 32) private String hallName; // 影厅名如 3 号厅 IMAX Column(nullable false) private LocalDateTime startTime; Column(nullable false, precision 10, scale 2) private BigDecimal price; // 用 BigDecimal别用 double 存金额 Column(nullable false) private Integer totalSeats; // 该场次总座位数 }座位不建议单独建一张「座位字典表」。对教学项目和中小型系统直接在订单明细里存座位号字符串如F7更省事配合唯一索引就能防重售Entity Table(name order_seat, uniqueConstraints UniqueConstraint(name uk_screening_seat, columnNames {screening_id, seat_no})) public class OrderSeat { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name screening_id, nullable false) private Long screeningId; Column(name seat_no, nullable false, length 8) private String seatNo; // 如 F7 Column(nullable false) private Long orderId; }逻辑说明这条uk_screening_seat唯一索引是整个防重售方案的兜底。应用层再怎么判断「这个座位有没有被占」都有并发窗口数据库的唯一约束是最后一道闸门冲突了就抛异常业务层捕获后提示「座位已被抢请重选」。这比在 Service 里加synchronized靠谱得多后者在多实例部署下直接失效。参数说明seatNo长度 8 够用A1到Z99screeningId建索引后按场次查已占座位很快BigDecimal配precision10, scale2支持到千万级金额、两位小数够任何票务场景。订单主表管状态流转字段里必须有一个expireAt这是后面做超时释放的钩子Entity Table(name ticket_order, indexes Index(name idx_status_expire, columnList status, expire_at)) public class TicketOrder { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, unique true, length 32) private String orderNo; // 业务单号给用户看、给支付回调对账 Column(nullable false) private Long userId; Column(nullable false) private Long screeningId; Column(nullable false, precision 10, scale 2) private BigDecimal amount; Enumerated(EnumType.STRING) Column(nullable false, length 16) private OrderStatus status; // CREATED / PAID / CANCELLED / TIMEOUT Column(name expire_at, nullable false) private LocalDateTime expireAt; }idx_status_expire这个联合索引是给定时任务用的扫「状态是 CREATED 且过期时间早于当前」的订单没有索引会全表扫描订单量一上来数据库直接躺平。索引列顺序不能反status选择性低放前面expire_at放后面做范围这是最左前缀的常规用法。3.3 Repository 接口与查询边界JPA 的方便之处是方法名即查询但方法名写过头就变成灾难。findByUserIdAndStatusAndCreateTimeBetweenOrderByCreateTimeDesc这种名字读三遍都不知道查的是什么业务。我的原则是单表条件查询用方法名多表或复杂条件写Query。Repository public interface MovieRepository extends JpaRepositoryMovie, Long { // 单表分页 标题模糊匹配方法名能表达清楚就用方法名 PageMovie findByTitleContaining(String keyword, Pageable pageable); // 上映中的影片按上映时间倒序首页展示用 ListMovie findByStatusOrderByReleaseDateDesc(String status); // 复杂统计用 JPQL别硬凑方法名 Query(select m.genre, count(m) from Movie m group by m.genre) ListObject[] countByGenre(); }逻辑说明PageMovie返回的不只是列表还带总条数、总页数前端分页控件直接拿ListObject[]是 JPQL 聚合查询的常见返回形态拿到后自己映射成{genre, count}的 DTO别指望框架帮你转。参数说明Pageable一般由PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, releaseDate))构造page从 0 开始这一点和前端传的页码差 1是新手高频翻车点。findByTitleContaining生成的是like %keyword%前置通配符走不了索引影片量过万时建议换成全文索引或搜索引擎别在这个阶段硬优化。4. 接口与页面REST 控制器和 Thymeleaf 模板怎么分工这套项目的页面渲染用的是 Thymeleaf接口层混用了RestController和Controller。混用本身没问题问题在于边界不清哪些请求该返回 JSON、哪些该跳页面、失败时给前端什么信号。这一章按「接口怎么做、页面怎么接」的顺序拆。4.1 注册登录接口的返回值与密码存储先看原文给的那版控制器注册成功返回字符串登录失败返回 401。方向对但缺了三样东西——参数校验、密码哈希、统一返回体。RestController RequestMapping(/api/user) public class UserController { private final UserService userService; // 构造器注入别用字段注入单测时能省一半功夫 public UserController(UserService userService) { this.userService userService; } PostMapping(/register) public ResponseEntity? register(RequestBody Valid RegisterRequest req) { if (userService.existsByUsername(req.getUsername())) { return ResponseEntity.badRequest().body(Map.of(msg, 用户名已存在)); } userService.register(req); // 内部完成加密与默认角色赋值 return ResponseEntity.ok(Map.of(msg, 注册成功)); } PostMapping(/login) public ResponseEntity? login(RequestBody Valid LoginRequest req) { String token userService.login(req); // 失败抛 BizException由全局处理器转 401 return ResponseEntity.ok(Map.of(token, token, msg, 登录成功)); } }逻辑说明注册与登录分开两个分支判断逻辑放在 Service控制器只负责协议层。登录失败不在这里写 if-else 返回 401而是让 Service 抛业务异常由RestControllerAdvice统一转成状态码这样十几个接口的异常格式是一致的前端只写一套错误处理。参数说明Valid配合 DTO 上的NotBlank、Size(min 6, max 32)做长度校验省掉手写 if。请求体用 DTO 而不是实体是为了防止前端传个role: ADMIN就把自己提权成管理员——这是教学项目里最容易被忽略的安全洞。密码存储单独说一句这段代码原文没给但必须有Service public class UserService { private final PasswordEncoder encoder new BCryptPasswordEncoder(); public void register(RegisterRequest req) { User u new User(); u.setUsername(req.getUsername()); // 存哈希不存明文BCrypt 自带盐同一密码每次结果都不同 u.setPassword(encoder.encode(req.getRawPassword())); u.setRole(Role.USER); userRepository.save(u); } public boolean matches(String raw, String encoded) { return encoder.matches(raw, encoded); // 校验时用 matches不要自己比对字符串 } }逻辑说明BCrypt 每次encode都会生成随机盐并拼进结果串所以库里同一个密码的哈希值互不相同撞库难度大幅提升。校验只能用matches不能拿encode(raw).equals(encoded)比那样永远不相等。参数说明BCryptPasswordEncoder默认强度 10编码耗时约几十毫秒本地开发够用如果要抗更强的暴力破解可以调到 12代价是登录接口响应变慢按实际机器性能权衡。依赖只需要spring-security-crypto这一个包不必引入完整的 Spring Security 框架教学项目里那套过滤器链配置反而增加理解成本。4.2 影片查询的分页与条件过滤影片列表是首页的门面也是查询参数最多的地方关键词、类型、上映状态、排序方式。全塞进一个方法名会失控用Specification或者干脆写一段 JPQL 更清楚。GetMapping(/movies) public String list(RequestParam(defaultValue ) String keyword, RequestParam(defaultValue 0) int page, RequestParam(defaultValue 12) int size, Model model) { // 页码兜底前端可能传负数或超大值 int safePage Math.max(page, 0); Pageable pageable PageRequest.of(safePage, size, Sort.by(Sort.Direction.DESC, releaseDate)); PageMovie result keyword.isBlank() ? movieRepository.findAll(pageable) : movieRepository.findByTitleContaining(keyword.trim(), pageable); model.addAttribute(movies, result.getContent()); model.addAttribute(currentPage, safePage); model.addAttribute(totalPages, result.getTotalPages()); model.addAttribute(keyword, keyword); return movie-list; // 对应 templates/movie-list.html }逻辑说明返回类型是String且类上是ControllerSpring 会把它当模板名解析去找templates/movie-list.html。result.getContent()只取当前页数据总页数单独给模板里画分页条用得上。参数说明size默认 12正好是三列四行的海报墙Sort.by(releaseDate)里的字段名必须是实体属性名写成数据库列名会启动即报错。keyword.isBlank()判断空串和纯空格比! null !isEmpty()少一层嵌套。注意PageRequest.of的页码从 0 开始前端如果传 1 表示第一页这里要减 1我在safePage上做了下限保护上限交给totalPages判断。4.3 Thymeleaf 列表渲染与表单绑定模板层最容易出的问题不是语法是「属性名对不上」和「表单提交后对象为空」。先看列表渲染!-- templates/movie-list.html 片段 -- div classmovie-grid div classmovie-card th:eachm : ${movies} img th:src{/static/poster/ ${m.poster}} alt海报 h3 th:text${m.title}影片名/h3 p th:text${#temporals.format(m.releaseDate, yyyy-MM-dd)}上映日期/p a th:href{/movies/ ${m.id}}查看详情/a /div /div !-- 分页条当前页高亮首尾页禁用 -- nav th:if${totalPages 1} a th:href{/movies?page ${currentPage - 1}} th:classappend${currentPage 0} ? disabled上一页/a span th:text${currentPage 1} / ${totalPages}1 / 5/span a th:href{/movies?page ${currentPage 1}} th:classappend${currentPage 1 totalPages} ? disabled下一页/a /nav逻辑说明th:each的写法是「变量 : 集合」变量名在循环体内直接用{}是 URL 表达式会自动处理上下文路径项目部署到/ticket这种子路径下也不会失效#temporals是 Thymeleaf 3 起内置的 Java 8 时间工具格式化LocalDateTime必须用它老教程里的#dates只认java.util.Date。参数说明th:classappend而不是th:class前者是在原 class 上追加不会把已有样式覆盖掉分页链接里的page参数保持从 0 开始和控制器口径一致前后端各转一次最容易错。表单提交这块用户下单时提交的是「场次 ID 座位号数组」绑定到 DTOform th:action{/orders} methodpost th:object${orderForm} input typehidden th:field*{screeningId} label th:eachs : ${seatList} input typecheckbox nameseatNos th:value${s} span th:text${s}A1/span /label button typesubmit确认下单/button /form逻辑说明th:object声明表单绑定对象th:field*{screeningId}会自动生成name、id、value三件套回显时也不用额外写。座位是多选不能直接用th:field改成原生nameseatNos后端用ListString seatNos接收。参数说明隐藏域里的screeningId是防止用户在页面上改场次的关键服务端必须再查一次该场次是否存在、是否已开场不能信前端。复选框的value直接是座位号字符串和order_seat.seat_no的存储格式保持一致中间不做任何转换减少出错面。5. 上线前排查五条血泪现场与对应修法项目能启动只是及格线能不能稳定跑完一条下单链路是另一回事。下面五条是我在这类项目上真实撞过的每条按「现象 → 原因 → 解决」写遇到同类问题可以直接对号入座。5.1 启动与依赖类现场现象一启动报Failed to configure a DataSource: url attribute is not specified。原因通常不是没写配置而是配置文件没被加载。要么application.properties放在了resources外面要么文件名拼成了application.yml却按 properties 的语法写要么用了多环境配置但没激活spring.profiles.active。 解决先看启动日志里打印的 active profile再确认target/classes下有没有这个文件。IDEA 里如果开启了「Build project automatically」但没重新构建改完配置不生效也是常事手动mvn clean一次最稳。现象二时间字段全部差 8 小时。原因有两个层次连接串里的serverTimezone没写或者写了但实体用的是java.util.Date配Temporal(TIMESTAMP)驱动和 JVM 时区各算一遍。 解决连接串固定serverTimezoneAsia/Shanghai实体统一用LocalDateTimeJVM 启动参数加-Duser.timezoneAsia/Shanghai。三处统一之后时区问题基本绝迹。现象三Thymeleaf 模板改了不生效重启才好。原因是spring.thymeleaf.cache默认是true开发期忘了关。更隐蔽的一种是 IDEA 没有把resources/templates标记为资源目录编译输出里根本没这个文件。 解决开发期显式写spring.thymeleaf.cachefalse并在 IDEA 的 Project Structure 里确认templates目录被标记为 Resources。改完还不行就去看target/classes/templates有没有对应的 html。5.2 数据与业务一致性现场现象四两个人同时下单同一座位卖出去两张。原因是在 Service 里先select判断座位是否被占再insert这两步之间存在并发窗口。加了synchronized也只是在单机有效多实例部署照样穿。 解决把判断交给数据库。order_seat上建(screening_id, seat_no)唯一索引插入时捕获DataIntegrityViolationException捕获到就回滚整单并提示重选。应用层的预检查保留作用是给用户快速反馈兜底永远靠约束。现象五订单显示已取消但座位还是「已占」用户买不了。原因是取消订单时只改了ticket_order.status忘了删order_seat里对应的行。或者反过来删了座位但订单状态没同步数据两头不一致。 解决把「改状态 释放座位」放进同一个Transactional方法里两件事要么都成要么都不成。另外检查一下有没有别的地方直接操作order_seat表绕过了 Service这类散落在 Controller 里的写操作是数据不一致的主要来源。提示这五条里第四条和第五条造成的返工最多建议在写完下单流程后立刻补上唯一索引和事务注解别等联调时才发现。6. 再压一层并发选座与订单超时的落地做法模型和接口都通了之后真正让这套系统从「能跑」变成「敢用」的是两件小事座位冲突的处理姿势以及未支付订单的自动回收。前者靠唯一索引兜底后者靠定时扫描加索引都不需要引入额外的中间件。6.1 唯一索引兜底 捕获冲突的写法下单入口不要写成一长串 if-else把「插座位」和「建订单」放在一个事务里冲突就整体回滚Transactional(rollbackFor Exception.class) public Long createOrder(Long userId, Long screeningId, ListString seatNos) { Screening sc screeningRepository.findById(screeningId) .orElseThrow(() - new BizException(场次不存在)); if (sc.getStartTime().isBefore(LocalDateTime.now())) { throw new BizException(该场次已开场); } TicketOrder order new TicketOrder(); order.setOrderNo(generateOrderNo(userId)); order.setStatus(OrderStatus.CREATED); order.setExpireAt(LocalDateTime.now().plusMinutes(15)); // 15 分钟支付窗口 order.setAmount(sc.getPrice().multiply(BigDecimal.valueOf(seatNos.size()))); orderRepository.save(order); try { for (String seat : seatNos) { orderSeatRepository.save(new OrderSeat(screeningId, seat, order.getId())); } } catch (DataIntegrityViolationException e) { // 撞唯一索引说明有人先占了整单回滚 throw new BizException(座位已被占用请重新选择); } return order.getId(); }逻辑说明Transactional(rollbackFor Exception.class)显式声明受检异常也回滚默认只回滚运行时异常这一点坑过很多人。异常抛在事务方法内部Spring 会标记回滚外面的全局异常处理器只负责翻译成人话。参数说明15 分钟支付窗口是行业常见值太短用户来不及付款太长座位被长期占用generateOrderNo建议用「日期 用户 ID 后四位 随机数」保证唯一且可读别用 UUID对账时看着累。6.2 定时扫单释放座位未支付订单到期后必须自动取消并释放座位否则座位会被幽灵订单永久占住。做法很朴素定时任务扫status CREATED and expire_at now()改成 TIMEOUT 并删座位行。前面ticket_order上的idx_status_expire就是为这个查询建的订单量上万也不会拖垮库。Scheduled(fixedDelay 60_000) // 每分钟扫一次上一轮跑完再计时 Transactional(rollbackFor Exception.class) public void releaseExpiredOrders() { ListTicketOrder expired orderRepository .findByStatusAndExpireAtBefore(OrderStatus.CREATED, LocalDateTime.now()); for (TicketOrder o : expired) { o.setStatus(OrderStatus.TIMEOUT); orderSeatRepository.deleteByOrderId(o.getId()); // 先释放座位 } orderRepository.saveAll(expired); // 再统一更新状态 }参数说明fixedDelay而不是fixedRate前者保证上一轮执行完才开始计时避免任务堆积批次量大时给findByStatusAndExpireAtBefore加Pageable限制每轮 500 条防止一次加载几十万对象把内存打满。释放座位和改状态在同一个事务里任何一步失败都不留半截数据。这套做法我在几个小体量项目上跑过单机每分钟处理几千条过期订单没什么压力。真正上量之后再考虑延迟队列那时候问题已经不是「怎么写」而是「值不值得为它引入一套消息中间件」。血泪教训是我最早那版把释放座位写在了 Controller 里取消订单时漏了一个分支导致一批座位被占了两天没人发现最后是用户投诉才查到。从那以后我每次写完订单状态机都会先把四个状态CREATED / PAID / CANCELLED / TIMEOUT之间的流转画在纸上逐个核对座位表的增删是否对称再动手写代码。源码包里的下单流程就是按这个习惯整理过的拿去改之前建议你也先对着状态机走一遍。希望帮到你。本文还有配套的精品资源点击获取