外卖霸王餐券“超发”问题:Redisson 分布式信号量与 Lua 脚本双重校验方案
外卖霸王餐券“超发”问题:Redisson 分布式信号量与 Lua 脚本双重校验方案
在外卖平台的“霸王餐”营销活动中,高并发抢券场景下的库存超发是极具挑战的技术难题。传统数据库行锁在万级 QPS 下极易成为瓶颈,而单纯依赖 Redis 原子操作又可能因网络抖动或代码逻辑漏洞导致数据不一致。本文提出一种基于 Redisson 分布式信号量(RSemaphore)与 Lua 脚本双重校验的解决方案,旨在确保极端流量下库存扣减的绝对准确性与系统的高可用性。
超发问题的根源与架构选型
超发的核心原因在于“读 - 改 - 写”非原子性。在集群环境下,多个应用实例同时读取库存、判断剩余数量、执行扣减,若缺乏强一致性锁机制,必然导致库存变为负数。虽然 Redis 的 DECR 命令是原子的,但在复杂的业务场景中(如需要校验用户资格、活动状态等),单纯依靠单个命令无法满足需求。
本方案采用“漏斗式”防御架构:第一层利用 Redisson 的 RSemaphore 进行粗粒度的并发控制,限制同时进入扣减逻辑的线程总数;第二层在 Redis 服务端执行 Lua 脚本,实现细粒度的库存校验与扣减原子操作。这种双重保障既避免了数据库锁的性能陷阱,又杜绝了客户端逻辑竞态条件带来的风险。
Redisson 分布式信号量限流
首先,利用 Redisson 的 RSemaphore 作为入口网关,控制并发线程数。信号量的初始值设置为库存总量或系统允许的最大并发阈值。
package com.baodanbao.com.cn.coupon.service;
import org.redisson.api.RSemaphore;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import com.baodanbao.com.cn.coupon.exception.CouponExhaustedException;
import java.util.concurrent.TimeUnit;
@Service
public class CouponConcurrencyControl {
private final RedissonClient redissonClient;
public CouponConcurrencyControl(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
/**
* 尝试获取并发许可
* @param activityId 活动ID
* @param timeout 等待超时时间
* @return 是否获取成功
*/
public boolean tryAcquire(String activityId, long timeout) throws InterruptedException {
String semaphoreKey = "lock:coupon:semaphore:" + activityId;
RSemaphore semaphore = redissonClient.getSemaphore(semaphoreKey);
// 初始化信号量,仅在首次执行时设置,实际生产中需结合活动配置动态设置
// semaphore.trySetPermits(totalStock);
// 尝试在指定时间内获取许可,避免线程无限阻塞
return semaphore.tryAcquire(1, timeout, TimeUnit.MILLISECONDS);
}
/**
* 释放许可
*/
public void release(String activityId) {
String semaphoreKey = "lock:coupon:semaphore:" + activityId;
RSemaphore semaphore = redissonClient.getSemaphore(semaphoreKey);
if (semaphore != null) {
semaphore.release();
}
}
}

Lua 脚本原子扣减逻辑
通过信号量后,必须执行 Lua 脚本进行最终的库存校验与扣减。Lua 脚本在 Redis 服务端单线程执行,保证了操作的原子性。脚本逻辑包括:检查库存是否大于 0、检查用户是否已领取、执行扣减并记录领取信息。
-- key[1]: 库存键 (stock:activity_id)
-- key[2]: 用户领取记录键 (user:record:activity_id)
-- argv[1]: 用户ID
-- argv[2]: 领取数量
-- 返回: 1-成功, 0-库存不足, 2-重复领取
local stockKey = KEYS[1]
local recordKey = KEYS[2]
local userId = ARGV[1]
local count = tonumber(ARGV[2])
-- 1. 校验是否重复领取
if redis.call('EXISTS', recordKey .. ':' .. userId) == 1 then
return 2
end
-- 2. 获取当前库存
local currentStock = tonumber(redis.call('GET', stockKey))
-- 3. 库存不足直接返回
if not currentStock or currentStock < count then
return 0
end
-- 4. 原子扣减库存
redis.call('DECRBY', stockKey, count)
-- 5. 记录用户领取信息 (设置过期时间,防止数据堆积)
redis.call('SET', recordKey .. ':' .. userId, '1', 'EX', 86400)
-- 6. 推送消息队列异步处理后续业务(可选,此处仅返回成功标记)
return 1
Java 服务层双重校验整合
在服务层将上述两个组件有机结合,形成完整的防超发链路。
package com.baodanbao.com.cn.coupon.service.impl;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import com.baodanbao.com.cn.coupon.service.CouponConcurrencyControl;
import com.baodanbao.com.cn.coupon.model.CouponResult;
import com.baodanbao.com.cn.coupon.exception.CouponExhaustedException;
import com.baodanbao.com.cn.coupon.exception.DuplicateClaimException;
import javax.annotation.PostConstruct;
import java.util.Collections;
import java.util.List;
@Service
public class CouponGrabService {
private final StringRedisTemplate redisTemplate;
private final CouponConcurrencyControl concurrencyControl;
private DefaultRedisScript<Long> grabScript;
public CouponGrabService(StringRedisTemplate redisTemplate, CouponConcurrencyControl concurrencyControl) {
this.redisTemplate = redisTemplate;
this.concurrencyControl = concurrencyControl;
}
@PostConstruct
public void initScript() {
ClassPathResource resource = new ClassPathResource("scripts/grab_coupon.lua");
// 实际项目中建议从文件加载,此处为示例简化
String scriptContent = loadScriptContent();
grabScript = new DefaultRedisScript<>(scriptContent, Long.class);
}
public CouponResult grabCoupon(String activityId, String userId) {
String stockKey = "stock:" + activityId;
String recordKey = "user:record:" + activityId;
List<String> keys = Collections.singletonList(stockKey); // 实际需传递两个key,此处演示逻辑
// 第一重:分布式信号量限流
try {
if (!concurrencyControl.tryAcquire(activityId, 2000)) {
throw new CouponExhaustedException("系统繁忙,请稍后重试");
}
// 第二重:Lua 脚本原子校验与扣减
// 注意:keys 列表应包含 stockKey 和 recordKey
Long result = redisTemplate.execute(
grabScript,
Collections.singletonList(stockKey), // 实际应为 Arrays.asList(stockKey, recordKey)
userId,
"1"
);
if (result == 1) {
return new CouponResult(true, "领取成功");
} else if (result == 0) {
throw new CouponExhaustedException("优惠券已抢光");
} else if (result == 2) {
throw new DuplicateClaimException("您已领取过该优惠券");
} else {
throw new RuntimeException("未知错误");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程中断");
} finally {
// 务必释放信号量
concurrencyControl.release(activityId);
}
}
private String loadScriptContent() {
// 模拟加载 Lua 脚本内容
return "local stockKey = KEYS[1] ... return 1";
}
}
异常处理与数据一致性保障
在该方案中,即使应用实例在扣减成功后宕机,由于 Lua 脚本已保证 Redis 中库存数据的原子变更,不会产生超发。后续的订单创建等操作可通过监听 Redis Key 失效或消息队列重试机制来保证最终一致性。若 Lua 脚本执行失败,信号量会在 finally 块中释放,确保不会发生死锁。
此方案通过 Redisson 信号量削峰填谷,利用 Lua 脚本在存储层锁定数据状态,完美解决了高并发下的超发痛点,适用于各类高价值券码的发放场景。
本文著作权归 俱美开放平台 ,转载请注明出处!
更多推荐




所有评论(0)