9. 分布式问题 9.1 分布式锁,为什么用?四大特性[❤️] 锁在程序中的作用就是同步工具,保证共享资源在同一时刻只能被一个线程访问。
Go语言中的锁(如sync.Mutex
、sync.RWMutex
等)只能用于在单个进程或单个机器上实现并发控制和数据同步。
在分布式环境下,常见的并发控制和数据同步方法包括 分布式锁 、分布式事务 和一致性协议 等。这些方法在保证数据一致性的同时,也能在分布式系统中实现并发控制。
分布式锁,顾名思义,就是分布式项目开发中用到的锁,可以用来控制分布式系统之间同步访问共享资源。
思路是:在整个系统提供一个全局、唯一 的获取锁的 “东西”,然后每个系统在需要加锁时,都去问这个“东西” 拿到一把锁,这样不同的系统拿到的就可以认为是同一把锁。至于这个“东西”,可以是 ZooKeeper、etcd、Redis等,也可以是数据库。
这些服务提供了分布式锁机制,可以协调多个节点之间的并发访问,确保在某个节点获取锁时其他节点无法同时获取锁,从而实现分布式环境下的并发控制。
一般来说,分布式锁需要满足的特性有这么几点:
互斥性 :在任何时刻,对于同一条数据,只有一台应用可以获取到分布式锁;高可用性/可靠性 :在分布式场景下,一小部分服务器宕机不影响正常使用,这种情况就需要将提供分布式锁的服务以集群的方式部署;安全性 :避免死锁情况发⽣。当⼀个竞争者在持有锁期间内,由于意外崩溃⽽导致未能主动解锁,其持有的锁也能够被正常释放,并保证后续其它竞争者也能加锁;独占性 :同⼀个锁,加锁解锁必须由同一台服务器进行,也就是锁的持有者才可以释放锁,不能出现你加的锁,别人给你解锁了。9.2 常见的分布式锁方案 实现分布式锁目前有三种流行方案,即基于关系型数据库、缓存(Redis)、ZooKeeper的方案:
基于关系型数据库的实现 :
用MySQL举例,可以使用数据库的事务和唯一约束来实现分布式锁。例如,在数据库中创建一个特定的表,使用某一行作为锁,通过事务来获取和释放锁。其他节点在尝试获取锁时会遇到唯一约束,从而实现了互斥访问。
缺点:
依赖于数据库的性能和可用性,数据库的故障可能导致锁失效。 依赖于数据库的性能和可用性,数据库的故障可能导致锁失效。 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。 基于缓存的实现 :
使用分布式缓存系统如Redis或Memcached来实现分布式锁。
通过在缓存中设置一个特定的键值对作为锁,利用缓存的原子操作(如setnx)来实现锁的获取和释放。其他节点在尝试获取锁时,如果发现锁已经存在,则表示锁被其他节点占用,需要等待或重试。
优点:
Redis 锁实现简单,理解逻辑简单,性能好,可以支撑高并发的获取、释放锁操作。 缓存系统通常具有高性能和低延迟,适合用于实现高并发的分布式锁。 缺点:
Redis 容易单点故障,集群部署,并不是强一致性的,锁的不够健壮; 需要保证缓存系统的高可用性和数据持久性。 需要自己不断去尝试获取锁,比较消耗性能。 基于ZooKeeper实现 :
使用ZooKeeper作为分布式协调服务,利用其顺序节点和临时节点的特性来实现分布式锁。每个节点在获取锁时在ZooKeeper中创建一个有序临时节点,根据节点的顺序来确定锁的归属。其他节点通过监视前一个节点的删除来判断是否获取到了锁。
优点
zookeeper 天生设计定位就是分布式协调,强一致性,锁很健壮。如果获取不到锁,只需要添加一个监听器就可以了(通过观察前一个节点的删除来实现锁的竞争),不用一直轮询,性能消耗较小。 缺点
在高请求高并发下,系统疯狂的加锁释放锁,最后 zk 承受不住这么大的压力可能会存在宕机的风险。 ZooKeeper的部署和维护相对复杂。 9.3 Redis实现分布式锁[❤️] 分布式锁的三个核心要素:
加锁 :
使用 setnx 来加锁,key 是锁的唯一标识,按业务来决定命名,value 这里设置为 test。
$ setnx key test
setnx:在缓存中设置一个键值对,只有在该键不存在时才设置成功,返回值表示设置是否成功。
当一个线程执行 setnx 返回 1,说明 key 原本不存在,该线程成功得到了锁;
当一个线程执行 setnx 返回 0,说明 key 已经存在,该线程抢锁失败;解锁 :
有加锁就得有解锁。当得到的锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式就是执行 del 指令。
$ del key
释放锁之后,其他线程就可以继续执行 setnx 命令来获得锁。锁超时 :
锁超时导致的是:如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住,别的线程别想进来。
所以,setnx 的 key 必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一段时间后自动释放。setnx 不支持超时参数,所以需要额外指令,
$ expire key 30但是,上面这种通过 setnx
+del
+expire
实现的分布式锁存在一定问题。
问题一 :setnx + expire 是非原子性的。
假设一个场景中,某一个线程刚执行 setnx,成功得到了锁。此时 setnx 刚执行成功,还未来得及执行 expire 命令,节点就挂掉了。此时这把锁就没有设置过期时间,别的线程就再也无法获得该锁。
解决措施 :
由于 setnx 指令本身是不支持传入超时时间的,而在 Redis2.6.12 版本上为 set 指令增加了可选参数, 用法如下:
$ SET lock_key unique_value [EX seconds][PX milliseconds] [NX|XX] # 例如: $ SET lock_key unique_value EX 10 NX
unique_value 是客户端⽣成的唯⼀的标识,区分来⾃不同客户端的锁操作 EX second: 设置键的过期时间为 second 秒; PX millisecond:设置键的过期时间为 millisecond 毫秒; NX:只在键不存在时,才对键进行设置操作; XX:只在键已经存在时,才对键进行设置操作; SET 操作完成时,返回 OK,否则返回 nil。
问题二 :锁误解除
如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。
解决方法 :
在 del 释放锁之前加一个判断,验证当前的锁是不是自己加的锁。
具体在加锁的时候把当前线程的 id 当做 value,可生成一个 UUID 标识当前线程,在删除之前验证 key 对应的 value 是不是自己线程的 id。
还可以使用 lua 脚本做验证标识和解锁操作。问题三 :超时解锁导致并发
如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。
解决方案 :
A、B 两个线程发生并发显然是不被允许的,一般有两种方式解决该问题:
将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成。 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。 问题四 :不可重入
当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。
Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。问题五 :无法等待锁释放
上述命令执行都是立即返回的,如果客户端可以等待锁释放就无法使用。
解决方案 :
可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。 另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅 锁释放消息,获取锁成功后释放时,发送锁释放消息。 9.4 了解RedLock吗? RedLock 是一种分布式锁算法,用于在多个 Redis 实例之间实现分布式锁。它是由 Redis 官方提供的一种分布式锁解决方案,旨在解决 Redis 单实例的单点故障和数据丢失的风险。
RedLock 的实现基于多个 Redis 实例,并且要求这些实例分布在不同的物理节点上。
RedLock 的目标是通过 多数原则 来确保锁的可用性和一致性。即使在少数 Redis 实例发生故障或网络分区的情况下,只要大多数实例可用,锁仍然可以被获取和释放。
需要注意的是,RedLock 仍然是一个简化的分布式锁方案,不是一个完美的解决方案,也有一些潜在的问题和限制。
此种方式具有以下特性:
互斥访问:即永远只有一个 client 能拿到锁 容错性:只要大部分 Redis 节点存活(一半以上),就可以正常提供服务 9.4 RedLock 的基本原理 获取当前时间戳 获取锁:
客户端在多个 Redis 实例上尝试获取锁。这些 Redis 实例应该分布在不同的物理节点上,以增加系统的可靠性。 客户端可以选择 获取锁时的当前时间 作为 锁的值,并设置锁的过期时间(TTL)。 客户端使用 SET
命令尝试在多个 Redis 实例上设置锁,设置成功的实例数需要满足多数原则(Majority),即大多数实例成功设置了锁,才认为获取到了锁。 如果成功获取到锁,即满足多数原则,并且客户端在获取锁的时间内没有超过锁的有效期,则表示获取锁成功。客户端可以继续执行临界区内的操作。 释放锁:
释放锁时,客户端会在所有获取锁的 Redis 实例上执行 DEL
命令,删除对应的锁。 需要确保在释放锁的时间内 没有 超过锁的有效期,以避免误释放其他客户端的锁。 可能有些迷惑,我们来举个例子:
9.4.1 正常情况 首先,我们需要至少5台(大于等于5的奇数个)Redis服务器,这5台Redis之间相互独立,没有任何主从、集群关系。
接着,我们按照从左到右的顺序,在Redis服务器上获取锁,我们假设
锁的过期时间为10s 加锁的开始时间是00:00:00 在第一台服务器上获取到锁的时间为00:00:01 在第二台服务器上获取到锁的时间为00:00:02 在第三台服务器上获取到锁的时间为00:00:03
现在,已经有超过半数(3/5)的Redis服务器获取到了锁。
获取锁所用的时间 = 最后一台获取到锁的Redis服务器获取到锁的时间 - 加锁的开始时间 锁的有效剩余时间(TTL) = 锁的过期时间 - 获取锁所用的时间 获取锁所用的时间 = 00:00:03 - 00:00:00 = 3s,TTL = 10s - (00:00:03 - 00:00:00) = 7s。
所以,获取锁的时间并没有超过锁的有效期,我们认为获取锁成功。
认为锁获取成功的条件有两个:
超过半数的Redis服务器获取到了锁 获取锁的时间没有超过锁的有效期 9.4.2 重试 以上列举的示例是非常顺利获取到锁的情况,然而很多时候,分布式锁的获取没那么顺利,很可能出现以下情况:
A已经获取到了两台Redis服务器的锁 B已经获取到了两台Redis服务器的锁 C已经获取到了一台Redis服务器的锁 如果三台客户端的请求一直处于阻塞状态(直到达到锁的有效期),会严重影响锁的获取效率,这时就需要重试机制 。
重试机制:在一开始,同时向所有的(这里是5台)Redis服务器,发送SET key value EX senconds NX
命令,当所有服务器都返回结果后,判断是否以达成“锁获取成功的两个条件”,如果达成了,则锁获取成功。如果没有,则立即将已获取的锁释放掉,并等待一小段时间,重复以上步骤(一般会尝试3次)。如果这期间仍未达成“锁获取成功的两个条件”,则认为锁获取失败。
我正在参与2023腾讯技术创作特训营第二期有奖征文,瓜分万元奖池和键盘手表