一、本章诉求
当通过抽奖策略计算出用户可获得的奖品 ID 后,接下来需要对这条奖品记录进行库存扣减。只有奖品库存扣减成功,用户才能获得该奖品 ID 对应的奖品;如果扣减失败,则走兜底奖品流程。
除此之外,本节还会对上一节实现的规则树节点进行完善,包括次数锁和兜底奖品节点。
二、流程设计
对于库存集中扣减类业务,不能直接依赖数据库表来承载高并发请求。
比如数据库表中有一条库存记录,如果通过锁定这条记录的方式,把库存从 10 更新为 9,再从 9 更新为 8,就会导致大量用户在应用获取数据库连接后,等待前一个用户更新完库存并释放锁,后一个用户才能继续扣减。
随着用户参与量增加,会有越来越多用户处于等待状态。而这些等待中的用户请求会持续占用数据库连接资源。数据库连接非常宝贵,一旦被大量等待请求占满,应用中的其他请求也无法正常进入,最终可能导致一个请求需要几分钟才能响应。前台用户越着急,就越容易频繁点击,系统也会越来越卡,直到崩溃。
所以,对于这类秒杀场景,一般会使用 Redis 缓存来处理库存,只要保证不超卖即可。但也要注意,不要用一条 key 加锁并等待释放的方式来处理库存,否则效率依然很低。因此,我们要尽可能分摊竞争,向无锁化方向设计,这也是分布式架构设计中的核心思路。

- 如果在前面章节所实现的规则树中,对于库存节点的操作,开发 decr 方式扣减库存。decr 是原子操作,效率非常高。这样要注意,setnx 加锁是一种兜底手段,避免后续有库存的恢复,导致库存从96消耗后又回到了98重复消费。所以对于每个key加锁,98、97、96... 即使有恢复库存也不会导致超卖。【setnx 在 redisson 是用 trySet 实现】
- 库存消耗完以后,还需要更新库表的数据量。但这会也不能随着用户消耗奖品库存的速率,对数据库表执行扣减操作。所以这里可以通过 Redisson 延迟队列的 + 定时任务的凡是,缓慢消耗队列数据来更新库表数据变化。
三、功能实现
1. 工程结构

如图为本节主要涉及的工程中代码的改造部分。
- 承接上一节规则树的使用,本节完善规则树节点的逻辑。包括;次数锁、兜底和奖品库存的处理。奖品库存的处理是大头。
- 奖品库存处理,就会涉及从 redis 缓存读取数据做 decr 扣减操作。而这部分缓存的数据,要放到装配处理里,事先做好数据的装配操作。
- 最后一步就是新增 IRaffleStock 接口,处理扣减库存结束后,写到到 redis 队列中的库存消耗数据,再由 trigger 中定时任务扫描获取 redis 队列数据,从而缓慢更新库表数据。
2. 抽奖模板主流程正式升级
java
/**
* @author Fuzhengwei bugstack.cn @小傅哥
* @description 抽奖策略抽象类,定义抽奖的标准流程
* @create 2024-01-06 09:26
*/
@Slf4j
public abstract class AbstractRaffleStrategy implements IRaffleStrategy, IRaffleStock {
// 策略仓储服务 -> domain层像一个大厨,仓储层提供米面粮油
protected IStrategyRepository repository;
// 策略调度服务 -> 只负责抽奖处理,通过新增接口的方式,隔离职责,不需要使用方关心或者调用抽奖的初始化
protected IStrategyDispatch strategyDispatch;
// 抽奖的责任链 -> 从抽奖的规则中,解耦出前置规则为责任链处理
protected final DefaultChainFactory defaultChainFactory;
// 抽奖的决策树 -> 负责抽奖中到抽奖后的规则过滤,如抽奖到A奖品ID,之后要做次数的判断和库存的扣减等。
protected final DefaultTreeFactory defaultTreeFactory;
public AbstractRaffleStrategy(IStrategyRepository repository, IStrategyDispatch strategyDispatch, DefaultChainFactory defaultChainFactory, DefaultTreeFactory defaultTreeFactory) {
this.repository = repository;
this.strategyDispatch = strategyDispatch;
this.defaultChainFactory = defaultChainFactory;
this.defaultTreeFactory = defaultTreeFactory;
}
@Override
public RaffleAwardEntity performRaffle(RaffleFactorEntity raffleFactorEntity) {
// 1. 参数校验
String userId = raffleFactorEntity.getUserId();
Long strategyId = raffleFactorEntity.getStrategyId();
if (null == strategyId || StringUtils.isBlank(userId)) {
throw new AppException(ResponseCode.ILLEGAL_PARAMETER.getCode(), ResponseCode.ILLEGAL_PARAMETER.getInfo());
}
// 2. 责任链抽奖计算【这步拿到的是初步的抽奖ID,之后需要根据ID处理抽奖】注意;黑名单、权重等非默认抽奖的直接返回抽奖结果
DefaultChainFactory.StrategyAwardVO chainStrategyAwardVO = raffleLogicChain(userId, strategyId);
log.info("抽奖策略计算-责任链 {} {} {} {}", userId, strategyId, chainStrategyAwardVO.getAwardId(), chainStrategyAwardVO.getLogicModel());
if (!DefaultChainFactory.LogicModel.RULE_DEFAULT.getCode().equals(chainStrategyAwardVO.getLogicModel())) {
return RaffleAwardEntity.builder()
.awardId(chainStrategyAwardVO.getAwardId())
.build();
}
// 3. 规则树抽奖过滤【奖品ID,会根据抽奖次数判断、库存判断、兜底兜里返回最终的可获得奖品信息】
DefaultTreeFactory.StrategyAwardVO treeStrategyAwardVO = raffleLogicTree(userId, strategyId, chainStrategyAwardVO.getAwardId());
log.info("抽奖策略计算-规则树 {} {} {} {}", userId, strategyId, treeStrategyAwardVO.getAwardId(), treeStrategyAwardVO.getAwardRuleValue());
// 4. 返回抽奖结果
return RaffleAwardEntity.builder()
.awardId(treeStrategyAwardVO.getAwardId())
.awardConfig(treeStrategyAwardVO.getAwardRuleValue())
.build();
}
/**
* 抽奖计算,责任链抽象方法
*
* @param userId 用户ID
* @param strategyId 策略ID
* @return 奖品ID
*/
public abstract DefaultChainFactory.StrategyAwardVO raffleLogicChain(String userId, Long strategyId);
/**
* 抽奖结果过滤,决策树抽象方法
*
* @param userId 用户ID
* @param strategyId 策略ID
* @param awardId 奖品ID
* @return 过滤结果【奖品ID,会根据抽奖次数判断、库存判断、兜底兜里返回最终的可获得奖品信息】
*/
public abstract DefaultTreeFactory.StrategyAwardVO raffleLogicTree(String userId, Long strategyId, Integer awardId);
}
这个类本身流程没大改,但它多实现了一个接口:
IRaffleStock
/**
* @author Fuzhengwei bugstack.cn @小傅哥
* @description 抽奖库存相关服务,获取库存消耗队列
* @create 2024-02-09 12:17
*/
public interface IRaffleStock {
/**
* 获取奖品库存消耗队列
*
* @return 奖品库存Key信息
* @throws InterruptedException 异常
*/
StrategyAwardStockKeyVO takeQueueValue() throws InterruptedException;
/**
* 更新奖品库存消耗记录
*
* @param strategyId 策略ID
* @param awardId 奖品ID
*/
void updateStrategyAwardStock(Long strategyId, Integer awardId);
}
这个变化说明抽奖策略服务除了执行抽奖,也开始对外暴露"库存消费队列"和"库存落库更新"的能力。
新增的接口是 IRaffleStock.java,里面定义了两个动作:
- takeQueueValue():从库存消费队列里取出待落库的奖品库存记录
- updateStrategyAwardStock(...):把库存消耗同步更新到数据库
这两个方法不是给用户抽奖接口直接用的,而是给后面的定时任务消费库存队列用的
3.默认抽奖策略把库存接口委托给仓储
java
/**
* @author Fuzhengwei bugstack.cn @小傅哥
* @description 默认的抽奖策略实现
* @create 2024-01-06 11:46
*/
@Slf4j
@Service
public class DefaultRaffleStrategy extends AbstractRaffleStrategy {
public DefaultRaffleStrategy(IStrategyRepository repository, IStrategyDispatch strategyDispatch, DefaultChainFactory defaultChainFactory, DefaultTreeFactory defaultTreeFactory) {
super(repository, strategyDispatch, defaultChainFactory, defaultTreeFactory);
}
@Override
public DefaultChainFactory.StrategyAwardVO raffleLogicChain(String userId, Long strategyId) {
ILogicChain logicChain = defaultChainFactory.openLogicChain(strategyId);
return logicChain.logic(userId, strategyId);
}
@Override
public DefaultTreeFactory.StrategyAwardVO raffleLogicTree(String userId, Long strategyId, Integer awardId) {
StrategyAwardRuleModelVO strategyAwardRuleModelVO = repository.queryStrategyAwardRuleModelVO(strategyId, awardId);
if (null == strategyAwardRuleModelVO) {
return DefaultTreeFactory.StrategyAwardVO.builder().awardId(awardId).build();
}
RuleTreeVO ruleTreeVO = repository.queryRuleTreeVOByTreeId(strategyAwardRuleModelVO.getRuleModels());
if (null == ruleTreeVO) {
throw new RuntimeException("存在抽奖策略配置的规则模型 Key,未在库表 rule_tree、rule_tree_node、rule_tree_line 配置对应的规则树信息 " + strategyAwardRuleModelVO.getRuleModels());
}
IDecisionTreeEngine treeEngine = defaultTreeFactory.openLogicTree(ruleTreeVO);
return treeEngine.process(userId, strategyId, awardId);
}
@Override
public StrategyAwardStockKeyVO takeQueueValue() throws InterruptedException {
return repository.takeQueueValue();
}
@Override
public void updateStrategyAwardStock(Long strategyId, Integer awardId) {
repository.updateStrategyAwardStock(strategyId, awardId);
}
}
这个类新增了两个实现:
它本身不处理库存细节,只是把请求委托
java
takeQueueValue()
updateStrategyAwardStock(...)
给 repository
这符合当前分层:
- 领域服务定义库存行为
- 仓储层负责 Redis 队列和数据库更新
- 定时任务通过领域接口调用,不直接碰底层仓储实现
这样 trigger 模块里的任务只依赖 IRaffleStock,不会直接绑死到基础设施层。
4.策略装配时预热奖品库存到 Redis
java
/**
* @author Fuzhengwei bugstack.cn @小傅哥
* @description 策略装配库(兵工厂),负责初始化策略计算
* @create 2023-12-23 10:02
*/
@Slf4j
@Service
public class StrategyArmoryDispatch implements IStrategyArmory, IStrategyDispatch {
@Resource
private IStrategyRepository repository;
private final SecureRandom secureRandom = new SecureRandom();
@Override
public boolean assembleLotteryStrategy(Long strategyId) {
// 1. 查询策略配置
List<StrategyAwardEntity> strategyAwardEntities = repository.queryStrategyAwardList(strategyId);
// 2 缓存奖品库存【用于decr扣减库存使用】
for (StrategyAwardEntity strategyAward : strategyAwardEntities) {
Integer awardId = strategyAward.getAwardId();
Integer awardCount = strategyAward.getAwardCount();
cacheStrategyAwardCount(strategyId, awardId, awardCount);
}
// 3.1 默认装配配置【全量抽奖概率】
assembleLotteryStrategy(String.valueOf(strategyId), strategyAwardEntities);
// 3.2 权重策略配置 - 适用于 rule_weight 权重规则配置【4000:102,103,104,105 5000:102,103,104,105,106,107 6000:102,103,104,105,106,107,108,109】
StrategyEntity strategyEntity = repository.queryStrategyEntityByStrategyId(strategyId);
String ruleWeight = strategyEntity.getRuleWeight();
if (null == ruleWeight) return true;
StrategyRuleEntity strategyRuleEntity = repository.queryStrategyRule(strategyId, ruleWeight);
// 业务异常,策略规则中 rule_weight 权重规则已适用但未配置
if (null == strategyRuleEntity) {
throw new AppException(ResponseCode.STRATEGY_RULE_WEIGHT_IS_NULL.getCode(), ResponseCode.STRATEGY_RULE_WEIGHT_IS_NULL.getInfo());
}
Map<String, List<Integer>> ruleWeightValueMap = strategyRuleEntity.getRuleWeightValues();
for (String key : ruleWeightValueMap.keySet()) {
List<Integer> ruleWeightValues = ruleWeightValueMap.get(key);
ArrayList<StrategyAwardEntity> strategyAwardEntitiesClone = new ArrayList<>(strategyAwardEntities);
strategyAwardEntitiesClone.removeIf(entity -> !ruleWeightValues.contains(entity.getAwardId()));
assembleLotteryStrategy(String.valueOf(strategyId).concat(Constants.UNDERLINE).concat(key), strategyAwardEntitiesClone);
}
return true;
}
/**
* 计算公式;
* 1. 找到范围内最小的概率值,比如 0.1、0.02、0.003,需要找到的值是 0.003
* 2. 基于1找到的最小值,0.003 就可以计算出百分比、千分比的整数值。这里就是1000
* 3. 那么「概率 * 1000」分别占比100个、20个、3个,总计是123个
* 4. 后续的抽奖就用123作为随机数的范围值,生成的值100个都是0.1概率的奖品、20个是概率0.02的奖品、最后是3个是0.003的奖品。
*/
private void assembleLotteryStrategy(String key, List<StrategyAwardEntity> strategyAwardEntities) {
// 1. 获取最小概率值
BigDecimal minAwardRate = strategyAwardEntities.stream()
.map(StrategyAwardEntity::getAwardRate)
.min(BigDecimal::compareTo)
.orElse(BigDecimal.ZERO);
// 2. 循环计算找到概率范围值
BigDecimal rateRange = BigDecimal.valueOf(convert(minAwardRate.doubleValue()));
// 3. 生成策略奖品概率查找表「这里指需要在list集合中,存放上对应的奖品占位即可,占位越多等于概率越高」
List<Integer> strategyAwardSearchRateTables = new ArrayList<>(rateRange.intValue());
for (StrategyAwardEntity strategyAward : strategyAwardEntities) {
Integer awardId = strategyAward.getAwardId();
BigDecimal awardRate = strategyAward.getAwardRate();
// 计算出每个概率值需要存放到查找表的数量,循环填充
for (int i = 0; i < rateRange.multiply(awardRate).intValue(); i++) {
strategyAwardSearchRateTables.add(awardId);
}
}
// 4. 对存储的奖品进行乱序操作
Collections.shuffle(strategyAwardSearchRateTables);
// 5. 生成出Map集合,key值,对应的就是后续的概率值。通过概率来获得对应的奖品ID
Map<Integer, Integer> shuffleStrategyAwardSearchRateTable = new LinkedHashMap<>();
for (int i = 0; i < strategyAwardSearchRateTables.size(); i++) {
shuffleStrategyAwardSearchRateTable.put(i, strategyAwardSearchRateTables.get(i));
}
// 6. 存放到 Redis
repository.storeStrategyAwardSearchRateTable(key, shuffleStrategyAwardSearchRateTable.size(), shuffleStrategyAwardSearchRateTable);
}
/**
* 转换计算,只根据小数位来计算。如【0.01返回100】、【0.009返回1000】、【0.0018返回10000】
*/
private double convert(double min) {
double current = min;
double max = 1;
while (current < 1) {
current = current * 10;
max = max * 10;
}
return max;
}
/**
* 缓存奖品库存到Redis
*
* @param strategyId 策略ID
* @param awardId 奖品ID
* @param awardCount 奖品库存
*/
private void cacheStrategyAwardCount(Long strategyId, Integer awardId, Integer awardCount) {
String cacheKey = Constants.RedisKey.STRATEGY_AWARD_COUNT_KEY + strategyId + Constants.UNDERLINE + awardId;
repository.cacheStrategyAwardCount(cacheKey, awardCount);
}
@Override
public Integer getRandomAwardId(Long strategyId) {
// 分布式部署下,不一定为当前应用做的策略装配。也就是值不一定会保存到本应用,而是分布式应用,所以需要从 Redis 中获取。
int rateRange = repository.getRateRange(strategyId);
// 通过生成的随机值,获取概率值奖品查找表的结果
return repository.getStrategyAwardAssemble(String.valueOf(strategyId), secureRandom.nextInt(rateRange));
}
@Override
public Integer getRandomAwardId(Long strategyId, String ruleWeightValue) {
String key = String.valueOf(strategyId).concat(Constants.UNDERLINE).concat(ruleWeightValue);
return getRandomAwardId(key);
}
@Override
public Integer getRandomAwardId(String key) {
// 分布式部署下,不一定为当前应用做的策略装配。也就是值不一定会保存到本应用,而是分布式应用,所以需要从 Redis 中获取。
int rateRange = repository.getRateRange(key);
// 通过生成的随机值,获取概率值奖品查找表的结果
return repository.getStrategyAwardAssemble(key, secureRandom.nextInt(rateRange));
}
@Override
public Boolean subtractionAwardStock(Long strategyId, Integer awardId) {
String cacheKey = Constants.RedisKey.STRATEGY_AWARD_COUNT_KEY + strategyId + Constants.UNDERLINE + awardId;
return repository.subtractionAwardStock(cacheKey);
}
}
这一节在策略装配时新增了库存预热逻辑。
原来 assembleLotteryStrategy(...) 主要做两件事:
- 装配默认概率表
- 装配权重概率表
现在在装配概率表之前,会先遍历当前策略下的奖品,把每个奖品库存缓存到 Redis。
缓存 key 形如:
java
strategy_award_count_key_{strategyId}_{awardId}
比如:
java
strategy_award_count_key_100006_101
这一步很关键,因为后续抽奖扣库存不直接打数据库,而是先扣 Redis 缓存库存。
也就是说,Redis 成了高并发抽奖时的第一道库存闸门。
另外这里还把 SecureRandom提成了成员变量,避免每次抽奖都重复 new 一个随机数对象,属于顺手的小优化。
5.抽奖调度接口新增扣减库存能力
java
/**
* @author Fuzhengwei bugstack.cn @小傅哥
* @description 策略抽奖调度
* @create 2023-12-31 15:15
*/
public interface IStrategyDispatch {
/**
* 获取抽奖策略装配的随机结果
*
* @param strategyId 策略ID
* @return 抽奖结果
*/
Integer getRandomAwardId(Long strategyId);
/**
* 获取抽奖策略装配的随机结果
*
* @param strategyId 权重ID
* @return 抽奖结果
*/
Integer getRandomAwardId(Long strategyId, String ruleWeightValue);
/**
* 获取抽奖策略装配的随机结果
*
* @param key = strategyId + _ + ruleWeightValue;
* @return 抽奖结果
*/
Integer getRandomAwardId(String key);
/**
* 根据策略ID和奖品ID,扣减奖品缓存库存
*
* @param strategyId 策略ID
* @param awardId 奖品ID
* @return 扣减结果
*/
Boolean subtractionAwardStock(Long strategyId, Integer awardId);
}
新增了:
java
Boolean subtractionAwardStock(Long strategyId, Integer awardId)
这个方法是给规则树库存节点调用的
也就是说,规则树节点不需要知道 Redis key 怎么拼,也不需要知道库存怎么扣,只需要调用调度服务:
java
strategyDispatch.subtractionAwardStock(strategyId, awardId)
这样库存扣减能力被收口到 StrategyArmoryDispatch,规则节点只关注业务判断。
6.规则树节点可以拿到节点配置值

上一节的树节点方法只有:
logic(userId, strategyId, awardId)
这一节改成:
logic(userId, strategyId, awardId, ruleValue)
也就是说,决策树引擎在执行节点时,会把当前节点配置的 ruleValue 一起传进去。
这一步非常重要。因为第 8、9 节里很多节点还是写死的:
- 次数锁写死放行
- 兜底奖品写死 101
- 库存节点只是占位
现在节点能拿到自己的配置值后,业务逻辑就可以真正根据配置执行。
7.次数锁节点从占位变成真实判断
java
/**
* @author Fuzhengwei bugstack.cn @小傅哥
* @description 次数锁节点
* @create 2024-01-27 11:22
*/
@Slf4j
@Component("rule_lock")
public class RuleLockLogicTreeNode implements ILogicTreeNode {
// 用户抽奖次数,后续完成这部分流程开发的时候,从数据库/Redis中读取
private Long userRaffleCount = 10L;
@Override
public DefaultTreeFactory.TreeActionEntity logic(String userId, Long strategyId, Integer awardId, String ruleValue) {
log.info("规则过滤-次数锁 userId:{} strategyId:{} awardId:{}", userId, strategyId, awardId);
long raffleCount = 0L;
try {
raffleCount = Long.parseLong(ruleValue);
} catch (Exception e) {
throw new RuntimeException("规则过滤-次数锁异常 ruleValue: " + ruleValue + " 配置不正确");
}
// 用户抽奖次数大于规则限定值,规则放行
if (userRaffleCount >= raffleCount) {
return DefaultTreeFactory.TreeActionEntity.builder()
.ruleLogicCheckType(RuleLogicCheckTypeVO.ALLOW)
.build();
}
// 用户抽奖次数小于规则限定值,规则拦截
return DefaultTreeFactory.TreeActionEntity.builder()
.ruleLogicCheckType(RuleLogicCheckTypeVO.TAKE_OVER)
.build();
}
}
上一节 rule_lock 节点直接返回 ALLOW,更像是为了测试规则树流转。
这一节它开始解析 ruleValue:
ruleValue = "1"
含义是:用户抽奖次数达到 1 次后才解锁。
节点逻辑变成:
- 把 ruleValue 转成需要达到的抽奖次数
- 用当前用户抽奖次数 userRaffleCount 做比较
- 满足次数则返回 ALLOW
- 不满足则返回 TAKE_OVER
虽然这里的 userRaffleCount 还是 mock 写死的,但结构已经对了:
次数锁节点开始真正根据规则配置做判断
8.兜底奖励节点从写死奖品变成解析配置
java
/**
* @author Fuzhengwei bugstack.cn @小傅哥
* @description 兜底奖励节点
* @create 2024-01-27 11:23
*/
@Slf4j
@Component("rule_luck_award")
public class RuleLuckAwardLogicTreeNode implements ILogicTreeNode {
@Override
public DefaultTreeFactory.TreeActionEntity logic(String userId, Long strategyId, Integer awardId, String ruleValue) {
log.info("规则过滤-兜底奖品 userId:{} strategyId:{} awardId:{} ruleValue:{}", userId, strategyId, awardId, ruleValue);
String[] split = ruleValue.split(Constants.COLON);
if (split.length == 0) {
log.error("规则过滤-兜底奖品,兜底奖品未配置告警 userId:{} strategyId:{} awardId:{}", userId, strategyId, awardId);
throw new RuntimeException("兜底奖品未配置 " + ruleValue);
}
// 兜底奖励配置
Integer luckAwardId = Integer.valueOf(split[0]);
String awardRuleValue = split.length > 1 ? split[1] : "";
// 返回兜底奖品
log.info("规则过滤-兜底奖品 userId:{} strategyId:{} awardId:{} awardRuleValue:{}", userId, strategyId, luckAwardId, awardRuleValue);
return DefaultTreeFactory.TreeActionEntity.builder()
.ruleLogicCheckType(RuleLogicCheckTypeVO.TAKE_OVER)
.strategyAwardVO(DefaultTreeFactory.StrategyAwardVO.builder()
.awardId(luckAwardId)
.awardRuleValue(awardRuleValue)
.build())
.build();
}
}
上一节兜底奖励节点写死返回:
awardId = 101 awardRuleValue = "1,100"
这一节改成从 ruleValue 解析。
SQL 中兜底节点配置也从:
1,100
改成了:
101:1,100
含义是:
- 101:兜底奖品 ID
- 1,100:兜底奖品规则配置
所以兜底节点现在会:
- 按冒号拆分 ruleValue
- 拿到兜底奖品 ID
- 拿到奖品规则值
- 返回 StrategyAwardVO
这样兜底节点不再写死奖品,开始由数据库配置驱动。
9.库存节点实现真实扣减
java
/**
* @author Fuzhengwei bugstack.cn @小傅哥
* @description 库存扣减节点
* @create 2024-01-27 11:25
*/
@Slf4j
@Component("rule_stock")
public class RuleStockLogicTreeNode implements ILogicTreeNode {
@Resource
private IStrategyDispatch strategyDispatch;
@Resource
private IStrategyRepository strategyRepository;
@Override
public DefaultTreeFactory.TreeActionEntity logic(String userId, Long strategyId, Integer awardId, String ruleValue) {
log.info("规则过滤-库存扣减 userId:{} strategyId:{} awardId:{}", userId, strategyId, awardId);
// 扣减库存
Boolean status = strategyDispatch.subtractionAwardStock(strategyId, awardId);
// true;库存扣减成功,TAKE_OVER 规则节点接管,返回奖品ID,奖品规则配置
if (status) {
log.info("规则过滤-库存扣减-成功 userId:{} strategyId:{} awardId:{}", userId, strategyId, awardId);
// 写入延迟队列,延迟消费更新数据库记录。【在trigger的job;UpdateAwardStockJob 下消费队列,更新数据库记录】
strategyRepository.awardStockConsumeSendQueue(StrategyAwardStockKeyVO.builder()
.strategyId(strategyId)
.awardId(awardId)
.build());
return DefaultTreeFactory.TreeActionEntity.builder()
.ruleLogicCheckType(RuleLogicCheckTypeVO.TAKE_OVER)
.strategyAwardVO(DefaultTreeFactory.StrategyAwardVO.builder()
.awardId(awardId)
.awardRuleValue(ruleValue)
.build())
.build();
}
// 如果库存不足,则直接返回放行
log.warn("规则过滤-库存扣减-告警,库存不足。userId:{} strategyId:{} awardId:{}", userId, strategyId, awardId);
return DefaultTreeFactory.TreeActionEntity.builder()
.ruleLogicCheckType(RuleLogicCheckTypeVO.ALLOW)
.build();
}
}
这是第 10 节最核心的类。
上一节库存节点只是占位,直接返回 TAKE_OVER。
这一节它开始做真正的库存扣减。
java
@Override
public Boolean subtractionAwardStock(String cacheKey) {
long surplus = redisService.decr(cacheKey);
if (surplus < 0) {
// 库存小于0,恢复为0个
redisService.setValue(cacheKey, 0);
return false;
}
// 1. 按照cacheKey decr 后的值,如 99、98、97 和 key 组成为库存锁的key进行使用。
// 2. 加锁为了兜底,如果后续有恢复库存,手动处理等,也不会超卖。因为所有的可用库存key,都被加锁了。
String lockKey = cacheKey + Constants.UNDERLINE + surplus;
Boolean lock = redisService.setNx(lockKey);
if (!lock) {
log.info("策略奖品库存加锁失败 {}", lockKey);
}
return lock;
}
执行逻辑是:
- 调用 strategyDispatch.subtractionAwardStock(strategyId, awardId) 扣减 Redis 库存
- 如果扣减成功,说明当前奖品库存还有余量
- 成功后把 strategyId + awardId 写入库存消费延迟队列
- 返回 TAKE_OVER,并携带当前中奖奖品 ID 和规则值
- 如果扣减失败,说明库存不足
- 返回 ALLOW,让决策树继续沿着库存不足的路径往下走,一般会流向兜底奖励节点
这个设计很有意思:
- 库存成功:节点接管,当前奖品可以发
- 库存失败:节点放行,让树继续走兜底分支
也就是说,ALLOW 在这里不是"中奖成功",而是"当前节点不接管,继续找下一条规则路径"。
10.Redis 库存扣减和库存位锁
java
package cn.bugstack.infrastructure.persistent.repository;
import cn.bugstack.domain.strategy.model.entity.StrategyAwardEntity;
import cn.bugstack.domain.strategy.model.entity.StrategyEntity;
import cn.bugstack.domain.strategy.model.entity.StrategyRuleEntity;
import cn.bugstack.domain.strategy.model.valobj.*;
import cn.bugstack.domain.strategy.repository.IStrategyRepository;
import cn.bugstack.infrastructure.persistent.dao.*;
import cn.bugstack.infrastructure.persistent.po.*;
import cn.bugstack.infrastructure.persistent.redis.IRedisService;
import cn.bugstack.types.common.Constants;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBlockingQueue;
import org.redisson.api.RDelayedQueue;
import org.springframework.stereotype.Repository;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* @author Fuzhengwei bugstack.cn @小傅哥
* @description 策略服务仓储实现
* @create 2023-12-23 10:33
*/
@Slf4j
@Repository
public class StrategyRepository implements IStrategyRepository {
@Resource
private IStrategyDao strategyDao;
@Resource
private IStrategyRuleDao strategyRuleDao;
@Resource
private IStrategyAwardDao strategyAwardDao;
@Resource
private IRedisService redisService;
@Resource
private IRuleTreeDao ruleTreeDao;
@Resource
private IRuleTreeNodeDao ruleTreeNodeDao;
@Resource
private IRuleTreeNodeLineDao ruleTreeNodeLineDao;
@Override
public List<StrategyAwardEntity> queryStrategyAwardList(Long strategyId) {
// 优先从缓存获取
String cacheKey = Constants.RedisKey.STRATEGY_AWARD_KEY + strategyId;
List<StrategyAwardEntity> strategyAwardEntities = redisService.getValue(cacheKey);
if (null != strategyAwardEntities && !strategyAwardEntities.isEmpty()) return strategyAwardEntities;
// 从库中获取数据
List<StrategyAward> strategyAwards = strategyAwardDao.queryStrategyAwardListByStrategyId(strategyId);
strategyAwardEntities = new ArrayList<>(strategyAwards.size());
for (StrategyAward strategyAward : strategyAwards) {
StrategyAwardEntity strategyAwardEntity = StrategyAwardEntity.builder()
.strategyId(strategyAward.getStrategyId())
.awardId(strategyAward.getAwardId())
.awardCount(strategyAward.getAwardCount())
.awardCountSurplus(strategyAward.getAwardCountSurplus())
.awardRate(strategyAward.getAwardRate())
.build();
strategyAwardEntities.add(strategyAwardEntity);
}
redisService.setValue(cacheKey, strategyAwardEntities);
return strategyAwardEntities;
}
@Override
public void storeStrategyAwardSearchRateTable(String key, Integer rateRange, Map<Integer, Integer> strategyAwardSearchRateTable) {
// 1. 存储抽奖策略范围值,如10000,用于生成1000以内的随机数
redisService.setValue(Constants.RedisKey.STRATEGY_RATE_RANGE_KEY + key, rateRange);
// 2. 存储概率查找表
Map<Integer, Integer> cacheRateTable = redisService.getMap(Constants.RedisKey.STRATEGY_RATE_TABLE_KEY + key);
cacheRateTable.putAll(strategyAwardSearchRateTable);
}
@Override
public Integer getStrategyAwardAssemble(String key, Integer rateKey) {
return redisService.getFromMap(Constants.RedisKey.STRATEGY_RATE_TABLE_KEY + key, rateKey);
}
@Override
public int getRateRange(Long strategyId) {
return getRateRange(String.valueOf(strategyId));
}
@Override
public int getRateRange(String key) {
return redisService.getValue(Constants.RedisKey.STRATEGY_RATE_RANGE_KEY + key);
}
@Override
public StrategyEntity queryStrategyEntityByStrategyId(Long strategyId) {
// 优先从缓存获取
String cacheKey = Constants.RedisKey.STRATEGY_KEY + strategyId;
StrategyEntity strategyEntity = redisService.getValue(cacheKey);
if (null != strategyEntity) return strategyEntity;
Strategy strategy = strategyDao.queryStrategyByStrategyId(strategyId);
if (null == strategy) return StrategyEntity.builder().build();
strategyEntity = StrategyEntity.builder()
.strategyId(strategy.getStrategyId())
.strategyDesc(strategy.getStrategyDesc())
.ruleModels(strategy.getRuleModels())
.build();
redisService.setValue(cacheKey, strategyEntity);
return strategyEntity;
}
@Override
public StrategyRuleEntity queryStrategyRule(Long strategyId, String ruleModel) {
StrategyRule strategyRuleReq = new StrategyRule();
strategyRuleReq.setStrategyId(strategyId);
strategyRuleReq.setRuleModel(ruleModel);
StrategyRule strategyRuleRes = strategyRuleDao.queryStrategyRule(strategyRuleReq);
if (null == strategyRuleRes) return null;
return StrategyRuleEntity.builder()
.strategyId(strategyRuleRes.getStrategyId())
.awardId(strategyRuleRes.getAwardId())
.ruleType(strategyRuleRes.getRuleType())
.ruleModel(strategyRuleRes.getRuleModel())
.ruleValue(strategyRuleRes.getRuleValue())
.ruleDesc(strategyRuleRes.getRuleDesc())
.build();
}
@Override
public String queryStrategyRuleValue(Long strategyId, String ruleModel) {
return queryStrategyRuleValue(strategyId, null, ruleModel);
}
@Override
public String queryStrategyRuleValue(Long strategyId, Integer awardId, String ruleModel) {
StrategyRule strategyRule = new StrategyRule();
strategyRule.setStrategyId(strategyId);
strategyRule.setAwardId(awardId);
strategyRule.setRuleModel(ruleModel);
return strategyRuleDao.queryStrategyRuleValue(strategyRule);
}
@Override
public StrategyAwardRuleModelVO queryStrategyAwardRuleModelVO(Long strategyId, Integer awardId) {
StrategyAward strategyAward = new StrategyAward();
strategyAward.setStrategyId(strategyId);
strategyAward.setAwardId(awardId);
String ruleModels = strategyAwardDao.queryStrategyAwardRuleModels(strategyAward);
if (null == ruleModels) return null;
return StrategyAwardRuleModelVO.builder().ruleModels(ruleModels).build();
}
@Override
public RuleTreeVO queryRuleTreeVOByTreeId(String treeId) {
// 优先从缓存获取
String cacheKey = Constants.RedisKey.RULE_TREE_VO_KEY + treeId;
RuleTreeVO ruleTreeVOCache = redisService.getValue(cacheKey);
if (null != ruleTreeVOCache) return ruleTreeVOCache;
// 从数据库获取
RuleTree ruleTree = ruleTreeDao.queryRuleTreeByTreeId(treeId);
List<RuleTreeNode> ruleTreeNodes = ruleTreeNodeDao.queryRuleTreeNodeListByTreeId(treeId);
List<RuleTreeNodeLine> ruleTreeNodeLines = ruleTreeNodeLineDao.queryRuleTreeNodeLineListByTreeId(treeId);
// 1. tree node line 转换Map结构
Map<String, List<RuleTreeNodeLineVO>> ruleTreeNodeLineMap = new HashMap<>();
for (RuleTreeNodeLine ruleTreeNodeLine : ruleTreeNodeLines) {
RuleTreeNodeLineVO ruleTreeNodeLineVO = RuleTreeNodeLineVO.builder()
.treeId(ruleTreeNodeLine.getTreeId())
.ruleNodeFrom(ruleTreeNodeLine.getRuleNodeFrom())
.ruleNodeTo(ruleTreeNodeLine.getRuleNodeTo())
.ruleLimitType(RuleLimitTypeVO.valueOf(ruleTreeNodeLine.getRuleLimitType()))
.ruleLimitValue(RuleLogicCheckTypeVO.valueOf(ruleTreeNodeLine.getRuleLimitValue()))
.build();
List<RuleTreeNodeLineVO> ruleTreeNodeLineVOList = ruleTreeNodeLineMap.computeIfAbsent(ruleTreeNodeLine.getRuleNodeFrom(), k -> new ArrayList<>());
ruleTreeNodeLineVOList.add(ruleTreeNodeLineVO);
}
// 2. tree node 转换为Map结构
Map<String, RuleTreeNodeVO> treeNodeMap = new HashMap<>();
for (RuleTreeNode ruleTreeNode : ruleTreeNodes) {
RuleTreeNodeVO ruleTreeNodeVO = RuleTreeNodeVO.builder()
.treeId(ruleTreeNode.getTreeId())
.ruleKey(ruleTreeNode.getRuleKey())
.ruleDesc(ruleTreeNode.getRuleDesc())
.ruleValue(ruleTreeNode.getRuleValue())
.treeNodeLineVOList(ruleTreeNodeLineMap.get(ruleTreeNode.getRuleKey()))
.build();
treeNodeMap.put(ruleTreeNode.getRuleKey(), ruleTreeNodeVO);
}
// 3. 构建 Rule Tree
RuleTreeVO ruleTreeVODB = RuleTreeVO.builder()
.treeId(ruleTree.getTreeId())
.treeName(ruleTree.getTreeName())
.treeDesc(ruleTree.getTreeDesc())
.treeRootRuleNode(ruleTree.getTreeRootRuleKey())
.treeNodeMap(treeNodeMap)
.build();
redisService.setValue(cacheKey, ruleTreeVODB);
return ruleTreeVODB;
}
@Override
public void cacheStrategyAwardCount(String cacheKey, Integer awardCount) {
if (redisService.isExists(cacheKey)) return;
redisService.setAtomicLong(cacheKey, awardCount);
}
@Override
public Boolean subtractionAwardStock(String cacheKey) {
long surplus = redisService.decr(cacheKey);
if (surplus < 0) {
// 库存小于0,恢复为0个
redisService.setValue(cacheKey, 0);
return false;
}
// 1. 按照cacheKey decr 后的值,如 99、98、97 和 key 组成为库存锁的key进行使用。
// 2. 加锁为了兜底,如果后续有恢复库存,手动处理等,也不会超卖。因为所有的可用库存key,都被加锁了。
String lockKey = cacheKey + Constants.UNDERLINE + surplus;
Boolean lock = redisService.setNx(lockKey);
if (!lock) {
log.info("策略奖品库存加锁失败 {}", lockKey);
}
return lock;
}
@Override
public void awardStockConsumeSendQueue(StrategyAwardStockKeyVO strategyAwardStockKeyVO) {
String cacheKey = Constants.RedisKey.STRATEGY_AWARD_COUNT_QUERY_KEY;
RBlockingQueue<StrategyAwardStockKeyVO> blockingQueue = redisService.getBlockingQueue(cacheKey);
RDelayedQueue<StrategyAwardStockKeyVO> delayedQueue = redisService.getDelayedQueue(blockingQueue);
delayedQueue.offer(strategyAwardStockKeyVO, 3, TimeUnit.SECONDS);
}
@Override
public StrategyAwardStockKeyVO takeQueueValue() throws InterruptedException {
String cacheKey = Constants.RedisKey.STRATEGY_AWARD_COUNT_QUERY_KEY;
RBlockingQueue<StrategyAwardStockKeyVO> destinationQueue = redisService.getBlockingQueue(cacheKey);
return destinationQueue.poll();
}
@Override
public void updateStrategyAwardStock(Long strategyId, Integer awardId) {
StrategyAward strategyAward = new StrategyAward();
strategyAward.setStrategyId(strategyId);
strategyAward.setAwardId(awardId);
strategyAwardDao.updateStrategyAwardStock(strategyAward);
}
}
这里实现了第 10 节防超卖的核心逻辑。
新增能力包括:
- 缓存奖品库存
- Redis 原子扣减库存
- 写入延迟队列
- 从队列取出待更新库存
- 更新数据库库存
最关键的是 subtractionAwardStock(...)
它的流程是:
- 对库存 key 执行 Redis decr
- 如果扣减后小于 0,说明库存已经没了,恢复为 0 并返回失败
- 如果扣减后大于等于 0,再根据扣减后的余量生成一个库存位锁 key
- 对这个库存位锁执行 setNx
- 只有加锁成功,才认为扣减成功
库存位锁 key 大致形如:
java
strategy_award_count_key_100006_101_2
strategy_award_count_key_100006_101_1
strategy_award_count_key_100006_101_0
这个设计的目的,是给每一个库存位置加一次性锁。
比如库存有 3 个,就只能成功拿到 3 个库存位,后面再怎么并发也不会重复占用同一个库存位。
所以这里的防超卖不是只靠数据库条件更新,而是先在 Redis 层完成并发拦截。
1. awardStockConsumeSendQueue(...):发送库存消费消息
这个方法是在库存扣减成功后调用的
比如在 RuleStockLogicTreeNode 里,Redis 库存扣减成功后,会调用:
java
strategyRepository.awardStockConsumeSendQueue(...)
它做的事情是:
java
String cacheKey = Constants.RedisKey.STRATEGY_AWARD_COUNT_QUERY_KEY;
RBlockingQueue<StrategyAwardStockKeyVO> blockingQueue = redisService.getBlockingQueue(cacheKey);
RDelayedQueue<StrategyAwardStockKeyVO> delayedQueue = redisService.getDelayedQueue(blockingQueue);
delayedQueue.offer(strategyAwardStockKeyVO, 3, TimeUnit.SECONDS);
含义是:
- 先拿到一个 Redis 阻塞队列
- 再基于这个阻塞队列创建一个延迟队列
- 把strategyId + awardId 这个库存消费记录放进去
- 延迟 3 秒后,这条消息才会进入可消费队列
它存进去的对象是 StrategyAwardStockKeyVO,里面只有:
java
strategyId awardId
也就是告诉后面的任务:
哪个策略下的哪个奖品,需要同步扣减数据库库存
2. takeQueueValue():从库存消费队列取消息
这个方法是给定时任务用的。
在 UpdateAwardStockJob里会调用它:
java
StrategyAwardStockKeyVO strategyAwardStockKeyVO = raffleStock.takeQueueValue();
它做的事情是:
String cacheKey = Constants.RedisKey.STRATEGY_AWARD_COUNT_QUERY_KEY; RBlockingQueue<StrategyAwardStockKeyVO> destinationQueue = redisService.getBlockingQueue(cacheKey);
return destinationQueue.poll();
含义是:
- 取出库存消费队列
- 从队列里拿一条已经到期的库存消费消息
- 如果没有消息,就返回 null
注意,这里用的是 poll(),所以它不是一直阻塞等消息,而是"有就拿,没有就返回"。
这条消息拿出来后,定时任务就知道要更新哪个奖品库存。
3. updateStrategyAwardStock(...):真正更新数据库库存
这个方法是真正落库的地方。
它做的事情很简单:
StrategyAward strategyAward = new StrategyAward(); strategyAward.setStrategyId(strategyId);
strategyAward.setAwardId(awardId); strategyAwardDao.updateStrategyAwardStock(strategyAward);
也就是封装一个 StrategyAward 对象,然后调用 DAO 更新数据库。
对应 SQL 是:
update strategy_award set award_count_surplus = award_count_surplus - 1 where strategy_id = #{strategyId} and award_id = #{awardId} and award_count_surplus > 0
所以它的作用是:
把 Redis 中已经扣减成功的库存消费,同步扣减到数据库表里。
完整链路是这样的
用户抽奖命中奖品后,进入规则树的库存节点:
RuleStockLogicTreeNode
然后:
Redis 扣减库存成功 -> awardStockConsumeSendQueue(...) 写入延迟队列 -> UpdateAwardStockJob 定时任务执行 -> takeQueueValue() 取出库存消费消息 -> updateStrategyAwardStock(...) 扣减数据库库存
所以这三个方法分别负责:
- awardStockConsumeSendQueue:生产库存消费消息
- takeQueueValue:消费库存消息
- updateStrategyAwardStock:把消费结果更新到数据库
11. 数据库库存更新加了条件保护
java
update strategy_award
set award_count_surplus = award_count_surplus - 1
where strategy_id = #{strategyId}
and award_id = #{awardId}
and award_count_surplus > 0
虽然真正的防超卖主要放在 Redis 层,但数据库这里也保留了 award_count_surplus > 0 的保护
12. Redis 能力扩展
Redis 服务新增了:
- setAtomicLong(...)
- getAtomicLong(...)
- setNx(...)
这些都是为了库存扣减服务的。
其中:
- setAtomicLong 用来初始化库存数量
- decr 用来原子扣减库存
- setNx 用来抢占库存位锁
13. 启用定时任务
Application.java
新增了:
java
@EnableScheduling
这是为了让 UpdateAwardStockJob 的 @Scheduled 生效。
如果没有这个注解,库存消费队列写进去了,但定时任务不会自动跑,数据库库存也不会被同步扣减。
14.Redis Key 常量新增
Constants.java
新增了两个 key:
- STRATEGY_AWARD_COUNT_KEY(strategy_award_count_key_100006_101)
- STRATEGY_AWARD_COUNT_QUERY_KEY(strategy_award_count_query_key)
前者用于奖品库存缓存。
后者用于库存消费队列
当用户成功扣减了奖品 101 的 Redis 库存后,系统会往这个队列里投递一条消息:
{ "strategyId": 100006, "awardId": 101 }
这条消息不会立刻更新数据库,而是先进入延迟队列。等延迟时间到了以后,定时任务 UpdateAwardStockJob 会从队列里取出这条消息,然后执行数据库库存扣减:
java
update strategy_award set award_count_surplus = award_count_surplus - 1 where strategy_id = 100006 and award_id = 101 and award_count_surplus > 0
15.新增定时任务
java
/**
* @author Fuzhengwei bugstack.cn @小傅哥
* @description 更新奖品库存任务;为了不让更新库存的压力打到数据库中,这里采用了redis更新缓存库存,异步队列更新数据库,数据库表最终一致即可。
* @create 2024-02-09 12:13
*/
@Slf4j
@Component()
public class UpdateAwardStockJob {
@Resource
private IRaffleStock raffleStock;
@Scheduled(cron = "0/5 * * * * ?")
public void exec() {
try {
log.info("定时任务,更新奖品消耗库存【延迟队列获取,降低对数据库的更新频次,不要产生竞争】");
StrategyAwardStockKeyVO strategyAwardStockKeyVO = raffleStock.takeQueueValue();
if (null == strategyAwardStockKeyVO) return;
log.info("定时任务,更新奖品消耗库存 strategyId:{} awardId:{}", strategyAwardStockKeyVO.getStrategyId(), strategyAwardStockKeyVO.getAwardId());
raffleStock.updateStrategyAwardStock(strategyAwardStockKeyVO.getStrategyId(), strategyAwardStockKeyVO.getAwardId());
} catch (Exception e) {
log.error("定时任务,更新奖品消耗库存失败", e);
}
}
}
核心流程

用户抽中奖品
-> 进入 rule_stock 库存节点
-> 调用 subtractionAwardStock(strategyId, awardId)
-> Redis 对库存 key 执行 decr
-> 判断扣减后的库存是否小于 0
-> 如果小于 0,说明库存不足,恢复为 0,返回失败
-> 如果大于等于 0,根据扣减后的库存余量生成库存位锁 key
-> setNx 加库存位锁
-> 加锁成功,说明成功占用一个库存位
-> 写入延迟队列
-> 定时任务取队列消息
-> 更新数据库库存
这套代码防超卖靠三层:
- Redis decr:原子扣减库存,判断是否还有库存
- setNx 库存位锁:确保每个库存位置只被占用一次
某个库存名额已经被一个请求成功拿走了,
后续因为并发、重试、恢复库存、重复消费等原因,
又有人试图再次占用这个库存名额。
- 数据库 award_count_surplus > 0:异步落库时兜底防止扣成负数
