一、核心概念
优惠券防刷是分布式系统安全防护的经典场景,核心考察点:
- 幂等性设计:同一请求多次执行,结果一致(防止重复领取)
- 分布式锁:并发环境下保证操作的原子性
- 防刷策略:识别并拦截恶意请求(频率限制、风控规则)
- 数据一致性:Redis 与 MySQL 的数据同步问题
二、技术难点分析
为什么会被重复刷取?
| 攻击场景 | 产生原因 | 示例 |
|---|---|---|
| 前端重复点击 | 用户快速点击”领取”按钮 | 按钮未置灰,1 秒内点击 10 次 |
| 接口重放攻击 | 抓包后重复发送请求 | Postman 循环发送领取请求 |
| 并发请求 | 多线程同时调用领取接口 | 脚本开 100 个线程并发领取 |
| 缓存击穿 | 缓存失效瞬间,大量请求打到数据库 | Redis 过期,1000 个请求同时查 DB |
| 分布式竞态条件 | 多实例同时判断”未领取” | 实例 A 和 B 同时读取余量为 1 |
三、多层防护方案架构
┌────────────────────────────────────────────┐
│ Layer 1: 前端防护(按钮防抖 + 验证码) │
├────────────────────────────────────────────┤
│ Layer 2: 网关层(IP 限流 + 黑名单) │
├────────────────────────────────────────────┤
│ Layer 3: 应用层(令牌桶 + 幂等性校验) │
├────────────────────────────────────────────┤
│ Layer 4: 缓存层(Redis 锁 + Lua 脚本) │
├────────────────────────────────────────────┤
│ Layer 5: 数据库层(唯一索引 + 乐观锁) │
└────────────────────────────────────────────┘
四、核心技术方案
方案 1:幂等性设计(数据库唯一索引)
CREATE TABLE `user_coupon` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`coupon_id` BIGINT NOT NULL,
`receive_time` DATETIME NOT NULL,
UNIQUE KEY `uk_user_coupon` (`user_id`, `coupon_id`)
) ENGINE=InnoDB;
Redis Set 前置校验:
public boolean receiveCoupon(Long userId, Long couponId) {
String key = "coupon:received:" + couponId;
// Redis Set 判断是否已领取
Boolean isNew = redisTemplate.opsForSet()
.add(key, String.valueOf(userId));
if (Boolean.FALSE.equals(isNew)) {
throw new BizException("您已领取过该优惠券");
}
return true;
}
方案 2:分布式锁(Redisson)
public boolean receiveCoupon(Long userId, Long couponId) {
String lockKey = "lock:coupon:" + couponId + ":" + userId;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
// 1. 校验是否已领取
if (checkReceived(userId, couponId)) {
throw new BizException("已领取");
}
// 2. 扣减库存
if (!deductStock(couponId)) {
throw new BizException("已抢光");
}
// 3. 保存记录
saveUserCoupon(userId, couponId);
return true;
}
} finally {
lock.unlock();
}
}
方案 3:Redis Lua 脚本(原子操作)
String luaScript =
"local stock = redis.call('get', KEYS[1]) " +
"if tonumber(stock) <= 0 then return 0 end " +
"local received = redis.call('sismember', KEYS[2], ARGV[1]) " +
"if received == 1 then return -1 end " +
"redis.call('decr', KEYS[1]) " +
"redis.call('sadd', KEYS[2], ARGV[1]) " +
"return 1";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Arrays.asList("coupon:stock:" + couponId, "coupon:received:" + couponId),
String.valueOf(userId)
);
// result: 1=成功, 0=库存不足, -1=已领取
方案 4:令牌机制(防重放)
// 1. 生成令牌
@GetMapping("/coupon/token")
public String generateToken(@RequestParam Long couponId) {
String token = UUID.randomUUID().toString();
String key = "coupon:token:" + couponId + ":" + token;
redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
return token;
}
// 2. 领取时校验
@PostMapping("/coupon/receive")
public Result receiveCoupon(@RequestParam String token) {
String key = "coupon:token:" + couponId + ":" + token;
Long deleted = redisTemplate.delete(key);
if (deleted == null || deleted == 0) {
throw new BizException("令牌无效或已使用");
}
// 执行领取逻辑...
}
方案 5:风控策略
频率限制
public boolean preHandle(HttpServletRequest request) {
String key = "rate:limit:coupon:" + userId;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
}
if (count > 3) {
throw new BizException("操作过于频繁");
}
return true;
}
用户行为分析
| 风控维度 | 检测规则 | 处置措施 |
|---|---|---|
| IP 维度 | 单 IP 1 分钟超 20 次 | IP 拉黑 1 小时 |
| 设备维度 | 同设备 5+ 账号 | 设备拉黑 |
| 账号维度 | 新注册立即领取 | 需实名认证 |
| 时间维度 | 凌晨 3-5 点大量领取 | 加强验证码 |
五、完整实现流程
@Transactional(rollbackFor = Exception.class)
public Result receiveCoupon(Long userId, Long couponId, String token) {
// 1. 验证令牌
validateToken(token, couponId);
// 2. 风控检查
riskControlService.checkRisk(userId, getIp(), getDeviceId());
// 3. 幂等性校验(Redis Set)
String receivedKey = "coupon:received:" + couponId;
Boolean isNew = redisTemplate.opsForSet()
.add(receivedKey, String.valueOf(userId));
if (Boolean.FALSE.equals(isNew)) {
throw new BizException("您已领取过该优惠券");
}
// 4. Redis Lua 扣减库存
Long result = deductStockByLua(couponId, userId);
if (result <= 0) {
redisTemplate.opsForSet().remove(receivedKey, String.valueOf(userId));
throw new BizException("优惠券已抢光");
}
// 5. 异步写入数据库
asyncSaveUserCoupon(userId, couponId);
return Result.success("领取成功");
}
六、性能优化
| 优化点 | 方案 | 效果 |
|---|---|---|
| 缓存预热 | 活动前 10 分钟预加载库存到 Redis | 避免缓存穿透 |
| 分段库存 | 10000 库存分 100 段,减少锁竞争 | 并发提升 10 倍 |
| 异步化 | 领取记录异步写 DB,先返回成功 | RT 降低 70% |
| CDN 加速 | 静态资源走 CDN | 加载速度提升 3 倍 |
七、常见问题应对
Q1: Redis 与数据库数据不一致?
→ 定时对账 + MQ 异步同步 + 兜底任务
Q2: 分布式锁失效?
→ Redisson 看门狗自动续期
Q3: 如何应对突发流量?
→ 前端排队 + MQ 削峰 + 熔断降级
八、总结
防刷核心:多层防护 + 幂等性 + 分布式锁 + 风控
| 层级 | 技术方案 | 核心作用 |
|---|---|---|
| 前端 | 按钮防抖 + 验证码 | 减少 40% 无效请求 |
| 网关 | IP 限流 + 黑名单 | 拦截恶意 IP |
| 应用 | 令牌 + 幂等性校验 | 防重放、防重复 |
| 缓存 | Redis 锁 + Lua 脚本 | 并发控制、原子扣减 |
| 数据库 | 唯一索引 + 乐观锁 | 最终兜底 |
面试回答要点:
- 先讲原理(幂等性、分布式锁)
- 再讲方案(Redis Lua、令牌机制)
- 补充风控(频率限制、行为分析)
- 量化成果(QPS 提升、错误率降低)