基于redis实现分布式锁的几种方式与问题
业务场景
在费用报销的业务场景下,可报销科目与科目的可报销金额全都存在redis中,比如下面这样:
现在我们需要通过程序来实现报销的逻辑。
初始版
代码样例
@PostMapping("/reimbursement")
public String reimbursement(@RequestBody ReimbursementVO vo) {
String jineStr = stringRedisTemplate.opsForValue().get(vo.getKmcode());
if (jineStr == null) {
logger.info("没钱了");
return "没钱了";
}
int jine = Integer.parseInt(jineStr);
if (jine < vo.getJine()) {
logger.info(vo.getKmcode() + "的剩余费用不足" + vo.getJine());
return vo.getKmcode() + "的剩余费用不足" + vo.getJine();
}
stringRedisTemplate.opsForValue().set(vo.getKmcode(), String.valueOf(jine- vo.getJine()));
logger.info("报销成功");
return "报销成功";
}
代码解析
在上面的这段逻辑中,没有锁的保护。在单机多线程的情况下会出现超额报销的问题, 比如一共5000块,实际报销了1000块,但是依旧剩余4000多的问题。
单机线程安全版
代码样例
private ConcurrentHashMap<String,Object> lockMap = new ConcurrentHashMap<>();
@PostMapping("/reimbursement2")
public String reimbursement2(@RequestBody ReimbursementVO vo) {
Object o = lockMap.computeIfAbsent(vo.getKmcode(), k -> new Object());
synchronized (o){
return reimbursement(vo);
}
}
代码解析
使用分段锁机制,在保证效率的情况下保证单个服务内部线程安全,单体服务下不会出现超额报销的问题。 但是在集群环境下还是会出现超额报销的问题,所以需要一定的手段来保证分布式环境下的线程安全。
使用redis实现分布式锁的第一版
代码样例
@PostMapping("/reimbursement3")
public String reimbursement3(@RequestBody ReimbursementVO vo) {
String kmcode = vo.getKmcode();
String lockKey = "lock:"+kmcode;
String lockValue = stringRedisTemplate.opsForValue().get(lockKey);
if (lockValue == null) {
stringRedisTemplate.opsForValue().set(lockKey, "1");
try {
return reimbursement(vo);
}finally {
stringRedisTemplate.delete(lockKey);
}
}else {
return "当前报销人数较多,请稍后再试";
}
}
代码解析
在上面的方案中使用redis中间件同步锁的状态,但是存在一定的问题:
- redis的get和set分成两行,无法保证原子性,在并发场景下会存在线程安全问题。 比如线程1 get出来的lockValue为null,此时线程1被挂起,线程2也过来get相同的lockKey,此时获取到的lockValue也是null。 所以线程1和线程2都可以加锁成功,也就导致了锁失效。
- 在redis中设置lockKey之后,并没有设置超时时间,假如一个服务加锁成功,但是因为某种原因异常宕机了, 此时任何其他服务都无法再针对相同的kmcode加锁成功了,也就形成了死锁。
使用redis实现实现分布式锁的第二版
代码样例
@PostMapping("/reimbursement4")
public String reimbursement4(@RequestBody ReimbursementVO vo) {
String kmcode = vo.getKmcode();
String lockKey = "lock:"+kmcode;
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
int tryCount = 1;
while (!lock && tryCount<3){
int randomTime = ThreadLocalRandom.current().nextInt(15);
try {
TimeUnit.MILLISECONDS.sleep(randomTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1",10,TimeUnit.SECONDS);
tryCount++;
}
if (lock) {
try {
return reimbursement(vo);
}finally {
stringRedisTemplate.delete(lockKey);
}
}else {
return "当前报销人数较多,请稍后再试";
}
}
代码解析
方案3的优化版:
- 使用redis的
setnx
命令保证当key不存在时才能设置成功同时设置了lockKey的超时时间, 不用担心加锁的服务异常宕机导致死锁发生。 - 增加了适当的自旋机制,尽可能保证加锁的成功率。
但是这个方案依旧存在问题。假如服务A加锁成功,但是没能在10秒内完成业务逻辑处理,导致锁自动失效。 锁失效后,服务B成功加锁,此时服务A的业务逻辑处理完毕,执行锁删除操作,又会在服务B没有执行完毕时释放锁, 最坏的情况会导致锁完全失效。
使用redis实现实现分布式锁的第三版
代码样例
@PostMapping("/reimbursement5")
public String reimbursement5(@RequestBody ReimbursementVO vo) {
String kmcode = vo.getKmcode();
String lockKey = "lock:"+kmcode;
String lockValue = UUID.randomUUID().toString();
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
int tryCount = 1;
while (!lock && tryCount<3){
int randomTime = ThreadLocalRandom.current().nextInt(15);
try {
TimeUnit.MILLISECONDS.sleep(randomTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue,10,TimeUnit.SECONDS);
tryCount++;
}
if (lock) {
try {
return reimbursement(vo);
}finally {
if (lockValue.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
stringRedisTemplate.delete(lockKey);
}
}
}else {
return "当前报销人数较多,请稍后再试";
}
}
代码解析
上面的代码中使用UUID作为lockValue
,在锁释放时判断一下当前redis中的lockValue
是不是本次加锁时存放的lockValue
。如果是,再删除lockKey
,如果不是,就不执行删除lockKey
的逻辑。
但是本方案依旧存在一定的漏洞,假如服务A获取到锁后,在finally
中判断当前的lockValue确实是本次加锁时的lockValue,判断成功后,发生FGC或其他原因导致redis中的lockKey过期。此时服务B加锁成功,服务B开始执行业务逻辑。然后A执行锁释放操作,依旧会导致锁失效问题。 只是现在出现锁失效的概率低了很多。
使用redis实现分布式锁的第四版
代码样例
@PostMapping("/reimbursement6")
public String reimbursement6(@RequestBody ReimbursementVO vo) {
String kmcode = vo.getKmcode();
String lockKey = "lock:"+kmcode;
RLock rLock = redissonClient.getLock(lockKey);
rLock.lock();
try {
return reimbursement(vo);
}finally {
rLock.unlock();
}
}
代码解析
当然了,基于redis实现分布式锁的终极解决方案还是使用Redisson
。 在Redisson
中使用lua脚本保证命令的原子性,同时使用watchDog实现锁续命。
总结
- 如果使用单redis节点,同时项目中的并发量也不并不是太大,那完全可使用第三版来手写分布式锁(需要设置一个长一些的timeout来避免过期导致的锁失效问题)
- 基于redis实现的分布式锁在redis集群或哨兵模式下,都可能出现失效的问题
- 可以使用Redisson的RedLock来实现基于CP的分布式锁,但是红锁需要至少三个独立的Redis服务,同时性能下降的比较厉害。如果一定要保证CP,那应该考虑使用基于ZooKeeper实现的分布式锁。