news 2026/9/22 10:52:22

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
搞定景区门票预订系统: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 启动器 --><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>

核心要点

  1. mysql-connector-j:MySQL 8 的驱动包名变了,老版本叫 mysql-connector-java,现在叫 mysql-connector-j。搜不到包?那就是版本号没写对。
  2. 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:处理金额时,严禁使用 doublefloat,否则会有精度丢失问题。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)

这是并发安全的核心。

  • 错误写法

    1. 线程A 查询 stock = 10
    2. 线程B 查询 stock = 10
    3. 线程A 计算 10-1=9,更新 stock=9
    4. 线程B 计算 10-1=9,更新 stock=9 结果:卖了2张票,库存只扣了1张。超卖!
  • 正确写法(数据库层原子操作)

    1. 线程A 执行 UPDATE ... WHERE stock >= 1,成功,stock=9
    2. 线程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 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 无法创建。 原因

  1. 使用了错误的 MyBatis-Plus 依赖(用了 starter 而不是 boot3-starter)。
  2. 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

现象:运行时抛出空指针异常。 原因: 查询了一个不存在的 ticketIdselectById 返回 null,后续调用 getStock() 报错。

解决: 在 Service 层增加判空逻辑(如 4.1 节代码所示)。永远不要假设数据库查询一定有结果。

5.3 BadSqlGrammarException: You have an error in your SQL syntax

现象:执行 deductStock 时报错。 原因

  1. SQL 语法错误。
  2. 数据库表结构与设计不符(例如字段名拼写错误,stock 写成了 stok)。

解决

  1. 打印完整 SQL 日志,复制到 MySQL 命令行执行,查看具体错误行。
  2. 检查 @Update 注解中的 SQL 语句,特别是 #{amount}#{id} 的参数名是否与 @Param 一致。

6. 小结与进阶

这套景区门票预订系统完整示例,覆盖了从环境配置到核心业务逻辑的全流程。

核心收获

  1. Spring Boot 3 迁移:注意依赖包名变更,特别是 MyBatis-Plus 和 MySQL 驱动。
  2. 并发安全:利用数据库的 UPDATE ... WHERE 原子操作,避免应用层锁的性能瓶颈。
  3. 防御式编程:对数据库查询结果进行判空,对业务参数进行边界校验。

进阶方向

  • Redis 缓存:将门票价格、剩余库存(非精确值)放入 Redis,减少数据库读压力。
  • 消息队列:将“创建订单”操作异步化,先扣库存,再发消息给 MQ,由消费者创建订单。
  • 分布式锁:如果 QPS 超过 5000,数据库行锁可能成为瓶颈,此时引入 Redisson 分布式锁。

掘金技术社区上有不少关于高并发票务系统的深度剖析,建议结合本文代码,搜索“Redis 库存扣减”或“分布式事务”进一步学习。

最后,抛出一个问题给大家讨论: 在门票预订场景中,你更倾向于使用 Redis + Lua 脚本 进行库存预扣,还是直接依赖 MySQL 行锁? 考虑到低延迟和高可用的权衡,你实际项目中更常用哪种写法?评论区交流,看看有多少同行踩过类似的坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/22 10:52:06

税务云开发3个坑让性能优化失效新手必看

税务云开发3个坑让性能优化失效新手必看 刚学完Python或Java语法,是不是觉得“我会写Hello World”就等于“我会开发”?错得离谱。很多应届生进组做税务云相关项目,第一天就卡在环境配置和接口调用的泥潭里。更扎心的是,代码跑通了,一上生产环境响应时间飙到5秒以上,这时候才意识到:…

作者头像 李华
网站建设 2026/9/22 10:51:55

3步搞定jscript教程完整示例:源码拆解解决报错

3步搞定jscript教程完整示例:源码拆解解决报错 凌晨三点,屏幕前只剩你一个人。IDE 飘红,控制台刷出一大片 Uncaught ReferenceError: ... is not defined ,StackTrace 像乱码天书,完全不知道错在哪。别慌,这种“报错一堆看不懂…

作者头像 李华
网站建设 2026/9/22 10:51:38

2026最新SQL内连接优化实战:告别配置卡顿与慢查询

2026最新SQL内连接优化实战:告别配置卡顿与慢查询 刚拿到新项目,环境配置就卡半天?别急,这种痛苦我太懂了。很多人以为SQL内连接(Inner Join)只是查个数据,其实它是性能优化的重灾区。2026最新的开发环境对并发要求极高,如果你的Join写得烂,整个系统直接卡死。今天不聊虚的,直接上干…

作者头像 李华
网站建设 2026/9/22 10:51:27

我可能不会爱上你面试必问:3步搞懂代码调试保姆级教程

我可能不会爱上你面试必问:3步搞懂代码调试保姆级教程 复制来的代码跑不通,报错信息像天书,不知道从哪下手调?别慌。这篇【保姆级教程】不讲虚的,直接拆解【我可能不会爱上你】这个看似浪漫实则硬核的面试高频考点。很多后端开发在准备 Java 或 Python…

作者头像 李华
网站建设 2026/9/22 10:51:25

批单底层原理剖析:告别Stacktrace报错,实现核心性能优化

批单底层原理剖析:告别Stacktrace报错,实现核心性能优化 面对满屏红色的StackTrace,你难道还在逐行硬啃那堆晦涩的堆栈信息吗?这种低效的排错方式不仅消耗精力,更让你无法触及系统瓶颈的核心,直接导致批单处理效率低下,错失性能优化的最佳窗口。别慌,今天咱们不聊虚的,直接拆解批单(Endo…

作者头像 李华
网站建设 2026/9/22 10:51:19

www.znhr.com源码解析:3步搞定官方文档痛点

www.znhr.com源码解析:3步搞定官方文档痛点 别再对着几百页的官方文档发呆抓瞎了。 很多开发者拿到 www.znhr.com 的相关资料,第一反应是头大。 页面层级深、术语堆砌多,根本抓不住核心重点。 其实,抛开那些花哨的营销词,我们回归到最底层的逻辑。 今天我们就直接上手,通过…

作者头像 李华