《大营销平台系统设计实现》 - 营销服务 第10节:不超卖库存规则实现

一、本章诉求

当通过抽奖策略计算出用户可获得的奖品 ID 后,接下来需要对这条奖品记录进行库存扣减。只有奖品库存扣减成功,用户才能获得该奖品 ID 对应的奖品;如果扣减失败,则走兜底奖品流程。

除此之外,本节还会对上一节实现的规则树节点进行完善,包括次数锁和兜底奖品节点。

二、流程设计

对于库存集中扣减类业务,不能直接依赖数据库表来承载高并发请求。

比如数据库表中有一条库存记录,如果通过锁定这条记录的方式,把库存从 10 更新为 9,再从 9 更新为 8,就会导致大量用户在应用获取数据库连接后,等待前一个用户更新完库存并释放锁,后一个用户才能继续扣减。

随着用户参与量增加,会有越来越多用户处于等待状态。而这些等待中的用户请求会持续占用数据库连接资源。数据库连接非常宝贵,一旦被大量等待请求占满,应用中的其他请求也无法正常进入,最终可能导致一个请求需要几分钟才能响应。前台用户越着急,就越容易频繁点击,系统也会越来越卡,直到崩溃。

所以,对于这类秒杀场景,一般会使用 Redis 缓存来处理库存,只要保证不超卖即可。但也要注意,不要用一条 key 加锁并等待释放的方式来处理库存,否则效率依然很低。因此,我们要尽可能分摊竞争,向无锁化方向设计,这也是分布式架构设计中的核心思路。

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

三、功能实现

1. 工程结构

如图为本节主要涉及的工程中代码的改造部分。

  1. 承接上一节规则树的使用,本节完善规则树节点的逻辑。包括;次数锁、兜底和奖品库存的处理。奖品库存的处理是大头。
  2. 奖品库存处理,就会涉及从 redis 缓存读取数据做 decr 扣减操作。而这部分缓存的数据,要放到装配处理里,事先做好数据的装配操作。
  3. 最后一步就是新增 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(...)

它的流程是:

  1. 对库存 key 执行 Redis decr
  2. 如果扣减后小于 0,说明库存已经没了,恢复为 0 并返回失败
  3. 如果扣减后大于等于 0,再根据扣减后的余量生成一个库存位锁 key
  4. 对这个库存位锁执行 setNx
  5. 只有加锁成功,才认为扣减成功

库存位锁 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:异步落库时兜底防止扣成负数
相关推荐
qq_2518364574 小时前
基于java 安卓-RSS阅读系统毕业论文
android·java·开发语言
lili00124 小时前
Gemini 3.5发布后的AI格局:谷歌重新定义行业标准
java·人工智能·python·ai编程
JAVA社区4 小时前
Java进阶全套教程(八)—— Docker超详细实战详解
java·运维·开发语言·docker·容器·面试·职场和发展
JunLa4 小时前
Java语法糖
java·python·哈希算法
Mr.Java.4 小时前
Spring AI MCP Server分布式翻车现场:Streamable协议的甜蜜与危险,以及无状态救赎
java·后端·spring·ai·负载均衡
ZC跨境爬虫4 小时前
跟着 MDN 学CSS day_7:(层叠优先级与继承)
前端·css·数据库·ui·html
夕除4 小时前
spring boot 11
java·spring boot·后端
TechPioneer_lp4 小时前
就业指导|中九非科班毕业,华为 OD 做 Java 后端想转 C++,能找到深度学习挂钩的岗工作吗?
java·c++·华为od·华为·就业指导·校招指导
YOU OU4 小时前
MyBatis 操作数据库(入门)
数据库·mybatis