实际开发过程中遇到了利用redis分布式锁解决评价并发提交问题,以及下单的并发提交问题,本文主要注重redis分布式锁在不同业务场景中的应用,以后再慢慢深入redis分布式锁的原理;
1.应用场景1:唯一订单评价的并发提交问题;
在评价中心建设过程中,由于网络原因,用户多次点击了评价提交按钮,前端没有限制,因此大量的请求发送到后台,造成同一订单写入多条同样的评价记录;
原因分析:接口幂等没有做好,一般来说,用户对于同一操作发起的一次请求或者多次请求的结果应该是一致的,数据库不应该写入重复数据。
解决方案:为了保证接口的幂等,考虑利用redis分布式锁来锁住评价提交请求,保证任意时刻只有一个请求执行写入操作(互斥锁),同时对于订单评价提交问题,我们只需要锁住订单号即可,对于重复请求没有获取到锁可以直接返回,可以利用分布式锁的非阻塞特性;
利用分布式锁进行接口幂等的一般流程如下:
上述加锁本质上是一种非阻塞锁,其他线程没拿到锁就会直接返回。如果出现最先拿到锁的请求执行完毕解锁,此时还有重复请求过来,我们好可以通过查库判断已评价来控制接口的幂等;
为了方便理解,本文给出该问题的交互时序图:
/**
boolean lockResult = true;
// 加锁key: prefix + settingsId + carrierId
StringBuilder key = new StringBuilder();
key.append(CommonConstants.COMMENT_CREATE_CONCURRENT_LOCK).append(settingsId)
.append(“_”).append(carrierId);
try {
RedisLock lock = RedisClientManagement.createLock(key.toString());
//判断是否获得锁,并设置锁过期时间(非阻塞)
lockResult = lock.acquireLock(LOCK_EXPIRE_TIME);
} catch (Exception e) {
log.error(“获取redis锁异常”);
LogMetricUtils.logErrorMetric(“获取redis锁异常”);
}
// 未获得锁的线程直接抛异常
if (!lockResult){
throw new RateBizException(RateErrors.CONCURRENT_LOCK_ERROR);
}
}
只设置线程同步执行的超时时间(3s),如果线程A拿到锁之后,在3s内执行了评价提交,但是没有执行到token更新(或者卡在token更新),然后自动解锁之后,线程C拿到锁,token没有更新,又一次执行了评价提交;
对于这种问题,可以通过手动释放锁的方式,更细粒度的使用锁,确保评价提交和token更新都在锁内;
/**
boolean lockResult = true;
RedisLock lock = null;
// 加锁key: prefix + settingsId + carrierId
StringBuilder key = new StringBuilder();
key.append(CommonConstants.COMMENT_CREATE_CONCURRENT_LOCK).append(settingsId)
.append(“_”).append(carrierId);
try {
lock = RedisClientManagement.createLock(key.toString());
//判断是否获得锁,并设置锁过期时间(非阻塞)
lockResult = lock.acquireLock(LOCK_EXPIRE_TIME);
} catch (Exception e) {
log.error(“获取redis锁异常”);
LogMetricUtils.logErrorMetric(“获取redis锁异常”);
}
// 未获得锁的线程直接抛异常
if (!lockResult){
throw new RateBizException(RateErrors.CONCURRENT_LOCK_ERROR);
}
return lock;
}
评价提交引入redis分布式锁的逻辑代码:
// 加锁
RedisLock lock = concurrentChecker.submitConcurrentLock(reqDTO.getSettingsId(),reqDTO.getCarrierId());
CommentCreateResDTO resDTO = null;
try {
// 评价提交业务,逻辑代码
// 获取评价token 对象,并对token校验,判断订单是否已经评价
String userId = String.valueOf(reqDTO.getUserNewId());
String rateToken = reqDTO.getRateToken();
RateTokenPack tokenPack = rateTokenManager.getAndRenewToken(userId, Long.valueOf(reqDTO.getSettingsId()), reqDTO.getCarrierId(), rateToken);
if (!tokenPack.getHasToken()) {
throw new RateBizException(RateErrors.USER_TOKEN_ERROR);
}
RateToken token = tokenPack.getToken();
if (Boolean.TRUE.equals(token.getExistRate())) {
throw new RateBizException(RateErrors.DUPLICATE_COMMENT_ERROR);
}
// 评价提交逻辑,省略部分对象转换逻辑
Result res = commentOperationService.createComment(request);
resDTO = res.getData();
// todo 重置redis评价曝光限制和城市回收限制
refreshCache(token);
// 评价已经创建,重新保存已评价token
token.setExistRate(true);
token.setExistKey(new RateKey(resDTO.getCommentId(), resDTO.getRateTime()));
rateTokenManager.saveToken(token, rateToken);
} catch (RateBizException e) {
throw e;
} finally {
//释放锁
lock.releaseLock();
}
2.应用场景2:大转盘抽奖问题(抽奖点击并发导致剩余次数为-1);
https://blog.csdn.net/weixin_40898368/article/details/96993944
剩余次数是 remainTimes = setTimes - drawTimes,即是由设置的活动抽奖次数减去已经抽的次数,设置的抽奖次数是在活动信息表里面,已经抽奖次数是count中奖记录表中中奖条数。首先,一个抽奖活动一个用户可以抽多次,-1问题出现的原因是因为这个用户疯狂的点击抽奖,前端没有限制,因此大量的请求发送到后台,当用户把抽奖次数耗光了,只剩下1次抽奖机会,两个请求同时过来,验证的时候发现剩余抽奖次数都是1,因此都可以进行抽奖,所以抽了两回,记录插入两回,剩余次数变成了1-2=-1
//加锁
String requestId = UUID.randomUUID().toString();
String lockKey = RedisKeyConstant.IDK_LUCKY_DRAW_KEY + customerId;
RedisLock lock = RedisClientManagement.createLock(lockKey.toString());
if(!lock.acquireLock(LOCK_EXPIRE_TIME)){
//抛出异常“您的操作过于频繁,请稍后再试”
throw new LuckyDrawException(ErrorCode.LUCKY_DRAW_TOO_MANY_TIMES);
}
try{
//业务代码开始
//得到剩余次数
int remainTimes = getRemainTimes(luckyDrawDoc,customerId);
//进行抽奖权限验证
//抽奖
//发奖
//插入抽奖记录
}catch (Exception e){
throw e;
}finally {
//释放锁
lock.releaseLock();
}
3.应用场景3:评价配置读取控制并发访问;
在评价中心建设过程中,经常需要通过评价配置id来查询评价配置,利用本地缓存来存储热点评价配置信息,首先spring容器启动的时候会加载配置信息到缓存;每次调用获取配置的接口都会先从本地缓存获取,本地缓存失效才会走数据库查询;
redis锁在这里的主要应用就是,针对缓存失效的情况,所有的评价配置获取请求都会走查询数据库,大量配置查询请求有可能打挂数据库,因此需要对评价配置请求进行并发控制;
加锁解决方案:为了保证所有的评价配置获取请求不会都走数据库查询,利用入参settingsId进行加锁,而且每一个请求都应当有返回结果,多个线程请求进行排队处理,因此这里应当使用阻塞锁;(阻塞锁确保每一个走数据库查询的请求都得到查询结果)
本文给出该问题并发线程加锁的交互时序图:
下面是走数据库查询的配置详情业务逻辑:
/**
/**
加载配置详情
@param settingsId
@return
*/
private SettingsExtVo loadSettings(Long settingsId) {
List settingsExtVos = null;
RedisLock redisLock = null;
try {
//对settingsId加分布式锁
redisLock = concurrentChecker.settingsCacheLock(settingsId);
if (null != redisLock) {
// 加锁成功,判断缓存中是否有值
SettingsExtVo present = settingsDetailCaches.getIfPresent(settingsId);
if (null != present) {
return present;
}
log.info(“loading cache from db:{}”, settingsId);
SettingsVO settingsVO = new SettingsVO().queryBySettingsId(settingsId);
settingsExtVos = loadSettingDetails(Lists.newArrayList(settingsVO));
}
} catch (Exception e) {
log.error(“settings cache lock error:{},{}”, settingsId, e);
} finally {
if (redisLock != null) {
redisLock.releaseLock();
}
}
return CollectionUtils.isEmpty(settingsExtVos)? null : settingsExtVos.get(0);
}
/**
RedisLock lock = null;
StringBuilder key = new StringBuilder();
key.append(CommonConstants.COMMENT_CACHE_CURRENT_LOCK).append(settingsId);
lock = RedisClientManagement.createLock(key.toString());
if (!lock.blockAcquireLock(2000, 2500)) { // 加锁超时时间,阻塞等待时间
log.error(“settings cache lock error:{}”, settingsId);
}
return lock;
}
4.应用场景4:充电宝应用扫机柜创建预置订单的控制;
背景/原因分析:共享充电宝业务中,在创建预置订单过程中,会产生并发场景;
解决方案:
下面是充电宝服务中,关于预置订单创建的加锁业务逻辑;
//分布式锁
RedisLock lock = RedisLockUtil.lock(CommonConstants.CREATE_LOCK_PRE+orderChargersCreateReqDTO.getCabinetId(),CommonConstants.LOCK_TIME,CommonConstants.LOCK_TIME_OUT,Errors.CREATE_LOCK_ERROR);
ChargersOrderEntity orderEntity = null;
try{
// 从机柜查询可用充电宝,可用且未被锁定;
// 利用数据库唯一索引吗,对充电宝加锁;
boolean addLockRes = chargerStatusInnerService.chargersLockInnerService.addLock(orderChargersCreateReqDTO.getUserNewId(),
chargerStatusResDTO.getChargerId(),orderChargersCreateReqDTO.getCabinetId());
if (!addLockRes){
throw new ChargersAppException(Errors.CHARGERS_LOCK_FAILED);
}
orderEntity = transactionTemplate.execute(status -> {
// 创建交易单
// 超时中心发送超时关单消息
// 创建冻结凭证号
// 创建业务单
});
}catch (Exception e){
log.error(“订单创建失败:{}”,e.toString());
throw new ChargersAppException(Errors.CREATE_ERROR);
}finally {
lock.releaseLock();
}
/**
加锁通用代码
@param key
@param lockTime
@param error
@return
*/
public static RedisLock lock(String key, long lockTime, long timeout, Errors error) {
boolean lockResult = true;
RedisLock lock = null;
try {
lock = RedisClientManagement.createLock(key);
lockResult = lock.blockAcquireLock(lockTime, timeout);
} catch (Exception e) {
log.error(“获取reids锁异常”, e);
LogMetricUtils.logErrorMetric(“获取reids锁异常”);
}
if (!lockResult) {
throw new ChargersAppException(error);
}
return lock;
}