likes
comments
collection
share

分布式场景下怎么上厕所? --Golang 分布式锁详解

作者站长头像
站长
· 阅读数 26

前言

并发场景中,为了保护临界资源,锁是我们经常使用的工具。目的是为了让混乱的并发访问退化为秩序的串行访问行为

然而在本地环境中,由于多线程之间能够共享进程的数据,因此可以简单地实现进程内的互斥锁。然而在分布式场景中,有时需要跨域多个物理节点执行加锁操作,因此我们就需要依赖类似于redis、mysql这样的存储状态组件实现所谓的分布式锁技术。

分布式锁的核心性质

要实现一把分布式锁,首先我们要了解一个分布式锁都有哪些核心性质。总体而言,一把分布式锁应该具备:

  • 独占性:锁的基本性质。同一把锁在同一时刻内只能被一个调用方占用。比如正常而言,厕所里一个坑位同一时刻只能有一个人。
  • 健壮性:也就是不能产生死锁。如果某个锁的占用方因为宕机或者陷入GC等长时间阻塞,而无法主动执行解锁的操作,锁也应该能够被正常继承下去,被其他调用方调用。比如,小明在厕所里拉到虚脱,需要有人帮他开门,让坑位空出来。
  • 对称性:加锁方和解锁方应当是同一调用者。
  • 高可用:提供分布式锁的基础组件存在少量结点故障时,要保证分布式锁依然可用。比如厕所里的锁坏掉了,一般会用东西顶住。

分布式锁的实现类型

为了实现上述性质,分布式锁有两种实现类型,分别是主动轮循型watch监听回调型

主动轮循型

类似于单机锁的主动轮询 + CAS乐观锁模型,当加锁方发现锁已经被占用,会对分布式锁持续发出尝试获取的动作,不断发起重试,直到加锁成功为止。

比如要小明上厕所发现厕所里有人,他选择一直在厕所门口等待,直到能够上厕所为止。

监听回调型

加锁方发现锁已经被占用时,会创建watch监视器订阅锁的释放事件。锁一旦被释放,加锁方会通过watch监视器感知到这一变化,然后重新发起加锁操作。

比如小明要上厕所时发现厕所有人,他会选择安插一个监控,检查厕所里没人了通知小明,之后小明再去尝试上厕所。

分析

主动轮询watch监听回调两种锁模型各有优劣,需要对CPU空转以及阻塞携程两种行为的损耗做出权衡。

然而,由于分布式场景中轮询这一动作背后存在的行为可能是一次甚至多次网络IO请求,成本相比于单机锁而言要高很多。这种情况下,加锁方基于watch监听回调的方式,确保锁已经被释放,自身有机会加锁的情况下重新发起加锁请求,这样可以很大程度上避免无意义的轮询损耗。

然而,主动轮询可以保证使用方始终占用流程的主动权,整个流程可以更加的轻便灵活。相比之下watch机制需要在实现过程中建立长连接完成watch的监听操作,也会存在一定的资源损耗。

因此,在并发激烈程度较高时倾向于使用watch监听回调分布式锁;反之,主动轮循型分布式锁可能是更好的选择。

主动轮询型

实现主动轮询型分布式锁时,我们常使用的组件是redis和mysql。

基于Redis实现主动轮询型分布式锁

redis 官方文档:redis.io/

redis 基于内存实现数据的存储,因此足够高轻便高效。 此外,redis 基于单线程模型完成数据处理工作,支持 SETNX 原子指令(set only if not exist),能够很方便地支持分布式锁的加锁操作。

setnx 使用文档:redis.io/commands/se… (事实上,在 redis 2.6.12 版本之后,setnx 操作已经被弃置,官方推荐大家使用 set 指令并附加 nx 参数来实现与 setnx 指令相同的效果)

此外,redis 还支持使用 lua 脚本自定义组装同一个 redis 节点下的多笔操作形成一个具备原子性的事务。

redis lua 脚本使用文档:redis.io/docs/manual…

加锁

加锁时,可以将key对应的value设置成加锁方的身份标识,例如唯一id。同时,为了避免产生死锁问题,我们还需要对锁设置一个过期时间expire time,这样即使使用方因为异常原因导致无法正常解锁时,锁对应的数据项也会在到达过期时间阈值后被自动删除。实现释放分布式锁的效果。

然而,这种过期机制可能会带来新的问题:锁的持有者并不能精确预判自己的持锁时间。因此可能会发生在处理业务流程过程中超过了锁的过期时间导致锁提前被释放的情况。比如小明上厕所的时候并不能精确预判自己上厕所需要花费多长时间,从而可能导致时间到了其他人闯进厕所的情况发生。

针对这个问题,分布式锁工具redisson中给出了解决方案:看门狗策略(watch dog strategy):在锁的持有方未完成业务逻辑的处理时,会对分布式锁的过期阈值进行延期操作。这部分我会单独开一篇文章介绍。

// redis加锁流程
SET my_lock unique_value NX PX 60000
// `my_lock`:锁的键名。
// `unique_value`:锁的唯一标识,用于区分不同的客户端。可以使用UUID或其他唯一值。
// `NX`:仅在键不存在时设置键。
//  `PX 60000`:键的过期时间为60秒。

解锁

解锁时,由于需要保证锁的对称性。需要先判断解锁方的身份,身份合法时才进行解锁。为了保证这两个步骤的原子性,可以通过lua脚本组装步骤。

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

弱一致性问题

为了避免单点故障,redis会基于主从复制的方式实现数据备份。以哨兵机制为例,哨兵会持续监听master节点的健康状况,如果master发生故障,哨兵会扶持slave节点上位,保证整个集群能够正常对外提供服务。此外,在CAP体系中,redis走的是AP路线。为了保证服务的吞吐性能,主从节点之间的数据同步是异步延迟进行的。

那么问题就来了,试想这样一种场景:如果使用方A在redis master结点加锁成功,但是对应的kv记录还未同步到slave结点的时候,master结点就挂掉了。哨兵扶持slave结点上位升级为master。但是此时新的master结点并没有使用方A持有锁的kv记录,因此使用方A持有的"凭证"就凭空消失了。于是不知情的B、C、D就都可能加锁成功。从而出现了一把锁被多方同时持有的问题。导致分布式锁最基本的独占性被破坏。

对于这个问题,一个比较经典的解决方案是:redis红锁(redlock),后续我会单开一篇文章介绍。

基于MySQL实现主动轮询型分布式锁

mysql 官方文档:www.mysql.com/

通过经典的关系型数据库MySQL也可以实现Redis类似的效果。

首先,我们需要创建一张存储分布式锁记录的数据表,并以分布式锁的标识键作为表中的唯一键。基于唯一键的特性,同一把锁只能被插入一条数据。因此只能由同一个使用方占用锁。

加锁

  • 尝试插入一条新纪录到锁表中。如果插入成功,说明我们已经获得了锁。
  • 更新锁的过期时间,如果锁已经存在,需要检查锁的过期时间。如果锁已经过期,需要更新它的过期时间,并且认为已经获得了锁。
-- 1. 尝试插入新锁
INSERT INTO distributed_locks (lock_name, expires_at)
VALUES ('my_lock', NOW() + INTERVAL 1 MINUTE)
ON DUPLICATE KEY UPDATE
expires_at = IF(expires_at < NOW(), VALUES(expires_at), expires_at);

解锁

  • 解锁时,只需要删除表中的对应记录即可。
DELETE FROM distributed_locks WHERE lock_name = 'my_lock';

监听回调型

与主动轮询型的分布式锁不同,watch监听回调型分布式锁在加锁失败时不会持续论文,而是会通过监听组件watch监听锁的删除事件。如果对应的事件发生,说明锁被释放了,此时才会继续尝试加锁。

在实现上,我们需要依赖提供watch机制的状态存储组件,不仅能够支持数据的存储和去重,还需要利用其中的watch监听回调功能进行锁释放事件的订阅感知。

为了满足以上需求,我们常用的技术组件包括etcd和zookeeper。

etcd

etcd 官方文档:etcd.io/

etcd 是一款适合用于共享配置和服务发现的分布式 kv 存储组件,底层基于分布式共识算法 raft 协议保证了存储服务的强一致和高可用。

在 etcd 中提供了watch 监听器的功能,即针对于指定范围的数据,通过与 etcd 服务端节点创建 grpc 长连接的方式持续监听变更事件。

此外,etcd 中写入数据时,还支持通过版本 revision 机制进行取锁秩序的统筹协调,是一款很适合用于实现分布式锁的组件.

加锁

  • 创建租约:这个租约会自动过期
  • 创建锁键:尝试在etcd中创建一个带有租约的键,作为锁。如果键创建成功,表示获取锁成功。
  • 续约:在持有锁的过程中,可以定期续约,防止锁过期。

解锁

  • 删除锁键:在etcd中删除锁键,释放锁。

  • 撤销租约:撤销租约,确保租约相关的资源被释放。

死锁问题

etcd提供了租约lease机制避免死锁问题的产生。顾名思义,一旦达到了租约上规定的截止时间,租约就会失去效力。同时,etcd中还提供了续约机制(keepAlive),用户可以通过续约操作延迟租约的过期时间。

如何利用租约机制解决死锁问题?

  • 加锁方先申请一份租约,设定好租约的截止时间;
  • 异步启动一个续约携程,在业务逻辑处理完成之前,按照一定的时间节奏进行续约操作;
  • 执行加锁动作,将对应于锁的kv数据和租约进行关联绑定,使得锁数据和租约拥有相同的过期时间属性;

惊群效应

惊群效应又称为羊群效应:羊群是一种纪律性很差的组织,平时就处在一种散漫无秩序地移动模式之下. 需要注意的是,在羊群中一旦有某只羊出现异动,其他的羊也会不假思索地一哄而上跑动起来,全然不估计附近可能有狼或者何处有更好的草源等客观问题.

在 watch 回调型分布式锁的实现过程中,可能也会存在类似于惊群效应的问题. 这里指的是:倘若一把分布式锁的竞争比较激烈,那么锁的释放事件可能同时被多个的取锁方所监听,一旦锁真的被释放了,所有的取锁方都会一拥而上尝试取锁,然而我们知道,一个轮次中真正能够取锁成功的只会有一名角色,因此这个过程中会存在大量无意义的性能损耗,且释放锁时刻瞬间激增的请求流量也可能会对系统稳定性产生负面效应.

为了避免惊群效应,etcd中提供了前缀prefix机制和版本revsion机制,和zookeeper的临时顺序节点功能有些类似:

  • 对于同一把分布式锁,锁记录数据的key拥有共同的前缀prefix,作为锁的标识;
  • 每个加锁方加锁时,会以锁前缀prefix拼接上自身的身份标识(租约id),生成完整的lock key。因此各个加锁方完整的lock key都是互不相同的(只是有着相同的前缀),理论上所有的加锁方都能够成功把锁记录数据插入到etcd中。
  • 每个加锁方插入锁记录数据的时候,会获得自身lock key处于锁前缀prefix范围下唯一且递增的版本号revision
  • 加锁方插入加锁记录不意味着加锁成功,而是需要在插入数据后查询一次锁前缀prefix下的记录列,判定自身lock key对应的revision是不是其中最小的,如果是的话,才表示加锁成功。
  • 如果锁被他人占用,加锁方会watch监听revision小于自己但是最接近自己的那个lock key的删除事件。

这样所有的加锁方就会在revision机制的协调下,根据加锁序号(revision)的先后顺序排成一条序列,每当锁被释放,只会惊动下一顺位的加锁方,从而解决惊群问题。

zookeeper

zookeeper 官方文档:zookeeper.apache.org/

ZooKeeper是一款开源的分布式应用协调服务,底层基于分布式共识算法 zab 协议保证了数据的强一致性和高可用性。

zookeeper 中提供了临时顺序节点(EPHEMERAL_SEQUENTIAL)类型以及 watch 监听器机制,能够满足实现 watch 回调型分布式锁所需要具备的一切核心能力。

加锁

  • 创建临时顺序结点:在Zookeeper中创建一个临时顺序结点,表示请求锁;
  • 获取最小顺序结点:检查当前创建的结点是否是最小的顺序结点,如果是,则表示获取锁成功;
  • 监听前一个节点:如果当前节点不是最小的顺序结点,则监听前一个顺序结点的删除事件;

解锁

  • 删除临时节点:在Zookeeper中删除临时节点,释放锁;

总结

本文和大家一起探讨了分布式场景下怎么上厕所,怎么使用分布式锁的问题,主要分析了使用redis实现主动轮询型和基于etcd实现weatch监听回调型分布式锁的方式。有关golang的源码大家可以移步公众号小徐先生的编程世界查看。

参考文献

本文参考了小徐先生的编程世界公众号的文章# Golang 分布式锁技术攻略,原文地址:Golang 分布式锁技术攻略 (qq.com)

转载自:https://juejin.cn/post/7386565810830098443
评论
请登录