做了几年的Java后端,中间接过不少票务类项目,这次从零搭一个演唱会售票系统,整个过程比我想象中更有代表性。售票系统最大的特点就是流量集中、抢票瞬间并发爆炸,但又不像电商秒杀那样可以容忍超卖,票务领域超卖一张就是事故。本文把我踩过的坑、用过的方案、优化过的代码,按照实际项目推进顺序整理出来,分享给正在做或计划做类似系统的朋友。
1. 为什么售票系统自然选择了Spring Boot,以及第一版项目怎么搭
1.1 选型阶段的真实思考过程
最早接到需求的时候,我并没有直接拍板用Spring Boot,而是先梳理了一下售票系统的核心诉求:一是要快速开发,项目周期通常就一两个月;二是要稳定扛住高峰期流量;三是团队里既有刚毕业的新人,也有五六年经验的老手,技术栈必须拉齐。综合考虑下来,Spring Boot几乎是必然选择。
Spring Boot在这个场景下的优势,我实际用下来的体会有三点。第一是自动装配机制,把Spring MVC、Jackson、数据源、事务管理这些底层东西全部封装好,新同学不用一开始就理解Bean的生命周期和代理机制,写业务代码就是Controller、Service、Mapper三层往下走,上手非常快。第二是生态足够成熟,Redis、MyBatis-Plus、RabbitMQ、XXL-Job这些配套组件都有对应的starter,引入依赖加配置就能用,省去大量XML配置和版本冲突的排查时间。第三是监控与运维方便,Spring Boot Actuator自带健康检查、指标暴露接口,接到Prometheus和Grafana上做监控很顺手,对于上线后随时可能被打爆的售票系统来说,这点非常关键。
但也有一个真实的管理成本:版本选择上我特意绕开了最新的Spring Boot 3.x,最终定了Spring Boot 2.7.18配JDK 1.8。原因很简单,团队现有系统大部分跑在JDK 8上,如果强行升级到JDK 17,不仅是版本号的问题,很多老依赖、自定义组件都要重新验证。而且我查了Spring Boot 2.7.18的社区反馈,它是2.x的最后一个维护版本,稳定性有保障,网上踩坑资料也齐全。如果你是新项目、团队没有历史包袱,直接上3.x也没问题,但如果你是接手存量系统,优先沿用团队成熟技术栈,版本升级单独排期,不要混在业务开发里做。
1.2 项目初始化与脚手架搭建
工程结构方面,我按常规的单体应用来组织,没有一开始就拆微服务。售票系统的核心链路是查询、下单、支付、出票,单体能搞定,拆微服务反而引入分布式事务、服务治理这些额外复杂度。我的分包结构如下:
bash复制concert-ticket/
├── src/main/java/com/example/ticket/
│ ├── controller/ # 接口层
│ ├── service/ # 业务层
│ ├── mapper/ # MyBatis-Plus Mapper接口
│ ├── entity/ # 数据实体
│ ├── dto/ # 请求响应对象
│ ├── config/ # 配置类
│ ├── common/ # 通用工具、常量、异常处理
│ ├── task/ # 定时任务
│ └── TicketApplication.java
├── src/main/resources/
│ ├── application.yml
│ ├── mapper/ # XML文件
│ └── db/ # SQL初始化脚本
搭建完成后,我先做了三件准备事项:第一件,统一返回结果Result<T>和全局异常处理器,后面所有接口都走这套规范,避免每个Controller里自己拼返回体,代码风格能保持干净。第二件,配置了MyBatis-Plus的逻辑删除和自动填充,create_time、update_time这些字段不用每次手动set。第三件,把连接池参数调了一下,Druid连接池初始连接数设为10,最大连接数设为50,后续压测根据QPS再调。这三件事看着基础,但很多项目后期维护乱,根子就在前期这里没理清楚。
1.3 环境与依赖版本
我列一下项目实际使用的依赖版本,方便直接抄作业:
| 组件 | 版本 | 说明 |
|---|---|---|
| Spring Boot | 2.7.18 | 2.x最终维护版,稳定 |
| JDK | 1.8 | 与团队存量系统对齐 |
| MyBatis-Plus | 3.5.3 | 单表操作免写SQL |
| Redis | 6.2 | 缓存、分布式锁、库存扣减 |
| MySQL | 8.0 | 主存储 |
| Druid | 1.2.20 | 连接池 |
| RabbitMQ | 3.12 | 异步处理订单超时、消息通知 |
注意:如果你不用Druid,用HikariCP也行,Spring Boot默认就是HikariCP。但售票系统的数据库连接非常宝贵,连接池参数要留足余量,压测阶段我会重点盯连接池的超时等待时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据模型设计:从用户、场次到订单,这几张表是核心骨架
2.1 需求梳理与核心实体识别
售票系统的业务可以拆成几个关键词:用户、演出、场次、座位/票档、订单、支付。演唱会售票与电影票不同,分看台、内场、VIP区域,每个区域价格不同,用户下单时通常不指定具体座位,而是指定票档和数量,系统按锁座规则分配。这个规则直接影响数据结构的设计。
我列一下核心表结构。第一张是用户表,字段包括用户ID、手机号(登录账号)、密码、昵称、创建时间等,用户表本身没啥特殊,重点是登录验证和防刷的字段。第二张是演出信息表,包括演出名称、艺人、城市、场馆、演出时间、开售时间、详情、封面图等,其中开售时间这个字段很关键,系统要判断当前时间是否在售票窗口内。第三张是场次与票档表,演唱会一般一天一场,但同一场可能分多个票档,这里我设计成show_session和show_ticket_grade两张表。
核心表字段我贴出来:
sql复制-- 演出场次表
CREATE TABLE `show_session` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`show_name` varchar(200) NOT NULL COMMENT '演出名称',
`artist` varchar(100) DEFAULT NULL COMMENT '艺人',
`city` varchar(50) DEFAULT NULL COMMENT '城市',
`venue` varchar(200) DEFAULT NULL COMMENT '场馆',
`session_time` datetime NOT NULL COMMENT '演出时间',
`sale_start_time` datetime NOT NULL COMMENT '开售时间',
`sale_end_time` datetime NOT NULL COMMENT '停售时间',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0未上架 1售票中 2已售罄 3已下架',
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_sale_start` (`sale_start_time`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql复制-- 票档库存表
CREATE TABLE `ticket_grade` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`session_id` bigint(20) NOT NULL COMMENT '场次ID',
`grade_name` varchar(50) NOT NULL COMMENT '票档名称,如看台票/内场票/VIP',
`price` decimal(10,2) NOT NULL COMMENT '票价',
`total_stock` int(11) NOT NULL COMMENT '总库存',
`sold_stock` int(11) NOT NULL DEFAULT '0' COMMENT '已售库存',
`locked_stock` int(11) NOT NULL DEFAULT '0' COMMENT '锁定库存(已下单未支付)',
`version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_session_id` (`session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.2 库存设计的关键思路
库存字段这里,我特意拆了三个指标:total_stock总库存、sold_stock已售、locked_stock锁定库存。为什么要拆?因为用户下单到支付完成有一个时间窗口,一般在15分钟左右。如果下单就直接把库存写成已售,那用户不支付就会造成库存虚耗;如果完全不管锁定,只在下单时检查sold_stock < total_stock,那两个人同时下单同一张票,最后只有一个能支付成功,另一个只能超时失败,用户体验极差。
我采用的是“先锁定、后确认”的模式:用户点击购买时,系统先尝试把locked_stock加1,且保证sold_stock + locked_stock <= total_stock,如果满足条件就创建待支付订单;15分钟后未支付,释放锁定库存;支付成功回调后,把locked_stock减1、sold_stock加1,此时库存才真正减少。这个流程和酒店订房、机票预订的逻辑是一致的,能最大程度保证库存不被浪费。
但这里有个问题:纯数据库的原子操作在低并发下没问题,高并发抢票场景下,UPDATE语句的锁竞争会非常激烈。所以库存扣减不能只靠上面这张表,下单核心链路的库存扣减我放在Redis里面做,数据库表作为最终落地的数据源,这个后面章节专门讲。
2.3 订单表与状态机设计
订单表是系统的核心,我设计成如下结构:
sql复制CREATE TABLE `order_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '订单号',
`user_id` bigint(20) NOT NULL,
`session_id` bigint(20) NOT NULL,
`grade_id` bigint(20) NOT NULL,
`ticket_num` int(11) NOT NULL DEFAULT '1' COMMENT '购买数量',
`order_amount` decimal(10,2) NOT NULL COMMENT '订单金额',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已取消 3已退款 4已出票',
`pay_time` datetime DEFAULT NULL,
`close_time` datetime DEFAULT NULL,
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_session_id` (`session_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
订单状态流转是我画了很多遍图的地方,实际使用中订单状态不能乱跳:
text复制待支付(0) -> 已支付(1) -> 已出票(4)
待支付(0) -> 已取消(2) // 超时未支付或用户主动取消
已支付(1) -> 已退款(3) // 演出取消或用户申请退款
这里最容易踩的坑是:用户支付成功后回调还没到,用户又点了取消订单。如果不做状态判断,可能出现已支付订单被取消,然后退款逻辑再跑一遍。所以订单状态更新时,必须在SQL里带上前置状态条件,比如UPDATE order_info SET status = 2 WHERE id = ? AND status = 0,更新影响行数为0说明状态已变化,需要重新查询再处理。
3. 核心链路:库存扣减的三种方案,我用Redis Lua最终定了哪种
3.1 为什么不能直接依赖数据库表扣减库存
先把最直观的方案摆出来:在MySQL中执行UPDATE ticket_grade SET locked_stock = locked_stock + 1 WHERE session_id = ? AND sold_stock + locked_stock < total_stock。这个SQL在并发量小的时候没有问题,InnoDB的行锁会保证同一行的更新是串行的,不会出现超卖。
但是演唱会开票场景下,比如热门歌手的巡演,开票瞬间可能有几万甚至几十万人同时抢。假设一个票档只有5000张票,10万人同时点击购买,这10万个请求打到MySQL,同一行的UPDATE排队执行,绝大多数请求都会等锁超时。即使MySQL能撑住,数据库的CPU和IO也会飙升,直接影响整个系统的稳定性。更麻烦的是,这张表是整个系统共享的,查询库存、订单详情都要读它,一旦写锁卡住,读操作也跟着遭殃。
所以在高并发场景下,数据库只做最终数据落地,绝对不能把热点行的修改放在请求链路上。我的选择是:Redis做库存预扣减,MySQL做最终确认和持久化。
3.2 三种库存扣减方案的实测对比
我把做过的三种方案列成表格,对比一下它们的优缺点:
| 方案 | 核心思路 | 优点 | 缺点 | 并发能力 |
|---|---|---|---|---|
| 数据库悲观锁 | SELECT ... FOR UPDATE 锁行后再更新 | 实现简单、绝对准确 | 锁竞争激烈、数据库压力大 | 低(百级并发就吃力) |
| 数据库乐观锁 | UPDATE ... WHERE version = 旧值 | 相对简单、无锁 | 并发高时大量更新失败,重试逻辑复杂 | 中(千级并发可扛) |
| Redis + Lua | 在Redis中用Lua脚本原子扣减 | 吞吐极高、跨请求原子、支持简单回滚 | 需处理Redis与MySQL数据一致性 | 高(万级并发无压力) |
我实际测试过,用JMeter模拟2000个并发请求抢500张票。纯数据库方案在200并发左右就开始出现明显的等待超时,接口响应时间从几十毫秒飙升到几秒;乐观锁方案在500并发左右会出现大量更新失败,需要引入失败重试队列,逻辑复杂度上升;Redis + Lua方案在2000并发下接口响应时间基本稳定在50毫秒以内,扣减结果准确,误差为零。
3.3 Redis Lua脚本的详细实现
最终我采用的是Redis + Lua方案。核心思路很简单:把票档库存放到Redis的Hash结构中,字段为ticket_grade:{sessionId}:{gradeId},值为剩余可锁定库存数;用户下单时,执行一个Lua脚本,原子地完成“判断库存是否充足 -> 扣减库存 -> 记录扣减流水”这三个步骤。
我贴一下Lua脚本的核心代码:
lua复制-- KEYS[1]: 库存key,例如 ticket_stock:10001:2001
-- KEYS[2]: 流水hash key,例如 ticket_stock_record:10001:2001
-- ARGV[1]: 本次购买数量
-- ARGV[2]: 用户ID
-- ARGV[3]: 订单号
local stock = redis.call('HGET', KEYS[1], 'stock')
if not stock then
return -1 -- 库存key不存在,可能是场次或票档无效
end
stock = tonumber(stock)
local buyNum = tonumber(ARGV[1])
if stock < buyNum then
return 0 -- 库存不足
end
redis.call('HSET', KEYS[1], 'stock', stock - buyNum)
redis.call('HINCRBY', KEYS[2], ARGV[2], buyNum)
return 1 -- 扣减成功
这里有几个设计要点,都是我实际踩出来的:
第一,为什么用Hash而不是简单的String?因为Hash可以挂多个字段,除了stock,我还可以记录total、sold等统计字段,而且后续如果需要展示某用户在某场次的抢购数量,直接查流水Hash即可,不用像String那样再设计一个额外的key。当然如果不需要这些扩展信息,直接用DECRBY命令更简单。
第二,HINCRBY KEYS[2] ARGV[2] buyNum这一步是为了防止用户重复下单。用户在一个场次最多买N张票(一般限购2~4张),我先在流水Hash里累加,下单前先执行HGET KEYS[2] userId判断该用户已购数量,加上本次购买数量是否超限。限购逻辑直接写在Lua里,可以避免先查后改的竞态条件。
第三,Lua脚本的原子性。Redis是单线程执行脚本,一个Lua脚本在运行期间不会被其他命令打断,所以“判断库存 -> 扣减库存 -> 记录流水”这三步天然是原子的,不需要额外加分布式锁。这是Redis官方推荐的做法,很多生产环境都在用。
3.4 Redis与MySQL的数据一致性处理
Redis扣减成功只是第一步,接下来必须把数据同步到MySQL。同步策略我采用的是:先写本地业务表(创建订单),再通过MQ异步通知扣减数据库库存。
具体流程是:
- 用户发起购买请求,先执行业务校验(用户登录、场次是否在售票期、限购数量)。
- 执行Redis Lua脚本扣减库存,如果返回0(库存不足)或-1(key不存在),直接返回“已售罄”。
- 扣减成功后,创建订单记录(状态为待支付),同时发送一条MQ消息。
- 消费者收到MQ消息后,更新MySQL里的
ticket_grade表的locked_stock字段。
这里有一个问题:如果第2步成功了,但第3步创建订单失败(比如数据库挂了),怎么处理?我采用的是Redis反向补偿:在创建订单失败的情况下,执行另一个Lua脚本把扣减的库存回补回去。这个回补操作本身也要原子,我就把回补脚本也写成Lua:
lua复制-- 回补库存
local stock = redis.call('HGET', KEYS[1], 'stock')
if not stock then
return -1
end
stock = tonumber(stock)
redis.call('HSET', KEYS[1], 'stock', stock + tonumber(ARGV[1]))
redis.call('HINCRBY', KEYS[2], ARGV[2], -tonumber(ARGV[1]))
return 1
注意:补偿逻辑一定要放在订单创建失败的catch块里,而且要保证补偿脚本一定执行成功。如果补偿也失败了,要记录异常日志并人工干预。这类极端情况虽然少见,但真发生一次就是线上事故。
4. 下单接口的完整实现:校验、限购、分布式锁与订单状态流转
4.1 下单接口的整体流程
订单生成接口是整个系统最核心的入口,也是并发压力最大的地方。我把整个接口拆成几个步骤,每一步都有明确的职责和异常处理:
| 步骤 | 处理内容 | 异常处理 |
|---|---|---|
| 第一步 | 校验用户登录态 | 未登录返回401 |
| 第二步 | 校验请求参数(场次ID、票档ID、购买数量) | 参数非法返回400 |
| 第三步 | 校验当前时间是否在售票期内 | 不在售票期返回业务异常 |
| 第四步 | 执行Redis Lua扣减库存 | 库存不足返回已售罄 |
| 第五步 | 创建待支付订单 | 创建失败则回补库存 |
| 第六步 | 发送MQ消息异步同步数据库库存 | MQ发送失败则订单状态改为未知,人工介入 |
| 第七步 | 返回订单号与支付参数 | 待支付状态 |
这里我想重点讲一下第四步的细节。很多人觉得Redis扣库存只要一个DECR就够了,但实际上售票场景还要同时检查限购,所以Lua脚本里既扣了库存又记了流水。我贴一下Java侧调用Lua的代码:
java复制@Resource
private StringRedisTemplate stringRedisTemplate;
private static final String STOCK_SCRIPT =
"local stock = redis.call('HGET', KEYS[1], 'stock') " +
"if not stock then return -1 end " +
"stock = tonumber(stock) " +
"local buyNum = tonumber(ARGV[1]) " +
"if stock < buyNum then return 0 end " +
"redis.call('HSET', KEYS[1], 'stock', stock - buyNum) " +
"redis.call('HINCRBY', KEYS[2], ARGV[2], buyNum) " +
"return 1";
public boolean tryLockStock(Long sessionId, Long gradeId, Long userId, Integer buyNum, String orderNo) {
String stockKey = "ticket_stock:" + sessionId + ":" + gradeId;
String recordKey = "ticket_stock_record:" + sessionId + ":" + gradeId;
Long result = stringRedisTemplate.execute(
new DefaultRedisScript<>(STOCK_SCRIPT, Long.class),
Arrays.asList(stockKey, recordKey),
String.valueOf(buyNum), String.valueOf(userId)
);
return Long.valueOf(1).equals(result);
}
这里有个容易忽略的点:DefaultRedisScript的返回类型一定要指定为Long.class,因为Lua脚本返回1、0、-1这些数字时,Redis会转成整数,如果你不指定类型,Spring可能会把它解析成String,导致后面的比较逻辑出错。
4.2 分布式锁在什么环节用
说到锁,我要泼一点冷水:不是所有环节都需要分布式锁。Redis Lua脚本本身就保证了库存扣减的原子性,不需要再包一层分布式锁;但如果用户同时发起两笔相同订单,比如快速连点两次“立即购买”,可能会创建两条待支付订单,都锁定了库存。这种情况要比锁库存更常见。
我的处理方式是:在创建订单前加一个防重锁,锁的key是order_prevent:userId:sessionId:gradeId,使用Redis的SETNX命令,设置过期时间30秒。如果SETNX返回false,说明用户正在创建同类订单,直接提示“请勿重复提交”。这样可以有效防止连点造成的重复下单,也能防止多个设备同时登录导致的重复操作。
java复制String lockKey = "order_prevent:" + userId + ":" + sessionId + ":" + gradeId;
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
if (Boolean.FALSE.equals(locked)) {
throw new BizException("请勿重复提交");
}
注意:这里用了setIfAbsent加过期时间,是原子操作。如果你先SETNX再EXPIRE,中间进程宕机可能导致锁永远不释放,这是个经典坑。
4.3 限购策略与黑名单校验
演唱会黄牛问题很头疼。需求方明确要求每个用户限购2张,而且同一手机号、同一设备ID都不能重复抢票。我在限购逻辑上做了三层:
第一层,Redis流水判断:下单前先HGET ticket_stock_record:{sessionId}:{gradeId} {userId},如果已购数量加上本次购买数超过限制,直接拒绝。
第二层,数据库唯一约束:在订单表上增加一个uk_user_session唯一索引(user_id, session_id, grade_id)?这里要小心,如果用户买完一张之后主动取消,又买了第二张,唯一索引会挡住合法下单。所以这个索引只对订单状态为“待支付”和“已支付”的订单生效,MySQL不支持部分唯一索引,我改用逻辑判断:先查count(*)当前有效订单(status in (0,1)),如果数量超过限制就拒绝。
第三层,风控标记:对于被系统识别为黄牛的高风险账号(比如同一IP高频访问、多个账号同一收货信息),在业务层直接拒绝下单。这个风控系统我做得比较简单,主要是接口访问频率限制加用户行为分析,如果要做到电商级别,可以引入专门的规则引擎。
4.4 订单状态流转的代码实现
订单状态更新,我采用的状态机模式,避免状态乱跳。定义一个枚举:
java复制public enum OrderStatusEnum {
PENDING_PAY(0, "待支付"),
PAID(1, "已支付"),
CANCELLED(2, "已取消"),
REFUNDED(3, "已退款"),
TICKETED(4, "已出票");
}
每次更新状态时,都通过SQL自带的条件更新来防止并发问题:
java复制@Update("UPDATE order_info SET status = #{newStatus}, update_time = NOW() " +
"WHERE id = #{orderId} AND status = #{expectedStatus}")
int compareAndSetStatus(@Param("orderId") Long orderId,
@Param("expectedStatus") Integer expectedStatus,
@Param("newStatus") Integer newStatus);
比如用户支付回调成功,执行compareAndSetStatus(orderId, OrderStatusEnum.PENDING_PAY.getCode(), OrderStatusEnum.PAID.getCode()),影响行数为1才说明支付成功且状态流转正确;如果影响行数为0,说明订单已经超时取消,需要走退款流程。这个Compare And Set模式,在并发场景下比“先查询再更新”安全得多。
5. 支付回调和订单超时关闭,这里埋了很多坑
5.1 支付回调的幂等性设计
支付环节对接的是微信支付和支付宝的统一下单接口。用户在前端拉起支付,支付成功后,支付平台会异步回调我们的后台接口,通知支付结果。这里有三个经典问题:回调可能重复通知、回调参数可能被伪造、回调处理失败需要重试。
回调幂等性的处理,我采用了“以支付流水号为唯一键”的设计。支付回调表如下:
sql复制CREATE TABLE `payment_callback` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL,
`payment_no` varchar(64) NOT NULL COMMENT '支付平台流水号',
`callback_data` text COMMENT '回调原始报文',
`process_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0未处理 1处理成功 2处理失败',
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_payment_no` (`payment_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
在收到回调后,先尝试插入payment_callback表,利用uk_payment_no唯一索引做防重。如果插入成功,说明这是第一次收到这个支付流水号,继续处理业务逻辑;如果插入失败(唯一索引冲突),说明已经处理过了,直接返回“success”给支付平台,不再重复处理。
这个设计能扛住支付平台的重复通知,也能扛住我们自己消费者的重复消费,不用再加分布式锁,原理很简单,唯一索引天然就是幂等拦截器。
5.2 订单超时关闭的几种实现方案
待支付订单超过15分钟必须自动关闭并释放库存。订单超时关闭在Java生态里有几种常见做法,我逐一分析过:
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 定时任务扫描 | Spring @Scheduled每分钟扫一次超时订单 | 实现简单,但延迟较大,且大量扫描浪费数据库IO |
| DelayQueue | JVM延迟队列 | 进程重启丢失数据,不可靠 |
| Redis Key过期监听 | 设置key过期时间,通过key过期事件回调 | 依赖Redis的keyspace notifications,消息可能丢失 |
| RabbitMQ延迟队列 | 消息发送到死信队列,延迟消费 | 可靠、可控、支持重试,但需要额外组件 |
| RocketMQ定时消息 | 原生支持延迟消息 | 最方便,但需要引入RocketMQ |
我最终选择的是RabbitMQ延迟队列。理由很直接:项目本来已经引入了RabbitMQ做异步消息,不用再额外引入其他中间件;而且延迟队列的机制很成熟,订单表里记了close_time,如果延迟消息丢失,还有定时任务兜底扫描。
RabbitMQ延迟队列的搭建方式:声明一个normal_exchange、normal_queue和delay_exchange、delay_queue。生产者发送订单超时消息到delay_exchange,消息设置x-delay属性(或TTL),延迟时间到了之后,消息自动路由到normal_queue,消费者消费normal_queue中的消息,执行订单超时关闭逻辑。
我用的是rabbitmq-delayed-message-exchange插件,这个插件支持直接在消息上指定延迟时间,比TTL+DLE的方案更灵活,代码也更简洁:
java复制@Configuration
public class RabbitDelayConfig {
@Bean
public CustomExchange delayExchange() {
Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");
return new CustomExchange("order.delay.exchange", "x-delayed-message", true, false, args);
}
@Bean
public Queue orderDelayQueue() {
return QueueBuilder.durable("order.delay.queue").build();
}
@Bean
public Binding binding() {
return BindingBuilder.bind(orderDelayQueue())
.to(delayExchange())
.with("order.delay.routingKey")
.noargs();
}
}
发送消息时,在消息头设置x-delay为15分钟(单位毫秒):
java复制MessageProperties properties = new MessageProperties();
properties.setDelay(15 * 60 * 1000);
Message message = new Message(JSON.toJSONBytes(orderNo), properties);
rabbitTemplate.send("order.delay.exchange", "order.delay.routingKey", message);
消费端处理完超时关闭后,要检查订单是否仍是待支付状态,如果是则关闭订单并回补库存,如果用户已经支付了就什么都不做。这里又用到compareAndSetStatus,把PENDING_PAY改为CANCELLED,影响行数为0说明订单已经支付或取消,不需要重复处理。
5.3 库存释放与回补的一致性
超时关闭订单后,释放库存有三个动作:更新订单状态为已取消、回补Redis库存、回补MySQL库存。这三个动作不能在一个事务里完成,因为Redis操作和MySQL操作是不同系统。
我的做法是:先更新订单状态(MySQL),成功后发送MQ消息异步回补库存。消费者收到消息后,先执行Redis Lua回补脚本,再更新MySQL表里的locked_stock。这样即使回补库存失败了,消息也会重新进入死信队列,等重试机制再次消费。
这里必须强调:订单状态的更新必须是“先置为取消,再回补库存”。如果先回补库存,用户看到有票又下了一单,但旧订单还没取消,两单都锁定了库存,就可能超出总的可售票数。
5.4 支付回调与订单超时的竞态处理
用户支付正好卡在15分钟超时节点,这是最容易出问题的场景。比如第14分59秒用户点了支付,第15分00秒支付平台回调成功,但延迟消息也同时触发,两个事件几乎同时到达。
我的解决方案是:把“关闭订单”和“支付成功”都设计成幂等操作,并且用compareAndSetStatus保证状态只能从PENDING_PAY变成PAID或CANCELLED其中一个,谁先执行成功谁生效。具体来说:
- 如果支付回调先执行,订单状态从
PENDING_PAY变为PAID,延迟消息到达后执行compareAndSetStatus(PENDING_PAY, CANCELLED)影响行数为0,说明订单已支付,直接结束。 - 如果延迟消息先执行,订单状态从
PENDING_PAY变为CANCELLED,支付回调到达后执行compareAndSetStatus(PENDING_PAY, PAID)影响行数为0,说明订单已取消,需要走退款流程。
这个逻辑写清楚后,并发竞态就不再是问题。这里的关键是:你要绝对信任数据库的状态流转,而不是靠判断时间戳或者其他外部因素来决定谁先谁后。
6. 缓存策略与热点数据治理,别让数据库被打崩
6.1 演出详情与场次信息的缓存设计
演唱会开票前,用户会反复刷新演出详情页和场次列表页,这一部分流量非常大。如果每个请求都打到MySQL,数据库肯定扛不住。
我的缓存策略是:演出信息、场次列表、票档价格等读多写少的数据,缓存到Redis,缓存key的设计如下:
| 缓存key | 缓存内容 | 过期时间 |
|---|---|---|
show:info:{showId} |
演出基本信息(名称、艺人、时间、场馆) | 1小时 |
show:session:{sessionId} |
场次信息(开售时间、状态) | 1小时 |
show:grade:{sessionId} |
票档列表(价格、剩余库存) | 30秒 |
这里有个细节:票档的剩余库存是高频变化的数据,不能缓存太长,否则用户看到的库存数和实际不一致,会影响购买决策。但也不能不缓存,否则每次刷新都查Redis再查MySQL。我用的方案是:剩余库存直接从Redis的ticket_stock:{sessionId}:{gradeId}读取,因为Redis里的库存本身就是实时更新的,不需要额外缓存。而票档的固定信息(价格、名称)才走缓存,库存是每次动态获取的。
6.2 接口层面如何限流
售票系统的流量不只是集中在下单接口,查票接口、详情接口同样会瞬时涌入大量请求。如果不加限流,一个恶意用户或爬虫脚本就能把后端接口打挂。
我采用的是Guava RateLimiter做单机限流,再叠加Sentinel做集群限流。单机限流的逻辑比较简单,每个接口设置一个QPS上限,超过上限的请求直接返回“系统繁忙”。比如查询场次列表接口,单机限流200 QPS;下单接口,单机限流100 QPS。
用Sentinel的原因是可以做集群限流和熔断,配置规则可视化,对于抢票场景可以按用户维度、IP维度设置不同的限流阈值。这里我踩过的一个坑是:不要把限流阈值设置得太低,否则正常用户的请求也会被误伤。我的做法是先用压测确定接口的瓶颈QPS,然后按照瓶颈值的60%到70%设置限流阈值,留出余量。
6.3 热点问题的预防与处理
演唱会抢票会形成典型的热点数据问题:某个场次的票是热点,多个请求同时访问同一个key。Redis虽然性能很高,但同一个key的读写是串行的,当并发量特别大时,Redis的CPU也会成为瓶颈。
我处理热点问题的思路有三个层面:
第一,key拆分。把同一个场次的库存key按照用户ID哈希拆分成多个片段,例如ticket_stock:{sessionId}:{gradeId}:{hash(userId) % 10},每个用户只访问其中一个片段,分散对热点key的压力。但要注意,库存拆分后每个片段的库存数量要预留一定的缓冲,否则会出现某个片段库存售罄但其他片段还有剩余的情况,造成用户体验不一致。
第二,本地缓存。对于查询类的热点数据(演出信息、场次信息),除了Redis缓存,再加一层JVM本地缓存(Caffeine),减少对Redis的访问。
第三,流量整形。在网关层对同一用户、同一IP的请求做限制,防止恶意刷量。我在订单接口上对同一用户限购次数和频率做了严格限制,毕竟正常用户抢票不会在一秒内提交三次以上订单。
6.4 缓存穿透、击穿、雪崩的应对
这三个老生常谈的问题,在售票系统中也有具体体现。
穿透:恶意用户频繁请求一个不存在的场次ID,导致每次请求都绕过缓存打到数据库。解决方法是参数校验加缓存空值。对于不存在的场次,在Redis中缓存一个空值,过期时间设置为1分钟,防止恶意刷库。
击穿:某个场次的缓存过期瞬间,大量请求同时打到数据库。解决方法是加互斥锁。查询缓存时发现key不存在,先尝试获取分布式锁,拿到锁的线程去数据库查询并重建缓存,其他线程等待后再次查询缓存。这个流程能保证只有少量请求打到数据库。
雪崩:缓存大面积同时过期,数据库压力骤增。解决方法是给缓存过期时间加一个随机值,比如基本过期时间1小时,再加0到10分钟的随机值,避免所有key在同一时刻失效。同时限流和熔断机制在雪崩时也能起到兜底作用。
7. 压测数据、性能调优与实战心得
7.1 使用JMeter模拟抢票场景的压测过程
项目上线前,我用JMeter做了多轮压测。压测环境是:2核4G的云服务器上跑应用,单独的4核8G服务器跑MySQL,Redis和RabbitMQ部署在同一台4核8G的服务器上。
压测场景分为两类:一是查询类场景,模拟用户进入详情页和票档列表;二是下单类场景,模拟用户点击购买。每轮压测前都会在Redis中初始化票档库存,比如500张。
第一轮压测,我只测了下单接口,没有做任何优化,结果2000并发下单,接口成功率只有65%,大部分失败原因是数据库连接池耗尽和MySQL行锁等待超时。我把数据库连接池从50调到了100,同时优化了SQL,去掉了一些不必要的索引和联表查询,成功率提升到82%。
第二轮压测,我接入了Redis Lua扣减库存和MQ异步同步库存,下单接口的成功率提升到99.7%,TPS稳定在1500左右。此时我发现Redis的CPU使用率偏高,排查后发现是查询库存的QPS过高,于是增加了Caffeine本地缓存,只在本地缓存失效时才查Redis,Redis的CPU使用率从85%降到了42%。
第三轮压测,我把限流、熔断、降级都配置好,用高并发打流量验证系统的稳定性。最终在3000并发下系统运行平稳,下单接口的TP99响应时间在120毫秒左右。
7.2 压测中发现的几个隐藏问题
这里说几个压测中发现的“隐藏坑”,都是网上教程里不容易看到的:
第一个坑是MySQL的max_connections默认值只有151。在高并发场景下,即使应用层连接池设置得很大,也会被数据库的连接数上限卡住。我把max_connections调到了512,同时监控每个应用实例的实际连接数,根据业务量合理设置连接池大小。
第二个坑是Druid连接池的maxWait参数。默认是-1,表示无限等待,在高并发下会出现大量线程阻塞在获取连接上。我把maxWait设置为1000毫秒,一旦连接池中的连接被占满,新请求快速失败并提示“系统繁忙”,而不是一直等待拖死整个应用。
第三个坑是Redis连接池的默认配置。Spring Boot默认的Lettuce连接池在并发高时会有连接泄漏问题,我换成了Jedis连接池,并设置maxTotal为100,maxIdle为50,实测稳定性明显提升。
这些参数调整不能靠猜,建议一边压测一边监控,看监控数据再调参数,一次只调一个变量,避免多个变量同时改导致无法定位瓶颈。
7.3 代码层面的性能优化点
我在写代码时特别注意几个可能影响性能的地方,给大家做个参考:
第一,批量操作代替循环单条操作。比如用户查看订单列表时,不要遍历订单去查票档信息,而是先把所有票档ID收集起来,用一次IN查询查出所有票档信息,再在内存中组装。
第二,减少大字段的查询。订单表的callback_data字段可能很大,列表查询时一定不要SELECT *,只查需要的字段。
第三,合理使用索引。我在order_info表的(user_id, status)、(session_id, status)上建了联合索引,因为订单列表和后台管理查询经常用这两个条件。但索引不是越多越好,写多读少的表索引过多会影响插入性能。
第四,异步化非核心操作。发送短信通知、邮件通知、日志记录这些不直接响应用户的操作,都放到MQ异步处理,缩短接口响应时间。
7.4 线上部署与资源规划建议
如果是小中型项目的部署,我的建议是至少准备三台服务器:
| 服务器 | 配置 | 部署内容 |
|---|---|---|
| 应用服务器 | 4核8G * 2台 | Spring Boot应用、Nginx入口、JVM配置-Xms2g -Xmx2g |
| 中间件服务器 | 4核8G * 1台 | Redis、RabbitMQ |
| 数据库服务器 | 4核16G SSD * 1台 | MySQL 8.0,配置innodb_buffer_pool_size=8G |
如果预算有限,开发环境可以用Docker Desktop跑Redis和RabbitMQ,但生产环境不要省,中间件和数据库分开部署,避免互相影响。线上部署时Spring Boot应用的启动参数我习惯配上-Dfile.encoding=utf-8 -Xms2g -Xmx2g -XX:+UseG1GC,避免中文乱码和堆内存不足。
我个人的体会是:售票系统最容不得侥幸。库存、订单、支付,任何一个环节的数据准确性出了问题,用户投诉和资损都很难收场。所以每一个核心流程都要想清楚“如果并发同时发生会怎样”,把竞态条件和数据一致性考虑到极致,而不是等出了问题再去打补丁。这个项目做完之后,我对高并发系统的理解和“先想清楚失败场景再写代码”的习惯,都有了质的提升。
