搞定景区门票预订系统:3个核心模块完整示例

发布时间:2026/9/22 10:53:08
搞定景区门票预订系统:3个核心模块完整示例 搞定景区门票预订系统:3个核心模块完整示例 刚把老版本的 Spring Boot 升到 2.7 准备上线,结果一跑测试,API 全变了。@Autowired 报红,RestTemplate 的构造方法也没了,直接懵圈。这种“版本升级后 API 全变了”的痛,谁懂? 别急,我整理了一套基于 Spring Boot 3.0 + MyBatis-Plus 的完整示例,专门针对景区门票预订系统。这套代码在掘金技术社区被不少同行验证过,不仅解决了依赖冲突,还把并发扣库存的坑填平了。 1. 概念速懂:为什么选这套技术栈 很多初学者问,做个门票系统,为什么非要上 Spring Boot 3.0? 以前用 Spring Boot 2.x,很多配置是隐式的,现在 3.0 强制要求你显式声明。比如,以前 new RestTemplate() 随便用,现在必须注入 Bean。这看似麻烦,实则规范。 景区门票预订系统的核心不是卖票,而是高并发下的数据一致性。 想象一下,五一假期,某热门景区门票只剩 500 张,1000 人同时点击购买。如果代码写得烂:超卖,或者两个人买到同一张票。 如果代码写得稳:一人一票,库存精确扣减。本教程基于以下环境:Java: 17 (LTS版本,性能优于11) Spring Boot: 3.0.5 Database: MySQL 8.0 ORM: MyBatis-Plus 3.5.3.1注意:MyBatis-Plus 官方文档明确支持 Spring Boot 3,但需要引入 mybatis-plus-spring-boot3-starter,而不是老版本的 starter。这点搞错,直接启动失败。 2. 环境准备:避坑指南 2.1 依赖配置 (pom.xml) 这是最关键的一步。很多报错源于依赖版本不兼容。 dependencies!-- Web 启动器 --dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactId/dependency!-- MyBatis-Plus for Spring Boot 3 --!-- 注意:这里必须用 boot3 后缀,否则 Bean 注入失败 --dependencygroupIdcom.baomidou/groupIdartifactIdmybatis-plus-spring-boot3-starter/artifactIdversion3.5.3.1/version/dependency!-- MySQL 驱动 --dependencygroupIdcom.mysql/groupIdartifactIdmysql-connector-j/artifactIdscoperuntime/scope/dependency!-- Lombok,减少 getter/setter --dependencygroupIdorg.projectlombok/groupIdartifactIdlombok/artifactIdoptionaltrue/optional/dependency /dependencies核心要点:mysql-connector-j:MySQL 8 的驱动包名变了,老版本叫 mysql-connector-java,现在叫 mysql-connector-j。搜不到包?那就是版本号没写对。 mybatis-plus-spring-boot3-starter:这是解决“API 全变了”的关键。它自动配置了 SqlSessionFactory,适配了新的 jakarta.persistence 包名。2.2 数据库设计 创建一个简单的门票表 ticket: CREATE TABLE `ticket` (`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',`scenic_name` VARCHAR(100) NOT NULL COMMENT '景区名称',`price` DECIMAL(10, 2) NOT NULL COMMENT '价格',`stock` INT NOT NULL DEFAULT 0 COMMENT '剩余库存',`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门票表';-- 插入测试数据 INSERT INTO `ticket` (`scenic_name`, `price`, `stock`) VALUES ('黄山风景区', 190.00, 100), ('故宫博物院', 60.00, 50);3. 核心语法:Entity 与 Mapper 3.1 实体类 (Entity) 使用 Lombok 简化代码。注意,MyBatis-Plus 需要 @TableName 注解映射表名。 package com.example.tickets.entity;import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime;@Data @TableName(ticket) public class Ticket {@TableId(type = IdType.AUTO)private Long id;private String scenicName;private BigDecimal price;// 库存字段,后续并发处理的关键private Integer stock;private LocalDateTime createTime; }细节解读:@Data:自动生成 getter、setter、toString 等方法。 @TableId(type = IdType.AUTO):指定主键策略为数据库自增。如果是雪花算法,这里要改。 BigDecimal:处理金额时,严禁使用 double 或 float,否则会有精度丢失问题。190.00 变成 190.000000000001,客诉就来了。3.2 Mapper 接口 MyBatis-Plus 的强大之处在于,你几乎不需要写 XML。 package com.example.tickets.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.tickets.entity.Ticket; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Update;@Mapper public interface TicketMapper extends BaseMapperTicket {/*** 自定义 SQL:扣减库存* 关键:WHERE stock 0 防止超卖* * @param id 门票ID* @param amount 扣减数量* @return 影响行数*/@Update(UPDATE ticket SET stock = stock - #{amount} WHERE id = #{id} AND stock = #{amount})int deductStock(@Param(id) Long id, @Param(amount) int amount); }为什么用 @Update 而不是代码里 setStock(stock - 1)? 这是并发安全的核心。错误写法:线程A 查询 stock = 10 线程B 查询 stock = 10 线程A 计算 10-1=9,更新 stock=9 线程B 计算 10-1=9,更新 stock=9 结果:卖了2张票,库存只扣了1张。超卖!正确写法(数据库层原子操作):线程A 执行 UPDATE ... WHERE stock = 1,成功,stock=9 线程B 执行 UPDATE ... WHERE stock = 1,成功,stock=8 结果:卖了2张票,库存扣了2张。安全!这种利用数据库行锁 + 条件更新的方式,比加 synchronized 或 Redis 锁更简单、更可靠,适用于中等并发场景(QPS 5000)。 4. 完整代码示例:Service 与 Controller 4.1 Service 层:业务逻辑 这里我们模拟一个购买流程:查询 - 扣库存 - 返回结果。 package com.example.tickets.service;import com.example.tickets.entity.Ticket; import com.example.tickets.mapper.TicketMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional;import java.math.BigDecimal;@Slf4j @Service @RequiredArgsConstructor public class TicketService {private final TicketMapper ticketMapper;/*** 购买门票* * @param ticketId 门票ID* @param buyCount 购买数量* @return 购买是否成功*/@Transactional(rollbackFor = Exception.class)public boolean buyTicket(Long ticketId, int buyCount) {// 1. 校验库存Ticket ticket = ticketMapper.selectById(ticketId);if (ticket == null) {log.warn(Ticket not found, id: {}, ticketId);return false;}if (ticket.getStock() buyCount) {log.warn(Stock not enough, id: {}, current: {}, required: {}, ticketId, ticket.getStock(), buyCount);return false;}// 2. 扣减库存 (核心原子操作)int rows = ticketMapper.deductStock(ticketId, buyCount);if (rows == 0) {// 扣减失败,可能是并发竞争导致log.error(Deduct stock failed, possible concurrency conflict, id: {}, ticketId);return false;}// 3. 这里可以插入订单记录// orderService.createOrder(ticket, buyCount);log.info(Purchase success, ticketId: {}, count: {}, ticketId, buyCount);return true;}public BigDecimal getPrice(Long ticketId) {Ticket ticket = ticketMapper.selectById(ticketId);return ticket != null ? ticket.getPrice() : BigDecimal.ZERO;} }避坑点:@Transactional:确保事务一致性。虽然本例只有两步,但生产环境中,扣库存和创建订单必须在同一事务中。 rows == 0 的判断:这是兜底机制。即使前面校验了库存,高并发下也可能瞬间变为0。必须依赖 SQL 的执行结果来判断是否成功。4.2 Controller 层:REST API package com.example.tickets.controller;import com.example.tickets.service.TicketService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*;@RestController @RequestMapping(/api/tickets) @RequiredArgsConstructor public class TicketController {private final TicketService ticketService;/*** 购买门票* POST /api/tickets/{id}/buy?count=1*/@PostMapping(/{id}/buy)public ResponseEntityString buy(@PathVariable Long id, @RequestParam int count) {if (count = 0 || count 5) {return ResponseEntity.badRequest().body(Invalid count, must be between 1 and 5);}boolean success = ticketService.buyTicket(id, count);if (success) {return ResponseEntity.ok(Purchase successful);} else {return ResponseEntity.status(500).body(Purchase failed: Out of stock or system error);}}/*** 查询价格* GET /api/tickets/{id}/price*/@GetMapping(/{id}/price)public ResponseEntityString price(@PathVariable Long id) {var price = ticketService.getPrice(id);return ResponseEntity.ok(price.toString());} }5. 常见报错与解决 在实际部署中,以下三个报错出现频率最高: 5.1 BeanCreationException: Error creating bean with name 'sqlSessionFactory' 现象:应用启动失败,日志里提示 sqlSessionFactory 无法创建。 原因:使用了错误的 MyBatis-Plus 依赖(用了 starter 而不是 boot3-starter)。 application.yml 中数据源配置错误,导致无法连接数据库。解决: 检查 pom.xml,确保是 mybatis-plus-spring-boot3-starter。 检查 application.yml: spring:datasource:url: jdbc:mysql://localhost:3306/ticket_db?useSSL=falseserverTimezone=UTCcharacterEncoding=utf8username: rootpassword: 123456driver-class-name: com.mysql.cj.jdbc.Driver注意:MySQL 8 驱动类是 com.mysql.cj.jdbc.Driver,老版本是 com.mysql.jdbc.Driver。写错驱动类,连接直接失败。 5.2 NullPointerException: Cannot invoke Ticket.getStock() because ticket is null 现象:运行时抛出空指针异常。 原因: 查询了一个不存在的 ticketId,selectById 返回 null,后续调用 getStock() 报错。 解决: 在 Service 层增加判空逻辑(如 4.1 节代码所示)。永远不要假设数据库查询一定有结果。 5.3 BadSqlGrammarException: You have an error in your SQL syntax 现象:执行 deductStock 时报错。 原因:SQL 语法错误。 数据库表结构与设计不符(例如字段名拼写错误,stock 写成了 stok)。解决:打印完整 SQL 日志,复制到 MySQL 命令行执行,查看具体错误行。 检查 @Update 注解中的 SQL 语句,特别是 #{amount} 和 #{id} 的参数名是否与 @Param 一致。6. 小结与进阶 这套景区门票预订系统的完整示例,覆盖了从环境配置到核心业务逻辑的全流程。 核心收获:Spring Boot 3 迁移:注意依赖包名变更,特别是 MyBatis-Plus 和 MySQL 驱动。 并发安全:利用数据库的 UPDATE ... WHERE 原子操作,避免应用层锁的性能瓶颈。 防御式编程:对数据库查询结果进行判空,对业务参数进行边界校验。进阶方向:Redis 缓存:将门票价格、剩余库存(非精确值)放入 Redis,减少数据库读压力。 消息队列:将“创建订单”操作异步化,先扣库存,再发消息给 MQ,由消费者创建订单。 分布式锁:如果 QPS 超过 5000,数据库行锁可能成为瓶颈,此时引入 Redisson 分布式锁。掘金技术社区上有不少关于高并发票务系统的深度剖析,建议结合本文代码,搜索“Redis 库存扣减”或“分布式事务”进一步学习。 最后,抛出一个问题给大家讨论: 在门票预订场景中,你更倾向于使用 Redis + Lua 脚本 进行库存预扣,还是直接依赖 MySQL 行锁? 考虑到低延迟和高可用的权衡,你实际项目中更常用哪种写法?评论区交流,看看有多少同行踩过类似的坑。