SpringCloud 秒杀系统生产级落地:Sentinel+Redis 联合优化,从限流防刷到库存闭环,彻底解决超卖 / 宕机 / 恶意刷

网上 90% 的秒杀教程都是 demo 级演示,要么只写个 Redis 扣库存的几行代码,要么只讲 Sentinel 限流的基础用法,根本无法落地生产。秒杀系统从来不是单一技术的堆砌,而是全链路的流量管控、安全防护、数据一致性保障的综合工程。

这篇博文,我会把多年大促实战沉淀的秒杀系统优化方案全部分享出来,从架构设计、全链路限流防刷、Redis 库存预扣防超卖、异步订单闭环,到兜底容错、踩坑实录、压测优化全流程讲透。所有方案都经过百万级 QPS 大促的实战检验,看完你就能直接落地到自己的项目里,避开那些我踩过的致命深坑。

本文你将学到什么

  1. 生产级秒杀系统的完整架构设计,解决瞬时高并发的核心思路
  2. Sentinel+Redis 联合实现的四层全链路限流防刷体系,彻底拦截恶意请求
  3. 基于 Redis Lua 脚本的库存预扣原子性实现,从根源解决超卖问题
  4. 热点 Key 分桶优化方案,解决秒杀商品单 Key 瓶颈,并发能力提升 10 倍
  5. 基于可靠消息的异步订单方案,保证库存与订单数据的最终一致性
  6. 秒杀系统全链路兜底容错方案,极端情况也能保证不超卖、不雪崩
  7. 10 个生产环境真实踩坑实录与解决方案,帮你少走 3 年弯路
  8. 从 100QPS 到 12 万 QPS 的压测优化全过程,可直接复用的优化思路

一、秒杀系统核心痛点与架构总览

1.1 秒杀业务的 6 大致命痛点

秒杀是典型的瞬时高并发、读多写少、强一致性、高安全要求的业务场景,和普通电商业务的逻辑完全不同,核心痛点集中在这 6 点,任何一点没处理好,都会引发线上事故:

  1. 瞬时流量洪峰:平时 QPS 几十的系统,秒杀开场瞬间冲到几十万甚至上百万,普通架构直接被打穿,服务全宕机
  2. 超卖资损:库存 100 件,最终卖出 150 件,直接造成公司资金损失,还会引发大规模客诉和品牌负面影响
  3. 恶意刷量攻击:黄牛用脚本无限循环刷接口,普通用户根本抢不到,活动完全失去意义,还会拖垮系统
  4. 链路雪崩:秒杀流量打垮商品服务,拖垮订单、支付、用户等整个微服务集群,导致全平台不可用
  5. 数据一致性问题:库存扣了但订单没生成,用户付了钱没抢到商品;或者订单生成了库存没扣,造成超卖
  6. 兜底容错缺失:Redis 宕机、MQ 故障等极端场景下,没有兜底方案,直接导致整个秒杀链路崩溃,甚至出现超卖

1.2 生产级秒杀系统全链路架构设计

下面是经过多次大促验证的秒杀系统架构图,清晰展示全链路的流量走向和分层设计,核心思路是层层削峰,把 99% 的无效流量挡在数据库之外

1.3 秒杀系统设计的 7 条铁则

这是我经过多次大促踩坑总结的铁则,任何一条不满足,生产环境都可能出大问题:

  1. 流量层层削峰:把无效流量挡在最外层,绝对不让无效流量穿透到后端服务,更不能打到数据库
  2. 原子性优先:库存扣减必须保证原子性,这是杜绝超卖的核心,任何非原子操作都不能用在库存扣减上
  3. 异步解耦:秒杀核心链路只做库存预扣和合法性校验,订单创建、支付、通知等非核心逻辑全部异步化
  4. 限流兜底:任何时候都要给系统设置流量上限,超过阈值直接拒绝,绝对不能让系统被流量打穿
  5. 数据一致性:不追求强一致性,通过可靠消息 + 定时兜底,保证库存和订单的最终一致性
  6. 可追溯性:每一次库存扣减、订单创建、用户请求都要全链路落日志,出问题可查可追溯
  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='秒杀用户黑名单表';

核心设计说明

  1. 秒杀订单表设置了user_id+goods_id+activity_id的联合唯一索引,从数据库层面保证一个用户一个商品只能抢一次,杜绝重复下单
  2. 秒杀商品表加了version乐观锁版本号,作为 Redis 预扣失败后的兜底防超卖手段
  3. 库存流水表记录每一次库存操作,出问题可对账、可追溯、可回滚,是资损防控的核心
  4. 所有核心字段都加了唯一索引,从数据库层面保证数据唯一性

2.2 微服务环境与组件配置规范

  1. 环境隔离:开发、测试、压测、生产环境完全隔离,生产环境的配置绝对不能提交到代码仓库,全部放在 Nacos 配置中心加密存储
  2. Redis 集群配置:生产环境必须用 Redis 集群(主从 + 哨兵 / Cluster 模式),禁止单机 Redis;开启 RDB+AOF 持久化,防止数据丢失;针对秒杀热点 Key 做热点隔离,避免单节点压力过大
  3. Sentinel 配置:Sentinel 控制台集群部署,规则持久化到 Nacos,服务重启后规则不丢失;配置集群限流模式,避免单机限流不准的问题
  4. RocketMQ 配置:开启消息持久化,同步刷盘机制,保证消息不丢失;配置死信队列,消费失败的消息进入死信队列,人工介入处理,不丢失数据
  5. 数据库配置:MySQL 主从分离,读写分离,秒杀相关的表用 InnoDB 引擎,行级锁,避免表锁;配置合理的连接池参数,避免连接数打满

2.3 秒杀活动预热全流程

秒杀活动的预热,是决定秒杀开场能不能扛住流量的关键,很多人就是因为没做预热,秒杀开始时大量请求打到数据库,直接宕机。

完整预热流程

  1. 活动提前配置:秒杀开始前 24 小时,完成活动、商品的配置,审核通过后锁定配置,禁止修改
  2. 数据预热:秒杀开始前 1 小时,把秒杀活动信息、商品信息、库存数据、限购规则,全量预热到 Redis 集群,同时写入布隆过滤器,过滤不存在的商品 ID 请求
  3. 规则预热:把 Sentinel 限流规则、黑名单规则、风控规则,提前推送到各个服务节点和网关,避免秒杀开始时拉取规则的请求压力
  4. 静态资源预热:把秒杀活动页、商品详情页的静态资源,提前预热到 CDN,避免秒杀开始时静态资源请求打满源站
  5. 服务预热:秒杀开始前 30 分钟,对秒杀核心接口做压测预热,让 JIT 提前编译,避免秒杀开始时的冷启动性能问题
  6. 全链路检查:秒杀开始前 10 分钟,检查 Redis、MQ、Sentinel、数据库的状态,检查限流规则、活动状态、库存数据是否正确

三、核心模块一:Sentinel+Redis 联合实现全链路限流防刷

限流防刷是秒杀系统的第一道防线,也是最核心的环节。如果限流防刷做不好,恶意请求会直接打满你的系统,正常用户根本进不来,甚至会把系统打崩。

我们采用四层全链路限流防刷体系,Sentinel 负责集群级别的流量管控,Redis 负责用户粒度的防刷和黑名单管控,两者联合实现层层削峰,把 99% 的无效流量挡在系统之外。

下面是全链路限流防刷的流程图:

3.1 限流防刷的核心设计理念:层层削峰,无效流量零穿透

秒杀系统的核心,不是怎么处理请求,而是怎么拦截无效请求。我们的设计理念是:能在前端拦的,绝不放到 CDN;能在 CDN 拦的,绝不放到网关;能在网关拦的,绝不放到后端服务;能在接口层拦的,绝不放到数据库

每一层都只放合法的、有效的请求到下一层,最终只有不到 1% 的有效流量,能进入到核心的库存扣减逻辑,这样才能扛住百万级的 QPS。

3.2 第一层:前端 & CDN 前置拦截,过滤 60% 无效流量

这一层是成本最低、效果最好的拦截层,能直接过滤掉 60% 以上的无效请求,很多新手完全忽略了这一层,导致大量无效请求打到后端。

核心拦截手段

  1. 按钮置灰控制:秒杀开始前,按钮置灰不可点击;秒杀开始后,用户点击一次后立即置灰,禁止重复点击,避免用户手滑重复发起请求
  2. 验证码校验:秒杀开始时,必须输入验证码(图形验证码 / 滑块验证码)才能发起请求,直接拦截掉 90% 的脚本自动请求
  3. CDN 静态化:秒杀活动页、商品详情页全量静态化,放到 CDN 节点,所有静态资源请求都由 CDN 处理,根本不会打到源站
  4. 前端频率限制:前端控制用户请求频率,1 秒内最多发起 1 次请求,超过的直接在前端拦截,不发起请求
  5. 活动时间校验:前端校验活动时间,未开始 / 已结束的活动,直接拦截请求,不向后端发送

【避坑提醒】:前端拦截只能防普通用户,绝对不能防黄牛脚本,所以这一层只是前置过滤,后端必须有完整的校验逻辑,绝对不能只靠前端做限制!

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"
    }
  }
]

规则说明

  1. 秒杀接口总 QPS 限制在 5 万 / 秒,突发流量允许 1 万的 burst,超过的直接快速失败,返回 "活动火爆,请稍后再试"
  2. 单个 IP 每秒最多允许 20 次请求,超过的直接拦截,防止脚本刷接口
  3. 所有规则持久化到 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. 同一个用户,1 秒内最多发起 5 次秒杀请求,超过的直接拦截
  2. 同一个用户,1 分钟内超过 30 次请求,直接加入黑名单,1 小时内禁止访问
  3. 黑名单数据提前预热到 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 第四层:业务层风控兜底,拦截剩余恶意请求

这一层是最后的兜底,针对那些绕过了前面三层拦截的恶意请求,做最终的风控拦截,核心手段:

  1. 用户行为校验:判断用户是否是正常用户,比如账号是否实名认证、是否有收货地址、是否有历史订单,拦截僵尸号、黄牛号
  2. 设备指纹校验:同一个设备号,对应多个用户账号,直接拦截,防止黄牛用群控设备刷量
  3. 收货地址校验:同一个收货地址,对应多个用户账号,限制抢购数量,防止黄牛批量下单
  4. 异常行为识别:比如用户请求的时间间隔完全一致、IP 地址归属地异常、账号刚注册就来抢秒杀,直接拦截

3.6 限流防刷高频踩坑与解决方案

  1. 坑:单机限流不准,集群总流量超过阈值解决方案:用 Sentinel 的集群限流模式,统一由 Token Server 分配流量,保证集群总流量不超过阈值,而不是单机限流累加
  2. 坑:Redis 滑动窗口大并发下性能问题解决方案:给滑动窗口的 Key 设置过期时间,避免 Redis 内存溢出;用 pipeline 批量操作,减少 Redis 的网络 IO 次数
  3. 坑:黑名单规则不生效,服务重启后丢失解决方案:黑名单数据同时存储在 Redis 和数据库,服务启动时全量加载到 Redis;定时任务同步数据库和 Redis 的数据,保证一致性
  4. 坑:恶意用户用代理 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 脚本,需要实现以下核心逻辑:

  1. 校验用户是否已经抢购过,限购逻辑
  2. 校验库存是否充足
  3. 库存预扣减
  4. 记录用户抢购记录,防止重复抢购
  5. 返回扣减结果,保证每一步都在同一个 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);
    }
}

【避坑提醒】:

  1. Lua 脚本中绝对不能写耗时操作,比如循环、大量 Key 操作,会阻塞 Redis 主线程,导致整个 Redis 集群宕机
  2. Lua 脚本的逻辑必须极简,只做核心的原子性操作,其他非核心逻辑(比如记录流水、发送消息)放到 Java 代码中异步执行
  3. 脚本必须提前加载到 Redis,避免每次执行都传输脚本,提升性能
  4. 必须处理脚本执行异常的情况,不能因为脚本异常导致服务崩溃

4.4 热点 Key 分桶优化:解决单 Key 瓶颈,并发翻倍

当秒杀商品的热度极高时,比如 1 元抢手机,每秒几十万的请求都打到同一个库存 Key 上,会出现Redis 单 Key 热点问题

  • Redis 单节点的 CPU 被打满,因为所有请求都落到同一个分片上
  • 单 Key 的请求量超过 Redis 单节点的处理上限,出现请求超时
  • 严重时会导致 Redis 节点宕机,整个秒杀系统崩溃

这是秒杀系统高并发下的核心痛点,解决方案就是库存分桶优化,把一个热点 Key 拆分成多个子 Key,分散到 Redis 集群的不同节点上,把单 Key 的压力分散到多个 Key,并发能力直接提升 10 倍以上。

分桶优化核心原理
  1. 把总库存拆分成 N 个桶,比如总库存 100 件,拆分成 10 个桶,每个桶 10 件库存
  2. 每个桶对应一个独立的 Redis Key,比如seckill:stock:1001:0 ~ seckill:stock:1001:9
  3. 用户请求进来时,随机生成一个桶编号,尝试对这个桶的库存进行扣减
  4. 如果这个桶的库存不足,就遍历其他桶,直到扣减成功,或者所有桶都库存不足
  5. 为了保证原子性,每个桶的扣减还是用 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. 出问题时可对账,核对库存扣减是否正确
  2. 订单取消时,可根据流水记录精准回滚库存,不会出现多回滚、少回滚的问题
  3. 资损防控的核心依据,出现超卖时可快速定位问题

核心设计规则

  1. 每一次库存操作,都生成唯一的流水号,全链路传递
  2. 流水记录必须包含操作前库存、操作后库存、操作数量、操作类型
  3. 流水记录必须异步写入,不能阻塞秒杀主流程
  4. 流水记录永久保存,至少保留 1 年以上,便于审计和对账

五、核心模块三:异步订单闭环与数据最终一致性保障

很多新手做秒杀,把订单创建、库存扣减、支付逻辑都同步执行,结果导致接口响应时间超长,服务线程池被打满,数据库压力巨大,大促时直接宕机。

秒杀系统的核心链路,只应该做库存预扣这一件事,订单创建、支付通知、库存确认等所有非核心逻辑,都必须异步化处理。

5.1 为什么秒杀订单必须做异步化?

同步下单的致命痛点:

  1. 接口响应慢:订单创建需要操作数据库,同步执行会导致接口响应时间从几毫秒变成几百毫秒,高并发下服务线程池很快被打满
  2. 数据库压力大:秒杀成功的请求,同时打到数据库创建订单,数据库的写入 QPS 瞬间拉满,很容易宕机
  3. 链路雪崩风险:订单服务宕机,会导致秒杀主流程失败,库存扣了但订单没创建,出现资损
  4. 无法应对流量洪峰:同步下单的模式,数据库的写入能力就是系统的上限,根本扛不住高并发

异步下单的核心优势:

  1. 接口响应极快:库存预扣成功后立即返回,接口响应时间控制在 10ms 以内,用户体验极好
  2. 削峰填谷:用 MQ 把瞬时的订单创建请求缓存起来,消费者匀速消费,把数据库的写入压力控制在可控范围内
  3. 链路隔离:订单服务的异常,不会影响秒杀主流程,就算订单服务宕机,消息也不会丢失,服务恢复后继续消费
  4. 可扩展性强:后续的支付、短信、积分等业务,都可以通过消费 MQ 消息实现,和主流程完全解耦

5.2 基于 RocketMQ 的可靠消息异步下单方案

我们采用可靠消息 + 最终一致性方案,保证库存预扣和订单创建的数据一致性,核心流程如下:

  1. 秒杀请求通过 Lua 脚本完成库存预扣,生成唯一的流水号和订单号
  2. 发送半消息到 RocketMQ,确认消息是否发送成功
  3. 消息发送成功后,返回秒杀成功给前端
  4. RocketMQ 消费者异步消费消息,创建订单,更新订单状态
  5. 订单创建成功后,确认库存扣减,更新库存流水
  6. 订单创建失败,触发库存回滚,把预扣的库存加回去

下面是异步下单全流程图:

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 订单创建的幂等性设计,杜绝重复下单

幂等性是异步下单的核心,必须保证同一条消息,无论消费多少次,都只会创建一个订单,不会出现重复下单的问题。我们做了三层幂等保障:

  1. 数据库唯一索引 :订单表设置了order_no唯一索引,同一个订单号,数据库只会插入一次,重复插入会报错
  2. 消费前校验:消费消息前,先查询订单是否已经存在,存在的直接返回,不做任何处理
  3. 消息去重:RocketMQ 的消息 ID 做去重处理,用 Redis 记录已经消费过的消息 ID,重复的消息直接拦截

5.4 库存回滚的兜底机制

秒杀系统中,库存回滚是必须的,否则会出现库存冻结,商品明明有库存却卖不出去的问题。需要回滚库存的场景:

  1. 下单消息发送失败,库存预扣了但订单没创建
  2. 订单创建失败,需要把预扣的库存加回去
  3. 用户 30 分钟内未支付,订单自动取消,库存回滚
  4. 用户主动取消订单,库存回滚
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 种降级规则:

  1. 熔断降级:当秒杀服务的异常比例超过 50%,或者响应时间超过 1s 的请求占比超过 80%,触发熔断,直接返回降级结果,避免服务被打垮
  2. 热点降级:当某个商品的请求量超过阈值,自动降级,返回活动火爆的提示,避免单个热点商品打垮整个服务
  3. 系统保护规则:当服务的 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
  }
]

规则说明

  1. 慢调用比例熔断:10 秒内,请求数超过 100,响应时间超过 1s 的请求占比超过 80%,触发熔断,熔断时间 10 秒
  2. 异常比例熔断:10 秒内,请求数超过 100,异常比例超过 50%,触发熔断,熔断时间 10 秒

6.2 秒杀活动全局开关:紧急场景一键止损

大促时如果出现紧急情况,比如超卖漏洞、恶意攻击、服务宕机,我们需要有一键止损的能力,立即关闭秒杀活动,避免故障扩大。

实现方案

  1. 在 Nacos 配置中心配置秒杀活动的全局开关,以及单个活动的开关
  2. 秒杀接口执行前,先判断开关是否开启,开关关闭的话,直接返回活动已结束
  3. 开关可在 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 出问题了,我们也要保证绝对不超卖,数据库的乐观锁就是最后一道兜底防线。

实现方案

  1. 订单创建时,除了 Redis 预扣,还要在数据库层面做乐观锁的库存扣减校验
  2. 只有数据库扣减成功,订单才算创建成功,否则触发库存回滚
  3. 就算 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% 的库存,普通用户根本抢不到,运营直接炸了。解决方案

  1. 把用户限购逻辑写到 Lua 脚本里,和库存扣减放在同一个原子操作中,保证一个用户只能抢一次
  2. 增加用户粒度的频率限制和黑名单机制,拦截脚本刷量
  3. 增加风控层,拦截僵尸号、黄牛号

坑 2:Lua 脚本里写了循环逻辑,导致 Redis 主线程阻塞,服务全宕机

事故背景 :做分桶优化时,把库存不足时的桶遍历逻辑写到了 Lua 脚本里,大促时大量库存不足的请求,导致 Lua 脚本执行时间超长,阻塞了 Redis 主线程,Redis 集群直接宕机,整个秒杀系统崩溃。解决方案

  1. Lua 脚本里绝对不能写循环、耗时操作,只做单桶的扣减逻辑,遍历逻辑放到 Java 代码中
  2. 给 Lua 脚本设置最大执行时间,超过时间自动终止
  3. 压测时重点监控 Redis 的慢日志,提前发现问题

坑 3:Sentinel 规则存在内存里,服务重启后规则丢失,大促时限流失效

事故背景 :最开始 Sentinel 规则只配置在控制台,存在服务的内存里,大促前服务重启了,规则全部丢失,秒杀开始时没有限流,直接把服务打崩了。解决方案

  1. Sentinel 规则全部持久化到 Nacos,服务启动时自动拉取规则,重启不丢失
  2. 大促前做全链路检查,确认限流规则、降级规则全部生效
  3. 配置规则变更审计,所有规则修改都有记录,防止误操作

坑 4:热点商品单 Key 瓶颈,Redis CPU 直接打满,服务超时

事故背景 :双 11 秒杀 1 元手机,单商品每秒 30 万请求,全部打到同一个库存 Key 上,Redis 对应节点的 CPU 直接打满 100%,请求全部超时,用户端全是系统异常。解决方案

  1. 采用库存分桶优化,把单 Key 拆分成 10 个分桶 Key,分散到 Redis 集群的不同节点,CPU 使用率直接降到 20%
  2. 配置 Redis 热点 Key 发现机制,自动把热点 Key 缓存到本地,减少 Redis 请求
  3. 提前对热点商品做预热,分散流量

坑 5:异步下单时 MQ 消息丢失,库存扣了订单没生成,用户投诉

事故背景 :最开始用异步发送消息,大促时 MQ 集群压力大,消息发送失败了也没处理,导致库存扣了,订单没创建,用户付了钱没抢到商品,大量投诉。解决方案

  1. 采用同步发送消息,确认消息发送成功后再返回,发送失败直接触发库存回滚
  2. 开启 RocketMQ 的同步刷盘机制,保证消息不丢失
  3. 配置死信队列,消费失败的消息进入死信队列,人工介入处理
  4. 定时任务兜底,扫描库存预扣了但订单没创建的记录,自动回滚库存

坑 6:超时订单取消时,库存重复回滚,导致库存多了,出现超卖

事故背景 :订单取消时,没有做幂等性校验,重复消费回滚消息,导致库存被重复加回去,原本 100 件的库存,变成了 120 件,最终超卖了 20 件。解决方案

  1. 库存回滚操作做幂等性校验,同一个订单号只能回滚一次
  2. 库存回滚用 Lua 脚本保证原子性,同时扣减用户的抢购记录
  3. 库存流水记录每一次回滚操作,可对账可追溯

坑 7:秒杀活动没做预热,秒杀开始时大量请求打到数据库,直接宕机

事故背景 :第一次做秒杀,没做数据预热,秒杀开始时,所有请求都去数据库查商品信息、库存数据,数据库的 QPS 瞬间拉满,直接宕机,秒杀活动完全失败。解决方案

  1. 秒杀开始前 1 小时,把所有活动、商品、库存数据全量预热到 Redis
  2. 用布隆过滤器过滤不存在的商品 ID 请求,不让无效请求打到数据库
  3. 商品详情页全量静态化,放到 CDN,所有静态请求不打到源站
  4. 秒杀核心接口,绝对不能直接查数据库,所有数据都从 Redis 获取

坑 8:网关层没做限流,恶意请求直接打满服务线程池,正常用户无法访问

事故背景 :大促时,黄牛用 DDOS 工具发起了每秒 10 万的请求,网关层没做限流,所有请求都打到后端服务,服务的线程池瞬间被打满,正常用户的请求根本进不来,页面全是 503。解决方案

  1. 网关层配置总流量限流和 IP 维度限流,超过阈值的请求直接在网关层拦截
  2. 配置 Sentinel 的系统保护规则,当服务 CPU、负载超过阈值时,自动限流
  3. 接入高防 IP,拦截 DDOS 攻击,恶意流量在网络层就被拦截

坑 9:分布式锁超时释放,导致并发扣减,出现超卖

事故背景 :最开始用 Redis 分布式锁做库存扣减,锁超时时间设置了 3 秒,大促时数据库响应慢,锁超时释放了,另一个线程拿到了锁,两个线程同时扣减库存,出现了超卖。解决方案

  1. 彻底放弃用分布式锁做库存扣减,改用 Lua 脚本原子操作,从根源解决问题
  2. 用 Redisson 的分布式锁,自带看门狗机制,业务没处理完,锁自动续期
  3. 就算用锁,也要在数据库层面加乐观锁兜底,绝对不超卖

坑 10:没有做降级方案,Redis 宕机后,整个秒杀服务直接不可用,全平台报错

事故背景 :Redis 集群其中一个节点宕机,秒杀服务所有请求都报 Redis 连接异常,直接返回系统错误,整个秒杀服务完全不可用,甚至影响了平台的其他业务。解决方案

  1. 配置 Sentinel 熔断降级,当 Redis 异常时,自动触发降级,返回友好提示,不抛出异常
  2. Redis 集群采用主从 + 哨兵模式,自动故障转移,节点宕机后自动切换
  3. 服务隔离,秒杀服务和其他业务服务完全隔离,秒杀服务的故障不会影响其他业务
  4. 极端情况,关闭秒杀活动的全局开关,一键止损

八、压测实战:从 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 句话:

  1. 把 99% 的无效流量挡在系统之外,用四层限流防刷体系,只让有效流量进入核心链路
  2. 用原子性操作解决超卖问题,Redis Lua 脚本是生产级秒杀系统的唯一最优解,从根源杜绝超卖
  3. 永远先考虑最坏的情况,全链路的兜底容错方案,是秒杀系统稳定运行的核心保障

本文分享的所有方案,都经过了多次百万级 QPS 大促的实战检验,你可以直接复用,也可以根据自己的业务场景做调整。

拓展

  1. 本地限流优化:针对热点商品,用 Caffeine 做本地缓存,把热点库存数据缓存到服务本地,进一步减少 Redis 的请求压力,提升并发能力

  2. 异地多活架构:超大规模的秒杀系统,需要做异地多活部署,多个机房同时提供服务,避免单机房故障导致整个活动失败

  3. 秒杀商品静态化:把秒杀活动页、商品详情页做全量静态化,甚至预渲染成 HTML,放到 CDN,极致提升页面加载速度,减少源站压力

  4. 验证码体系优化:用滑块验证码、点选验证码、人机验证等方式,进一步拦截脚本请求,提升黄牛的刷量成本

  5. 全链路压测体系 :搭建生产环境全链路压测平台,大促前模拟真实流量做压测,提前发现系统瓶颈,优化性能

相关推荐
语戚2 小时前
Nginx vs Ribbon:负载均衡的两种核心范式(反向代理 vs 客户端负载)
java·nginx·spring·spring cloud·面试·ribbon·负载均衡
minhuan3 小时前
大模型应用:AI智能体高并发实战:Redis缓存+负载均衡协同解决推理超时难题.133
人工智能·redis·智能体推理缓存·智能体负载均衡·大模型集群应用
wuyikeer10 小时前
docker下搭建redis集群
redis·docker·容器
BduL OWED15 小时前
Redis之Redis事务
java·数据库·redis
Zzxy16 小时前
Redis集成与基础操作
spring boot·redis
amIZ AUSK18 小时前
Redis——使用 python 操作 redis 之从 hmse 迁移到 hset
数据库·redis·python
青槿吖18 小时前
第一篇:Redis集群从入门到踩坑:3主3从保姆级搭建+核心原理一次性讲透|面试必看
前端·redis·后端·面试·职场和发展·bootstrap·html
zs宝来了20 小时前
Redis 网络模型:IO 多路复用与 ae 事件循环
redis·epoll·事件循环·io多路复用·网络模型
羊小猪~~20 小时前
Redis学习笔记(数据类型、持久化、事件、管道、发布订阅等)
开发语言·数据库·c++·redis·后端·学习·缓存