Redis基础知识(学习笔记2--分布式锁)
1.并发问题
例如,一个操作是修改用户的账户的状态。修改前需要先读取,在内存里修改,修改完了,再存进去。
如果这样的操作同时进行,就会出现并发问题,因为“读取”和“保持”(设置)这两个操作不是原子操作。
原子操作是指不会被线程调度机制打断的操作。这种操作一旦开始,就会一直运行到结束,中间不会有任何线程切换。
2.分布式锁的奥义
分布式锁要实现的最终目标就是在redis里面占一个“坑”,当别的进程也要来占“坑”时,发现那里已经有一个“大萝卜”了,就智能放弃或者稍后再试。
占坑一般使用setnx指令,指允许被一个客户端占坑。先来先占,用完了,再调用del指令释放“坑”。
3.占坑指令--SETNX
setnx key value
将key的值设为value,当且仅当key不存在。
若给定的key已经存在,则SETNX不做任何动作。
SETNX是【SET if NOT eXists】(如果不存在,则SET)的缩写。
4. 示例
//这里的冒号“:”就是一个普通的字符,没特殊含义,它可以是其它任意字符。 > setnx lock:codehole true OK ... do something critical ... > del lock:codehole (integer) 1
5.优化1--添加过期时间
待优化的地方:如果逻辑执行到中间出现了问题,可能会导致del指令没有被调用,这样就会陷入死锁,锁永远等不到释放。
优化思路; 在拿到锁之后,再给锁加上一个过期时间,比如2s,这样即使中间出现了异常,也可以保证2s之后,锁会自动释放。
> setnx lock:codehole true OK > expire lock:codehole 2 ... do something critical ... > del lock:codehole (integer) 1
6. 继续优化2--过期时间的设置
上面的code,逻辑上还有问题:如果在setnx 和 expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是人为造成的,这就会导致expire得不到执行,也会造成死锁。
这种问题的根源在于setnx和expire是两条指令而不是原子指令。
如果这两条指令可以一起执行就不会出现问题,也许你会想到用redis事务来解决,但在这里不行,因为expire是依赖setnx的执行结果,如果setnx没抢到锁,expire是不应该执行的。事务里没有if-else分支逻辑,事务的特点是一口气执行,要么全部执行,要么一个都不执行。
Redis 2.8 版本之后,set指令进行了参数扩展,使setnx 和 expire指令可以一起执行。
> setnx lock:codehole true ex 5 nx OK ... do something critical ... > del lock:codehole
7. 继续优化3--锁名称的设置
Redis的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的限时限制,就会出现问题。
因为这时候第一个线程持有的锁过期了,但临界区的逻辑(脚本)还没执行完,而同时第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格的串行执行。
出问题的关键点:在于释放锁/删除锁时,容易把别人添加的锁释放掉。优化的思路,就是在释放锁的时候,判断下,是不是自己之前添加的锁。
例如,针对每一个请求,生成一个基于clientid(或者UUID.randomUUID().toString())的value。
即将set指令的value参数设置为一个随机数,释放时,先匹配随机数释放一致,然后再删除。
这是为了确保当前线程占有的锁不会被其它线程释放,除非这个锁是因为过期了而被服务器自动释放。
tag = random.nextint() ##随机数 if redis.set(key, tag, nx=True, ex=5): do_something() redis.delifequals(key,tag) ##抽象的功能代码
但是匹配value和删除key不是一个原子操作,redis也没有提供类似的delifequals这样的指令,需要使用Lua脚本处理(Lua脚本可以保证连续多个指令的原子性执行)
# delifequals if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
但这也不是一个完美的方案,它只是相对安全一点,因为如果真的超时了,当前线程的逻辑没有执行完,其它线程的逻辑也会乘虚而入。
8. 继续优化4--锁续期
redis锁的过期时间能够自动续期。
使用redis客户端redisson。redisson在加锁成功后,会注册一个定时任务监听这个锁,每隔10秒就去查看这个锁,如果还持有锁,就对过期时间进行续期。默认过期时间30秒。
举例子:假如加锁的时间是30秒,过10秒检查一次,一旦加锁的业务没有执行完,就会进行一次续期,把锁的过期时间再次重置成30秒。
1. 加锁成功后,启动一个定时任务,每个一段时间检测是否执行完成业务代码,依据是锁是否还存在。
2. 这个定时任务每隔三分之一时间检查一次,比如锁过期时间是30秒,就每隔10秒检查一次。
3. 如果锁还存在,就把锁过期时间继续设置成30秒。
Redisson框架已经实现了这个流程,名叫看门狗机制(Watch Dog),底层使用 Lua 脚本实现。
// 获取 Redisson 锁 RLock lock = redissonClient.getLock(lock_key); try { // 加锁,并设置3秒后过期 lock.lock(3, TimeUnit.SECONDS); // 执行业务代码 doBusiness(); } catch (Exception e) { System.out.println("加锁超时"); } finally { // 释放锁 lock.unlock(); }
学习参阅声明
1.《Redis深度历险--核心原理与应用实践》
2. 《Redis分布式锁如何实现续期》 https://www.jb51.net/article/233992.htm
3. 《高并发必备,使用Redis分布式锁必须注意的10个细节》https://zhuanlan.zhihu.com/p/647085067