
每逢热门演唱会开票最紧张的不只是粉丝还有背后负责写票务系统的研发人员。像薛之谦2023“天外来物”巡回演唱会石家庄站这种体量的演出一到开票时间瞬时流量可能是平时的几十倍甚至上百倍。如果系统还是靠 Synchronized 锁单机进程、再直接 UPDATE 数据库库存很容易出现超卖、重复下单、接口卡死、数据库连接被瞬间打满等问题。这篇文章不聊演唱会本身而是站在开发者的角度以“薛之谦2023天外来物石家庄站”这类热门演出为业务背景动手实现一个高并发的演唱会门票抢购系统。整体技术栈以 Spring Boot、Redis、RabbitMQ、MySQL 为主覆盖库存预热、Redis Lua 原子扣减、MQ 异步削峰、订单幂等、超时回补库存等常见问题。代码会按文件给出方便你直接照着建项目、跑流程、做压测。文章适合有一定 Spring Boot 基础、想学习秒杀/抢购类系统设计思路的同学阅读。如果你正在做活动预约、优惠券秒杀、限量课程抢购等场景这套方案同样可以迁移过去。1. 背景与核心概念1.1 演唱会抢票到底难在哪我们可以把“薛之谦2023天外来物石家庄站”的购票流程抽象成这样一个模型一个场次的票总量固定大量用户在同一秒进入系统点击购票后系统需要快速判断“还有没有票”“这个用户能不能买”“如何分配这张票”。这个模型的核心难点有三个。第一个是超卖问题。票的总量是有限的如果不做原子控制两个请求同时读到剩余库存为 1然后都执行扣减最终卖出去的票可能比实际库存多。这在数据库操作里表现为“丢失更新”。第二个是重复下单问题。用户手速快、网络抖动导致点击多次或者前端按钮没有及时禁用同一个用户可能在同一毫秒对同一个场次发出多笔请求。系统需要保证同一个用户在一个场次只能有一次有效订单。第三个是削峰问题。开票瞬间流量过于集中如果所有请求都直接打到 MySQL数据库连接会迅速耗尽。即使每一条 SQL 本身很快但连接池只有几十个连接排队一旦起来响应时间就会飙升最终表现为系统卡死。所以抢票系统的设计重点不是“怎么写 CRUD”而是如何通过缓存、队列、分布式锁、原子脚本等手段把瞬时高并发流量挡在数据库前面同时保证数据最终一致。1.2 方案选型为什么用 Redis 和 RabbitMQ先看一个非常原始的方案所有用户请求直接执行“查询库存大于 0然后 UPDATE 扣减”。数据库事务可以保证原子性但高并发下性能很差因为大量请求会阻塞在行锁上。再往上一层很多同学会想到加分布式锁。例如提前引入 Redis 分布式锁抢到锁的请求才操作数据库。这种方式能解决超卖但是锁竞争会让大量请求阻塞用户体验不好。更常见的做法是分两层处理用 Redis 做库存预扣和用户去重。用 RabbitMQ 削峰把“已经扣减 Redis 库存”的请求异步投递给订单服务订单服务再去写 MySQL。这样做的好处是Redis 单线程执行 Lua 脚本时可以实现原子操作吞吐量远高于 MySQL 行锁而 MySQL 只接收被削峰之后的下单请求连接压力大幅降低。用户侧不需要同步等待数据库写入可以理解为“先占住一张票随后异步确认订单”。当然Redis MQ 方案会引入缓存与数据库一致性、消息可靠消费、超时释放等新的复杂度。本文的代码会在这些方面给出可落地的示例并把仍然存在的风险点单独列出来。1.3 系统整体流程下面用一个简单的时序描述系统主流程客户端请求 - 抢票入口 Controller - Redis Lua 脚本检查用户是否重复、检查库存并扣减 - RabbitMQ 订单消息 - 订单消费服务幂等校验 MySQL 库存扣减 创建待支付订单 - 超时任务扫描未支付订单释放库存并补偿 Redis用户看到“抢票成功”的状态后会进入待支付状态超过一定时间未支付系统会把票释放回池子。真实票务系统还有风控、实名认证、验证码、支付回调、退票等环节本文聚焦最核心的库存与订单链路。2. 环境准备与项目结构2.1 环境版本为了能顺利运行本文代码你需要准备以下环境JDK 17 或更高版本如果使用 Spring Boot 2.x则 JDK 8 也可以但命名空间需要从jakarta换回javax。Maven 3.6 及以上。Spring Boot 3.2.x示例基于 3.2.0。MySQL 5.7 或 8.0。Redis 5.0 及以上。RabbitMQ 3.x本地默认 guest 账号。IDEA 或任意你习惯的 IDE。版本不是僵硬的规定。实际项目中请优先参考 Spring Boot 官方目前维护的稳定版本本文代码中的核心逻辑不受 Spring Boot 2/3 大版本影响差异主要在jakarta.servlet和部分自动配置参数上。2.2 项目结构规划建议创建一个名为ticket-grab-demo的 Maven 项目整体目录结构如下ticket-grab-demo ├── pom.xml └── src └── main ├── java │ └── com │ └── example │ └── ticket │ ├── TicketApplication.java │ ├── config │ │ └── RabbitConfig.java │ ├── controller │ │ └── TicketController.java │ ├── service │ │ ├── TicketService.java │ │ ├── OrderService.java │ │ ├── OrderMessageListener.java │ │ └── OrderExpireTask.java │ ├── repository │ │ └── TicketRepository.java │ ├── model │ │ ├── TicketOrder.java │ │ └── OrderMessage.java │ ├── dto │ │ └── GrabRequest.java │ └── common │ └── ApiResult.java └── resources └── application.yml控制器、服务、仓储、配置分层清晰后续扩展实名校验、支付回调时也更容易维护。2.3 Maven 依赖在pom.xml中加入以下依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.0/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里没有引入 MyBatis 或 JPA主要目的是减少第三方版本干扰方便你理解核心链路。持久化操作使用 Spring JDBC 提供的JdbcTemplate足以完成库存扣减和订单写入。2.4 配置文件新建src/main/resources/application.ymlserver: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ticket_demo?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: root data: redis: host: localhost port: 6379 database: 0 timeout: 3s rabbitmq: host: localhost port: 5672 username: guest password: guest virtual-host: / jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai数据库需要你提前创建CREATE DATABASE IF NOT EXISTS ticket_demo DEFAULT CHARACTER SET utf8mb4;3. 数据库设计3.1 场次表为了方便演示这里把演出信息、场次价格和库存放在同一张表show_session中。如果要做多个城市、多日期的真实场次可以拆分show和show_session两张表但核心库存字段设计思路是一样的。CREATE TABLE IF NOT EXISTS show_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 场次ID, show_name VARCHAR(128) NOT NULL COMMENT 演出名称, city VARCHAR(64) NOT NULL COMMENT 城市, session_time DATETIME NOT NULL COMMENT 演出时间, price DECIMAL(10, 2) NOT NULL COMMENT 票价, stock INT NOT NULL DEFAULT 0 COMMENT 剩余库存, sold INT NOT NULL DEFAULT 0 COMMENT 已售数量, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, KEY idx_city_time (city, session_time) ) ENGINE InnoDB COMMENT 演出场次表;金额字段建议使用DECIMAL不要使用浮点数存储票价避免精度问题。库存stock是数据库侧的最后一道防线Redis 扣减只相当于入口闸门最终订单确认时必须再次扣减数据库库存。3.2 订单表订单表除了记录用户、场次、金额之外必须包含一个唯一的request_id字段。这个字段用于解决 MQ 重复消费和前端重复提交问题。CREATE TABLE IF NOT EXISTS ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 订单ID, request_id VARCHAR(64) NOT NULL COMMENT 幂等请求号, user_id BIGINT NOT NULL COMMENT 用户ID, show_id BIGINT NOT NULL COMMENT 场次ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 订单状态0待支付1已支付2已取消, amount DECIMAL(10, 2) NOT NULL COMMENT 订单金额, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, pay_time DATETIME DEFAULT NULL COMMENT 支付时间, UNIQUE KEY uk_request_id (request_id), KEY idx_user_show (user_id, show_id) ) ENGINE InnoDB COMMENT 演出订单表;request_id的唯一索引非常关键可以在数据库层面挡住完全相同的请求即使 MQ 消息因为重试被投递了多次也不会生成重复订单。3.3 初始化数据插入一条测试数据模拟某场演出、票价 1000 元、总库存 100 张INSERT INTO show_session (show_name, city, session_time, price, stock, sold) VALUES (薛之谦2023“天外来物”巡回演唱会·石家庄站, 石家庄, 2023-06-23 19:30:00, 1000.00, 100, 0);注意这里只是借用热门演唱会作为业务背景做技术演示数据均为模拟不代表真实场次和票务信息。4. 核心代码实现4.1 启动类与公共类启动类需要开启定时任务调度// 文件路径src/main/java/com/example/ticket/TicketApplication.java package com.example.ticket; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; EnableScheduling SpringBootApplication public class TicketApplication { public static void main(String[] args) { SpringApplication.run(TicketApplication.class, args); } }定义一个统一返回结果类方便前后端交互// 文件路径src/main/java/com/example/ticket/common/ApiResult.java package com.example.ticket.common; import lombok.Data; Data public class ApiResultT { private int code; private String message; private T data; public static T ApiResultT ok(String message) { ApiResultT result new ApiResult(); result.setCode(200); result.setMessage(message); return result; } public static T ApiResultT ok(String message, T data) { ApiResultT result ok(message); result.setData(data); return result; } public static T ApiResultT error(int code, String message) { ApiResultT result new ApiResult(); result.setCode(code); result.setMessage(message); return result; } }这个类不是核心但是能让接口返回更清晰。真实项目中可以使用更规范的全局异常结构这里先保持精简。4.2 订单消息体与请求 DTO发送到 RabbitMQ 的消息对象// 文件路径src/main/java/com/example/ticket/model/OrderMessage.java package com.example.ticket.model; import lombok.Data; import java.io.Serializable; Data public class OrderMessage implements Serializable { private String requestId; private Long showId; private Long userId; }这里选择实现Serializable是为了保证对象在 MQ 传输过程中具备良好的兼容性。由于请求参数非常简单前端传参可以用一个 DTO 封装// 文件路径src/main/java/com/example/ticket/dto/GrabRequest.java package com.example.ticket.dto; import lombok.Data; Data public class GrabRequest { private Long userId; private Long showId; private String requestId; }4.3 RabbitMQ 配置RabbitMQ 在高并发抢票链路中承担消息队列角色。我们需要定义交换机、队列和绑定关系同时配置 JSON 消息转换器。// 文件路径src/main/java/com/example/ticket/config/RabbitConfig.java package com.example.ticket.config; import org.springframework.amqp.core.*; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; import org.springframework.amqp.support.converter