一、前言
前面我们搭建了活动订单业务的整体骨架,设计了整体的活动订单流程,我们在责任链中设计了两个节点,一个用于校验,一个用于扣减库存,但是在上一节我们是没有写逻辑的,所以这一节的第一件事就是去补齐逻辑。
除此之外,我们需要引入MQ来处理SKU的库存一致性,这里的一致性不是指的前面抽奖那种延迟队列异步扣减库存,而是当缓存中的库存为0时就不要再浪费性能去执行定时任务调度了,直接用MQ传一个清空库存的消息,直接清空延迟队列,并且将数据库中的库存直接清0。
二、SKU校验和SKU库存扣减
这两个业务我们放在了上一节的两个空节点中去实现:
java
@Slf4j
@Component("activity_base_action")
public class ActivityBaseActionChain extends AbstractActionChain {
@Override
public boolean action(ActivitySkuEntity activitySkuEntity, ActivityEntity activityEntity, ActivityCountEntity activityCountEntity) {
log.info("活动责任链-基础信息【有效期、状态、库存(sku)】校验开始:sku:{} activityId:{}", activitySkuEntity.getSku(), activityEntity.getActivityId());
//校验活动状态
if (!ActivityStateVO.open.equals(activityEntity.getState())) {
throw new AppException(ResponseCode.ACTIVITY_STATE_ERROR.getCode(), ResponseCode.ACTIVITY_STATE_ERROR.getInfo());
}
//校验活动是否在有效期内
Date currentDate = new Date();
if (activityEntity.getBeginDateTime().after(currentDate) || activityEntity.getEndDateTime().before(currentDate)) {
throw new AppException(ResponseCode.ACTIVITY_DATE_ERROR.getCode(), ResponseCode.ACTIVITY_DATE_ERROR.getInfo());
}
//校验活动sku库存
if (activitySkuEntity.getStockCountSurplus() <= 0) {
throw new AppException(ResponseCode.ACTIVITY_SKU_STOCK_ERROR.getCode(), ResponseCode.ACTIVITY_SKU_STOCK_ERROR.getInfo());
}
return next().action(activitySkuEntity, activityEntity, activityCountEntity);
}
}
java
@Slf4j
@Component("activity_sku_stock_action")
public class ActivitySkuStockActionChain extends AbstractActionChain {
@Resource
private IActivityDispatch activityDispatch;
@Resource
private IActivityRepository activityRepository;
@Override
public boolean action(ActivitySkuEntity activitySkuEntity, ActivityEntity activityEntity, ActivityCountEntity activityCountEntity) {
log.info("活动责任链-商品库存处理【扣减】开始:sku:{} activityId:{}", activitySkuEntity.getSku(), activityEntity.getActivityId());
// 扣减缓存中的库存
boolean status = activityDispatch.subtractionActivitySkuStock(activitySkuEntity.getSku(), activityEntity.getEndDateTime());
// true;库存扣减成功
if (status) {
log.info("活动责任链-商品库存处理【有效期、状态、库存(sku)】成功。sku:{} activityId:{}", activitySkuEntity.getSku(), activityEntity.getActivityId());
// 写入延迟队列,延迟消费更新库存记录
activityRepository.activitySkuStockConsumeSendQueue(ActivitySkuStockKeyVO.builder()
.sku(activitySkuEntity.getSku())
.activityId(activityEntity.getActivityId())
.build());
return true;
}
throw new AppException(ResponseCode.ACTIVITY_SKU_STOCK_ERROR.getCode(), ResponseCode.ACTIVITY_SKU_STOCK_ERROR.getInfo());
}
}
其中扣减数据库中SKU库存的动作放到延迟队列中去实现:
java
@Override
public void activitySkuStockConsumeSendQueue(ActivitySkuStockKeyVO activitySkuStockKeyVO) {
String cacheKey = Constants.RedisKey.ACTIVITY_SKU_COUNT_QUERY_KEY;
RBlockingQueue<ActivitySkuStockKeyVO> blockingQueue = redisService.getBlockingQueue(cacheKey);
RDelayedQueue<ActivitySkuStockKeyVO> delayedQueue = redisService.getDelayedQueue(blockingQueue);
delayedQueue.offer(activitySkuStockKeyVO, 3, TimeUnit.SECONDS);
}
而上面扣减redis中库存的方法我们留到后面讲,这里先只看整体流程。
三、SKU库存预热
这里的预热就是将数据库的库存数据同步到redis中去,和抽奖策略领域中的奖品库存一样,我们将每个扣减的数据都上锁,避免运营时的失误,详情请看第一阶段的解决超卖问题和库存回补后的重复扣减问题章节。
这里还是通过实现两个接口来划分功能边界:
java
/**
* @author 印东升
* @description 活动装配预热
* @create 2026-07-20 17:49
*/
public interface IActivityArmory {
boolean assembleActivitySku(Long sku);
}
java
/**
* @author 印东升
* @description 活动调度【扣减库存】
* @create 2026-07-20 18:00
*/
public interface IActivityDispatch {
/**
*
* @param sku 互动SKU
* @param endDateTime 活动结束时间,根据结束时间设置加锁的key为结束时间
* @return
*/
Boolean subtractionActivitySkuStock(Long sku, Date endDateTime);
}
java
/**
* @author 印东升
* @description 活动装配预热
* @create 2026-07-20 17:50
*/
@Slf4j
@Service
public class ActivityArmory implements IActivityArmory,IActivityDispatch {
@Resource
private IActivityRepository activityRepository;
@Override
public boolean assembleActivitySku(Long sku) {
ActivitySkuEntity activitySkuEntity = activityRepository.queryActivitySku(sku);
cacheActivitySkuStockCount(sku, activitySkuEntity.getStockCount());
//预热活动【查询时预热到缓存】
activityRepository.queryRaffleActivityByActivityId(activitySkuEntity.getActivityId());
//预热活动次数【查询时预热到缓存】
activityRepository.queryRaffleActivityCountByActivityCountId(activitySkuEntity.getActivityCountId());
return false;
}
private void cacheActivitySkuStockCount(Long sku, Integer stockCount) {
String cacheKey = Constants.RedisKey.ACTIVITY_SKU_STOCK_COUNT_KEY + sku;
activityRepository.cacheActivitySkuStockCount(cacheKey, stockCount);
}
@Override
public Boolean subtractionActivitySkuStock(Long sku, Date endDateTime) {
String cacheKey = Constants.RedisKey.ACTIVITY_SKU_STOCK_COUNT_KEY + sku;
return activityRepository.subtractionActivitySkuStock(sku,cacheKey,endDateTime);
}
}
下面是仓储中的实现,其实很简单,就两行,至于redisService的API,详见第一阶段。
java
@Override
public void cacheActivitySkuStockCount(String cacheKey, Integer stockCount) {
if (redisService.isExists(cacheKey)) return;
redisService.setAtomicLong(cacheKey, stockCount);
}
四、SKU库存扣减一致性处理
1.细节部分
这里我们注意到,在责任链的第二个SKU库存扣减节点中有这么一个方法来用于redis缓存中的SKU库存扣减:
java
// 扣减库存
boolean status = activityDispatch.subtractionActivitySkuStock(activitySkuEntity.getSku(), activityEntity.getEndDateTime());
这里我将完整链路展示出来:
ActivitySkuStockActionChain -> ActivityArmory -> ActivityRepository
ActivityArmory:
java
@Override
public Boolean subtractionActivitySkuStock(Long sku, Date endDateTime) {
String cacheKey = Constants.RedisKey.ACTIVITY_SKU_STOCK_COUNT_KEY + sku;
return activityRepository.subtractionActivitySkuStock(sku,cacheKey,endDateTime);
}
ActivityRepository:
java
@Override
public Boolean subtractionActivitySkuStock(Long sku, String cacheKey, Date endDateTime) {
long surplus = redisService.decr(cacheKey);
if (surplus == 0){
//库存消耗完毕。发送MQ消息,直接清空库存
eventPublisher.publish(activitySkuStockZeroMessageEvent.topic(),activitySkuStockZeroMessageEvent.buildEventMessage(sku));
return false;
} else if (surplus<0) {
//库存小于0,恢复为0
redisService.setAtomicLong(cacheKey,0);
return false;
}
//1.按照cacheKey decr 后的值,如99 98 97
String lockKey = cacheKey + Constants.UNDERLINE + surplus;
long expireMillis = endDateTime.getTime() - System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1);
Boolean lock = redisService.setNx(lockKey, expireMillis, TimeUnit.MILLISECONDS);
if (!lock){
log.info("活动sku库存加锁失败 {}",lockKey);
}
return lock;
}
而异步扣减的定时任务如下,每五秒去扣减一个在延迟队列中的库存:
java
/**
* @author 印东升
* @description 更新活动sku库存任务
* @create 2026-07-21 11:37
*/
@Slf4j
public class UpdateActivitySkuStockJob {
@Resource
private ISkuStock skuStock;
@Scheduled(cron = "0/5 * * * * ?")
public void exec(){
try{
log.info("定时任务,更新活动sku库存【延迟队列获取,降低对数据库的更新频次,不要产生竞争】");
ActivitySkuStockKeyVO activitySkuStockKeyVO = skuStock.takeQueueValue();
if (null==activitySkuStockKeyVO) return;
log.info("定时任务,更新活动sku库存 sku:{} activityId:{}",activitySkuStockKeyVO.getSku(),activitySkuStockKeyVO.getActivityId());
skuStock.updateActivitySkuStock(activitySkuStockKeyVO.getSku());
} catch (Exception e) {
log.error("定时任务,更新活动sku库存失败",e);
}
}
}
2.MQ部分
注意,对于库存的异步扣减依旧是采用的Redisson的延迟队列一个一个扣减,MQ的作用仅仅是在库存为0后停止任务调度,节约定时任务的开销。
而MQ部分也就是下面的代码,就是将库存为0的消息传给监听器,监听器去直接清空数据库中的库存,并停止定时任务。
java
if (surplus == 0){
//库存消耗完毕。发送MQ消息,直接清空库存
eventPublisher.publish(activitySkuStockZeroMessageEvent.topic(),activitySkuStockZeroMessageEvent.buildEventMessage(sku));
return false;
}
这个**"库存为0的消息"**我们用一个类来表示,并且指定了交换机和传的信息(清空指定sku的库存)。
java
/**
* @author 印东升
* @description 活动sku库存清空消息
* @create 2026-07-21 11:02
*/
@Component
public class ActivitySkuStockZeroMessageEvent extends BaseEvent<Long> {
@Value("${spring.rabbitmq.topic.activity_sku_stock_zero}")
private String topic;
@Override
public EventMessage<Long> buildEventMessage(Long sku) {
return EventMessage.<Long>builder()
.id(RandomStringUtils.randomNumeric(11))
.timestamp(new Date())
.data(sku)
.build();
}
@Override
public String topic() {
return topic;
}
}
监听器,用于清空队列,并且清空库存:
java
/**
* @author 印东升
* @description 活动sku库存耗尽
* @create 2026-07-21 11:42
*/
@Slf4j
@Component
public class ActivitySkuStockZeroCustomer {
@Value("activity_sku_stock_zero")
private String topic;
@Resource
private ISkuStock skuStock;
@RabbitListener(queuesToDeclare = @Queue(value = "activity_sku_stock_zero"))
public void listener(String message){
try{
log.info("监听活动sku库存消耗为0消息 topic:{} message:{}",topic,message);
//转换对象
BaseEvent.EventMessage<Long> eventMessage = JSON.parseObject(message,new TypeReference<BaseEvent.EventMessage<Long>>(){
}.getType());
Long sku = eventMessage.getData();
//更新库存
skuStock.clearActivitySkuStock(sku);
//清空队列(此时就不需要延迟更新数据库记录了)
skuStock.clearQueueValue();
}catch (Exception e){
log.error("监听活动sku库存消耗为0消息,消费失败 topic:{} message:{}",topic,message);
throw e;
}
}
}
而具体的实现(清空队列,清空库存)放在了Service中实现,然后再在仓储中实现,其实就是个透传,仓储透到服务,服务再用到监听器。
java
@Override
public ActivitySkuStockKeyVO takeQueueValue() throws InterruptedException {
return activityRepository.takeQueueValue();
}
@Override
public void clearQueueValue() {
activityRepository.clearQueueValue();
}
@Override
public void updateActivitySkuStock(Long sku) {
activityRepository.updateActivitySkuStock(sku);
}
@Override
public void clearActivitySkuStock(Long sku) {
activityRepository.clearActivitySkuStock(sku);
}
java
@Override
public ActivitySkuStockKeyVO takeQueueValue() {
String cacheKey = Constants.RedisKey.ACTIVITY_SKU_COUNT_QUERY_KEY;
RBlockingQueue<ActivitySkuStockKeyVO> destinationQueue = redisService.getBlockingQueue(cacheKey);
return destinationQueue.poll();
}
@Override
public void clearActivitySkuStock(Long sku) {
raffleActivitySkuDao.clearActivitySkuStock(sku);
}
@Override
public void clearQueueValue() {
String cacheKey = Constants.RedisKey.ACTIVITY_SKU_COUNT_QUERY_KEY;
RBlockingQueue<ActivitySkuStockKeyVO> destinationQueue = redisService.getBlockingQueue(cacheKey);
destinationQueue.clear();
}
@Override
public void updateActivitySkuStock(Long sku) {
raffleActivitySkuDao.updateActivitySkuStock(sku);
}
五、测试
java
/**
* 测试库存消耗和最终一致更新
* 1. raffle_activity_sku 库表库存可以设置20个
* 2. 清空 redis 缓存 flushall
* 3. for 循环20次,消耗完库存,最终数据库剩余库存为0
*/
@Test
public void test_createSkuRechargeOrder() throws InterruptedException {
for (int i = 0; i < 20; i++) {
try {
SkuRechargeEntity skuRechargeEntity = new SkuRechargeEntity();
skuRechargeEntity.setUserId("xiaofuge");
skuRechargeEntity.setSku(9011L);
// outBusinessNo 作为幂等仿重使用,同一个业务单号2次使用会抛出索引冲突 Duplicate entry '700091009111' for key 'uq_out_business_no' 确保唯一性。
skuRechargeEntity.setOutBusinessNo(RandomStringUtils.randomNumeric(12));
String orderId = raffleOrder.createSkuRechargeOrder(skuRechargeEntity);
log.info("测试结果:{}", orderId);
} catch (AppException e) {
log.warn(e.getInfo());
}
}
new CountDownLatch(1).await();
}
当库存扣减完了,MQ发送消息到监听器:

而如果只是扣减部分,就会通过定时任务一个一个地扣减:

六、Bug修改和细节优化
1.Bug优化
测试看上去是没有问题的,但是实际上这里有一个bug,就是当我们有多个sku时,这些sku共用一个延迟队列,当其中一个sku的库存为0时,整个延迟队列的其他sku也会被全部删除。
因此这里是有问题的:
注释掉就好,只是以前残留的sku库存为0的消息还会在队列中
java
//清空队列(此时就不需要延迟更新数据库记录了) 有点bug
//skuStock.clearQueueValue();
优化:节省 处理sku库存为0的消息的 性能
java
//优化,每次判断出队列的SKU是否库存已经为0,为0就跳过,节省去update的性能
Long currentStock = skuStock.getSkuStockSurplus("activity_sku_stock_count_key" + activitySkuStockKeyVO.getSku());
if (currentStock == 0) {
log.info("定时任务,活动sku库存已经为0,跳过该消息 sku:{} activityId:{}", activitySkuStockKeyVO.getSku(), activitySkuStockKeyVO.getActivityId());
return;
}
2.延迟优化
延迟优化: 首先,做异步更新的目的是减少直接访问数据库的次数 ,因此基于这一点,其他的部分可以优化,比如下面是我给出的优化,通过循环将连续的、sku的库存为已经0的全部一次性跳过,这样可以大幅降低redis和mysql数据库的数据更新的延迟(假如有20条这样的信息,我通过一次性跳过,就可以将延迟缩短100秒),并且是不影响性能的,本质是在内存中跑,不会直接访问数据库。
java
@Slf4j
@Component
public class UpdateActivitySkuStockJob {
@Resource
private ISkuStock skuStock;
@Scheduled(cron = "0/5 * * * * ?")
public void exec() {
try {
while(true) {//优化,让那些sku库存为0的消息一次性跳过(如果连续的话)
log.info("定时任务,更新活动sku库存【延迟队列获取,降低对数据库的更新频次,不要产生竞争】");
ActivitySkuStockKeyVO activitySkuStockKeyVO = skuStock.takeQueueValue();
if (null == activitySkuStockKeyVO) return;
//优化,每次判断出队列的SKU是否库存已经为0,为0就跳过,节省去update的性能
Long currentStock = skuStock.getSkuStockSurplus("activity_sku_stock_count_key" + activitySkuStockKeyVO.getSku());
if (currentStock == 0) {
log.info("定时任务,活动sku库存已经为0,跳过该消息 sku:{} activityId:{}", activitySkuStockKeyVO.getSku(), activitySkuStockKeyVO.getActivityId());
continue;
}
log.info("定时任务,更新活动sku库存 sku:{} activityId:{}", activitySkuStockKeyVO.getSku(), activitySkuStockKeyVO.getActivityId());
skuStock.updateActivitySkuStock(activitySkuStockKeyVO.getSku());
break;
}
} catch (Exception e) {
log.error("定时任务,更新活动sku库存失败", e);
}
}
}