高并发下的抢优惠券如何设计

高并发下的抢优惠券如何设计

整体架构流程图 📊

flowchart LR A[用户请求] --> B[前端限流/防重] B --> C[网关层限流/风控] C --> D[Redis库存预校验] D -->|库存不足| Z[返回抢券失败] D -->|库存充足| E[MQ异步削峰] E --> F[消费者Lua原子扣减] F -->|扣减失败| Z F -->|扣减成功| G[生成用户券记录] G --> H[异步同步数据库] H --> I[返回抢券成功] J[库存预热] --> D K[降级熔断] --> Z L[监控告警] --> K

核心模块拆解(技术关键点)

1. 前置:库存预热与隔离 ✅

  • 预热时机 :活动开始前 10-30 分钟,将优惠券总库存批量加载到独立 Redis 集群(与业务 Redis 隔离,避免雪崩)
  • 存储结构 :使用Hash结构 key=coupon:{activityId}, field={batchId}, value={stock},支持多批次库存管理
  • 过期策略 :设置活动时长+1小时的过期时间,活动结束自动清理无效数据

2. 第一层:全链路流量削峰 🚀

层级 技术方案 核心作用
前端 按钮置灰 + 倒计时 + 图形验证码 挡住 90% 无效点击和机器刷量
网关 Sentinel 令牌桶限流(按 IP / 用户) 限制单用户 / IP 每秒请求≤5 次
服务 随机延迟 100-300ms 打散集中请求,削平峰值
消息队列 RocketMQ/Kafka 异步化 将同步请求转为异步,削峰填谷

3. 第二层:原子性库存扣减 ⚠️(最核心)

绝对禁止 使用Redis decr()单独扣减(会出现超卖且无法控制负数),必须使用Lua 脚本保证原子性:

lua 复制代码
-- 原子性库存扣减脚本
local key = KEYS[1]
local field = ARGV[1]
local num = tonumber(ARGV[2])

local stock = tonumber(redis.call('HGET', key, field))
if not stock or stock < num then
    return 0  -- 库存不足
end
redis.call('HINCRBY', key, field, -num)
return 1  -- 扣减成功
  • 优势:一次网络往返完成 "查→判→扣" 三个操作,无并发间隙
  • 注意:Lua 脚本长度控制在 1KB 以内,避免 Redis 阻塞

4. 第三层:防刷防重与风控 🛡️

  • 防重领取 :扣减前用Redis SETNX存储user:{userId}:coupon:{activityId},过期时间 = 活动时长
  • 恶意拦截:风控系统实时拦截高频 IP、黑名单用户、设备指纹异常请求
  • 次数限制:Redis 记录单用户领取次数,超过上限直接拒绝

5. 第四层:兜底降级与数据一致性 🔒

  • 熔断降级:Sentinel 监控 Redis 和 MQ 状态,异常时直接返回 "活动火爆,请稍后再试"
  • 最终一致性:Redis 扣减成功后,异步通过 MQ 同步到 MySQL,定时任务校验 Redis 与数据库库存差异
  • 紧急开关:配置中心动态调整限流阈值、活动上下线开关,出现问题秒级切流

常见坑与避坑指南 ❌

常见问题 根本原因 解决方案
超卖 库存扣减非原子性 强制使用 Lua 脚本,禁止分步骤操作
少卖 扣减成功但生成券失败 本地事务 + 消息重试,保证最终一致性
缓存击穿 热点 key 失效 热点 key 永不过期,后台定时更新
Redis 雪崩 单集群承载所有流量 活动 Redis 独立部署,主从 + 哨兵架构

进阶优化方向 💡

  1. 分段库存:将总库存分成 10-20 段,每段单独扣减,大幅提升并发量
  2. 本地缓存:网关层缓存 "库存为 0" 的活动,避免无效请求穿透到 Redis
  3. 热点探测:实时监控 Redis 热点 key,自动迁移到专用节点
  4. 预生成券码:活动前预生成所有券码,抢券时直接分配,减少生成耗时

举个例子 🎯

某电商平台,双11整点秒杀,10万张优惠券,瞬时流量可能是平常的100倍。

面试官想听的不是你背方案,而是你如何把大问题拆小,一层层解决。我们假定场景:

🧠 核心痛点拆解

整个抢券链路,有四个要害:

  1. 超高并发:大量请求瞬间打到服务器。
  2. 库存超卖:券只有10万张,绝不能多发。
  3. 数据库击穿:直接读写MySQL必挂。
  4. 公平性与体验:黄牛脚本、用户反复刷新。

我们用一张架构全景图来梳理:

flowchart LR A[用户点击抢券] --> B[前端防重 & 验证码] B --> C[API 网关<br/>限流/鉴权] C --> D[优惠券服务<br/>Redis 库存扣减] D --> E{扣减成功?} E -- 是 --> F[发送 MQ 消息] E -- 否 --> G[返回已抢光/失败] F --> H[消费者写库<br/>生成用户券] H --> I[(MySQL 持久化)]

🔧 逐层落地实现

1️⃣ 前端:第一时间拦住无效请求

  • 按钮置灰 & 防重:点击后立刻变灰,避免用户狂点,减少80%的无效流量。
  • 验证码/滑块 :在真正高并发场景下,弹出简单滑块,拉长用户请求时间,削峰填谷,还能防机器脚本。

👉 "同学你记着,能用前端拦住的流量,千万别让它进后端,这是最经济的削峰。"

2️⃣ 网关层:令牌桶限流,保护系统

使用 Nginx + LuaSpring Cloud Gateway + Redis 实现令牌桶算法:

  • 对整个抢券接口设置 QPS 上限,比如 50000 QPS。
  • 超过的请求直接返回"活动太火爆,请稍后再试",不进入业务层。
text 复制代码
令牌桶像一个水库,每秒放入固定令牌,请求取不到令牌就直接拒绝,
保证下游压力恒定。

3️⃣ 核心:Redis 原子扣减库存,防超卖

这是整个方案的灵魂。

预热库存到 Redis :活动开始前,把10万张券的数量 SET 进 Redis:

redis 复制代码
SET coupon:stock:12345 100000

扣减逻辑必须用 Lua 脚本,保证"判断+扣减"是原子操作,一次网络往返完成。

lua 复制代码
-- 原子扣减库存的 Lua 脚本
local key = KEYS[1]
local stock = tonumber(redis.call('get', key))
if stock and stock > 0 then
    redis.call('decr', key)
    return 1  -- 抢券成功
else
    return 0  -- 库存不足
end

Java 侧调用:

java 复制代码
Long result = redisTemplate.execute(
    luaScript, 
    Collections.singletonList("coupon:stock:12345"));
if (result == 1) {
    // 扣减成功,发消息异步发券
}

亮点:单机 Redis 可轻松抗住 10w+ QPS 的扣减操作,完全无锁,无超卖。

4️⃣ 异步发券 & 削峰填谷

Redis 扣减成功后,不直接操作数据库 ,而是发送一条消息到 RocketMQ / Kafka

sequenceDiagram participant U as 用户 participant S as 抢券服务 participant R as Redis participant MQ as 消息队列 participant C as 消费者 participant DB as MySQL U->>S: 发起抢券请求 S->>R: 执行 Lua 脚本扣减库存 R-->>S: 返回成功 S->>MQ: 发送"发券"消息(userId + couponId) S-->>U: 返回"抢券成功,稍后到账" MQ->>C: 消费消息 C->>DB: 写入用户优惠券记录
  • 削峰:MQ 积压几千条消息毫无压力,消费者匀速写入数据库。
  • 用户体验:用户立即得到反馈("抢到啦!🔥"),真正的券几秒内到账,体验丝滑。

5️⃣ 数据库层防丢失 & 最终一致性

  • 消费端做好幂等 :根据 userId + couponId + 活动ID 建唯一索引,即使消息重复消费也不会多发。
  • 如果写库失败,进行重试或记录异常日志,人工兜底,保证最终一致。

📊 额外加分项:热 Key 问题怎么破?

如果一张券的 Redis key 成为热点,打到了一台 Redis 分片上,可以用以下手段:

  • 库存分片 :把 coupon:stock:12345 拆成 10 个 key:coupon:stock:12345:0 ~ 9,每个存 1/10 的库存。请求随机取一个 key 扣减,压力散开。
  • 本地缓存预减:JVM 内用 AtomicLong 先预减一部分库存,减完再从 Redis 拉取新额度。(需注意一致性,稍复杂)

👔 面试官总结

"同学你看,整个设计从 前端→网关→Redis原子扣减→MQ异步写库 ,每一层都在干自己最擅长的事,没有单点瓶颈,也不会超卖。你能把这套逻辑流利讲出来,并且提到 Lua 原子性、最终一致性、热 Key 方案,在大多数大厂面试里就已经是 Strong Yes 了 🎉。"

核心代码和技术难点

面试官您好,我再补充一下核心可运行代码 和本场景专属技术难点及解决方案,这两部分是实际落地和面试考察的重中之重。

核心代码实现(带技术亮点)

1. 原子性库存扣减 Lua 脚本(最核心亮点 ✨)

技术亮点:一次网络往返完成 "校验→扣减" 全流程,无任何并发间隙;支持多批次库存;自带参数校验,防止非法请求。

lua 复制代码
-- 文件名:coupon_stock_deduct.lua
-- KEYS[1]: 优惠券库存HashKey coupon:{activityId}
-- ARGV[1]: 批次ID batchId
-- ARGV[2]: 扣减数量(通常为1)
-- ARGV[3]: 用户ID(用于日志追踪)
-- 返回值:1=扣减成功 0=库存不足 -1=参数错误

-- 1. 参数校验
if #KEYS ~= 1 or #ARGV ~= 3 then
    return -1
end

local key = KEYS[1]
local batchId = ARGV[1]
local deductNum = tonumber(ARGV[2])
local userId = ARGV[3]

if deductNum <= 0 then
    return -1
end

-- 2. 原子性库存校验与扣减
local currentStock = tonumber(redis.call('HGET', key, batchId))
if not currentStock or currentStock < deductNum then
    return 0
end

redis.call('HINCRBY', key, batchId, -deductNum)
-- 可选:记录扣减日志,用于事后对账
redis.call('LPUSH', 'coupon:deduct:log:'..key, userId..':'..batchId..':'..deductNum..':'..redis.call('TIME')[1])
return 1

2. Spring Boot 抢券核心接口

技术亮点:分层限流 + 防重 + 异步化;使用 RedisTemplate 执行 Lua 脚本;统一异常处理;快速失败机制。

java 复制代码
@RestController
@RequestMapping("/coupon")
public class CouponController {

    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    // 预加载Lua脚本,避免每次编译
    private final DefaultRedisScript<Long> deductScript;

    // 构造器初始化Lua脚本(启动时只编译一次)
    public CouponController() {
        deductScript = new DefaultRedisScript<>();
        deductScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/coupon_stock_deduct.lua")));
        deductScript.setResultType(Long.class);
    }

    @SentinelResource(value = "grabCoupon", blockHandler = "grabCouponBlockHandler")
    @PostMapping("/grab/{activityId}")
    public ResponseEntity<String> grabCoupon(@PathVariable Long activityId, @RequestParam Long userId) {
        // 1. 快速失败:活动状态校验(本地缓存,避免查DB)
        if (!ActivityCache.isActivityActive(activityId)) {
            return ResponseEntity.ok("活动已结束");
        }

        // 2. 防重领取:SETNX原子锁,过期时间=活动时长
        String userLockKey = "user:" + userId + ":coupon:" + activityId;
        Boolean isFirstGrab = redisTemplate.opsForValue().setIfAbsent(userLockKey, "1", Duration.ofHours(2));
        if (Boolean.FALSE.equals(isFirstGrab)) {
            return ResponseEntity.ok("您已参与过本次活动");
        }

        try {
            // 3. 执行Lua脚本原子扣减库存
            String stockKey = "coupon:" + activityId;
            Long result = redisTemplate.execute(
                deductScript,
                Collections.singletonList(stockKey),
                "batch_001", // 这里简化为单批次,实际可做负载均衡选择批次
                "1",
                userId.toString()
            );

            if (result == null || result == 0) {
                // 库存不足,删除防重锁,允许用户重试
                redisTemplate.delete(userLockKey);
                return ResponseEntity.ok("优惠券已抢完");
            } else if (result == -1) {
                return ResponseEntity.badRequest().body("参数错误");
            }

            // 4. 扣减成功,发送MQ异步生成用户券
            CouponMessage message = new CouponMessage(activityId, userId, "batch_001");
            rocketMQTemplate.convertAndSend("coupon-create-topic", message);

            return ResponseEntity.ok("抢券成功,优惠券将在1-2分钟内到账");
        } catch (Exception e) {
            // 异常回滚:删除防重锁,允许用户重试
            redisTemplate.delete(userLockKey);
            log.error("抢券异常,userId:{}, activityId:{}", userId, activityId, e);
            return ResponseEntity.ok("系统繁忙,请稍后再试");
        }
    }

    // Sentinel限流降级方法
    public ResponseEntity<String> grabCouponBlockHandler(Long activityId, Long userId, BlockException ex) {
        return ResponseEntity.ok("活动太火爆了,请稍后再试");
    }
}

3. MQ 消费者幂等处理(关键兜底 🔒)

技术亮点:数据库唯一索引 + Redis 幂等记录双重保证;死信队列处理失败消息;定时任务对账。

java 复制代码
@Component
@RocketMQMessageListener(topic = "coupon-create-topic", consumerGroup = "coupon-create-consumer-group")
public class CouponCreateConsumer implements RocketMQListener<CouponMessage> {

    @Autowired
    private UserCouponService userCouponService;
    @Autowired
    private StringRedisTemplate redisTemplate;

    @Override
    public void onMessage(CouponMessage message) {
        String idempotentKey = "coupon:idempotent:" + message.getUserId() + ":" + message.getActivityId();
        
        // 1. Redis幂等校验
        if (Boolean.TRUE.equals(redisTemplate.hasKey(idempotentKey))) {
            log.info("重复消息,已处理:{}", message);
            return;
        }

        try {
            // 2. 数据库唯一索引兜底(防止Redis失效)
            userCouponService.createUserCoupon(message);
            
            // 3. 记录幂等标记,过期时间7天
            redisTemplate.opsForValue().set(idempotentKey, "1", Duration.ofDays(7));
        } catch (DuplicateKeyException e) {
            // 唯一索引冲突,说明已处理过
            log.info("重复创建用户券,已跳过:{}", message);
            redisTemplate.opsForValue().set(idempotentKey, "1", Duration.ofDays(7));
        } catch (Exception e) {
            // 其他异常,抛出让MQ重试,超过重试次数进入死信队列
            log.error("创建用户券失败:{}", message, e);
            throw new RuntimeException("创建用户券失败", e);
        }
    }
}

4. Sentinel 热点参数限流配置

技术亮点:针对活动 ID 进行热点限流,保护热点活动不拖垮整个系统;支持动态调整阈值。

java 复制代码
@Configuration
public class SentinelConfig {

    @PostConstruct
    public void initHotParamRules() {
        List<ParamFlowRule> rules = new ArrayList<>();
        ParamFlowRule rule = new ParamFlowRule("grabCoupon")
            .setGrade(RuleConstant.FLOW_GRADE_QPS)
            .setCount(1000) // 全局QPS阈值
            .setParamIdx(0) // 限流第一个参数(activityId)
            .setDurationInSec(1); // 统计窗口1秒

        // 针对超级热点活动单独设置更高阈值
        ParamFlowItem item = new ParamFlowItem()
            .setObject(String.valueOf(10001)) // 活动ID
            .setClassType(String.class.getName())
            .setCount(5000);
        rule.setParamFlowItemList(Collections.singletonList(item));

        rules.add(rule);
        ParamFlowRuleManager.loadRules(rules);
    }
}

5. 热点库存分片------对抗热 Key

java 复制代码
// 初始化:把总库存均分到 10 个分片
int totalStock = 100000;
int shardCount = 10;
for (int i = 0; i < shardCount; i++) {
    redisTemplate.opsForValue().set("coupon:stock:12345:" + i, totalStock / shardCount);
}

// 抢券时随机选片扣减
public Long deductWithShard(String couponId, Long userId) {
    int shard = ThreadLocalRandom.current().nextInt(10);
    String key = "coupon:stock:" + couponId + ":" + shard;
    return redisTemplate.execute(luaScript, Collections.singletonList(key), 1, userId.toString());
}

亮点:随机分片让 Redis 集群压力均匀分散,避免单 key 热点打爆单分片,且 Lua 原子性依然生效。

本场景专属技术难点与解决方案

技术难点 问题本质 影响 最优解决方案 技术亮点
绝对防止超卖 库存 "查 - 判 - 扣" 三个操作非原子性,存在并发间隙 优惠券多发,造成资损 强制使用 Lua 脚本原子执行;禁止任何分步骤操作;数据库最终校验兜底 Lua 脚本原子性;无锁设计,性能极高
避免少卖与数据不一致 Redis 扣减成功但生成券失败;MQ 消息丢失 / 重复 用户抢到券但未到账,体验差;库存浪费 本地事务 + MQ 事务消息;幂等表 + 数据库唯一索引;定时任务每日对账 最终一致性保证;失败自动重试;人工兜底
热点 Key 导致 Redis 集群雪崩 单个活动 Key 流量集中,打垮 Redis 单节点 整个抢券系统瘫痪 1. 分段库存:将总库存分成 N 段,每段独立扣减2. 本地缓存:网关层缓存 "库存为 0" 状态3. 热点探测:自动迁移热点 Key 到专用节点 分段库存可将并发量提升 10-20 倍;无侵入式热点治理
大流量下的系统稳定性 瞬时流量超过系统承载能力,导致服务雪崩 所有请求超时,系统不可用 全链路限流(前端→网关→服务→Redis);熔断降级;随机延迟打散请求;队列削峰 分层防护;快速失败;保护核心系统
恶意刷量与羊毛党攻击 机器批量刷券,真实用户抢不到 活动效果差;品牌受损 1. 图形验证码 / 滑块验证2. IP / 设备 / 用户多维度限流3. 风控系统实时拦截异常行为4. 实名制限制 多层风控;动态调整策略;黑白名单机制
缓存击穿与穿透 活动开始瞬间缓存未预热;无效活动 ID 大量请求 数据库压力骤增 1. 活动前 10-30 分钟预热库存2. 布隆过滤器过滤无效活动 ID3. 热点 Key 永不过期 预热机制;布隆过滤器零成本过滤无效请求
活动结束后数据清理 大量过期数据占用 Redis 内存 Redis 性能下降 1. 设置合理的过期时间2. 活动结束后异步批量清理3. 冷数据归档到数据库 自动过期;批量清理;内存优化

额外加分项(面试拔高)

  1. 分段库存进阶:支持动态调整各段库存,避免某段先卖完导致总库存剩余
  2. 预生成券码:活动前预生成所有券码并存储在 Redis,抢券时直接分配,减少数据库压力
  3. 灰度发布:活动分批次放量,逐步验证系统稳定性
  4. 全链路压测:活动前进行压测,提前发现系统瓶颈
  5. 实时监控:监控库存消耗速度、QPS、成功率、异常率等指标,及时告警

Java 架构师,专注后端技术进阶与生产实战分享。 公众号:Rain的Java大神之路 | 回复「资料」领 Java 架构大礼包

相关推荐
钟智强1 小时前
AWS CloudGoat 实战:一个 SSRF 如何撬动整个云账户
后端
Ai拆代码的曹操1 小时前
@Transactional 写在 private 方法上,事务根本没生效
后端
云技纵横1 小时前
@Async 默认执行器又炸了:SimpleAsyncTaskExecutor 为什么不是线程池?
后端·面试
程序员Agions1 小时前
Fypro 新手快速上手与实战指南
后端
舒心的云朵1 小时前
用三张图片详解Asp.Net 全生命周期
后端·asp.net
卷无止境1 小时前
C++ 类型转换变迁之路:从隐式转换到 static_cast
c++·后端
孝顺的书包1 小时前
用Asp.net写自己的服务框架
后端·asp.net
糊涂的世界1 小时前
IE10、IE11 User-Agent 导致的 ASP.Net 网站无法写入Cookie 问题
后端·asp.net
易协同低代码1 小时前
通达OA配置文件详解
后端