十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot+MyBatis-Plus电影院购票系统:从需求到并发控制实战

Spring Boot+MyBatis-Plus电影院购票系统:从需求到并发控制实战 简介在业务系统开发中数据库设计与并发控制是两大核心难点尤其对于涉及选座、订单、支付的场景。通过合理设计表结构利用条件更新实现原子性锁座结合事务保证订单与座位状态一致可有效避免超卖与数据不一致。以电影院购票系统为例基于Spring Boot与MyBatis-Plus从需求拆解、五张核心表设计到下单支付、超时取消的完整实现并剖析并发测试与事务失效等常见问题帮助开发者快速掌握可靠业务系统的搭建方法。 上一段时间帮一个学 Java 的朋友处理毕业设计选题就是“电影院购票系统”。聊下来发现很多人拿到这种经典选题反而不知道从哪下手需求太散、表结构理不清、选座逻辑一做就乱、并发一压就超卖。这篇文章我就以这个项目为完整案例把从需求拆解、数据库设计、核心代码实现到并发问题排查的整个链路从头捋一遍所有代码都是我实际验证过能跑的版本希望能帮正在做课设或想用这个题目练手的你少走几个月的弯路。1. 需求梳理从“买一张电影票”拆出完整功能清单1.1 用户视角和管理端视角分别要什么做系统之前一定要先画清楚角色。电影院购票系统里最基本的是两个角色用户和管理员。用户做的事情很固定注册登录、浏览电影、查看某部电影的场次、选座、下单、支付、查看自己的订单和取票码。管理员做的事情则是维护电影信息、管理影厅、配置场次哪个厅、几点放、票价多少、查看订单数据必要时对场次做下架处理。很多新手一上来就写代码结果做着做着发现“座位状态什么时候改”“订单取消之后座位怎么释放”这种问题完全没想清楚。所以第一步硬性建议把用户从打开 App 到拿到电影票的完整流程走一遍每一步标记出要操作哪张表。我梳理下来一条完整的购票链路是这样的用户注册/登录登录后才有资格购票和查订单。浏览正在上映的电影列表。点击某部电影查看它的所有排片场次。进入某个场次查看座位图选定一个或多个座位。提交订单系统锁定座位生成“待支付”订单。用户模拟支付项目里一般是钱包余额扣款或点击“模拟支付”按钮。支付成功后座位状态变成“已售出”订单变成“已支付”生成取票码。用户在我的订单里查看购票记录和取票码。管理端对应的流程是录入电影、创建影厅、给影厅排场次、查看某场次卖了多少票。到这里功能范围就非常清楚了接下来要做的就是把这些功能落到数据表上。1.2 容易被忽略的业务规则业务规则是这种系统的灵魂也是最容易在答辩或面试时被问到的点。我整理了几个必需要处理的隐性规则同一个座位在同一场次只能被卖一次。下单后如果 15 分钟内未支付订单要自动取消座位要释放回可售状态。用户选座后到提交订单之间座位不能被别人抢走。支付接口要保证幂等避免重复扣款。订单取消、座位释放、订单状态更新这几个操作必须在一个事务里完成否则会留下一堆脏数据。这些规则单独看都很简单但组合在一起就对代码的事务边界和并发控制提出了要求。后面第四、五节我会重点讲这些怎么落地。2. 技术选型Spring Boot 3 MyBatis-Plus MySQL 的组合逻辑2.1 为什么不用纯 JSP/Servlet也不用 SSM 手写配置现在网上大量老项目教程还在用 SSMSpring SpringMVC MyBatis不能说过时但配置成本确实太高各种 XML、各种扫描配置新手很容易被配置折磨到放弃。而 Spring Boot 把绝大多数配置都自动化了尤其是内嵌 Tomcat打包后一个 java -jar 就能跑对课设和练手项目非常友好。所以我这次的选型是Spring Boot 3.x MyBatis-Plus MySQL 8.0。前端部分我用了简单的 Thymeleaf 模板加少量 JavaScript如果你想前后端分离把后端 API 写好之后套一层 Vue 或者 React 也完全没问题接口设计思路是一样的。这里也顺便对比一下常见的组合方案方便你自己判断技术组合优势劣势适合场景JSP Servlet入门简单原理清晰页面和 Java 混在一起维护吃力刚学完 JavaWeb 的作业SSM 手写配置理解框架底层机制配置繁琐开发效率低想深入框架原理Spring Boot MyBatis-Plus效率高样板代码少社区资料多封装太强部分原理被屏蔽课设、毕设、快速上线项目Spring Boot JPACRUD 更快复杂查询不如 MyBatis 直观简单管理系统我这次选了 MyBatis-Plus理由很实际它帮我省掉了大量单表 CRUD 的 XML 编写尤其像订单、电影这些表的增删改查几乎不用写 SQL复杂一点的座位更新、统计报表我再手写 SQL 也不迟。对“电影院购票系统”这种典型的管理交易型项目这个组合性价比是最高的。2.2 还有哪些技术可以留到进阶再加这个项目如果只是课设用 MySQL 一张数据库就够。但为了让你面试时有东西可聊我建议你了解但先不急着加如下技术Redis用来缓存热门电影的场次和座位状态减少数据库压力也可以用来实现分布式锁解决高并发下座位超卖。消息队列订单超时取消除了定时任务扫表也可以用延迟队列实现延迟更精准。微服务拆分真实的影院系统会把用户、订单、排片拆成独立服务但练手项目做成单体即可。先学会用数据库自身的能力解决并发问题再去引入中间件这是比较扎实的技术成长路径。我见过太多人一上来就堆 Redis、MQ问到底层原理却说不清楚反而给答辩减分。3. 数据库设计五张核心表如何撑起整个购票流程3.1 从业务名词到表结构的映射思路电影院购票系统的核心实体其实是四个用户、电影、场次、订单。再加上一个座位表用来存每个场次的座位占用情况。五张表的关系我建议这样理解电影和场次是一对多场次和座位是一对多用户和订单是一对多订单和座位通过冗余的座位编号字符串关联练手项目这么做最直接。建表语句我直接贴出来都是我在本地 MySQL 8.0 上跑过没报错的-- 用户表 CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 加密后的密码, phone VARCHAR(20) DEFAULT NULL, balance DECIMAL(10,2) DEFAULT 0 COMMENT 钱包余额, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 电影表 CREATE TABLE film ( id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT 电影名称, category VARCHAR(50) DEFAULT NULL COMMENT 类型, director VARCHAR(50) DEFAULT NULL, actors VARCHAR(200) DEFAULT NULL, duration INT DEFAULT NULL COMMENT 时长/分钟, release_date DATE DEFAULT NULL, poster_url VARCHAR(255) DEFAULT NULL, description TEXT, status TINYINT DEFAULT 1 COMMENT 1上映中 0已下架, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 场次表 CREATE TABLE schedule ( id BIGINT NOT NULL AUTO_INCREMENT, film_id BIGINT NOT NULL, hall_name VARCHAR(50) NOT NULL COMMENT 影厅名如1号厅, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL, total_seats INT DEFAULT 0 COMMENT 座位总数, status TINYINT DEFAULT 1 COMMENT 1可售 0已下架, PRIMARY KEY (id), KEY idx_film_id (film_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 座位表记录某场次每个座位的实时状态 CREATE TABLE seat ( id BIGINT NOT NULL AUTO_INCREMENT, schedule_id BIGINT NOT NULL, row_no INT NOT NULL COMMENT 排号, col_no INT NOT NULL COMMENT 列号, seat_code VARCHAR(10) NOT NULL COMMENT 座号如A-03, state TINYINT DEFAULT 0 COMMENT 0可选 1锁定 2已售, order_id BIGINT DEFAULT NULL COMMENT 锁定/购买该座位的订单号用于追溯, PRIMARY KEY (id), UNIQUE KEY uk_schedule_seat (schedule_id, row_no, col_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, film_title VARCHAR(100) DEFAULT NULL COMMENT 冗余方便查询, hall_name VARCHAR(50) DEFAULT NULL, start_time DATETIME DEFAULT NULL, seat_codes VARCHAR(200) DEFAULT NULL COMMENT 座位编号逗号分隔, total_price DECIMAL(10,2) DEFAULT 0, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;3.2 为什么座位表要单独存每个座位的状态很多人第一版设计会图省事直接在 schedule 表里放一个“剩余座位数”下单就减 1。这样确实简单但有个致命问题你只知道还剩多少个座位不知道具体是哪几个座位被买了选座功能直接没法做。所以必须有一个表把每个座位、每个场次的占用状态存下来。座位表中的 state 字段是整套系统的核心状态机。0 表示可选1 表示用户选了但还没付钱锁定2 表示已售出。为什么要在“已售出”之前多一个“锁定”状态因为支付需要时间。如果不加锁定状态用户 A 下单后支付前座位一直被标记为可选用户 B 也能下单最后两个人都以为自己买到了同一个座位影院线下一定出事。加了锁定状态A 一旦下单座位立刻从 0 变成 1B 再查这个座位看到的就是不可选等 A 支付完成后才变成 2。为什么 order_id 字段不在订单表里关联这一堆座位而要在座位表里冗余一个 order_id这是为了方便后续做“根据订单取消释放座位”的操作。释放时只要 UPDATE seat SET state 0 WHERE order_id ? 就行不需要先去查订单里存了哪些座位编号。虽然有一点点冗余但在数据量不大的项目里清爽的查询逻辑比严格的范式更重要。订单表里的 film_title、hall_name、start_time 也是同样的道理都是冗余字段。下单时从 schedule 表里带出来存一份查订单列表时就不用跨三张表 join 了。对练手项目来说这是非常划算的取舍但同时你要在代码注释里写清楚这些字段是冗余的避免以后自己都看不懂。4. 核心代码实现从查座位到支付出票的完整链路4.1 项目目录结构和基础配置我用 Maven 标准结构包名用的 com.example.cinema。分层很简单controller、service、mapper、entity、dto、config。建议你也按这个来面试官问起项目结构时能讲清楚每一层的职责。src/main/java/com/example/cinema ├── config │ └── MybatisPlusConfig.java ├── controller │ ├── AuthController.java │ ├── FilmController.java │ ├── ScheduleController.java │ ├── SeatController.java │ └── OrderController.java ├── entity │ ├── User.java │ ├── Film.java │ ├── Schedule.java │ ├── Seat.java │ └── Orders.java ├── mapper │ ├── UserMapper.java │ ├── FilmMapper.java │ ├── ScheduleMapper.java │ ├── SeatMapper.java │ └── OrdersMapper.java ├── service │ ├── OrderService.java │ ├── OrderServiceImpl.java │ └── ... └── dto ├── LoginDTO.java ├── OrderCreateDTO.java └── ...application.yml 里需要配置的数据源我贴一个最常用的版本spring: datasource: url: jdbc:mysql://localhost:3306/cinema_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl4.2 查询接口电影列表、场次列表、座位图先从一个最简单的接口开始查询某部电影所有场次。这个接口直接查 schedule 表关联一下 film 表拿电影名放到 VO视图对象里返回给前端。GetMapping(/schedule/film/{filmId}) public Result listSchedules(PathVariable Long filmId) { ListScheduleVO list scheduleMapper.selectScheduleVOByFilmId(filmId); return Result.success(list); }对应的 Mapper SQL 我直接用注解写Select(SELECT s.id, s.film_id, f.title AS filmTitle, s.hall_name, s.start_time, s.end_time, s.price, s.total_seats - (SELECT COUNT(*) FROM seat WHERE schedule_id s.id AND state IN (1, 2)) AS available_seats FROM schedule s JOIN film f ON s.film_id f.id WHERE s.film_id #{filmId} AND s.status 1) ListScheduleVO selectScheduleVOByFilmId(Long filmId);这里 available_seats 是实时算出来的好处是准确坏处是场次很火时查询会稍微慢一点。练手项目直接这样算完全没问题真要到高并发场景再去考虑冗余一个剩余票数字段。座位图的查询就更简单了一个场次对应 50 个座位5 排 × 10 列选出所有座位按排和列排好返回前端前端渲染成一个电影厅的座位格子。state 为 0 的座位可以点state 为 1 或 2 的座位显示为灰色不可点。4.3 核心下单接口事务 锁座这是全项目最关键的代码。我先说思路用户提交 OrderCreateDTO里面有 scheduleId 和选中的座位 id 集合。服务端要做四件事检查订单里的座位在当前场次是否都还是 state 0。如果都是可选的把这些座位全部更新为 state 1锁定并记上 orderId。计算总价创建订单状态为待支付。返回订单号给前端前端跳转到支付页面。我用 UPDATE 条件更新的方式来实现锁座不用 SELECT FOR UPDATE因为 UPDATE 的方式更简洁而且天然具有“原子性判断锁定”的效果。Override Transactional(rollbackFor Exception.class) public OrderVO createOrder(OrderCreateDTO dto, Long userId) { // 1. 查出这些座位校验归属 ListSeat seats seatMapper.selectBatchIds(dto.getSeatIds()); if (seats.isEmpty() || seats.stream().anyMatch(s - !s.getScheduleId().equals(dto.getScheduleId()))) { throw new BizException(座位数据异常); } // 2. 尝试逐个锁定座位注意这里的关键是 UPDATE ... WHERE state 0 String orderNo generateOrderNo(); for (Seat seat : seats) { int rows seatMapper.lockSeat(seat.getId(), orderNo); if (rows 0) { // 锁定失败说明座位被别人抢先了抛出异常触发整体回滚 throw new BizException(座位 seat.getSeatCode() 已被他人锁定或购买); } } // 3. 计算总价 Schedule schedule scheduleMapper.selectById(dto.getScheduleId()); if (schedule null || schedule.getStatus() ! 1) { throw new BizException(场次不存在或已下架); } BigDecimal totalPrice schedule.getPrice().multiply(BigDecimal.valueOf(seats.size())); // 4. 创建订单 Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setScheduleId(schedule.getId()); order.setFilmTitle(schedule.getFilmTitle()); // 冗余字段 order.setHallName(schedule.getHallName()); order.setStartTime(schedule.getStartTime()); order.setSeatCodes(seats.stream().map(Seat::getSeatCode).collect(Collectors.joining(,))); order.setTotalPrice(totalPrice); order.setStatus(0); ordersMapper.insert(order); return new OrderVO(order.getOrderNo(), totalPrice, order.getSeatCodes()); }seatMapper.lockSeat 对应的 SQL 是这个Update(UPDATE seat SET state 1, order_id #{orderId} WHERE id #{id} AND state 0) int lockSeat(Long id, String orderId);这个方法返回受影响的行数如果返回 0说明 UPDATE 的条件不满足也就是这个座位已经不是可选状态了。InnoDB 在执行这个 UPDATE 时会自动给符合条件的行加行锁并发过来的两个请求只有一个能成功。这就是数据库层面对抗“超卖”的天然能力不需要额外引入分布式锁。4.4 支付接口扣款 更新座位状态 更新订单状态支付是第二个容易写崩的地方。我的模拟支付逻辑是用户的钱包余额足够就扣钱然后更新订单状态为已支付同时把对应座位的 state 从 1 改成 2。这三个动作必须在一个事务里任何一个失败都要整体回滚不能让“钱扣了但票没出”的情况发生。Override Transactional(rollbackFor Exception.class) public void pay(String orderNo, Long userId) { // 1. 查出订单校验归属和状态 Orders order ordersMapper.selectByOrderNo(orderNo); if (order null || !order.getUserId().equals(userId)) { throw new BizException(订单不存在); } if (order.getStatus() ! 0) { throw new BizException(订单状态不是待支付可能已支付或已取消); } // 2. 扣用户余额真实项目这里会调支付网关我们做模拟 int updateRows userMapper.deductBalance(userId, order.getTotalPrice()); if (updateRows 0) { throw new BizException(余额不足); } // 3. 更新订单状态 int orderRows ordersMapper.markPaid(orderNo); if (orderRows 0) { throw new BizException(订单状态更新失败); } // 4. 更新座位为已售 seatMapper.markSoldByOrderId(orderNo); }userMapper.deductBalance 的 SQL 也要用条件更新防止并发扣款把余额扣成负数Update(UPDATE user SET balance balance - #{amount} WHERE id #{userId} AND balance #{amount}) int deductBalance(Long userId, BigDecimal amount);注意这里 balance amount 的条件让数据库帮我们挡住“余额不足但并发点击支付”的情况。虽然逻辑上 pay 方法里已经查过一次余额但查完之后和更新之间是有时间窗口的加上这个条件才是真正保险的写法。4.5 自动取消超时未支付订单用户下单后不支付不能让他永久占着座位。我用了 Spring 自带的定时任务每 30 秒扫一次待支付超过 15 分钟的订单取消订单并释放座位。Component public class OrderTimeoutTask { Autowired private OrdersMapper ordersMapper; Autowired private SeatMapper seatMapper; Scheduled(fixedDelay 30000) Transactional(rollbackFor Exception.class) public void cancelTimeoutOrders() { // 查出所有待支付且创建时间超过15分钟的订单 ListOrders timeoutOrders ordersMapper.selectTimeoutOrders(15); for (Orders order : timeoutOrders) { // 先把订单标记为取消注意加状态条件防止重复取消 int rows ordersMapper.markCancelled(order.getId()); if (rows 0) { // 释放座位 seatMapper.releaseSeatsByOrderId(order.getOrderNo()); } } } }releaseSeatsByOrderId 的 SQL 是Update(UPDATE seat SET state 0, order_id NULL WHERE order_id #{orderId} AND state 1) int releaseSeatsByOrderId(String orderId);加 state 1 这个条件很重要。如果检测到超时的时候用户刚好已经支付成功了那么这个 UPDATE 会匹配 0 行不会把已经售出的票释放掉。很多新手漏掉这个条件导致用户明明付了钱座位却被定时任务释放了这是大型演出票务事故。5. 并发与数据一致性三个最容易翻车的点5.1 座位超卖模拟“30 个人抢同一个座位”项目做完之后一定要做并发测试。我用 JMeter 建了一个 50 线程的并发请求全部抢同一个座位接口返回值里能看到只有 1 个请求成功其余 49 个都返回“座位已被他人锁定或购买”。原因就在那句 UPDATE seat SET state 1 WHERE id ? AND state 0。但如果你把锁座逻辑写成“先 SELECT 查状态再 UPDATE”中间不加任何控制那么 50 个并发请求可能同时查到 state 0然后 50 个 UPDATE 全部成功或者相互覆盖最终 50 个人都以为自己买到了这个座位。这就在数据库层面出现了超卖。所以再次强调判断和更新必须合并为一条 UPDATE用受影响行数来判断是否抢到。如果你非要用 SELECT FOR UPDATE也不是不行但要注意必须在一段显式事务里完成查完后立刻 UPDATE然后提交事务释放行锁。逻辑上等价但代码更繁琐。我建议优先用条件 UPDATE代码少还容易看懂。数据库层面还有一个细节座位表要用 InnoDB 引擎不能用 MyISAM。MyISAM 不支持行锁也不支持事务并发更新时要么锁表导致性能极差要么产生脏数据。建表语句里我已经写了 ENGINEInnoDB这是底线。5.2 事务失效同一个类内部调用为什么连带锁都不生效Spring 的 Transactional 本质上是通过 AOP 代理生效的。当你在 ServiceImpl 里写一个方法 A同一个类里另一个方法 B 直接调用 A这个调用其实是 this.A()走的是原生对象不是 Spring 生成的代理对象事务注解根本不生效。我见过一个典型的错误案例Service public class OrderServiceImpl { public void createAndPay(OrderCreateDTO dto, Long userId) { this.createOrder(dto, userId); // 事务不生效 this.pay(xxx, userId); // 事务不生效 } Transactional public OrderVO createOrder(...) { ... } Transactional public void pay(String orderNo, Long userId) { ... } }这样写的话createAndPay 里两个方法的数据库操作各自都没有事务保护。一旦 createOrder 里锁座成功pay 里扣款失败就会留下“座位锁了、订单待支付、钱没扣”的脏场景。解决方案有两个。一个是把涉及事务的操作独立到不同的 Service Bean 里通过注入其他 Service 来调用这样调用的是代理对象事务生效另一个是干脆用编程式事务Autowired private TransactionTemplate transactionTemplate; public void createAndPay(OrderCreateDTO dto, Long userId) { transactionTemplate.execute(status - { orderService.createOrder(dto, userId); orderService.pay(orderNo, userId); return null; }); }我个人的经验是事务分层要清晰。一个 Service 方法就对应一个完整的业务操作不要让两个 Transactional 方法在同一个类里互相嵌套调用。看不清事务边界是代码里最大的隐患之一。5.3 状态一致性问题订单更新和座位释放必须原子化订单状态和座位状态是跨表的强关联数据。比如取消订单的场景如果先更新订单状态为取消再释放座位释放座位时数据库突然报错那么订单是取消状态但座位还是锁定状态这个座位就永远卖不出去了。所以释放座位和更新订单必须放在同一个事务里。同理支付成功时把订单改成已支付和把座位改成已售也必须是一个事务。我在上面 pay 方法里已经写了 Transactional(rollbackFor Exception.class)目的就是这个。注意 rollbackFor 一定要写 Exception.class 而不是默认的 RuntimeException因为很多数据库异常在 Spring 的默认配置下不会触发回滚写了之后更保险。还有一个容易被忽略的点是幂等。用户在支付成功页疯狂刷新或者支付网关回调重试同一个支付回调可能到达多次。我的处理方式是在 pay 方法里先查订单状态只有 state 0 才继续往下走并且 markPaid 的 SQL 里也加了 status 0 条件Update(UPDATE orders SET status 1, paid_at NOW() WHERE order_no #{orderNo} AND status 0) int markPaid(String orderNo);这样即使同一个请求进来 10 次也只有第一次能真正扣款和出票后面 9 次都会因为更新行数为 0 而返回“订单状态异常”。这就是接口幂等性的最简单实现。6. 项目跑通与自测环境、目录、验证流程6.1 本地环境准备和最小配置我给这个项目建议的环境非常简单JDK 17、Maven 3.8、MySQL 8.0、IDEA没有额外装 Redis 和其他中间件。JDK 版本是 Spring Boot 3.x 的硬性要求如果你用的 JDK 8那建议把 Spring Boot 版本降到 2.7.x否则起不来。数据库准备只需要两步创建数据库 cinema_db然后执行我第三节里面的建表 SQL。创建完不要直接跑先检查两个地方一是 MySQL 的时区要设置为 Asia/Shanghai连接 URL 里已经加了 serverTimezoneAsia/Shanghai二是如果在 Windows 上遇到中文乱码检查 MySQL 的 my.ini 里 character-set-server 是否为 utf8mb4。6.2 一套完整自测流程从注册到出票项目启动后我习惯用 Postman 把整条链路跑一遍确认没问题再让前端页面接入。下面是完整的自测清单和对应接口注册用户POST /api/user/register传 username、password、phone。登录POST /api/user/login拿到用户 id。管理员录入电影POST /api/film/add。管理员排场次POST /api/schedule/add传 filmId、hallName、startTime、price。查询场次GET /api/schedule/film/{filmId}。查看座位图GET /api/seat/list?scheduleId1。下单POST /api/order/create传 scheduleId、seatIds。支付POST /api/order/pay传 orderNo。查订单GET /api/order/list?userId1。每一步都去数据库里看一眼对应表的状态变化。下单后看 seat 表的 state 是不是变成 1支付后再看是不是变成 2orders 表的 status 是不是变成 1如果等 16 分钟不支付再看定时任务有没有把座位释放、订单置为取消。6.3 常规报错怎么处理端口占用、时区警告、依赖下载慢新手在跑这个项目时大概率会遇到几个经典的启动报错我提前给你排查思路端口 8080 被占用报错信息里会有 Web server failed to start. Port 8080 was already in use。在 application.yml 里改 server.port 为 8081 即可。数据库连接失败确认 MySQL 服务确实启动了用户名密码正确数据库 cinema_db 建好了。时区警告连接 URL 里一定带 serverTimezoneAsia/Shanghai。Maven 依赖下载慢换阿里云镜像源在 Maven settings.xml 里加 mirror。这些都不是代码逻辑问题排查思路是先看异常栈里有没有 Caused by顺着 Caused by 一层层往底层找大多数问题都是配置问题而不是业务 Bug。6.4 压测步骤用 JMeter 验证并发安全要验证第五节讲的并发控制是否有效最快的办法是 JMeter。步骤很简单添加线程组线程数设 50Ramp-Up 设 0表示 50 个请求同时打进来添加 HTTP 请求指向 POST /api/order/create传同一个 scheduleId 和同一个 seatId添加查看结果树和聚合报告跑一次看响应。判断标准只有一个成功的请求数必须等于 1。如果发现多个成功说明锁座逻辑有问题回去检查 UPDATE 条件是否真的带了 state 0。如果全部失败那可能你的测试数据有问题比如选中的座位初始状态就不是 0或者 scheduleId 不对。压测完以后还要做一个“恢复测试”把数据库里测试产生的脏数据清掉重新跑一遍完整流程确保定时任务、支付、取消都正常。这一步虽然枯燥但能帮你发现很多隐藏状态不一致问题。7. 这个项目做完之后我总结的几条经验项目做完了最有价值的不是那一堆代码而是踩坑之后沉淀的几条认知。第一做业务系统前画状态机绝对值得座位状态、订单状态的变化路径画清楚写代码时心里就有底了很多 bug 本质上是状态机设计得不清不楚。第二数据库层的条件更新是解决并发问题最简单可靠的手段不要一开始就想着上分布式锁、中间件先把 UPDATE 条件写对大部分超卖问题都能解决。第三测试要模拟真实场景尤其要模拟并发同一个座位、支付超时、重复支付回调这三个场景跑通这个项目就可以拿去答辩了。如果你做完这个基础版还想继续扩展我建议优先考虑两个方向一是把 Redis 引进来做场次座位缓存热点场次的座位图不用每次查数据库二是用延迟队列替代定时任务扫表让订单超时更精准。这两个点都能在面试时讲出深度也是从“会写代码”到“会设计系统”之间很好的练习。最后再分享一个小技巧项目跑起来后把 MyBatis-Plus 的日志打开每次执行 SQL 都能在控制台看到调试选座逻辑时一眼就能看出 UPDATE 影响了几行排查问题会顺手很多。本文还有配套的精品资源点击获取
返回列表