
Hash结构 key=coupon:{activityId}, field={batchId}, value={stock},支持多批次库存管理 活动时长+1小时的过期时间,活动结束自动清理无效数据层级 | 技术方案 | 核心作用 |
|---|---|---|
前端 | 按钮置灰 + 倒计时 + 图形验证码 | 挡住 90% 无效点击和机器刷量 |
网关 | Sentinel 令牌桶限流(按 IP / 用户) | 限制单用户 / IP 每秒请求≤5 次 |
服务 | 随机延迟 100-300ms | 打散集中请求,削平峰值 |
消息队列 | RocketMQ/Kafka 异步化 | 将同步请求转为异步,削峰填谷 |
绝对禁止使用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 -- 扣减成功Redis SETNX存储user:{userId}:coupon:{activityId},过期时间 = 活动时长 常见问题 | 根本原因 | 解决方案 |
|---|---|---|
超卖 | 库存扣减非原子性 | 强制使用 Lua 脚本,禁止分步骤操作 |
少卖 | 扣减成功但生成券失败 | 本地事务 + 消息重试,保证最终一致性 |
缓存击穿 | 热点 key 失效 | 热点 key 永不过期,后台定时更新 |
Redis 雪崩 | 单集群承载所有流量 | 活动 Redis 独立部署,主从 + 哨兵架构 |
某电商平台,双11整点秒杀,10万张优惠券,瞬时流量可能是平常的100倍。
面试官想听的不是你背方案,而是你如何把大问题拆小,一层层解决。我们假定场景:
整个抢券链路,有四个要害:
我们用一张架构全景图来梳理:

👉 “同学你记着,能用前端拦住的流量,千万别让它进后端,这是最经济的削峰。”
使用 Nginx + Lua 或 Spring Cloud Gateway + 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 的扣减操作,完全无锁,无超卖。
Redis 扣减成功后,不直接操作数据库,而是发送一条消息到 RocketMQ / Kafka:

userId + couponId + 活动ID 建唯一索引,即使消息重复消费也不会多发。 如果一张券的 Redis key 成为热点,打到了一台 Redis 分片上,可以用以下手段:
coupon:stock:12345 拆成 10 个 key:coupon:stock:12345:0 ~ 9,每个存 1/10 的库存。请求随机取一个 key 扣减,压力散开。 “同学你看,整个设计从 前端→网关→Redis原子扣减→MQ异步写库,每一层都在干自己最擅长的事,没有单点瓶颈,也不会超卖。你能把这套逻辑流利讲出来,并且提到 Lua 原子性、最终一致性、热 Key 方案,在大多数大厂面试里就已经是 Strong Yes 了 🎉。”
面试官您好,我再补充一下核心可运行代码和本场景专属技术难点及解决方案,这两部分是实际落地和面试考察的重中之重。
技术亮点:一次网络往返完成 "校验→扣减" 全流程,无任何并发间隙;支持多批次库存;自带参数校验,防止非法请求。
-- 文件名: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技术亮点:分层限流 + 防重 + 异步化;使用 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("活动太火爆了,请稍后再试");
}
}技术亮点:数据库唯一索引 + 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);
}
}
}技术亮点:针对活动 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);
}
}// 初始化:把总库存均分到 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 单节点 | 整个抢券系统瘫痪 |
| 分段库存可将并发量提升 10-20 倍;无侵入式热点治理 |
大流量下的系统稳定性 | 瞬时流量超过系统承载能力,导致服务雪崩 | 所有请求超时,系统不可用 | 全链路限流(前端→网关→服务→Redis);熔断降级;随机延迟打散请求;队列削峰 | 分层防护;快速失败;保护核心系统 |
恶意刷量与羊毛党攻击 | 机器批量刷券,真实用户抢不到 | 活动效果差;品牌受损 |
| 多层风控;动态调整策略;黑白名单机制 |
缓存击穿与穿透 | 活动开始瞬间缓存未预热;无效活动 ID 大量请求 | 数据库压力骤增 |
| 预热机制;布隆过滤器零成本过滤无效请求 |
活动结束后数据清理 | 大量过期数据占用 Redis 内存 | Redis 性能下降 |
| 自动过期;批量清理;内存优化 |
Java 架构师,专注后端技术进阶与生产实战分享。
公众号:Rain的Java大神之路 | 回复「资料」领 Java 架构大礼包
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。