使用分布式锁的正确姿势
1 背景
应用开发时,如果需要在同进程内的不同线程并发访问某项资源,可以使用各种互斥锁、读写锁;如果一台主机上的多个进程需要并发访问某项资源,则可以使用进程间同步的原语,例如信号量、管道、共享内存等。但如果多台主机需要同时访问某项资源,就需要使用一种在全局可见并具有互斥性的锁了。这种锁就是分布式锁,可以在分布式场景中对资源加锁,避免竞争资源引起的逻辑错误。
2 分布式锁的特性
3 分布式锁的实现方式
根据锁资源本身的安全性,我们将分布式锁分为三个大类:
单点读写系统
基于异步复制的分布式系统,例如 mysql,redis 等;
基于分布式一致性协议的分布式系统,例如 zookeeper,etcd,consul 等;
三种实现方式对比:
单点系统性能最好,可用性最差,适用于锁故障对业务影响相对可控的服务。
基于异步复制的分布式系统,存在数据丢失(丢锁)的风险,不够安全,往往通过 TTL 的机制承担细粒度的锁服务,该系统接入简单,适用于对时间很敏感,期望设置一个较短的有效期,执行短期任务,丢锁对业务影响相对可控的服务。
基于分布式一致性协议的分布式系统,通过分布式一致性协议保证数据的多副本,数据安全性高,往往通过租约(会话)的机制承担粗粒度的锁服务,该系统需要一定的门槛,适用于对安全性很敏感,希望长期持有锁,不期望发生丢锁现象的服务。
PS:redis 作为异步复制的分布式系统,为了解决数据丢失的问题,引入了 redlock 机制和 WAIT 命令,具体实现方式粘贴在附录中
4 分布式锁注意事项
加锁和解锁的操作要保证原子性
这种实现方式把加锁和设置过期时间的步骤分成两步,他们并不是原子操作,如果加锁成功之后程序崩溃、服务宕机等异常情况,导致没有设置过期时间,那么就会导致死锁的问题,其他线程永远都无法获取这个锁。
正确用法(解锁操作同理)
服务端每把锁都和唯一的会话绑定,每个会话设置唯一编号,避免误删除
设置合理的超时时间,通过其他的线程自动续租,为将要过期的锁延长持有时间
不续租可能导致同一个锁被多个客户端持有
在异步线程中续期,避免操作时间太长续期不及时
处理十分重要的数据时,引入 io fence 机制
在极端情况下,设置了合理的超时时间和续租也没有办法保证完全正确,如下图所示,Client1 获取了锁,在操作数据的时候发生了 GC,在 GC 完成时候丢失了锁的所有权,造成了数据不一致。(错误举例:系统GC导致秒杀商品超卖的例子)
需要分布式锁系统、业务系统和底层存储同时协作来完成一个完全正确的互斥访问,在存储系统引入 IO Fence 能力,如下图所示,全局锁服务提供全局自增的 token,Client1 拿到锁返回的 token 是 33,并带入存储系统,发生 GC,当 Client2 抢锁成功返回 34,带入存储系统,存储系统会拒绝 token 较小的请求,那么经过了长时间 full gc 重新恢复后的 Client 1 再次写入数据的时候,因为存储层记录的 Token 已经更新,携带 token 值为 33 的请求将被直接拒绝,从而达到了数据保护的效果。
5 分布式锁的方案选择
对于并发不高并且比较简单的场景,有什么现成的就用什么
6 如果分布式锁出现故障了,系统怎么保证不故障的
数据分类,根据每个业务操作的数据特点,进行分类
业务处理,针对不同的数据分类,选择不同的故障处理方式
目前想到的处理方式包括:数据库加锁、数据原子操作、降级为单机锁直接执行、IO Fence、直接报错不执行。
分布式锁出故障的地方要留有足够的日志,方便及时发现故障和事后数据的修正。
附录:
redis 的分布式锁的特殊用法:
redlock 方式
因为在 Redis 的主从架构下,主从同步是异步的,如果在 Master 节点加锁成功后,指令还没有同步到 Slave 节点,此时 Master 挂掉,Slave 被提升为 Master,新的 Master 上并没有锁的数据,其他的客户端仍然可以加锁成功。对于这种问题,Redis 作者提出了 RedLock 红锁的概念。
RedLock 的理念下需要至少 2 个 Master 节点,多个 Master 节点之间完全互相独立,彼此之间不存在主从同步和数据复制。
主要步骤如下:
获取当前 Unix 时间
按照顺序依次尝试从多个节点锁,如果获取锁的时间小于超时时间,并且超过半数的节点获取成功,那么加锁成功。这样做的目的就是为了避免某些节点已经宕机的情况下,客户端还在一直等待响应结果。举个例子,假设现在有 5 个节点,过期时间=100ms,第一个节点获取锁花费 10ms,第二个节点花费 20ms,第三个节点花费 30ms,那么最后锁的过期时间就是 100-(10+20+30),这样就是加锁成功,反之如果最后时间<0,那么加锁失败
如果加锁失败,那么要释放所有节点上的锁
红锁的问题在于:
加锁和解锁的延迟较大。
占用的资源过多,为了实现红锁,需要创建多个互不相关的云 Redis 实例或者自建 Redis。
节点崩溃重启时会导致异常情况,比如有 1~5 号五个节点,并且没有开启持久化,客户端 A 在 1,2,3 号节点加锁成功,此时 3 号节点崩溃宕机后发生重启,就丢失了加锁信息,客户端 B 在 3,4,5 号节点加锁成功。
使用 WAIT 命令
Redis 的 WAIT 命令会阻塞当前客户端,直到这条命令之前的所有写入命令都成功从 master 同步到指定数量的 replica,命令中可以设置单位为毫秒的等待超时时间。在云 Redis 版中使用 WAIT 命令提高分布式锁一致性的示例如下:
SET resource_1 random_value NX EX 5
WAIT 1 5000
使用以上代码,客户端在加锁后会等待数据成功同步到 replica 才继续进行其它操作,最大等待时间为 5000 毫秒。执行 WAIT 命令后如果返回结果是 1 则表示同步成功,无需担心数据不一致。相比红锁,这种实现方法极大地降低了成本。
需要注意的是:
WAIT 只会阻塞发送它的客户端,不影响其它客户端。
WAIT 返回正确的值表示设置的锁成功同步到了 replica,但如果在正常返回前发生高可用切换,数据还是可能丢失,此时 WAIT 只能用来提示同步可能失败,无法保证数据不丢失。您可以在 WAIT 返回异常值后重新加锁或者进行数据校验。
解锁不一定需要使用 WAIT,因为锁只要存在就能保持互斥,延迟删除不会导致逻辑问题。
两种方式对比
使用红锁实现成本高,优势是 Redis 节点越多则一致性越强。
使用 WAIT 命令最大优势是实现成本低,但是 redis 数据复制是有成本的,一个 master 节点下面无法挂很多 slave 节点,一致性不如红锁
评论