分布式应用进行逻辑处理时经常会遇到并发问题。
比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修
改,改完了再存回去。如果这样的操作同时进行了,就会出现并发问题,因为读取和保存状
态这两个操作不是原子的。(Wiki 解释:所谓 原子操作是指不会被线程调度机制打断的操
作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch 线程切换。)
分布式锁
分布式锁本质上要实现的目标就是在 Redis 里面占一个“茅坑”,当别的进程也要来占
时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。
占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用
完了,再调用 del 指令释放茅坑。
// 这里的冒号:就是一个普通的字符,没特别含义,它可以是任意其它字符,不要误解
setnx lock:codehole true
OK
... do something critical ...
del lock:codehole
(integer) 1
但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样
就会陷入死锁,锁永远得不到释放。
于是我们在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也
可以保证 5 秒之后锁会自动释放。
setnx lock:codehole true
OK
expire lock:codehole 5
... do something critical ...
del lock:codehole
(integer) 1
但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,可能是因
为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。
这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可
以一起执行就不会出现问题。也许你会想到用 Redis 事务来解决。但是这里不行,因为 expire
是依赖于 setnx 的执行结果的,如果 setnx 没抢到锁,expire 是不应该执行的。事务里没有 if-
else 分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。
为了解决这个疑难,Redis 开源社区涌现了一堆分布式锁的 library,专门用来解决这个问
题。实现方法极为复杂,小白用户一般要费很大的精力才可以搞懂。如果你需要使用分布式锁,
意味着你不能仅仅使用 Jedis 或者 redis-py 就行了,还得引入分布式锁的 library。
为了治理这个乱象,Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和
expire 指令可以一起执行,彻底解决了分布式锁的乱象。从此以后所有的第三方分布式锁
library 可以休息了。
set lock:codehole true ex 5 nx
OK
... do something critical ...
del lock:codehole
上面这个指令就是 setnx 和 expire 组合在一起的原子指令,它就是分布式锁的
奥义所在。
超时问题
Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至
于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,
但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻
辑执行完之间拿到了锁。
为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了,数
据出现的小波错乱可能需要人工介入解决。
有一个稍微安全点的方案:在抢锁时,set指令的value参数设置为一个随机数,释放锁时先匹配value是否一致,再进行删除key。这种方式可以确保当前连接的操作,不会被其他连接释放,除非是过期自动释放。
以上的匹配value和删除key不是原子性的,所以需要使用lua脚本,来保证连续多个指令的原子性执行。但是这也不是一个完美的方案,只是相对安全一点。它始终没能解决锁超时,其他线程“乘虚而入”的问题。