背景:我们的优惠券系统发券是采用异步发券的模式。当优惠券发全量太大的时,因为要不断的去更新库存,这时候系统会出现热key问题,而且量越大速度越慢,同时还会导致mq消息大量堆积。大致数据如下,发放40万的券需要4个小时,近期我们要上线一个活动,预计同时需要发放100万的券,我们估算需要12个小时。
问题分析:目前业务上一个批次的库存量有上百万,而且还可以不断的追加库存,库存量越来越大,我们每次发券都会update库存,在并发较大的情况下,一旦开启事务,就会导致同批次的更新串行化,服务线程阻塞在该处,造成超时。cat上反馈的接口信息
上图表面,一旦出现该sql阻塞的情况,就会造成,请求越大,sql执行效率越低,接口耗时越长,影响服务正常吞吐。
解决方案:我们采用Redis预扣库存的方式:第一次发券请求时申请固定步长的量放入Redis同时进行数据库扣减,后续每次请求都从Redis扣减,直到扣减完步长的量然后再次申请,我们假定步长是1万,那么1万次发券我们才进行一次数据库扣减库存操作,这样就解决了热key问题。
具体调用逻辑图如下:
/**
* 更新库存接口
* @param batchId
*/
public Integer updateOutCountFromRedis(Integer batchId, String batchCountKey, int addOutCount)
throws Exception {
//1.判断redis是否存在该批次库存
if (!redisTemplate.exist(batchCountKey)){
//1.1 不存在执行增加预扣库存原子操作
Integer result = addWithholdingInventory(batchId, batchCountKey, addOutCount);
if (UPDATE_FAIL.equals(result)){
return result;
}
}
//2.判断redis 该批次库存是否足够
Long redisOutCount = redisTemplate.getCount(batchCountKey);
if (redisOutCount == null || redisOutCount < addOutCount){
// 2.1 该批次库存不足时,进行增加预扣库存原子操作
Integer result = addWithholdingInventory(batchId, batchCountKey, addOutCount);
if (UPDATE_FAIL.equals(result)){
return result;
}
}
//3.执行redis扣减库存
Long remainRedisCount = redisTemplate.increment(batchCountKey,-addOutCount);
Cat.logEvent("postCoupon", batchId + "increment");
if (remainRedisCount < 0){
//如果发现不够扣减的,回退库存,且再次执行扣减
Long returnCount = redisTemplate.increment(batchCountKey, addOutCount);
Cat.logEvent("postCoupon", batchId + "getAndDecrement");
return updateOutCountFromRedis(batchId, batchCountKey, addOutCount);
} else {
Cat.logEvent("postCoupon", batchId + "getAndIncrementsuccess");
return UPDATE_SUCCESS;
}
}
/**
* 增加预扣库存原子操作
* @param batchId
*/
public Integer addWithholdingInventory(Integer batchId, String batchCountKey, int addOutCount)
throws Exception {
String lock = "redisOutCount_lock_" + batchId + "_lock";
try {
//分布式锁 + 双重检查
if (redisTemplate.lock(lock,120, TimeUnit.SECONDS)){
if (redisTemplate.exist(batchCountKey)){
Long redisOutCount = redisTemplate.getCount(batchCountKey);
if (redisOutCount != null && redisOutCount > addOutCount){
return UPDATE_SUCCESS;
}
}
Integer redisOutCountSegment = apolloConfigData.getIntValue("withholding.inventory.segment", 1000);
CouponBatch couponBatch = getById(batchId);
Integer remainCount = couponBatch.getTotalCount() - couponBatch.getOutCount();
if (remainCount <= 0){
return UPDATE_FAIL;
}
if (remainCount < redisOutCountSegment){
redisOutCountSegment = remainCount;
}
Integer updateMysqlResult = couponBatchMapper.updateOutcount(batchId, redisOutCountSegment);
if (updateMysqlResult == null || updateMysqlResult == 0){
return UPDATE_FAIL;
}
redisTemplate.increment(batchCountKey,redisOutCountSegment);
logger.info(batchCountKey + "增加预扣库存原子操作成功,增加值:" + redisOutCountSegment);
return UPDATE_SUCCESS;
} else {
Thread.sleep(10);
return addWithholdingInventory(batchId, batchCountKey, addOutCount);
}
} catch (Exception e){
logger.error("增加预扣库存原子操作失败!" , e);
throw e;
} finally {
redisTemplate.unlock(lock);
}
}
优化前后发券性能对比:
操作 | 消费组 | 发券数 | 发券耗时 | 速度 |
优化前 | 4个消费组 | 15万 | 35分钟 | 4285/分钟 |
优化前 | 4个消费组 | 42万 | 250分钟 | 1680/分钟 |
优化后 | 4个消费组 | 62万 | 53分钟 | 11698/分钟 |
效果:可以看出优化后发券速度提升了7倍。