高并发下的抢优惠券如何设计
高并发下的抢优惠券如何设计
面试官您好,关于高并发抢优惠券的设计,我会遵循 "先挡流量→再削峰值→原子扣减→兜底保障" 的核心思路,从全链路维度拆解,整体架构如下:
整体架构流程图 📊
核心模块拆解(技术关键点)
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 脚本保证原子性:
-- 原子性库存扣减脚本
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 独立部署,主从 + 哨兵架构 |
进阶优化方向 💡
- 分段库存:将总库存分成 10-20 段,每段单独扣减,大幅提升并发量
- 本地缓存:网关层缓存 "库存为 0" 的活动,避免无效请求穿透到 Redis
- 热点探测:实时监控 Redis 热点 key,自动迁移到专用节点
- 预生成券码:活动前预生成所有券码,抢券时直接分配,减少生成耗时
举个例子 🎯
某电商平台,双11整点秒杀,10万张优惠券,瞬时流量可能是平常的100倍。
面试官想听的不是你背方案,而是你如何把大问题拆小,一层层解决。我们假定场景:
🧠 核心痛点拆解
整个抢券链路,有四个要害:
- 超高并发:大量请求瞬间打到服务器。
- 库存超卖:券只有10万张,绝不能多发。
- 数据库击穿:直接读写MySQL必挂。
- 公平性与体验:黄牛脚本、用户反复刷新。
我们用一张架构全景图来梳理:
🔧 逐层落地实现
1️⃣ 前端:第一时间拦住无效请求
- 按钮置灰 & 防重:点击后立刻变灰,避免用户狂点,减少80%的无效流量。
- 验证码/滑块:在真正高并发场景下,弹出简单滑块,拉长用户请求时间,削峰填谷,还能防机器脚本。
👉 “同学你记着,能用前端拦住的流量,千万别让它进后端,这是最经济的削峰。”
2️⃣ 网关层:令牌桶限流,保护系统
使用 Nginx + Lua 或 Spring Cloud Gateway + Redis 实现令牌桶算法:
- 对整个抢券接口设置 QPS 上限,比如 50000 QPS。
- 超过的请求直接返回“活动太火爆,请稍后再试”,不进入业务层。
令牌桶像一个水库,每秒放入固定令牌,请求取不到令牌就直接拒绝,
保证下游压力恒定。3️⃣ 核心:Redis 原子扣减库存,防超卖
这是整个方案的灵魂。
预热库存到 Redis:活动开始前,把10万张券的数量 SET 进 Redis:
SET coupon:stock:12345 100000扣减逻辑必须用 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 -- 库存不足
endJava 侧调用:
Long result = redisTemplate.execute(
luaScript,
Collections.singletonList("coupon:stock:12345"));
if (result == 1) {
// 扣减成功,发消息异步发券
}✨ 亮点:单机 Redis 可轻松抗住 10w+ QPS 的扣减操作,完全无锁,无超卖。
4️⃣ 异步发券 & 削峰填谷
Redis 扣减成功后,不直接操作数据库,而是发送一条消息到 RocketMQ / Kafka:
- 削峰: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 脚本(最核心亮点 ✨)
技术亮点:一次网络往返完成 "校验→扣减" 全流程,无任何并发间隙;支持多批次库存;自带参数校验,防止非法请求。
-- 文件名: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 12. Spring Boot 抢券核心接口
技术亮点:分层限流 + 防重 + 异步化;使用 RedisTemplate 执行 Lua 脚本;统一异常处理;快速失败机制。
@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 幂等记录双重保证;死信队列处理失败消息;定时任务对账。
@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 进行热点限流,保护热点活动不拖垮整个系统;支持动态调整阈值。
@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
// 初始化:把总库存均分到 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. 布隆过滤器过滤无效活动 ID 3. 热点 Key 永不过期 | 预热机制;布隆过滤器零成本过滤无效请求 |
| 活动结束后数据清理 | 大量过期数据占用 Redis 内存 | Redis 性能下降 | 1. 设置合理的过期时间 2. 活动结束后异步批量清理 3. 冷数据归档到数据库 | 自动过期;批量清理;内存优化 |
额外加分项(面试拔高)
- 分段库存进阶:支持动态调整各段库存,避免某段先卖完导致总库存剩余
- 预生成券码:活动前预生成所有券码并存储在 Redis,抢券时直接分配,减少数据库压力
- 灰度发布:活动分批次放量,逐步验证系统稳定性
- 全链路压测:活动前进行压测,提前发现系统瓶颈
- 实时监控:监控库存消耗速度、QPS、成功率、异常率等指标,及时告警
