小米信息部技术团队:https://xiaomi-info.github.io/2019/12/17/redis-distributed-lock/
我们在系统中修改已有数据时,需要先读取,然后进行修改保存,此时很容易遇到并发问题。由于修改和保存不是原子操作,在并发场景下,部分对数据的操作可能会丢失。在单服务器系统我们常用本地锁来避免并发带来的问题,然而,当服务采用集群方式部署时,本地锁无法在多个服务器之间生效,这时候保证数据的一致性就需要分布式锁来实现。
分布式锁需要解决的问题
- 互斥性
- 安全性
- 死锁
- 容错
Redis 以其高性能著称,但使用其实现分布式锁来解决并发仍存在一些困难。Redis 分布式锁只能作为一种缓解并发的手段,如果要完全解决并发问题,仍需要数据库的防并发手段。
加锁、解锁、锁超时
# 使用setnx来加锁。key是锁的唯一标识,按业务来决定命名
setnx key value # 时间复杂度O(1)
# 如果key不存在,则创建并赋值, 成功返回1
# 如果key存在,失败返回0
# 当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;
# 当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败;
# 使用del解锁,释放锁之后,其他线程就可以继续执行setnx命令来获得锁。
del key
expire key time
# 设置key的生产时间,在time时间后释放key过期会被自动删除
# 即锁超时
# SETNX 和 EXPIRE 非原子性
解释:假设一个场景中,某一个线程刚执行setnx,成功得到了锁。此时setnx刚执行成功,还未来得及执行expire命令,节点就挂掉了。此时这把锁就没有设置过期时间,别的线程就再也无法获得该锁。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
问题:不满足原子性(SETNX 和 EXPIRE 非原子性)( 如果在得到锁之后,且未执行到redisService.expire(key,expire)时挂掉,key不过期,会一直不能释放线程。资源被永远锁住,其他进程进不来)
SET key value [EX seconds] [PX milliseconds] [NX|XX]
EX second:设置键的过期时间为 second 秒
PX millisecond:设置键的过期时间为 millisecond 毫秒
NX:只在键不存在时,才对键进行设置操作
XX:只在键已经存在时,才对键进行设置操作
SET操作成功完成时,返回OK,否则返回nil
set locktarget ex 10 nx # 表示在locktarget键不存在时,设置过期时间为10s
解决:实现分布式锁且保证了原子性<br />![image.png](https://img-blog.csdnimg.cn/img_convert/363246dba84142ce2cd8ce101243d286.png#averageHue=#262921&clientId=u6e48d7c0-b217-4&from=paste&height=134&id=uddddb9d0&originHeight=167&originWidth=1107&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=70225&status=done&style=none&taskId=u969f33f9-15ad-41c6-b455-7e15daf327e&title=&width=885.6)
```java
RedisService redisService = SpringUtils.getBean(RedisService.class);
String result result = redisService.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (result.equals("OK")){
// 执行独占资源逻辑
doOcuppiedwork();
}
大量key同时过期的注意事项:
集中过期,由于清除大量的key很耗时,会出现短暂的卡顿现象
- 解决方案:在设置key的过期时间时,给每个key加上随机值
锁误解除
场景:如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。
**解决办法:**在del释放锁之前加一个判断,验证当前的锁是不是自己加的锁。
- 具体在加锁的时候把当前线程的id当做value,可生成一个 UUID 标识当前线程,在删除之前验证key对应的value是不是自己线程的id。
- 还可以使用 lua 脚本做验证标识和解锁操作。
超时解锁导致并发
场景:如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。
A、B 两个线程发生并发显然是不被允许的,一般有两种方式解决该问题:
- 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成。
- 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。
不可重入
当线程在持有锁的情况下再次请求锁,如果一个锁支持一个线程多次加锁,就是可重入锁。
场景:如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。Redis可通过对锁进行重入计数,加锁时加1,减锁时减1,当计数为0时释放锁。
无法等待所释放
上述命令执行都是立即返回的,如果客户端可以等待锁释放就无法使用。
- 可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。
- 另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息。如下: