搞定景区门票预订系统: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 启动器 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- MyBatis-Plus for Spring Boot 3 --><!-- 注意:这里必须用 boot3 后缀,否则 Bean 注入失败 --><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId><version>3.5.3.1</version></dependency><!-- MySQL 驱动 --><dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency><!-- Lombok,减少 getter/setter --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</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 BaseMapper<Ticket> {/*** 自定义 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张。安全!
- 线程A 执行
这种利用数据库行锁 + 条件更新的方式,比加 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 ResponseEntity<String> 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 ResponseEntity<String> 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=false&serverTimezone=UTC&characterEncoding=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 行锁? 考虑到低延迟和高可用的权衡,你实际项目中更常用哪种写法?评论区交流,看看有多少同行踩过类似的坑。