背景redis
在编程领域,幂等性是指对同一个系统,使用一样的条件,一次请求和重复的屡次请求对系统资源的影响是一致的。编程
在分布式系统里,client 调用 server 提供的服务,因为网络环境的复杂性,调用可能有如下几种状况:网络
server 收到 client 的请求,client 也收到 server 的响应结果架构
client 发出了请求,但 server 未收到,多是 server 重启、网络超时等缘由分布式
server 发出了响应,但 client 未收到ide
对于后两种状况,client 通常会进行一次重试,这样 server 可能会收到屡次重复的请求。对于某些自然就幂等的服务来讲,好比对资源的读操做,无论读多少次,资源不会有变化;但对非幂等服务,server 执行一次和重复执行屡次,对资源的影响就不肯定了。函数
例如银行扣款服务,用函数表示为 bool withdraw(account_id, amount),client 发起一次调用 withdraw(1001, 10) 请求从账户 1001 中扣除 10 元,若是发生了上图所示的第 2 种错误,这时候 server 端在账户里已经完成了扣款,但 client 并不知道,若是重试调用 withdraw(1001, 10) ,server 端又会从 账户 1001 扣除 10 元,显然这个非幂等的扣款服务并非 client 想要的。post
若是将 client 的一次扣款操做和后续的重试用一个额外的 id 来标识:bool withdraw(id, account_id, amount),server 针对一个 id 的相同请求只执行一次,这样就能够避免上述的问题了。此时扣款服务也是幂等的了。学习
实现方案ui
按照上面介绍的幂等的扣款服务的实现思路,抽象出一个通用的中间层,非幂等的服务要改形成幂等的,只须要增长一个额外的 id 参数。服务实现里先根据此 id 去中间层查询服务是否执行过,根据查询结果决定的是否继续后续的业务流程。中间层至关于一个特殊的分布式互斥锁,根据 id 查询的过程至关于对某把锁尝试加锁的操做。锁被锁住后永远不释放(除非锁过时了,这里为了叙述方便简单认为永远不释放)。锁被一个进程锁住后其余进程都没法再加锁,这样就保证了服务是幂等的了。
第一个对互斥锁加锁的进程任务没有执行完就挂掉,锁又是不会释放的,其余进程又没法重复加锁,致使这个失败的任务也不能被其余进程从新执行。为了不这种状况,将加锁的操做分红 2 步:
TryAcquire
尝试获取锁,结果有两种状况:
1.1 拿到了锁(锁转到 TryAcquired 状态),这时候能够执行正常的业务流程,执行完了须要再调用第二步 Confirm 明确锁已被锁住(锁转到 Confirmed 状态),这以后其余进程都拿不到这把锁;
1.2 没拿到锁,多是如下三种状况之一:
1.2.1 锁处于 Confirmed 状态,这种状况不该该继续业务流程处理直接返回;
1.2.2 锁处于 TryAcquired 状态,但超时时间没到,说明这个时候有其余进程拿到了锁正在进行相应的业务流程,本进程不该该执行相应的业务流程直接返回;
1.2.3 锁处于 TryAcquired 状态,但超时时间到了,说明已有其余进程拿到了锁,但好久没有 Confirm ,有多是执行过程当中挂掉了,这时候本进程应该要执行相应的业务流程,而后调用第二步 Confirm 。
Confirm
将锁置成 Confirmed 状态,表示互斥锁被永久锁住。
锁的状态转换以下所示(expire 为 redis key 过时):
使用 Redis 实现,key 为互斥锁的标识,value 为锁的状态:
0:初始状态* -1:Confirmed 状态
其余值:TryAcquired 状态,value 为业务执行截止时间 deadline
server 在增长了保证幂等性的流程图以下(交易表示既定的业务执行流程):
流程图里省略了 redis 错误处理的分支,redis 错误 TryAcquire 直接返回 true 。
TryAcqurie 和 Confirm 实现用伪码描述以下:
id 由 client 根据具体的业务场景决定,能够本地生成或者是从第三方服务获取,要求须要保证能惟一标识某个业务下的一次交易。server 端将此 id 视为互斥锁的惟一标识。
timeout 应该比正常的交易时间大,不然会致使多个进程都能拿到锁不能保证幂等;可是又不能设得太大,不然会致使交易执行失败时要过好久才能从新执行交易。
TryAcquire 和 Confirm 都应该保证原子性,Confirm 只有一个简单的 SET 操做,这个没有问题。TryAcquire 实际上分红两步:1.1 SETNX 和 1.2 GET&SET(不是 redis 是 GETSET 命令)。 上面的伪码中 1.2 GET&SET 的 SET 换成了 INCRBY 并增长了一次返回值比较,至关于使用了乐观锁,因此 GET&SET 的原子性是 OK 的。在此我向你们推荐一个架构学习交流裙。交流学习裙号:821169538,里面会分享一些资深架构师录制的视频录像
下面说明下为何 1.1 和 1.2 整个过程没有保证原子性也是 OK 的:
最坏的状况下假设进程 a 进入 TryAcquire 执行完了 1.1 而后被操做系统调度出去了,此时进程 b 进入 TryAcquire 执行了整个流程拿到了锁,而后执行了一次交易。这时候进程 a 从新被调度执行,这个时候因为进程 b 更新了 deadline 甚至执行完了 Confirm,进程 a 会在 1.2.1 或 1.2.2 处退出而且不会执行交易,若是走到了 1.2.3 而且拿到了锁说明进程 b 执行交易时挂掉了,这时由进程 a 从新执行交易也是正确的逻辑。
这个方案忽略了 redis 异常状况,这种状况下 TryAcquire 老是返回 true ,可能会使交易重复执行不能保证幂等。也能够将 redis 异常返回给调用者,由调用者根据业务场景来决定是否须要从新执行交易。
另一种状况进程经过 TryAcquire 拿到锁后执行完了交易,但 Confirm 失败(挂掉或者网络问题),这种状况在 dealine 到了后,其余进程仍然能够拿到锁并执行交易,这时候也不能保证幂等。
缺陷的本质是这个轻量级的解决方案没法保证分布式事务的原子性。