分布式锁 事务 一致性

这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山:NPC

  • N:Network Delay,网络延迟
  • P:Process Pause,进程暂停(GC)
  • C:Clock Drift,时钟漂移

分布式锁

redis

  • 加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
  • 解锁命令:DEL key,通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。
  • 锁超时:EXPIRE key timeout, 设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。
1
2
3
4
5
6
7
8
if (setnx(key, 1) == 1){
    expire(key, 30)
    try {
        //TODO 业务逻辑
    } finally {
        del(key)
    }
}

SETNX 和 EXPIRE 非原子性

setnx与expire不是一个原子操作 如果程序执行完第一步后异常了,第二步expire没有得到执行,相当于这个锁没有过期时间,有产生死锁的可能。正对这个问题如何改进

set key value nx ex sec

还存在的问题

  • 锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
  • 释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁

从 Redis 2.6.12 版本开始, SET 命令的行为可以通过一系列参数来修改:

  • EX seconds : 将键的过期时间设置为 seconds 秒。 执行 SET key value EX seconds 的效果等同于执行 SETEX key seconds value
  • PX milliseconds : 将键的过期时间设置为 milliseconds 毫秒。 执行 SET key value PX milliseconds 的效果等同于执行 PSETEX key milliseconds value
  • NX : 只在键不存在时, 才对键进行设置操作。 执行 SET key value NX 的效果等同于执行 SETNX key value
  • XX : 只在键已经存在时, 才对键进行设置操作。

锁误解除

如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁

检查是否属于自已 判断锁与解锁 非原子 也可以value是特殊的value, value不对就不删了

1
2
3
4
5
6
7
8
9
10
// 加锁
String uuid = UUID.randomUUID().toString().replaceAll("-","");
SET key uuid NX EX 30

//lua脚本删除key原子操作
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。

  • 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成。
  • 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。

不可重入

线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。

如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。

Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。

无法等待锁释放

如果客户端可以等待锁释放就无法使用。

  • 可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。
  • 另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息。

img

主备切换

为了保证 Redis 的可用性,一般采用主从方式部署。主从数据同步有异步和同步两种方式,Redis 将指令记录在本地内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一致的状态,一边向主节点反馈同步情况。

在包含主从模式的集群部署方式中,当主节点挂掉时,从节点会取而代之,但客户端无明显感知。当客户端 A 成功加锁,指令还未同步,此时主节点挂掉,从节点提升为主节点,新的主节点没有锁的数据,当客户端 B 加锁时就会成功。

img

fecing token

利用递增token,拒绝迟到的人

图片

  • 使用分布式锁,在上层完成互斥目的,虽然极端情况下锁会失效,但它可以最大程度把并发请求阻挡在最上层,减轻操作资源层的压力。

  • 但对于要求数据绝对正确的业务,在资源层一定要做好兜底,设计思路可以借鉴 fecing token 的方案来做。

集群脑裂

集群脑裂指因为网络问题,导致 Redis master 节点跟 slave 节点和 sentinel 集群处于不同的网络分区,因为 sentinel 集群无法感知到 master 的存在,所以将 slave 节点提升为 master 节点,此时存在两个不同的 master 节点。Redis Cluster 集群部署方式同理。

当不同的客户端连接不同的 master 节点时,两个客户端可以同时拥有同一把锁。

img

故强一致性要求的业务不推荐使用Reids,推荐使用zk。

Redis集群各方法的响应时间均为最低。随着并发量和业务数量的提升其响应时间会有明显上升(公有集群影响因素偏大),但是极限qps可以达到最大且基本无异常

操作失败后,需要轮询,占用cpu资源;

锁删除失败 过期时间不好控制

Redlock

解决主从切换后,锁失效问题。

Redlock 的方案基于 2 个前提:

  1. 不再需要部署从库哨兵实例,只部署主库
  2. 但主库要部署多个,官方推荐至少 5 个实例

也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。

流程(加锁和释放都要用上面说的原子操作):

  • 客户端先获取当前时间戳T1
  • 客户端依次向这 5 个 Redis 实例发起加锁请求,如果某一个实例加锁失败,就立即向下一个 Redis 实例申请加锁。
  • 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取当前时间戳T2,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。
  • 加锁成功,去操作共享资源
  • 加锁失败,向全部节点发起释放锁请求

为什么多个实例上加锁?为什么大多数加锁成功,才算成功

本质上是为了容错,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。

这是一个分布式系统容错问题,这个问题的结论是:如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。

为什么计算加锁的累计耗时

因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。

所以,即使大多数节点加锁成功,但如果加锁的累计耗时>锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。

为什么释放锁,要操作所有节点

在某一个 Redis 节点加锁时,可能因为网络原因导致加锁失败。所以,释放锁时,不管之前有没有加锁成功,需要释放所有节点的锁,以保证清理节点上残留的锁。

zk

Zookeeper 的有序节点

  • 永久节点:不会因为会话结束或者超时而消失

  • 临时节点:如果会话结束或者超时就会消失

  • 有序节点:会在节点名的后面加一个数字后缀,并且是有序的,例如生成的有序节点为 /lock/node-0000000000,它的下一个有序节点则为 /lock/node-0000000001,依次类推

创建一个锁目录 /lock。 在 /lock 下创建临时的且有序的子节点(临时的就不会死锁),

第一个客户端对应的子节点为 /lock/lock-0000000000,第二个为 /lock/lock-0000000001,以此类推。 客户端获取 /lock 下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁。 否则监听自己的前一个子节点,获得子节点的变更通知后重复此步骤直至获得锁。 执行业务代码,完成后,删除对应的子节点。

使用ZooKeeper集群,锁原理是使用ZooKeeper的临时节点,临时节点的生命周期在Client与集群的Session结束时结束。因此如果某个Client节点存在网络问题,与ZooKeeper集群断开连接,Session超时同样会导致锁被错误的释放(导致被其他线程错误地持有),因此ZooKeeper也无法保证完全一致。

ZK具有较好的稳定性;响应时间抖动很小,没有出现异常。但是随着并发量和业务数量的提升其响应时间和qps会明显下降。

真的安全吗

客户端创建临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁呢

客户端此时会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端定时心跳来维持连接。如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。

Zookeeper 的优点:

  1. 不需要考虑锁的过期时间
  2. watch 机制,加锁失败,可以 watch 等待锁释放,实现乐观锁

但它的劣势是:

  1. 性能不如 Redis
  2. 部署和运维成本高
  3. 客户端与 Zookeeper 的长时间失联,锁被释放问题

分布式事务

分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。例如在大型电商系统中,下单接口通常会扣减库存、减去优惠、生成订单 id, 而订单服务与库存、优惠、订单 id 都是不同的服务,下单接口的成功与否,不仅取决于本地的 db 操作,而且依赖第三方系统的结果,这时候分布式事务就保证这些操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。

一致性

  • 强一致性

    任何一次读都能读到某个数据的最近一次写的数据。系统中的所有进程,看到的操作顺序,都和全局时钟下的顺序一致。简言之,在任意时刻,所有节点中的数据是一样的。

  • 弱一致性

    数据更新后,如果能容忍后续的访问只能访问到部分或者全部访问不到,则是弱一致性。

  • 最终一致性

    不保证在任意时刻任意节点上的同一份数据都是相同的,但是随着时间的迁移,不同节点上的同一份数据总是在向趋同的方向变化。简单说,就是在一段时间后,节点间的数据会最终达到一致状态。

数据库事务的属性-ACID(四个英文单词的首写字母):

  • 原子性(Atomicity)

所谓原子性就是将一组操作作为一个操作单元,是原子操作,即要么全部执行,要么全部不执行。

  • 一致性(Consistency)

事务的一致性指的是在一个事务执行之前和执行之后数据库都必须处于一致性状态。如果事务成功地完成,那么系统中所有变化将正确地应用,系统处于有效状态。如果在事务中出现错误,那么系统中的所有变化将自动地回滚,系统返回到原始状态。

  • 隔离性(Isolation)

隔离性指并发的事务是相互隔离的。即一个事务内部的操作及正在操作的数据必须封锁起来,不被其它企图进行修改的事务看到。

  • 持久性(Durability)

持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和数据库故障不应该对其有任何影响。即一旦一个事务提交,DBMS(Database Management System)保证它对数据库中数据的改变应该是永久性的,持久性通过数据库备份和恢复来保证。

CAP

  • 一致性(Consistence)

    在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)

  • 可用性(Availability)

    每一个操作总是能够在一定的时间返回结果(对数据更新具备高可用性)

  • 分区容错性(Partition tolerance)

    分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务。

    这里的网络分区是指由于某种原因,网络被分成若干个孤立的区域,而区域之间互不相通。

XA协议的两阶段提交(2PC)

存在一个负责协调各个本地资源管理器的事务管理器本地资源管理器一般是由数据库实现,

事务管理器在第一阶段的时候询问各个资源管理器是否都就绪?如果收到每个资源的回复都是 yes,则在第二阶段提交事务,如果其中任意一个资源的回复是 no, 则回滚事务。

  • 第一阶段(prepare):事务管理器向所有本地资源管理器发起请求,询问是否是 ready 状态,所有参与者都将本事务能否成功的信息反馈发给协调者;
  • 第二阶段 (commit/rollback):事务管理器根据所有本地资源管理器的反馈,通知所有本地资源管理器,步调一致地在所有分支上提交或者回滚。

缺点

同步阻塞:当参与事务者存在占用公共资源的情况,其中一个占用了资源,其他事务参与者就只能阻塞等待资源释放,处于阻塞状态。

单点故障:一旦事务管理器出现故障,整个系统不可用

数据不一致:在阶段二,如果事务管理器只发送了部分 commit 消息,此时网络发生异常,那么只有部分参与者接收到 commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。

不确定性:当协事务管理器发送 commit 之后,并且此时只有一个参与者收到了 commit,那么当该参与者与事务管理器同时宕机之后,重新选举的事务管理器无法确定该条消息是否提交成功。

TCC

Try-Confirm-Cancel

  1. 解决了协调者单点,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。
  2. 同步阻塞:引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。
  3. 数据一致性,有了补偿机制之后,由业务活动管理器控制一致性
  • Try 阶段:

    尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)

  • Confirm 阶段:

    确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作满足幂等性。要求具备幂等设计,Confirm 失败后需要进行重试。

  • Cancel 阶段:

    取消执行,释放 Try 阶段预留的业务资源 Cancel 操作满足幂等性 Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致。

基于 TCC 实现分布式事务,会将原来只需要一个接口就可以实现的逻辑拆分为 Try、Confirm、Cancel 三个接口,所以代码实现复杂度相对较高。

本地消息表

  • A(消息生产方)需要额外建一个消息表,并记录消息发送状态

    同一数据库同一事务操作

    • 更新数据库的业务表
    • 更新消息表
  • 消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。
  • B 消费 MQ 中的消息,并处理业务逻辑。
    • 如果本地事务处理失败,会在继续消费 mq 中的消息进行重试
    • 如果业务上的失败,生产方发送一个业务补偿消息,可以通知系统 A 进行回滚操作
  • 生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。

kafka事务

  • 上图中的 Transaction Coordinator 运行在 Kafka 服务端,下面简称 TC 服务。

  • __transaction_state 是 TC 服务持久化事务信息的 topic 名称,下面简称事务 topic。

  • Producer 向 TC 服务发送的 commit 消息,下面简称事务提交消息。

  • TC 服务向分区发送的消息,下面简称事务结果消息。

事务场景

  • 生产者发送多条消息可以封装在一个事务中,形成一个原子操作。多条消息要么都发送成功,要么都发送失败。
  • read-process-write模式:将消息消费和生产封装在一个事务中,形成一个原子操作。在一个流式处理的应用中,常常一个服务需要从上游接收消息,然后经过处理后送达到下游,这就对应着消息的消费和生成。

  • kafka系列九、kafka事务原理、事务API和使用场景

  • Kafka 事务实现原理