Redis 分布式锁
一、简介
分布式锁,即支持分布式集群环境下的锁:查询DB,只有一个线程能访问,其他线程都需要等待第一个线程释放完锁资源后,竞争获取锁后才能继续执行。
二、本地锁
假设微服务被拆分为4个,前端发起10w请求,被转发到不同的微服务,每个微服务接收2.5w个请求。假如缓存失效,每个微服务在访问数据库时加锁,通过锁(synchronizied或者lock)来锁住自己的线程资源,从而防止缓存击穿。
问题:分布式情况下会带来数据不一致问题。如服务A获取数据后,更新缓存Key = 100,服务B不受服务A的锁限制,并发去更新缓存Key = 99,最后结果可能是99或者100,是一种未知状态,与期望结果不一致。
三、Redis的SETNX
用Redis实现分布式锁,都是用SETNX命令。高阶方案的参数不一样而已。
SETNX:set if not exist。当key不存在,设置key的值,如存在,啥也不做。
在Redis命令行执行命令:
set <key> <value> NX
在Redis容器中执行SETNX命令:
docker exec -it <容器 id> redid-cli
返回ok,表示设置成功。返回nil表示设置失败。
四、青铜方案
4.1、流程图
4.2、步骤:
- 多个并发线程都去Redis中申请锁,也就是执行setnx命令。假设线程A执行成功,说明当前线程A获取了锁。
- 其他线程执行setnx命令都会失败,所以需要等待线程A释放锁。
- 线程A执行完自己的业务后,删除锁。
- 其他线程继续抢占锁。因为线程A已经删除,所以其他线程可以抢到锁。
4.3、代码:
以java为例,java中setnx命令对应代码为setifAbsent,方法第一个参数代表key,第二个参数代表value。
// 1.抢占锁 Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123"); if(lock) { // 2.如抢占成功,执行业务 List<TypeEntity> typeEntityListFromDb = getDataFromDB(); // 3.释放锁 redisTemplate.delete("lock"); return typeEntityListFromDb; } else { // 4.休眠一段时间 sleep(100); // 5.抢占失败,等待锁释放 return getTypeEntityListByRedisDistributedLock(); }
Tips:为何要sleep一段时间?因为该程序存在递归调用,可能会导致栈空间溢出。
4.4、缺陷与解决方案
【缺陷】:setnx抢占锁成功,业务代码出现异常或者服务器宕机,没有执行删除锁的逻辑,就会导致死锁。
【解决方案】:设置锁自动过期时间。过一段时间后,自动删除锁,其他线程就能获取到锁。
五、白银方案
5.1、流程图
5.2、代码
清理redis key代码如下:
// 在 10s 以后,自动清理 lock redisTemplate.expire("lock", 10, TimeUnit.SECONDS);
完整代码如下:
// 1.先抢占锁 Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123"); if(lock) { // 2.在 10s 以后,自动清理 lock redisTemplate.expire("lock", 10, TimeUnit.SECONDS); // 3.抢占成功,执行业务 List<TypeEntity> typeEntityListFromDb = getDataFromDB(); // 4.解锁 redisTemplate.delete("lock"); return typeEntityListFromDb; }
5.3、缺陷和解决方案
白银方案看似解决了线程异常或服务器宕机导致的锁未释放问题,但还有其他问题存在。
【缺陷】:占锁和设置过期是分两步执行的,所以如果在这两步之间发生异常,则锁的过期时间根本就没有设置成功。所以和青铜方案一样问题:锁永远不能过期。
六、黄金方案
6.1、原子指令
原子性:多条指令要么都执行成功,要么都不执行。
合并两步放在一步中执行:占锁+设置过期时间。
Redis正好支持这种操作:
# 设置某个 key 的值并设置多少毫秒或秒 过期。 set <key> <value> PX <多少毫秒> NX 或 set <key> <value> EX <多少秒> NX
之后通过命令查看key变化
ttl <key>
整体指令
# 设置 key=wukong,value=1111,过期时间=5000ms set wukong 1111 PX 5000 NX # 查看 key 的状态 ttl wukong
6.2、流程图
代码:
设置lock的值等于123,过期时间10秒,10秒后,lock还存在,则清理lock。
setIfAbsent("lock", "123", 10, TimeUnit.SECONDS);
6.3、缺陷与解决方案
【缺陷】: 如果用户A处理任务所需时间大于锁自动释放时间,爱自动开锁后,又有其他用户抢到了锁。且都是锁编码为“123”,就会发生·冲突。
【解决方案】:给锁设定不同编码。
七、铂金方案
7.1、流程图
- 设置锁的过期时间时,还需要设置唯一编码;
- 主动删除锁的时候,需要判断编号是否和设置的一致,如果一致,则认为是自己设置的锁,就可以主动删除。
7.2、代码
// 1.生成唯一 id string uuid = UUID.randomUUID().toString(); // 2. 抢占锁 Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS); if(lock) { System.out.println("抢占成功:" + uuid); // 3.抢占成功,执行业务 List<TypeEntity> typeEntityListFromDb = getDataFromDB(); // 4.获取当前锁的值 String lockValue = redisTemplate.opsForValue().get("lock"); // 5.如果锁的值和设置的值相等,则清理自己的锁 if(uuid.equals(lockValue)) { System.out.println("清理锁:" + lockValue); redisTemplate.delete("lock"); } return typeEntityListFromDb; } else { System.out.println("抢占失败,等待锁释放"); // 4.休眠一段时间 sleep(100); // 5.抢占失败,等待锁释放 return getTypeEntityListByRedisDistributedLock(); }
步骤:
- 生成随机唯一id,给锁加上唯一值;
- 抢占做,设置过期时间为10s,且锁具有随机唯一id;
- 抢占成功,执行业务;
- 执行完业务后,获取当前锁的值;
- 如果值和设置的值相等,这清理自己的锁。
7.3、缺陷
第4步获取锁和第5步释放锁并非原子性。
假设线程A获取到锁,然后向Redis查询到当前的Key值。之后锁过期释放,线程B抢到了锁。线程A在查询中耗时长,最后拿到了锁,然后比较值,发现相等,清理锁,但是实际这个锁是线程B的。
八、钻石方案
只要保证线程查询锁和删除锁的逻辑是原子性即可,我们吧可以采用lua脚本。Redis本身也支持lua脚本,lua脚本本身就是原子性的。
8.1、代码示例
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
这段脚本在java执行:先定义脚本,再用redisTemplate.execute 方法执行脚本。
// 脚本解锁 String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);
上面代码中,KEYS[1]对应“lock”,ARGV[1]对应“uuid”,如果lock的value等于uuid则删除lock。因为这段Redis脚本是由Redis内嵌到Lua环境执行的,所以又称为Lua脚本。
九、王者
分布式锁的王者:Redision。优点是基本比较完美,缺陷是只能在java环境使用,而且是高级功能付费使用。
十、总结
青铜方案:
-
- 缺陷:业务代码出现异常或者服务器宕机,没有执行主动删除锁的逻辑,就造成了死锁。
- 改进:设置锁的自动过期时间,过一段时间后,自动删除锁,这样其他线程就能获取到锁了。
白银方案:
-
- 缺陷:占锁和设置锁过期时间是分步两步执行的,不是原子操作。
- 改进:占锁和设置锁过期时间保证原子操作。
黄金方案:
-
- 缺陷:主动删除锁时,因锁的值都是相同的,将其他客户端占用的锁删除了。
- 改进:每次占用的锁,随机设为较大的值,主动删除锁时,比较锁的值和自己设置的值是否相等。
铂金方案:
-
- 缺陷:获取锁、比较锁的值、删除锁,这三步是非原子性的。中途又可能锁自动过期了,又被其他客户端抢占了锁,导致删锁时把其他客户端占用的锁删了。
- 改进:使用 Lua 脚本进行获取锁、比较锁、删除锁的原子操作。
钻石方案:
-
- 缺陷:非专业的分布式锁方案。
- 改进:Redission 分布式锁。
十一、推荐值
Redis设置超时时间30s,重试次数300次,重试等待时间10ms,可以根据实际需要灵活修改。