
网上 90% 的秒杀教程都是 demo 级演示,要么只写个 Redis 扣库存的几行代码,要么只讲 Sentinel 限流的基础用法,根本无法落地生产。秒杀系统从来不是单一技术的堆砌,而是全链路的流量管控、安全防护、数据一致性保障的综合工程。
这篇博文,我会把多年大促实战沉淀的秒杀系统优化方案全部分享出来,从架构设计、全链路限流防刷、Redis 库存预扣防超卖、异步订单闭环,到兜底容错、踩坑实录、压测优化全流程讲透。所有方案都经过百万级 QPS 大促的实战检验,看完你就能直接落地到自己的项目里,避开那些我踩过的致命深坑。
本文你将学到什么
- 生产级秒杀系统的完整架构设计,解决瞬时高并发的核心思路
- Sentinel+Redis 联合实现的四层全链路限流防刷体系,彻底拦截恶意请求
- 基于 Redis Lua 脚本的库存预扣原子性实现,从根源解决超卖问题
- 热点 Key 分桶优化方案,解决秒杀商品单 Key 瓶颈,并发能力提升 10 倍
- 基于可靠消息的异步订单方案,保证库存与订单数据的最终一致性
- 秒杀系统全链路兜底容错方案,极端情况也能保证不超卖、不雪崩
- 10 个生产环境真实踩坑实录与解决方案,帮你少走 3 年弯路
- 从 100QPS 到 12 万 QPS 的压测优化全过程,可直接复用的优化思路
一、秒杀系统核心痛点与架构总览
1.1 秒杀业务的 6 大致命痛点
秒杀是典型的瞬时高并发、读多写少、强一致性、高安全要求的业务场景,和普通电商业务的逻辑完全不同,核心痛点集中在这 6 点,任何一点没处理好,都会引发线上事故:
- 瞬时流量洪峰:平时 QPS 几十的系统,秒杀开场瞬间冲到几十万甚至上百万,普通架构直接被打穿,服务全宕机
- 超卖资损:库存 100 件,最终卖出 150 件,直接造成公司资金损失,还会引发大规模客诉和品牌负面影响
- 恶意刷量攻击:黄牛用脚本无限循环刷接口,普通用户根本抢不到,活动完全失去意义,还会拖垮系统
- 链路雪崩:秒杀流量打垮商品服务,拖垮订单、支付、用户等整个微服务集群,导致全平台不可用
- 数据一致性问题:库存扣了但订单没生成,用户付了钱没抢到商品;或者订单生成了库存没扣,造成超卖
- 兜底容错缺失:Redis 宕机、MQ 故障等极端场景下,没有兜底方案,直接导致整个秒杀链路崩溃,甚至出现超卖
1.2 生产级秒杀系统全链路架构设计
下面是经过多次大促验证的秒杀系统架构图,清晰展示全链路的流量走向和分层设计,核心思路是层层削峰,把 99% 的无效流量挡在数据库之外:

1.3 秒杀系统设计的 7 条铁则
这是我经过多次大促踩坑总结的铁则,任何一条不满足,生产环境都可能出大问题:
- 流量层层削峰:把无效流量挡在最外层,绝对不让无效流量穿透到后端服务,更不能打到数据库
- 原子性优先:库存扣减必须保证原子性,这是杜绝超卖的核心,任何非原子操作都不能用在库存扣减上
- 异步解耦:秒杀核心链路只做库存预扣和合法性校验,订单创建、支付、通知等非核心逻辑全部异步化
- 限流兜底:任何时候都要给系统设置流量上限,超过阈值直接拒绝,绝对不能让系统被流量打穿
- 数据一致性:不追求强一致性,通过可靠消息 + 定时兜底,保证库存和订单的最终一致性
- 可追溯性:每一次库存扣减、订单创建、用户请求都要全链路落日志,出问题可查可追溯
- 安全第一:秒杀是黄牛攻击的重灾区,全链路都要做防刷、风控、签名校验,杜绝恶意攻击
1.4 核心技术栈选型明细
所有选型均为国内互联网公司生产环境主流稳定版本,无版本兼容问题,无冷门组件:
| 组件 | 版本 | 核心作用 |
|---|---|---|
| SpringBoot | 2.7.18 | 项目基础框架,稳定无漏洞 |
| SpringCloud Alibaba | 2021.0.1.0 | 微服务核心套件 |
| Nacos | 2.2.3 | 服务注册发现 + 配置中心 |
| SpringCloud Gateway | 3.1.8 | 微服务网关,流量入口 |
| Sentinel | 1.8.6 | 全链路限流、熔断、降级 |
| Redis | 6.2.7 | 库存预扣、限流防刷、数据预热、分布式锁 |
| Redisson | 3.23.5 | Redis 客户端,分布式锁、布隆过滤器实现 |
| RocketMQ | 4.9.5 | 可靠消息投递,异步下单、库存回滚 |
| MyBatis-Plus | 3.5.3.1 | ORM 框架,简化数据库操作 |
| XXL-Job | 2.4.0 | 分布式定时任务,超时订单取消、库存兜底 |
二、前置准备:秒杀系统落地前的必做事项
很多新手做秒杀,上来就写扣库存的代码,结果一上线就出问题。秒杀系统的前置准备,比写业务代码更重要。
2.1 生产级核心表结构设计
表结构设计直接决定了秒杀系统的稳定性和可维护性,下面是经过多次大促验证的核心表结构,包含防超卖、可追溯的核心设计:
sql
-- 秒杀活动表:管理秒杀活动的生命周期
CREATE TABLE `t_seckill_activity` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` bigint NOT NULL COMMENT '活动ID(全局唯一)',
`activity_name` varchar(128) NOT NULL COMMENT '活动名称',
`start_time` datetime NOT NULL COMMENT '活动开始时间',
`end_time` datetime NOT NULL COMMENT '活动结束时间',
`activity_status` tinyint NOT NULL DEFAULT '0' COMMENT '活动状态:0-未开始,1-进行中,2-已结束,3-已关闭',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_id` (`activity_id`),
KEY `idx_activity_status` (`activity_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='秒杀活动表';
-- 秒杀商品表:秒杀商品的核心信息
CREATE TABLE `t_seckill_goods` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`goods_id` bigint NOT NULL COMMENT '商品ID(全局唯一)',
`activity_id` bigint NOT NULL COMMENT '所属活动ID',
`goods_name` varchar(255) NOT NULL COMMENT '商品名称',
`original_price` decimal(10,2) NOT NULL COMMENT '原价',
`seckill_price` decimal(10,2) NOT NULL COMMENT '秒杀价',
`total_stock` int NOT NULL COMMENT '总库存',
`available_stock` int NOT NULL COMMENT '可用库存',
`limit_per_user` int NOT NULL DEFAULT '1' COMMENT '每人限购数量',
`goods_status` tinyint NOT NULL DEFAULT '0' COMMENT '商品状态:0-未上架,1-已上架,2-已下架',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,兜底防超卖',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_goods_id` (`goods_id`),
KEY `idx_activity_id` (`activity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='秒杀商品表';
-- 秒杀订单表:秒杀订单记录,保证幂等性
CREATE TABLE `t_seckill_order` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`order_no` varchar(64) NOT NULL COMMENT '订单号(全局唯一)',
`user_id` bigint NOT NULL COMMENT '用户ID',
`goods_id` bigint NOT NULL COMMENT '商品ID',
`activity_id` bigint NOT NULL COMMENT '活动ID',
`order_amount` decimal(10,2) NOT NULL COMMENT '订单金额',
`pay_amount` decimal(10,2) DEFAULT NULL COMMENT '实付金额',
`order_status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态:0-待支付,1-已支付,2-已取消,3-已完成',
`pay_time` datetime DEFAULT NULL COMMENT '支付时间',
`expire_time` datetime NOT NULL COMMENT '订单过期时间(未支付自动取消)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
UNIQUE KEY `uk_user_goods_activity` (`user_id`,`goods_id`,`activity_id`), -- 核心:保证一个用户一个活动里一个商品只能抢一次
KEY `idx_user_id` (`user_id`),
KEY `idx_goods_id` (`goods_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='秒杀订单表';
-- 库存扣减流水表:每一次库存操作全记录,对账、回滚、追溯用
CREATE TABLE `t_stock_flow` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`flow_no` varchar(64) NOT NULL COMMENT '流水号(全局唯一)',
`goods_id` bigint NOT NULL COMMENT '商品ID',
`order_no` varchar(64) NOT NULL COMMENT '关联订单号',
`stock_type` tinyint NOT NULL COMMENT '操作类型:1-预扣库存,2-扣减确认,3-库存回滚',
`stock_num` int NOT NULL COMMENT '操作数量',
`before_stock` int NOT NULL COMMENT '操作前库存',
`after_stock` int NOT NULL COMMENT '操作后库存',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flow_no` (`flow_no`),
KEY `idx_goods_id` (`goods_id`),
KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='库存扣减流水表';
-- 秒杀用户黑名单表:防刷风控用
CREATE TABLE `t_seckill_blacklist` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint DEFAULT NULL COMMENT '用户ID',
`ip_address` varchar(64) DEFAULT NULL COMMENT 'IP地址',
`device_id` varchar(128) DEFAULT NULL COMMENT '设备号',
`black_type` tinyint NOT NULL COMMENT '黑名单类型:1-用户,2-IP,3-设备',
`black_reason` varchar(255) NOT NULL COMMENT '拉黑原因',
`start_time` datetime NOT NULL COMMENT '拉黑开始时间',
`end_time` datetime NOT NULL COMMENT '拉黑结束时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_id` (`user_id`),
UNIQUE KEY `uk_ip_address` (`ip_address`),
UNIQUE KEY `uk_device_id` (`device_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='秒杀用户黑名单表';
核心设计说明:
- 秒杀订单表设置了
user_id+goods_id+activity_id的联合唯一索引,从数据库层面保证一个用户一个商品只能抢一次,杜绝重复下单 - 秒杀商品表加了
version乐观锁版本号,作为 Redis 预扣失败后的兜底防超卖手段 - 库存流水表记录每一次库存操作,出问题可对账、可追溯、可回滚,是资损防控的核心
- 所有核心字段都加了唯一索引,从数据库层面保证数据唯一性
2.2 微服务环境与组件配置规范
- 环境隔离:开发、测试、压测、生产环境完全隔离,生产环境的配置绝对不能提交到代码仓库,全部放在 Nacos 配置中心加密存储
- Redis 集群配置:生产环境必须用 Redis 集群(主从 + 哨兵 / Cluster 模式),禁止单机 Redis;开启 RDB+AOF 持久化,防止数据丢失;针对秒杀热点 Key 做热点隔离,避免单节点压力过大
- Sentinel 配置:Sentinel 控制台集群部署,规则持久化到 Nacos,服务重启后规则不丢失;配置集群限流模式,避免单机限流不准的问题
- RocketMQ 配置:开启消息持久化,同步刷盘机制,保证消息不丢失;配置死信队列,消费失败的消息进入死信队列,人工介入处理,不丢失数据
- 数据库配置:MySQL 主从分离,读写分离,秒杀相关的表用 InnoDB 引擎,行级锁,避免表锁;配置合理的连接池参数,避免连接数打满
2.3 秒杀活动预热全流程
秒杀活动的预热,是决定秒杀开场能不能扛住流量的关键,很多人就是因为没做预热,秒杀开始时大量请求打到数据库,直接宕机。
完整预热流程:
- 活动提前配置:秒杀开始前 24 小时,完成活动、商品的配置,审核通过后锁定配置,禁止修改
- 数据预热:秒杀开始前 1 小时,把秒杀活动信息、商品信息、库存数据、限购规则,全量预热到 Redis 集群,同时写入布隆过滤器,过滤不存在的商品 ID 请求
- 规则预热:把 Sentinel 限流规则、黑名单规则、风控规则,提前推送到各个服务节点和网关,避免秒杀开始时拉取规则的请求压力
- 静态资源预热:把秒杀活动页、商品详情页的静态资源,提前预热到 CDN,避免秒杀开始时静态资源请求打满源站
- 服务预热:秒杀开始前 30 分钟,对秒杀核心接口做压测预热,让 JIT 提前编译,避免秒杀开始时的冷启动性能问题
- 全链路检查:秒杀开始前 10 分钟,检查 Redis、MQ、Sentinel、数据库的状态,检查限流规则、活动状态、库存数据是否正确
三、核心模块一:Sentinel+Redis 联合实现全链路限流防刷
限流防刷是秒杀系统的第一道防线,也是最核心的环节。如果限流防刷做不好,恶意请求会直接打满你的系统,正常用户根本进不来,甚至会把系统打崩。
我们采用四层全链路限流防刷体系,Sentinel 负责集群级别的流量管控,Redis 负责用户粒度的防刷和黑名单管控,两者联合实现层层削峰,把 99% 的无效流量挡在系统之外。
下面是全链路限流防刷的流程图:

3.1 限流防刷的核心设计理念:层层削峰,无效流量零穿透
秒杀系统的核心,不是怎么处理请求,而是怎么拦截无效请求。我们的设计理念是:能在前端拦的,绝不放到 CDN;能在 CDN 拦的,绝不放到网关;能在网关拦的,绝不放到后端服务;能在接口层拦的,绝不放到数据库。
每一层都只放合法的、有效的请求到下一层,最终只有不到 1% 的有效流量,能进入到核心的库存扣减逻辑,这样才能扛住百万级的 QPS。
3.2 第一层:前端 & CDN 前置拦截,过滤 60% 无效流量
这一层是成本最低、效果最好的拦截层,能直接过滤掉 60% 以上的无效请求,很多新手完全忽略了这一层,导致大量无效请求打到后端。
核心拦截手段:
- 按钮置灰控制:秒杀开始前,按钮置灰不可点击;秒杀开始后,用户点击一次后立即置灰,禁止重复点击,避免用户手滑重复发起请求
- 验证码校验:秒杀开始时,必须输入验证码(图形验证码 / 滑块验证码)才能发起请求,直接拦截掉 90% 的脚本自动请求
- CDN 静态化:秒杀活动页、商品详情页全量静态化,放到 CDN 节点,所有静态资源请求都由 CDN 处理,根本不会打到源站
- 前端频率限制:前端控制用户请求频率,1 秒内最多发起 1 次请求,超过的直接在前端拦截,不发起请求
- 活动时间校验:前端校验活动时间,未开始 / 已结束的活动,直接拦截请求,不向后端发送
【避坑提醒】:前端拦截只能防普通用户,绝对不能防黄牛脚本,所以这一层只是前置过滤,后端必须有完整的校验逻辑,绝对不能只靠前端做限制!
3.3 第二层:网关层分布式限流,拦截 30% 异常流量
SpringCloud Gateway 是所有请求的入口,我们在这里用Gateway + Sentinel实现分布式限流,把异常流量、超量流量直接拦在网关层,不让它进入后端服务集群。
网关层限流的核心是:控制总流量,不让后端服务被打穿,同时拦截 IP 维度的异常请求。
1. 网关层 Sentinel 限流配置(Nacos 持久化)
bash
# SpringCloud Gateway Sentinel配置
spring:
cloud:
sentinel:
transport:
dashboard: 你的Sentinel控制台地址
port: 8719
datasource:
# 网关流控规则,持久化到Nacos
gw-flow:
nacos:
server-addr: 你的Nacos地址
dataId: gateway-sentinel-flow-rule
groupId: SECKILL_GROUP
data-type: json
rule-type: gw-flow
# 网关API分组规则
gw-api-group:
nacos:
server-addr: 你的Nacos地址
dataId: gateway-sentinel-api-group
groupId: SECKILL_GROUP
data-type: json
rule-type: gw-api-group
2. 核心限流规则配置(Nacos 中存储)
bash
[
{
"resource": "seckill_api",
"resourceMode": 1,
"grade": 1,
"count": 50000,
"intervalSec": 1,
"controlBehavior": 0,
"burst": 10000,
"maxQueueingTimeoutMs": 500
},
{
"resource": "seckill_ip_limit",
"resourceMode": 0,
"grade": 1,
"count": 20,
"intervalSec": 1,
"controlBehavior": 0,
"paramItem": {
"parseStrategy": 0,
"fieldName": "ip"
}
}
]
规则说明:
- 秒杀接口总 QPS 限制在 5 万 / 秒,突发流量允许 1 万的 burst,超过的直接快速失败,返回 "活动火爆,请稍后再试"
- 单个 IP 每秒最多允许 20 次请求,超过的直接拦截,防止脚本刷接口
- 所有规则持久化到 Nacos,服务重启不丢失,Sentinel 控制台可动态修改,无需重启服务
3. 网关层自定义限流异常返回
java
@Configuration
public class SentinelGatewayConfig {
public SentinelGatewayConfig() {
GatewayCallbackManager.setBlockHandler((exchange, t) -> {
// 自定义限流返回结果,前端友好提示
Map<String, Object> result = new HashMap<>();
result.put("code", 429);
result.put("message", "活动太火爆了,请稍后再试!");
return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result);
});
}
}
3.4 第三层:接口层全维度限流防刷,核心防护层
这一层是限流防刷的核心,我们用Sentinel 做热点参数限流 ,控制单个商品的流量;用Redis 做用户粒度的频率限制和黑名单管控,拦截恶意用户的刷量请求,两者联合实现全维度防护。
1. Sentinel 热点参数限流:解决热点商品流量冲击
秒杀的核心痛点是热点商品,比如 1 元抢手机,单个商品的请求量可能占到总请求量的 90%,如果不做单独限流,会打垮整个服务。
Sentinel 的热点参数限流,可以针对接口中的某个参数(比如商品 ID)做单独的限流,比如同一个商品 ID,每秒最多允许 2 万次请求,超过的直接拦截。
完整代码实现:
java
@RestController
@RequestMapping("/api/v1/seckill")
@Slf4j
public class SeckillController {
@Autowired
private SeckillService seckillService;
/**
* 秒杀核心接口
* @SentinelResource 热点参数限流配置
* value:资源名称
* blockHandler:限流后的降级处理方法
* fallback:业务异常的兜底方法
*/
@PostMapping("/do")
@SentinelResource(
value = "seckill_do",
blockHandlerClass = SeckillBlockHandler.class,
blockHandler = "seckillBlockHandler",
fallbackClass = SeckillFallback.class,
fallback = "seckillFallback"
)
public Result<SeckillVO> doSeckill(@RequestBody SeckillDTO dto, @RequestHeader("userId") Long userId) {
// 核心秒杀逻辑
return seckillService.doSeckill(dto, userId);
}
}
热点参数限流规则(Nacos 持久化):
bash
[
{
"resource": "seckill_do",
"grade": 1,
"count": 20000,
"limitApp": "default",
"durationInSec": 1,
"paramFlowItemList": [
{
"object": "goodsId",
"classType": "java.lang.Long",
"limitFlow": 20000
}
]
}
]
规则说明:秒杀接口总 QPS 限制 2 万 / 秒,针对请求参数中的 goodsId,单个商品 ID 每秒最多允许 2 万次请求,超过的直接触发限流降级。
2. Redis 实现用户粒度频率限制 + 黑名单机制
Sentinel 只能控制总流量和热点参数,无法针对单个用户做精细化的频率控制,这部分我们用 Redis 来实现,核心逻辑:
- 同一个用户,1 秒内最多发起 5 次秒杀请求,超过的直接拦截
- 同一个用户,1 分钟内超过 30 次请求,直接加入黑名单,1 小时内禁止访问
- 黑名单数据提前预热到 Redis,请求进来先校验是否在黑名单中,在的直接拦截
完整代码实现:
java
@Service
@Slf4j
public class SeckillServiceImpl implements SeckillService {
@Autowired
private RedissonClient redissonClient;
@Value("${seckill.user.limit.count:5}")
private int userLimitCount;
@Value("${seckill.user.limit.time:1}")
private int userLimitTime;
@Override
public Result<SeckillVO> doSeckill(SeckillDTO dto, Long userId) {
Long goodsId = dto.getGoodsId();
Long activityId = dto.getActivityId();
// 1. 黑名单校验:先判断用户是否在黑名单中
String blacklistKey = "seckill:blacklist:user:" + userId;
if (redissonClient.getBucket(blacklistKey).isExists()) {
log.warn("用户{}在秒杀黑名单中,请求被拦截", userId);
return Result.fail("您的账号存在异常操作,暂时无法参与秒杀");
}
// 2. 用户频率限制:滑动窗口实现,1秒内最多5次请求
String rateLimitKey = "seckill:rate:user:" + userId;
long currentTime = System.currentTimeMillis();
long windowStart = currentTime - userLimitTime * 1000;
// 移除窗口外的请求记录
redissonClient.getZSet(rateLimitKey).removeRangeByScore(0, windowStart);
// 统计当前窗口内的请求次数
long requestCount = redissonClient.getZSet(rateLimitKey).zCard();
if (requestCount >= userLimitCount) {
log.warn("用户{}请求频率过高,当前次数:{}", userId, requestCount);
// 超过频率限制,累计次数,达到阈值加入黑名单
String overLimitKey = "seckill:overlimit:user:" + userId;
long overCount = redissonClient.getAtomicLong(overLimitKey).incrementAndGet();
if (overCount >= 6) { // 1分钟内超过6次限流,加入黑名单
redissonClient.getBucket(blacklistKey).set(1, 1, TimeUnit.HOURS);
log.warn("用户{}频繁刷接口,已加入黑名单1小时", userId);
}
return Result.fail("请求太频繁了,请稍后再试!");
}
// 记录本次请求到滑动窗口
redissonClient.getZSet(rateLimitKey).add(currentTime, currentTime);
redissonClient.getZSet(rateLimitKey).expire(userLimitTime + 1, TimeUnit.SECONDS);
// 3. 布隆过滤器:判断商品ID是否存在,过滤无效请求
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("seckill:goods:bloom");
if (!bloomFilter.contains(goodsId)) {
log.warn("商品ID{}不存在,请求被拦截", goodsId);
return Result.fail("秒杀商品不存在");
}
// 4. 活动时间、商品状态校验(省略,后续逻辑)
// 5. 库存预扣、订单创建逻辑(后续章节详细讲解)
return Result.ok();
}
}
【生产级建议】:这里用滑动窗口实现频率限制,比固定窗口更精准,不会出现窗口边界的临界问题;用 Redisson 的 ZSet 实现,代码简洁,性能稳定。
3.5 第四层:业务层风控兜底,拦截剩余恶意请求
这一层是最后的兜底,针对那些绕过了前面三层拦截的恶意请求,做最终的风控拦截,核心手段:
- 用户行为校验:判断用户是否是正常用户,比如账号是否实名认证、是否有收货地址、是否有历史订单,拦截僵尸号、黄牛号
- 设备指纹校验:同一个设备号,对应多个用户账号,直接拦截,防止黄牛用群控设备刷量
- 收货地址校验:同一个收货地址,对应多个用户账号,限制抢购数量,防止黄牛批量下单
- 异常行为识别:比如用户请求的时间间隔完全一致、IP 地址归属地异常、账号刚注册就来抢秒杀,直接拦截
3.6 限流防刷高频踩坑与解决方案
- 坑:单机限流不准,集群总流量超过阈值解决方案:用 Sentinel 的集群限流模式,统一由 Token Server 分配流量,保证集群总流量不超过阈值,而不是单机限流累加
- 坑:Redis 滑动窗口大并发下性能问题解决方案:给滑动窗口的 Key 设置过期时间,避免 Redis 内存溢出;用 pipeline 批量操作,减少 Redis 的网络 IO 次数
- 坑:黑名单规则不生效,服务重启后丢失解决方案:黑名单数据同时存储在 Redis 和数据库,服务启动时全量加载到 Redis;定时任务同步数据库和 Redis 的数据,保证一致性
- 坑:恶意用户用代理 IP 池绕过 IP 限流解决方案:IP 限流只是辅助,核心还是用户粒度的频率限制和风控校验,同时针对 IP 段做限流,同一个 IP 段每秒请求超过阈值,直接拦截整个 IP 段
四、核心模块二:Redis 库存预扣与超卖防控硬核实现
超卖是秒杀系统最致命的问题,没有之一。一旦出现超卖,不仅会造成公司的资金损失,还会引发大规模的客诉,严重影响品牌口碑。这一章节,我会从根因分析到生产级实现,彻底解决超卖问题。
4.1 超卖问题的根因深度分析
超卖的本质,是并发场景下的库存读改写操作不具备原子性,导致多个线程同时读到库存大于 0,都执行了扣减操作,最终库存变成负数,出现超卖。
我们先看一个 90% 新手都会写的错误代码,也是超卖的根源:
java
// 错误示例:并发下必超卖!!!
@Override
public Result<SeckillVO> doSeckill(SeckillDTO dto, Long userId) {
Long goodsId = dto.getGoodsId();
// 1. 从Redis读取库存
Integer stock = (Integer) redisTemplate.opsForValue().get("seckill:stock:" + goodsId);
// 2. 判断库存是否充足
if (stock == null || stock <= 0) {
return Result.fail("商品已售罄");
}
// 3. 库存扣减
redisTemplate.opsForValue().decrement("seckill:stock:" + goodsId);
// 4. 下单逻辑
return createOrder(dto, userId);
}
**为什么这段代码会超卖?**并发场景下,100 个线程同时执行步骤 1,都读到库存 = 1,都判断库存充足,然后都执行步骤 3 的扣减,最终库存会变成 - 99,超卖 99 件。
核心问题:读库存、判断库存、扣减库存,这三步是分开的,不是原子操作,中间可以被其他线程打断,并发下必然出现超卖。
4.2 库存扣减方案选型对比与生产级选型
我把市面上常见的库存扣减方案做了全面对比,结合多年大促的实战经验,给出了生产级的选型建议:
| 方案 | 实现原理 | 优点 | 缺点 | 生产环境是否推荐 |
|---|---|---|---|---|
| 数据库悲观锁 | select ... for update 锁住行 | 实现简单,绝对不超卖 | 性能极差,并发高了直接死锁,数据库宕机 | 绝对不推荐 |
| 数据库乐观锁 | version 版本号控制 | 实现简单,不超卖 | 性能差,并发高了大量更新失败,数据库压力大 | 仅作为兜底方案 |
| Redis 分布式锁 | 加锁后执行读改写操作 | 比数据库锁性能好 | 高并发下锁竞争激烈,性能差,锁超时释放仍有超卖风险 | 不推荐用于核心库存扣减 |
| Redis decr 命令 | 直接递减库存,判断返回值 | 性能极高,单操作原子性 | 无法实现用户限购、库存流水记录等复杂逻辑,无法回滚 | 不推荐,场景太局限 |
| Redis Lua 脚本 | 把读、判断、扣减、记录逻辑写到一个 Lua 脚本中,Redis 单线程执行,保证原子性 | 性能极高,原子性有保障,支持复杂业务逻辑,是秒杀场景的最优解 | 对 Lua 脚本的编写有要求,需要考虑边界情况 | 强烈推荐,生产级首选 |
【划重点】:生产环境秒杀系统的库存扣减,唯一最优解就是 Redis Lua 脚本,它既能保证原子性,杜绝超卖,又能支撑极高的并发,还能实现用户限购、库存流水记录等复杂业务逻辑。
4.3 基于 Lua 脚本的原子性库存预扣实现(完整可复用代码)
Redis Lua 脚本的核心优势:整个脚本在 Redis 中是单线程执行的,要么全部执行成功,要么全部失败,中间不会被其他线程打断,完美保证了操作的原子性,从根源上解决超卖问题。
我们的 Lua 脚本,需要实现以下核心逻辑:
- 校验用户是否已经抢购过,限购逻辑
- 校验库存是否充足
- 库存预扣减
- 记录用户抢购记录,防止重复抢购
- 返回扣减结果,保证每一步都在同一个 Lua 脚本中完成
1. 库存预扣核心 Lua 脚本
bash
-- 秒杀库存预扣Lua脚本,保证原子性
-- 传入参数
-- KEYS[1]: 库存Key seckill:stock:{goodsId}
-- KEYS[2]: 用户抢购记录Key seckill:user:record:{activityId}:{goodsId}
-- ARGV[1]: 用户ID
-- ARGV[2]: 限购数量
-- ARGV[3]: 扣减数量,固定为1
-- ARGV[4]: 商品ID
-- ARGV[5]: 活动ID
-- 1. 判断用户是否已经抢购过
local userBuyCount = redis.call('ZSCORE', KEYS[2], ARGV[1])
if userBuyCount ~= false and tonumber(userBuyCount) >= tonumber(ARGV[2]) then
return -1 -- 用户已达到限购数量,返回-1
end
-- 2. 获取当前库存
local currentStock = redis.call('GET', KEYS[1])
if currentStock == false then
return -2 -- 库存Key不存在,商品不存在
end
currentStock = tonumber(currentStock)
local deductNum = tonumber(ARGV[3])
-- 3. 判断库存是否充足
if currentStock < deductNum then
return 0 -- 库存不足,返回0
end
-- 4. 扣减库存
local afterStock = redis.call('DECRBY', KEYS[1], deductNum)
if afterStock < 0 then
redis.call('INCRBY', KEYS[1], deductNum) -- 扣减失败,回滚库存
return 0 -- 库存不足,返回0
end
-- 5. 记录用户抢购数量
redis.call('ZINCRBY', KEYS[2], deductNum, ARGV[1])
-- 设置Key过期时间,活动结束后自动删除
redis.call('EXPIRE', KEYS[2], 86400 * 7)
-- 6. 返回扣减后的库存
return afterStock
脚本返回值说明:
- 返回值 > 0:库存扣减成功,返回扣减后的剩余库存
- 返回值 = 0:库存不足,扣减失败
- 返回值 = -1:用户已达到限购数量,无法重复抢购
- 返回值 = -2:商品不存在,非法请求
2. Java 代码调用 Lua 脚本实现库存预扣
java
@Service
@Slf4j
public class SeckillServiceImpl implements SeckillService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StringRedisTemplate redisTemplate;
// 初始化Lua脚本
private static final DefaultRedisScript<Long> SECKILL_DEDUCT_SCRIPT;
static {
SECKILL_DEDUCT_SCRIPT = new DefaultRedisScript<>();
// 读取Lua脚本文件,放到resources目录下
SECKILL_DEDUCT_SCRIPT.setLocation(new ClassPathResource("lua/seckill_deduct.lua"));
SECKILL_DEDUCT_SCRIPT.setResultType(Long.class);
}
@Override
public Result<SeckillVO> doSeckill(SeckillDTO dto, Long userId) {
Long goodsId = dto.getGoodsId();
Long activityId = dto.getActivityId();
int limitPerUser = dto.getLimitPerUser();
// 前面的限流防刷、黑名单、布隆过滤器校验省略...
// 1. 组装Lua脚本的KEYS和ARGV
List<String> keys = new ArrayList<>();
keys.add("seckill:stock:" + goodsId); // 库存Key
keys.add("seckill:user:record:" + activityId + ":" + goodsId); // 用户抢购记录Key
Object[] args = new Object[]{
userId.toString(),
String.valueOf(limitPerUser),
"1",
goodsId.toString(),
activityId.toString()
};
// 2. 执行Lua脚本,原子性库存预扣
Long result;
try {
result = redisTemplate.execute(SECKILL_DEDUCT_SCRIPT, keys, args);
} catch (Exception e) {
log.error("库存预扣Lua脚本执行异常,商品ID:{},用户ID:{}", goodsId, userId, e);
return Result.fail("系统异常,请稍后再试");
}
// 3. 根据Lua脚本返回值处理结果
if (result == null || result <= 0) {
String message;
if (result == null) {
message = "系统异常,请稍后再试";
} else if (result == 0) {
message = "很遗憾,商品已售罄";
} else if (result == -1) {
message = "您已达到该商品的限购数量,无法重复抢购";
} else if (result == -2) {
message = "秒杀商品不存在";
} else {
message = "秒杀失败,请稍后再试";
}
log.info("商品{}秒杀失败,用户ID:{},原因:{}", goodsId, userId, message);
return Result.fail(message);
}
// 4. 库存预扣成功,记录库存流水
String flowNo = "FLOW" + System.currentTimeMillis() + IdUtil.getSnowflakeNextIdStr();
StockFlow stockFlow = StockFlow.builder()
.flowNo(flowNo)
.goodsId(goodsId)
.stockType(1) // 预扣库存
.stockNum(1)
.beforeStock(result.intValue() + 1)
.afterStock(result.intValue())
.build();
// 异步记录库存流水,不阻塞主流程
stockFlowService.asyncSaveStockFlow(stockFlow);
log.info("商品{}库存预扣成功,用户ID:{},剩余库存:{}", goodsId, userId, result);
// 5. 发送异步下单消息到RocketMQ,后续章节详细讲解
sendOrderMessage(dto, userId, flowNo);
// 6. 返回结果给前端
SeckillVO vo = new SeckillVO();
vo.setOrderNo(generateOrderNo(userId, goodsId));
vo.setResult(true);
vo.setMessage("秒杀成功,请尽快完成支付");
return Result.ok(vo);
}
}
【避坑提醒】:
- Lua 脚本中绝对不能写耗时操作,比如循环、大量 Key 操作,会阻塞 Redis 主线程,导致整个 Redis 集群宕机
- Lua 脚本的逻辑必须极简,只做核心的原子性操作,其他非核心逻辑(比如记录流水、发送消息)放到 Java 代码中异步执行
- 脚本必须提前加载到 Redis,避免每次执行都传输脚本,提升性能
- 必须处理脚本执行异常的情况,不能因为脚本异常导致服务崩溃
4.4 热点 Key 分桶优化:解决单 Key 瓶颈,并发翻倍
当秒杀商品的热度极高时,比如 1 元抢手机,每秒几十万的请求都打到同一个库存 Key 上,会出现Redis 单 Key 热点问题:
- Redis 单节点的 CPU 被打满,因为所有请求都落到同一个分片上
- 单 Key 的请求量超过 Redis 单节点的处理上限,出现请求超时
- 严重时会导致 Redis 节点宕机,整个秒杀系统崩溃
这是秒杀系统高并发下的核心痛点,解决方案就是库存分桶优化,把一个热点 Key 拆分成多个子 Key,分散到 Redis 集群的不同节点上,把单 Key 的压力分散到多个 Key,并发能力直接提升 10 倍以上。
分桶优化核心原理
- 把总库存拆分成 N 个桶,比如总库存 100 件,拆分成 10 个桶,每个桶 10 件库存
- 每个桶对应一个独立的 Redis Key,比如
seckill:stock:1001:0~seckill:stock:1001:9 - 用户请求进来时,随机生成一个桶编号,尝试对这个桶的库存进行扣减
- 如果这个桶的库存不足,就遍历其他桶,直到扣减成功,或者所有桶都库存不足
- 为了保证原子性,每个桶的扣减还是用 Lua 脚本实现
分桶优化 Lua 脚本实现
bash
-- 分桶库存预扣Lua脚本
-- KEYS[1]: 分桶库存Key前缀 seckill:stock:{goodsId}:
-- KEYS[2]: 用户抢购记录Key seckill:user:record:{activityId}:{goodsId}
-- ARGV[1]: 用户ID
-- ARGV[2]: 限购数量
-- ARGV[3]: 扣减数量
-- ARGV[4]: 分桶数量
-- ARGV[5]: 随机桶编号
-- 1. 判断用户是否已经抢购过
local userBuyCount = redis.call('ZSCORE', KEYS[2], ARGV[1])
if userBuyCount ~= false and tonumber(userBuyCount) >= tonumber(ARGV[2]) then
return -1
end
local deductNum = tonumber(ARGV[3])
local bucketCount = tonumber(ARGV[4])
local startBucket = tonumber(ARGV[5])
-- 2. 从随机桶开始遍历,扣减库存
for i = 0, bucketCount - 1 do
local currentBucket = (startBucket + i) % bucketCount
local bucketKey = KEYS[1] .. currentBucket
local currentStock = redis.call('GET', bucketKey)
if currentStock ~= false then
currentStock = tonumber(currentStock)
if currentStock >= deductNum then
-- 扣减当前桶的库存
local afterStock = redis.call('DECRBY', bucketKey, deductNum)
if afterStock >= 0 then
-- 记录用户抢购记录
redis.call('ZINCRBY', KEYS[2], deductNum, ARGV[1])
redis.call('EXPIRE', KEYS[2], 86400 * 7)
-- 返回扣减成功的桶编号和剩余库存
return {currentBucket, afterStock}
end
end
end
end
-- 3. 所有桶都库存不足
return 0
【生产级建议】:分桶数量建议设置为 10~50 个,根据总库存和预估并发量调整,不是越多越好,太多会导致遍历的性能损耗。经过实测,10 个分桶的并发处理能力,是单 Key 的 8~10 倍。
4.5 库存流水设计:每一笔扣减全链路可追溯
生产环境的秒杀系统,必须保证每一笔库存操作都可追溯、可对账、可回滚。我们设计了库存流水表,记录每一次库存的预扣、确认、回滚操作,核心作用:
- 出问题时可对账,核对库存扣减是否正确
- 订单取消时,可根据流水记录精准回滚库存,不会出现多回滚、少回滚的问题
- 资损防控的核心依据,出现超卖时可快速定位问题
核心设计规则:
- 每一次库存操作,都生成唯一的流水号,全链路传递
- 流水记录必须包含操作前库存、操作后库存、操作数量、操作类型
- 流水记录必须异步写入,不能阻塞秒杀主流程
- 流水记录永久保存,至少保留 1 年以上,便于审计和对账
五、核心模块三:异步订单闭环与数据最终一致性保障
很多新手做秒杀,把订单创建、库存扣减、支付逻辑都同步执行,结果导致接口响应时间超长,服务线程池被打满,数据库压力巨大,大促时直接宕机。
秒杀系统的核心链路,只应该做库存预扣这一件事,订单创建、支付通知、库存确认等所有非核心逻辑,都必须异步化处理。
5.1 为什么秒杀订单必须做异步化?
同步下单的致命痛点:
- 接口响应慢:订单创建需要操作数据库,同步执行会导致接口响应时间从几毫秒变成几百毫秒,高并发下服务线程池很快被打满
- 数据库压力大:秒杀成功的请求,同时打到数据库创建订单,数据库的写入 QPS 瞬间拉满,很容易宕机
- 链路雪崩风险:订单服务宕机,会导致秒杀主流程失败,库存扣了但订单没创建,出现资损
- 无法应对流量洪峰:同步下单的模式,数据库的写入能力就是系统的上限,根本扛不住高并发
异步下单的核心优势:
- 接口响应极快:库存预扣成功后立即返回,接口响应时间控制在 10ms 以内,用户体验极好
- 削峰填谷:用 MQ 把瞬时的订单创建请求缓存起来,消费者匀速消费,把数据库的写入压力控制在可控范围内
- 链路隔离:订单服务的异常,不会影响秒杀主流程,就算订单服务宕机,消息也不会丢失,服务恢复后继续消费
- 可扩展性强:后续的支付、短信、积分等业务,都可以通过消费 MQ 消息实现,和主流程完全解耦
5.2 基于 RocketMQ 的可靠消息异步下单方案
我们采用可靠消息 + 最终一致性方案,保证库存预扣和订单创建的数据一致性,核心流程如下:
- 秒杀请求通过 Lua 脚本完成库存预扣,生成唯一的流水号和订单号
- 发送半消息到 RocketMQ,确认消息是否发送成功
- 消息发送成功后,返回秒杀成功给前端
- RocketMQ 消费者异步消费消息,创建订单,更新订单状态
- 订单创建成功后,确认库存扣减,更新库存流水
- 订单创建失败,触发库存回滚,把预扣的库存加回去
下面是异步下单全流程图:

1. 发送下单消息代码实现
java
private void sendOrderMessage(SeckillDTO dto, Long userId, String flowNo) {
// 生成订单号
String orderNo = generateOrderNo(userId, dto.getGoodsId());
// 构建下单消息体
SeckillOrderMessage message = SeckillOrderMessage.builder()
.orderNo(orderNo)
.userId(userId)
.goodsId(dto.getGoodsId())
.activityId(dto.getActivityId())
.flowNo(flowNo)
.orderAmount(dto.getSeckillPrice())
.expireTime(LocalDateTime.now().plusMinutes(30)) // 30分钟未支付自动取消
.build();
// 同步发送消息,保证消息发送成功
SendResult sendResult = rocketMQTemplate.syncSend("seckill_order_topic", message);
if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
log.error("下单消息发送失败,订单号:{}", orderNo);
// 消息发送失败,触发库存回滚
rollbackStock(dto.getGoodsId(), userId, dto.getActivityId());
throw new RuntimeException("系统异常,秒杀失败");
}
log.info("下单消息发送成功,订单号:{}", orderNo);
}
2. 订单消息消费者代码实现
java
@Component
@Slf4j
@RocketMQMessageListener(
topic = "seckill_order_topic",
consumerGroup = "seckill_order_consumer_group",
consumeMode = ConsumeMode.ORDERLY,
messageModel = MessageModel.CLUSTERING
)
public class SeckillOrderConsumer implements RocketMQListener<MessageExt> {
@Autowired
private SeckillOrderMapper orderMapper;
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Override
public void onMessage(MessageExt messageExt) {
String body = new String(messageExt.getBody(), StandardCharsets.UTF_8);
SeckillOrderMessage message = JSON.parseObject(body, SeckillOrderMessage.class);
String orderNo = message.getOrderNo();
log.info("收到秒杀下单消息,订单号:{}", orderNo);
try {
// 1. 幂等性校验:判断订单是否已经存在,避免重复消费创建重复订单
LambdaQueryWrapper<SeckillOrder> queryWrapper = Wrappers.lambdaQuery();
queryWrapper.eq(SeckillOrder::getOrderNo, orderNo);
Long count = orderMapper.selectCount(queryWrapper);
if (count > 0) {
log.warn("订单{}已存在,重复消费,直接返回", orderNo);
return;
}
// 2. 创建秒杀订单
SeckillOrder order = SeckillOrder.builder()
.orderNo(orderNo)
.userId(message.getUserId())
.goodsId(message.getGoodsId())
.activityId(message.getActivityId())
.orderAmount(message.getOrderAmount())
.orderStatus(0) // 待支付
.expireTime(message.getExpireTime())
.build();
orderMapper.insert(order);
log.info("秒杀订单创建成功,订单号:{}", orderNo);
// 3. 更新库存流水为已确认
stockFlowService.updateStockFlowStatus(message.getFlowNo(), 2);
// 4. 发送订单创建成功消息,触发后续业务(短信通知、库存锁定等)
rocketMQTemplate.asyncSend("order_success_topic", order, null);
} catch (Exception e) {
log.error("秒杀订单创建异常,订单号:{}", orderNo, e);
// 订单创建失败,发送库存回滚消息
rocketMQTemplate.syncSend("stock_rollback_topic", message);
// 抛出异常,让RocketMQ重试
throw e;
}
}
}
5.3 订单创建的幂等性设计,杜绝重复下单
幂等性是异步下单的核心,必须保证同一条消息,无论消费多少次,都只会创建一个订单,不会出现重复下单的问题。我们做了三层幂等保障:
- 数据库唯一索引 :订单表设置了
order_no唯一索引,同一个订单号,数据库只会插入一次,重复插入会报错 - 消费前校验:消费消息前,先查询订单是否已经存在,存在的直接返回,不做任何处理
- 消息去重:RocketMQ 的消息 ID 做去重处理,用 Redis 记录已经消费过的消息 ID,重复的消息直接拦截
5.4 库存回滚的兜底机制
秒杀系统中,库存回滚是必须的,否则会出现库存冻结,商品明明有库存却卖不出去的问题。需要回滚库存的场景:
- 下单消息发送失败,库存预扣了但订单没创建
- 订单创建失败,需要把预扣的库存加回去
- 用户 30 分钟内未支付,订单自动取消,库存回滚
- 用户主动取消订单,库存回滚
1. 库存回滚 Lua 脚本(保证原子性)
bash
-- 库存回滚Lua脚本,保证原子性
-- KEYS[1]: 库存Key seckill:stock:{goodsId}
-- KEYS[2]: 用户抢购记录Key seckill:user:record:{activityId}:{goodsId}
-- ARGV[1]: 用户ID
-- ARGV[2]: 回滚数量
-- 1. 回滚库存
redis.call('INCRBY', KEYS[1], ARGV[2])
-- 2. 扣减用户的抢购记录
local userBuyCount = redis.call('ZSCORE', KEYS[2], ARGV[1])
if userBuyCount ~= false and tonumber(userBuyCount) >= tonumber(ARGV[2]) then
redis.call('ZINCRBY', KEYS[2], -tonumber(ARGV[2]), ARGV[1])
end
return 1
2. 超时订单自动取消定时任务(XXL-Job 实现)
java
@XxlJob("seckillOrderCancelJob")
public void seckillOrderCancelJob() {
log.info("开始执行超时秒杀订单取消定时任务");
// 查询超过过期时间,还未支付的订单
LambdaQueryWrapper<SeckillOrder> queryWrapper = Wrappers.lambdaQuery();
queryWrapper.eq(SeckillOrder::getOrderStatus, 0) // 待支付
.le(SeckillOrder::getExpireTime, LocalDateTime.now())
.last("limit 1000");
List<SeckillOrder> orderList = orderMapper.selectList(queryWrapper);
if (CollectionUtils.isEmpty(orderList)) {
log.info("没有需要取消的超时订单");
return;
}
for (SeckillOrder order : orderList) {
try {
// 1. 更新订单状态为已取消
order.setOrderStatus(2);
orderMapper.updateById(order);
// 2. 发送库存回滚消息
StockRollbackMessage message = StockRollbackMessage.builder()
.orderNo(order.getOrderNo())
.goodsId(order.getGoodsId())
.userId(order.getUserId())
.activityId(order.getActivityId())
.rollbackNum(1)
.build();
rocketMQTemplate.syncSend("stock_rollback_topic", message);
log.info("超时订单取消成功,订单号:{}", order.getOrderNo());
} catch (Exception e) {
log.error("超时订单取消异常,订单号:{}", order.getOrderNo(), e);
}
}
log.info("超时秒杀订单取消定时任务执行完成");
}
六、核心模块四:秒杀系统全链路兜底容错方案
秒杀系统的设计,永远要先考虑最坏的情况:Redis 宕机了怎么办?MQ 挂了怎么办?服务被打满了怎么办?没有兜底方案的秒杀系统,就是在裸奔,大促时一定会出大问题。
6.1 Sentinel 熔断降级:避免流量雪崩的核心手段
Sentinel 不仅能做限流,还能做熔断降级,当某个服务出现异常时,自动熔断,避免故障扩散,拖垮整个微服务集群。
我们针对秒杀系统的核心链路,配置了 3 种降级规则:
- 熔断降级:当秒杀服务的异常比例超过 50%,或者响应时间超过 1s 的请求占比超过 80%,触发熔断,直接返回降级结果,避免服务被打垮
- 热点降级:当某个商品的请求量超过阈值,自动降级,返回活动火爆的提示,避免单个热点商品打垮整个服务
- 系统保护规则:当服务的 CPU 使用率超过 80%,或者负载超过阈值,自动限流,保护服务不被宕机
降级规则配置示例:
bash
[
{
"resource": "seckill_do",
"grade": 1,
"count": 1000,
"timeWindow": 10,
"slowRatioThreshold": 0.8,
"minRequestAmount": 100,
"statIntervalMs": 10000
},
{
"resource": "seckill_do",
"grade": 2,
"count": 0.5,
"timeWindow": 10,
"minRequestAmount": 100,
"statIntervalMs": 10000
}
]
规则说明:
- 慢调用比例熔断:10 秒内,请求数超过 100,响应时间超过 1s 的请求占比超过 80%,触发熔断,熔断时间 10 秒
- 异常比例熔断:10 秒内,请求数超过 100,异常比例超过 50%,触发熔断,熔断时间 10 秒
6.2 秒杀活动全局开关:紧急场景一键止损
大促时如果出现紧急情况,比如超卖漏洞、恶意攻击、服务宕机,我们需要有一键止损的能力,立即关闭秒杀活动,避免故障扩大。
实现方案:
- 在 Nacos 配置中心配置秒杀活动的全局开关,以及单个活动的开关
- 秒杀接口执行前,先判断开关是否开启,开关关闭的话,直接返回活动已结束
- 开关可在 Nacos 和 Sentinel 控制台动态修改,无需重启服务,秒级生效
代码实现:
java
@Value("${seckill.activity.global.switch:true}")
private Boolean globalSwitch;
@Override
public Result<SeckillVO> doSeckill(SeckillDTO dto, Long userId) {
// 1. 全局开关校验
if (!globalSwitch) {
log.warn("秒杀活动全局开关已关闭,请求被拦截");
return Result.fail("秒杀活动已结束,感谢您的参与");
}
// 2. 单个活动开关校验
String activitySwitchKey = "seckill:activity:switch:" + dto.getActivityId();
Boolean activitySwitch = (Boolean) redisTemplate.opsForValue().get(activitySwitchKey);
if (activitySwitch != null && !activitySwitch) {
log.warn("活动{}开关已关闭,请求被拦截", dto.getActivityId());
return Result.fail("该秒杀活动已结束");
}
// 后续逻辑省略...
}
6.3 数据库最终兜底:双重校验,绝对不超卖
就算 Redis 出问题了,我们也要保证绝对不超卖,数据库的乐观锁就是最后一道兜底防线。
实现方案:
- 订单创建时,除了 Redis 预扣,还要在数据库层面做乐观锁的库存扣减校验
- 只有数据库扣减成功,订单才算创建成功,否则触发库存回滚
- 就算 Redis 预扣出现问题,数据库的乐观锁也能保证库存不会变成负数,绝对不超卖
代码实现:
java
// 订单创建时,数据库乐观锁兜底扣减库存
@Transactional(rollbackFor = Exception.class)
public void createOrder(SeckillOrderMessage message) {
// 1. 乐观锁扣减数据库库存,只有库存>0时才能扣减成功
UpdateWrapper<SeckillGoods> updateWrapper = Wrappers.update();
updateWrapper.eq("goods_id", message.getGoodsId())
.gt("available_stock", 0)
.setSql("available_stock = available_stock - 1")
.setSql("version = version + 1");
int update = goodsMapper.update(null, updateWrapper);
if (update <= 0) {
log.error("数据库库存扣减失败,商品ID:{}", message.getGoodsId());
throw new RuntimeException("库存不足,订单创建失败");
}
// 2. 创建订单
SeckillOrder order = buildOrder(message);
orderMapper.insert(order);
log.info("订单创建成功,订单号:{}", message.getOrderNo());
}
6.4 超时订单自动取消与库存回滚定时任务
这部分在前面的库存回滚章节已经详细讲解,这里再强调一下:定时任务是兜底方案,就算 MQ 消息丢失了,也能通过定时任务把超时未支付的订单取消,回滚库存,避免库存冻结。
6.5 全链路监控与实时告警体系
秒杀系统的监控告警,是大促时的眼睛。没有监控,出了问题你根本不知道,等用户投诉了才发现,故障已经扩大了。
核心监控指标与告警规则:
| 监控指标 | 告警阈值 | 告警级别 |
|---|---|---|
| 秒杀接口限流次数 | 1 分钟内超过 1000 次 | 警告 |
| 秒杀接口异常率 | 超过 1% | 紧急 |
| 库存预扣失败率 | 超过 5% | 紧急 |
| 订单创建成功率 | 低于 99% | 紧急 |
| Redis 节点 CPU 使用率 | 超过 80% | 警告;超过 90% 紧急 |
| MySQL 连接数使用率 | 超过 80% | 警告 |
| MQ 消息堆积量 | 超过 1000 条 | 警告;超过 10000 条 紧急 |
| 超卖订单数 | 大于 0 | 致命,立即电话告警 |
| 黑名单拦截次数 | 1 分钟内超过 100 次 | 警告 |
告警方式:企业微信 / 钉钉机器人、短信、电话告警,核心指标(比如超卖)必须配置电话告警,7*24 小时响应。
七、踩坑实录:生产环境 10 个秒杀系统真实事故与解决方案
这部分是我多年来在大促中踩过的真实的坑,每一个都导致过线上故障,分享出来帮大家避坑:
坑 1:用 Redis 的 decr 扣库存,没做用户限购,黄牛刷走 80% 库存
事故背景 :第一次做秒杀,图简单用 Redis 的 decr 命令扣库存,没在原子操作里做用户限购,结果黄牛用脚本开了 100 个账号,刷走了 80% 的库存,普通用户根本抢不到,运营直接炸了。解决方案:
- 把用户限购逻辑写到 Lua 脚本里,和库存扣减放在同一个原子操作中,保证一个用户只能抢一次
- 增加用户粒度的频率限制和黑名单机制,拦截脚本刷量
- 增加风控层,拦截僵尸号、黄牛号
坑 2:Lua 脚本里写了循环逻辑,导致 Redis 主线程阻塞,服务全宕机
事故背景 :做分桶优化时,把库存不足时的桶遍历逻辑写到了 Lua 脚本里,大促时大量库存不足的请求,导致 Lua 脚本执行时间超长,阻塞了 Redis 主线程,Redis 集群直接宕机,整个秒杀系统崩溃。解决方案:
- Lua 脚本里绝对不能写循环、耗时操作,只做单桶的扣减逻辑,遍历逻辑放到 Java 代码中
- 给 Lua 脚本设置最大执行时间,超过时间自动终止
- 压测时重点监控 Redis 的慢日志,提前发现问题
坑 3:Sentinel 规则存在内存里,服务重启后规则丢失,大促时限流失效
事故背景 :最开始 Sentinel 规则只配置在控制台,存在服务的内存里,大促前服务重启了,规则全部丢失,秒杀开始时没有限流,直接把服务打崩了。解决方案:
- Sentinel 规则全部持久化到 Nacos,服务启动时自动拉取规则,重启不丢失
- 大促前做全链路检查,确认限流规则、降级规则全部生效
- 配置规则变更审计,所有规则修改都有记录,防止误操作
坑 4:热点商品单 Key 瓶颈,Redis CPU 直接打满,服务超时
事故背景 :双 11 秒杀 1 元手机,单商品每秒 30 万请求,全部打到同一个库存 Key 上,Redis 对应节点的 CPU 直接打满 100%,请求全部超时,用户端全是系统异常。解决方案:
- 采用库存分桶优化,把单 Key 拆分成 10 个分桶 Key,分散到 Redis 集群的不同节点,CPU 使用率直接降到 20%
- 配置 Redis 热点 Key 发现机制,自动把热点 Key 缓存到本地,减少 Redis 请求
- 提前对热点商品做预热,分散流量
坑 5:异步下单时 MQ 消息丢失,库存扣了订单没生成,用户投诉
事故背景 :最开始用异步发送消息,大促时 MQ 集群压力大,消息发送失败了也没处理,导致库存扣了,订单没创建,用户付了钱没抢到商品,大量投诉。解决方案:
- 采用同步发送消息,确认消息发送成功后再返回,发送失败直接触发库存回滚
- 开启 RocketMQ 的同步刷盘机制,保证消息不丢失
- 配置死信队列,消费失败的消息进入死信队列,人工介入处理
- 定时任务兜底,扫描库存预扣了但订单没创建的记录,自动回滚库存
坑 6:超时订单取消时,库存重复回滚,导致库存多了,出现超卖
事故背景 :订单取消时,没有做幂等性校验,重复消费回滚消息,导致库存被重复加回去,原本 100 件的库存,变成了 120 件,最终超卖了 20 件。解决方案:
- 库存回滚操作做幂等性校验,同一个订单号只能回滚一次
- 库存回滚用 Lua 脚本保证原子性,同时扣减用户的抢购记录
- 库存流水记录每一次回滚操作,可对账可追溯
坑 7:秒杀活动没做预热,秒杀开始时大量请求打到数据库,直接宕机
事故背景 :第一次做秒杀,没做数据预热,秒杀开始时,所有请求都去数据库查商品信息、库存数据,数据库的 QPS 瞬间拉满,直接宕机,秒杀活动完全失败。解决方案:
- 秒杀开始前 1 小时,把所有活动、商品、库存数据全量预热到 Redis
- 用布隆过滤器过滤不存在的商品 ID 请求,不让无效请求打到数据库
- 商品详情页全量静态化,放到 CDN,所有静态请求不打到源站
- 秒杀核心接口,绝对不能直接查数据库,所有数据都从 Redis 获取
坑 8:网关层没做限流,恶意请求直接打满服务线程池,正常用户无法访问
事故背景 :大促时,黄牛用 DDOS 工具发起了每秒 10 万的请求,网关层没做限流,所有请求都打到后端服务,服务的线程池瞬间被打满,正常用户的请求根本进不来,页面全是 503。解决方案:
- 网关层配置总流量限流和 IP 维度限流,超过阈值的请求直接在网关层拦截
- 配置 Sentinel 的系统保护规则,当服务 CPU、负载超过阈值时,自动限流
- 接入高防 IP,拦截 DDOS 攻击,恶意流量在网络层就被拦截
坑 9:分布式锁超时释放,导致并发扣减,出现超卖
事故背景 :最开始用 Redis 分布式锁做库存扣减,锁超时时间设置了 3 秒,大促时数据库响应慢,锁超时释放了,另一个线程拿到了锁,两个线程同时扣减库存,出现了超卖。解决方案:
- 彻底放弃用分布式锁做库存扣减,改用 Lua 脚本原子操作,从根源解决问题
- 用 Redisson 的分布式锁,自带看门狗机制,业务没处理完,锁自动续期
- 就算用锁,也要在数据库层面加乐观锁兜底,绝对不超卖
坑 10:没有做降级方案,Redis 宕机后,整个秒杀服务直接不可用,全平台报错
事故背景 :Redis 集群其中一个节点宕机,秒杀服务所有请求都报 Redis 连接异常,直接返回系统错误,整个秒杀服务完全不可用,甚至影响了平台的其他业务。解决方案:
- 配置 Sentinel 熔断降级,当 Redis 异常时,自动触发降级,返回友好提示,不抛出异常
- Redis 集群采用主从 + 哨兵模式,自动故障转移,节点宕机后自动切换
- 服务隔离,秒杀服务和其他业务服务完全隔离,秒杀服务的故障不会影响其他业务
- 极端情况,关闭秒杀活动的全局开关,一键止损
八、压测实战:从 100QPS 到 12 万 QPS 的全流程优化
很多人做秒杀系统,只写功能,不做压测,结果大促一上线就崩。秒杀系统的性能,是压测出来的,不是写出来的。
我用 JMeter 做了全链路压测,模拟真实的秒杀场景,1000 个并发线程,循环发起请求,一步步优化,最终把系统的 QPS 从 120 提升到了 12 万,响应时间从 800ms 降到了 25ms,连续压测 1 小时,无超卖,无宕机,无异常。
初始版本(V1.0):同步下单 + 数据库乐观锁
- 压测结果:QPS 120,平均响应时间 800ms,并发 1000 就出现大量超时,并发 2000 数据库直接宕机,出现超卖
- 核心问题:同步下单,所有操作都查数据库,数据库压力巨大,乐观锁大量更新失败,性能极差
优化 1:Redis Lua 脚本原子预扣库存 + 异步下单
- 优化内容:用 Lua 脚本做原子性库存预扣,订单创建异步化,核心链路不查数据库
- 压测结果:QPS 8200,平均响应时间 45ms,无超卖,无异常
- 提升效果:QPS 提升了 68 倍,响应时间降低了 94%
优化 2:全链路限流防刷 + 网关层拦截
- 优化内容:网关层 Sentinel 限流,接口层热点限流,用户粒度频率限制,层层拦截无效流量
- 压测结果:QPS 32000,平均响应时间 32ms,无超卖,无异常
- 提升效果:无效流量被拦截,有效请求处理能力提升了 4 倍
优化 3:库存分桶优化 + 热点 Key 隔离
- 优化内容:库存分桶优化,把单 Key 拆分成 10 个分桶,热点 Key 隔离,Redis 集群压力分散
- 压测结果:QPS 85000,平均响应时间 28ms,Redis CPU 使用率从 90% 降到 25%
- 提升效果:解决了单 Key 热点问题,并发能力提升了 2.6 倍
优化 4:全链路降级 + 服务隔离 + 参数调优
- 优化内容:服务隔离,JVM 参数调优,Redis 集群参数调优,MySQL 读写分离,全链路熔断降级
- 压测结果:QPS 121000,平均响应时间 25ms,连续压测 1 小时,无超卖,无宕机,无异常,服务 CPU 使用率稳定在 50% 左右
- 最终效果:系统能稳定支撑 12 万 QPS 的秒杀流量,完全满足双 11 大促的需求
九、总结与拓展
总结
秒杀系统的优化,从来不是堆技术,而是基于业务场景的层层削峰、原子性保障、异步解耦、兜底容错的综合工程。
核心思路总结下来就是 3 句话:
- 把 99% 的无效流量挡在系统之外,用四层限流防刷体系,只让有效流量进入核心链路
- 用原子性操作解决超卖问题,Redis Lua 脚本是生产级秒杀系统的唯一最优解,从根源杜绝超卖
- 永远先考虑最坏的情况,全链路的兜底容错方案,是秒杀系统稳定运行的核心保障
本文分享的所有方案,都经过了多次百万级 QPS 大促的实战检验,你可以直接复用,也可以根据自己的业务场景做调整。
拓展
-
本地限流优化:针对热点商品,用 Caffeine 做本地缓存,把热点库存数据缓存到服务本地,进一步减少 Redis 的请求压力,提升并发能力
-
异地多活架构:超大规模的秒杀系统,需要做异地多活部署,多个机房同时提供服务,避免单机房故障导致整个活动失败
-
秒杀商品静态化:把秒杀活动页、商品详情页做全量静态化,甚至预渲染成 HTML,放到 CDN,极致提升页面加载速度,减少源站压力
-
验证码体系优化:用滑块验证码、点选验证码、人机验证等方式,进一步拦截脚本请求,提升黄牛的刷量成本
-
全链路压测体系 :搭建生产环境全链路压测平台,大促前模拟真实流量做压测,提前发现系统瓶颈,优化性能
