第10节:不超卖库存规则实现

  • 今天主要学习了扣减库存

performRaffle(100006, "xiaofuge")

├─ 📦 阶段一:责任链抽奖(不变)
│   └─ ... 同之前,随机到 awardId=102 → logicModel="rule_default" → 进入规则树

├─ 🌲 阶段二:规则树抽奖
│   │
│   ├─ raffleLogicTree("xiaofuge", 100006, 102)
│   │   │
│   │   ├─ queryStrategyAwardRuleModel → ruleModels = "tree_lock"
│   │   ├─ queryRuleTreeVOByTreeId("tree_lock") → 获得完整规则树
│   │   ├─ openLogicTree → new DecisionTreeEngine
│   │   │
│   │   └─ treeEngine.process("xiaofuge", 100006, 102)
│   │       │
│   │       ├─ 🔄 第1轮:node = rule_lock
│   │       │   ├─ logicTreeNode = RuleLockLogicTreeNode
│   │       │   ├─ ruleValue = "1"(来自 rule_tree_node 表的 rule_value 字段)  ← 🆕 新增:把 ruleValue 传给节点
│   │       │   ├─ long raffleCount = Long.parseLong("1")  ← 🆕 从 ruleValue 读取限定次数,不再写死
│   │       │   ├─ userRaffleCount(10) >= raffleCount(1) → true
│   │       │   ├─ 返回 ALLOW, strategyAwardVO=null
│   │       │   └─ nextNode("ALLOW", ...) → "rule_stock"
│   │       │
│   │       ├─ 🔄 第2轮:node = rule_stock  ← 🆕🆕 重点变化
│   │       │   ├─ logicTreeNode = RuleStockLogicTreeNode
│   │       │   ├─ ruleValue = null(rule_stock 节点未配 rule_value)
│   │       │   ├─ 🆕 步骤①:原子扣减 Redis 缓存库存
│   │       │   │   ├─ strategyDispatch.subtractionAwardStock(100006, 102)
│   │       │   │   │   └─ cacheKey = "strategy_award_count_key_100006_102"
│   │       │   │   │   └─ redisService.decr(cacheKey)
│   │       │   │   │       ├─ 库存从 97 → 96(decr 后 surplus=96)
│   │       │   │   │       ├─ surplus >= 0 → 没有超卖
│   │       │   │   │       ├─ lockKey = "strategy_award_count_key_100006_102_96"
│   │       │   │   │       ├─ redisService.setNx(lockKey) → 给这个"槽位"加锁
│   │       │   │   │       └─ return true(扣减成功)
│   │       │   │   │
│   │       │   │   └─ 如果 surplus < 0:
│   │       │   │       ├─ redisService.setValue(cacheKey, 0) → 恢复到 0
│   │       │   │       └─ return false(库存不足)
│   │       │   │
│   │       │   ├─ 🆕 步骤②:扣减成功(status=true)
│   │       │   │   ├─ 组装 StrategyAwardStockKeyVO{strategyId=100006, awardId=102}
│   │       │   │   ├─ awardStockConsumeSendQueue → 放入 Redisson 延迟队列
│   │       │   │   │   └─ delayedQueue.offer(vo, 3, TimeUnit.SECONDS)
│   │       │   │   │       └─ 3 秒后这条消息才可被消费
│   │       │   │   └─ 返回 TAKE_OVER, strategyAwardVO={awardId=102, awardRuleValue=null}
│   │       │   │
│   │       │   └─ 如果扣减失败(status=false):
│   │       │       └─ 返回 ALLOW, strategyAwardVO=null(库存不足,放行让下一节点兜底)
│   │       │
│   │       └─ 🔄 第3轮:node = rule_luck_award  ← 🆕 不再写死 101
│   │           └─ ruleValue = "1,100"(来自数据库 rule_tree_node 表)
│   │           ├─ split(":") → ["1,100"](只有一段)
│   │           ├─ luckAwardId = Integer.valueOf("1,100") → 抛异常!(格式不对)
│   │           │                                    ↑
│   │           │    ⚠️ 这里有个坑:分隔符是冒号 ":",但数据存的是逗号 "1,100"
│   │           │    big-market 数据库里的值是 "101:1,100" 这样的格式
│   │           │    101 = 兜底奖品ID, 1,100 = 积分范围
│   │           └─ ...

  • 新增内容拆解

1. ILogicTreeNode.logic() 新增 ruleValue 参数

之前:logic(userId, strategyId, awardId)
现在:logic(userId, strategyId, awardId, ruleValue)  ← 🆕 多了一个参数

每个规则节点不再写死数值,而是从 rule_tree_node.rule_value 读取配置:

  • rule_lock 的 rule_value = "1" → 表示"抽奖 1 次后解锁"
  • rule_luck_award 的 rule_value = "101:1,100" → 表示"兜底奖品 101,积分范围 1~100"

2. rule_lock:从永远放行 → 根据配置判断

之前:
  rule_lock.logic() → 永远返回 ALLOW(等于没作用)

现在:
  rule_lock.logic(userId, strategyId, awardId, "1")
  │
  ├─ raffleCount = Long.parseLong(ruleValue) → 1(需要抽奖 1 次后解锁)
  ├─ userRaffleCount(10) >= raffleCount(1) → true → 返回 ALLOW(放行)
  │
  └─ 如果 userRaffleCount < raffleCount  → 返回 TAKE_OVER(拦截,不让抽)
      └─ 拦截后走 rule_lock → rule_luck_award 的连线,给兜底奖品

3. rule_stock:从空壳 → Redis 原子扣减 + 延迟队列异步落库

这是本次最大的变化,分 3 个步骤:

rule_stock.logic(userId, strategyId, awardId, ruleValue)
│
├─ 🔴 步骤①:Redis 原子扣减
│   ├─ strategyDispatch.subtractionAwardStock(100006, 102)
│   │   ├─ cacheKey = "strategy_award_count_key_100006_102"
│   │   └─ redisService.decr(cacheKey)
│   │       ├─ 库存 97 → 96(decr 后 surplus=96)
│   │       ├─ surplus >= 0 → 没超卖 ✅
│   │       ├─ setNx("strategy_award_count_key_100006_102_96") → 对槽位 96 加锁
│   │       │   └─ 作用:即使将来手动恢复库存,已卖出的槽位也无法再被扣减
│   │       └─ return true
│   │
│   ├─ 扣减成功 → 执行步骤②③
│   │
│   ├─ 🟡 步骤②:放入延迟队列(异步刷库)
│   │   ├─ VO = StrategyAwardStockKeyVO{strategyId=100006, awardId=102}
│   │   ├─ delayedQueue.offer(VO, 3, TimeUnit.SECONDS)
│   │   │   └─ 放入 Redisson 延迟队列,3 秒后可被消费
│   │   │
│   │   └─ 返回 TAKE_OVER, strategyAwardVO={awardId=102, awardRuleValue=null}
│   │
│   └─ 扣减失败(stock < 0):
│       ├─ redisService.setValue(cacheKey, 0) → 恢复为 0
│       └─ 返回 ALLOW, strategyAwardVO=null → 放行到 rule_luck_award 兜底
│
└─ 🟢 步骤③:定时任务消费队列 → 更新数据库(异步)
    │
    └─ UpdateAwardStockJob.exec()(每 5 秒执行一次)
        ├─ raffleStock.takeQueueValue()
        │   └─ blockingQueue.poll() → StrategyAwardStockKeyVO{100006, 102}
        ├─ raffleStock.updateStrategyAwardStock(100006, 102)
        │   └─ UPDATE strategy_award
        │       SET award_count_surplus = award_count_surplus - 1
        │       WHERE strategy_id=100006 AND award_id=102 AND award_count_surplus > 0
        └─ 数据库库存从 97 → 96

4. rule_luck_award:从写死 → 读数据库配置

之前:
  return {awardId=101, awardRuleValue="1,100"}  ← 写死

现在:
  ruleValue = "101:1,100"(来自 rule_tree_node 表)
  │
  ├─ split(":") → ["101", "1,100"]
  ├─ luckAwardId = 101
  ├─ awardRuleValue = "1,100"
  └─ return {awardId=101, awardRuleValue="1,100"}  ← 从配置读取

5. assembleLotteryStrategy:新增库存缓存预热

之前:只做概率装配
现在:先缓存库存到 Redis,再做概率装配

assembleLotteryStrategy(100006)
│
├─ 🆕 步骤①:缓存库存
│   └─ for each award:
│       ├─ cacheKey = "strategy_award_count_key_100006_102"
│       ├─ redisService.setAtomicLong(cacheKey, awardCount)
│       │   └─ 把数据库的 award_count 写入 Redis(如 97)
│       └─ 如果 key 已存在 → 跳过(避免重复装配时覆盖实时数据)
│
└─ 步骤②:概率装配(不变)
    └─ 构建概率查找表存入 Redis

为什么这样设计?(关键点)

问题方案
高并发下直接 UPDATE 数据库有行锁竞争 Redis decr 原子操作,无锁竞争
Redis 扣了但数据库还没更新 → 不一致 最终一致性:Redis 是实时的,数据库慢 3~5 秒追上
Redis 挂了库存数据丢失 装配时从数据库恢复缓存(isExists 判断)
手动恢复库存后超卖 每个扣减后的槽位用 setNx 加锁,恢复的库存占新槽位,老槽位已被锁
数据库 UPDATE 太频繁 延迟队列缓冲 3 秒 + 定时任务每 5 秒批量消费

完整调用链路(一句话总结)

assembleLotteryStrategy 时把库存写入 Redis
    → 抽奖走到 rule_stock 节点
        → Redis decr 原子扣库存
            → 扣减成功 → 写入延迟队列(3 秒后可用)
                → 返回奖品
                    → UpdateAwardStockJob(每 5 秒)从队列取 → UPDATE 数据库

 

  • 问题:

1.为什么有了setValue 还要写setAtomicLong?

setValue 存的是通用对象(什么都能放),setAtomicLong 存的是数字(只能放 long)。存数字用 setAtomicLong,后面才能用 decr/incr 做原子加减。存通用对象用 setValue,后面用 getValue 读。

2.

请你跟我描述一下这个 public Boolean subtractionAwardStock(String cacheKey) {
//返回的是剩余库存值
long surplus = redisService.decr(cacheKey);

if (surplus < 0) {
redisService.setValue(cacheKey,0);
return false;
}
String lockKey= cacheKey+Constants.UNDERLINE+surplus;
Boolean lock=redisService.setNx(lockKey);

if (!lock){
log.info(“策略奖品枷锁失败{}”,lockKey);
}

return lock;
}我不知道他的功能是什么

答:

subtractionAwardStock("strategy_award_count_key_100006_102")

├─ long surplus = redisService.decr(cacheKey)
│   └─ Redis 中 "strategy_award_count_key_100006_102" 的值:97 → 96
│   └─ surplus = 96

├─ if (surplus < 0) → 96 < 0? → false → 跳过

├─ String lockKey = "strategy_award_count_key_100006_102_96"
│   └─ 用 decr 后的值(96)拼出一个唯一的"槽位锁 key"

├─ Boolean lock = redisService.setNx(lockKey)
│   └─ setNx = "如果这个 key 不存在,就写入,返回 true;如果已存在,什么都不做,返回 false"
│   └─ 第一次执行:"strategy_award_count_key_100006_102_96" 不存在 → 写入成功 → lock = true

├─ if (!lock) → false → 跳过日志

└─ return true ✅(扣减成功)

decr 后 surplus=96 → setNx("..._96") → 这个槽位被占用了
decr 后 surplus=95 → setNx("..._95") → 这个槽位被占用了
...
decr 后 surplus=0  → setNx("..._0")  → 这个槽位被占用了

 

请求1:decr → 3→2 → setNx("count_key_2") → true ✅ 卖出第1个
请求2:decr → 2→1 → setNx("count_key_1") → true ✅ 卖出第2个
请求3:decr → 1→0 → setNx("count_key_0") → true ✅ 卖出第3个
请求4:decr → 0→-1 → surplus < 0 → return false ❌ 卖完了

Redis 里现在有这些 key:
  count_key = 0
  count_key_2、count_key_1、count_key_0  ← 这 3 个是 setNx 留下的"已卖出凭证"

运营把数据库 award_count_surplus 从 0 改回 3
重新装配 → Redis "count_key" = 3

 

请求5:decr → 3→2 → 成功 ✅ → 卖出第4个(实际只有3个库存!超卖了!)
请求6:decr → 2→1 → 成功 ✅ → 卖出第5个
请求7:decr → 1→0 → 成功 ✅ → 卖出第6个

请求5:decr → 3→2 → setNx("count_key_2")
        ⚠️ "count_key_2" 已经存在!(第1个请求卖 2→1 时留下来的)
        setNx 返回 false → 返回 false ❌ 不放行!
        
请求6:decr → 2→1 → setNx("count_key_1")
        ⚠️ "count_key_1" 也已经被占用了!
        setNx 返回 false ❌

请求7:decr → 1→0 → setNx("count_key_0")
        ⚠️ "count_key_0" 也已经被占用了!
        setNx 返回 false ❌

posted @ 2026-07-24 15:46  超级无敌大帅比  阅读(2)  评论(0)    收藏  举报