ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java+Spring Boot电影订票系统:数据模型与并发选座

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 'ticket'@'localhost' IDENTIFIED BY 'Ticket@2024'; GRANT ALL PRIVILEGES ON movie_ticket.* TO 'ticket'@'localhost'; FLUSH PRIVILEGES;

参数说明:utf8mb4而不是utf8,是因为 MySQL 的utf8只支持三字节,遇到四字节字符会截断;utf8mb4_general_ci在本地开发够用,如果要做严格的姓名、片名排序可以换utf8mb4_0900_ai_ci(MySQL 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.url=jdbc:mysql://localhost:3306/movie_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true spring.datasource.username=ticket spring.datasource.password=Ticket@2024 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # 2. JPA:开发期用 update 自动建表,上线前必须换成 none 或 validate spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.jpa.open-in-view=false # 3. 模板:开发期关缓存,改完页面刷新即生效 spring.thymeleaf.cache=false spring.thymeleaf.encoding=UTF-8 # 4. 服务端口与上下文 server.port=8080 server.servlet.encoding.charset=UTF-8 server.servlet.encoding.force=true

逐条说。serverTimezone=Asia/Shanghai不写会报The server time zone value is unrecognized,这是 MySQL 8 驱动最常见的拦路虎;useSSL=false只是本地省掉证书握手,线上必须开;allowPublicKeyRetrieval=true配合 MySQL 8 的caching_sha2_password认证插件使用,不写会报公钥检索失败。

ddl-auto=update是双刃剑。它在启动时对比实体和表结构,缺字段就加、缺表就建,开发期非常省事;但它不会删字段、不会改类型,跑久了表结构会和实体悄悄漂移。我的做法是开发期用update,交付前用validate跑一遍,确认实体和库对得上,再切到none。

open-in-view=false是我强烈建议加的一项。默认开启时,Hibernate 的 Session 会一直开到视图渲染结束,Thymeleaf 模板里访问懒加载关联不会报错,但一旦改成false,模板里再碰未加载的关联就是LazyInitializationException。早点暴露比上线暴露好,逼着你在 Service 层把数据取干净。

spring.thymeleaf.cache=false只用于开发。上线必须改回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"到"Z99"),screeningId建索引后按场次查已占座位很快;BigDecimal配precision=10, scale=2,支持到千万级金额、两位小数,够任何票务场景。

订单主表管状态流转,字段里必须有一个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 JpaRepository<Movie, Long> { // 单表分页 + 标题模糊匹配,方法名能表达清楚就用方法名 Page<Movie> findByTitleContaining(String keyword, Pageable pageable); // 上映中的影片,按上映时间倒序,首页展示用 List<Movie> findByStatusOrderByReleaseDateDesc(String status); // 复杂统计用 JPQL,别硬凑方法名 @Query("select m.genre, count(m) from Movie m group by m.genre") List<Object[]> countByGenre(); }

逻辑说明:Page<Movie>返回的不只是列表,还带总条数、总页数,前端分页控件直接拿;List<Object[]>是 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")); Page<Movie> 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且类上是@Controller,Spring 会把它当模板名解析,去找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 class="movie-grid"> <div class="movie-card" th:each="m : ${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 + 座位号数组」,绑定到 DTO:

<form th:action="@{/orders}" method="post" th:object="${orderForm}"> <input type="hidden" th:field="*{screeningId}"> <label th:each="s : ${seatList}"> <input type="checkbox" name="seatNos" th:value="${s}"> <span th:text="${s}">A1</span> </label> <button type="submit">确认下单</button> </form>

逻辑说明:th:object声明表单绑定对象,th:field="*{screeningId}"会自动生成name、id、value三件套,回显时也不用额外写。座位是多选,不能直接用th:field,改成原生name="seatNos",后端用List<String> 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 时区各算一遍。 解决:连接串固定serverTimezone=Asia/Shanghai,实体统一用LocalDateTime,JVM 启动参数加-Duser.timezone=Asia/Shanghai。三处统一之后,时区问题基本绝迹。

现象三:Thymeleaf 模板改了不生效,重启才好。原因是spring.thymeleaf.cache默认是true,开发期忘了关。更隐蔽的一种是 IDEA 没有把resources/templates标记为资源目录,编译输出里根本没这个文件。 解决:开发期显式写spring.thymeleaf.cache=false,并在 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, List<String> 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() { List<TicketOrder> 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)之间的流转画在纸上,逐个核对座位表的增删是否对称,再动手写代码。源码包里的下单流程就是按这个习惯整理过的,拿去改之前,建议你也先对着状态机走一遍。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进