分布式事务、锁

118 阅读15分钟

分布式事务

cloud.tencent.com/developer/a…

两阶段提交(2PC)

第一个阶段是**「投票阶段」**

  • 1.协调者首先将命令**「写入日志」**
    1. **「发一个prepare命令」**给B和C节点这两个参与者
  • 3.B和C收到消息后,根据自己的实际情况,「判断自己的实际情况是否可以提交」
  • 4.将处理结果**「记录到日志」**系统
  • 5.将结果**「返回」**给协调者

第二个阶段是**「决定阶段」** 当A节点收到B和C参与者所有的确认消息后

  • 「判断」所有协调者「是否都可以提交」
    • 如果可以则**「写入日志」**并且发起commit命令
    • 有一个不可以则**「写入日志」**并且发起abort命令
  • 参与者收到协调者发起的命令,「执行命令」
  • 将执行命令及结果**「写入日志」**
  • **「返回结果」**给协调者
可能会存在哪些问题?
  1. 同步阻塞:无论是在第一阶段的过程中,还是在第二阶段,所有的参与者资源和协调者资源都是被锁住的,只有当所有节点准备完毕,事务协调者才会通知进行全局提交,参与者进行本地事务提交后才会释放资源。这样的过程会比较漫长,对性能影响比较大
  2. 单点故障:如果协调者出现问题,那么整个二阶段提交流程将无法运转。另外,如果协调者是在第二阶段出现了故障,那么其它参与者将会处于锁定事务资源的状态中
  3. 数据不一致性:当协调者在第二阶段向所有参与者发送 Commit 请求后,发生了局部网络异常或者协调者在尚未发送完 Commit 请求之前自身发生了崩溃,导致只有部分参与者接收到 Commit 请求,那么接收到的参与者就会进行提交事务,进而形成了数据不一致性

三阶段提交(3PC)

三阶段提交又称3PC,相对于2PC来说增加了CanCommit阶段和超时机制。如果短时间内没有收到协调者的commit请求,那么就会自动进行commit,解决了2PC单点故障的问题。

3PC 相比较于 2PC 最大的优点就是降低了参与者的阻塞范围,并且能够在协调者出现单点故障后继续达成一致

虽然通过超时机制解决了资源永久阻塞的问题,但是 3PC 依然存在数据不一致的问题。当参与者接收到 PreCommit 消息后,如果网络出现分区,此时协调者与参与者无法进行正常通信,这种情况下,参与者依然会进行事务的提交

  • 第一阶段:**「CanCommit阶段」**这个阶段所做的事很简单,就是协调者询问事务参与者,你是否有能力完成此次事务。

    • 如果都返回yes,则进入第二阶段
    • 有一个返回no或等待响应超时,则中断事务,并向所有参与者发送abort请求
  • 第二阶段:**「PreCommit阶段」**此时协调者会向所有的参与者发送PreCommit请求,参与者收到后开始执行事务操作,并将Undo和Redo信息记录到事务日志中。参与者执行完事务操作后(此时属于未提交事务的状态),就会向协调者反馈“Ack”表示我已经准备好提交了,并等待协调者的下一步指令。

  • 第三阶段:**「DoCommit阶段」**在阶段二中如果所有的参与者节点都可以进行PreCommit提交,那么协调者就会从“预提交状态”转变为“提交状态”。然后向所有的参与者节点发送"doCommit"请求,参与者节点在收到提交请求后就会各自执行事务提交操作,并向协调者节点反馈“Ack”消息,协调者收到所有参与者的Ack消息后完成事务。相反,如果有一个参与者节点未完成PreCommit的反馈或者反馈超时,那么协调者都会向所有的参与者节点发送abort请求,从而中断事务。

补偿事务(TCC)

「Try,Confirm,Cancel」

  • Try阶段主要是对**「业务系统做检测及资源预留」**,其主要分为两个阶段

    • Confirm 阶段主要是对**「业务系统做确认提交」**,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
    • Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,「预留资源释放」

TCC 就是通过代码人为实现了两阶段提交,不同的业务场景所写的代码都不一样,并且很大程度的**「增加」了业务代码的「复杂度」**,因此,这种模式并不能很好地被复用。

本地消息表 (保证一定写入mq)

image.png

消息事务

消息事务的原理是将两个事务**「通过消息**中间件进行异步解耦」,和上述的本地消息表有点类似,但是是通过消息中间件的机制去做的,其本质就是'将本地消息表封装到了消息中间件中'。

执行流程:

  • 发送prepare消息到消息中间件

  • 发送成功后,执行本地事务

    • 如果事务执行成功,则commit,消息中间件将消息下发至消费端
    • 如果事务执行失败,则回滚,消息中间件将这条prepare消息删除
  • 消费端接收到消息进行消费,如果消费失败,则不断重试

这种方案也是实现了**「最终一致性」,对比本地消息表实现方案,不需要再建消息表,「不再依赖本地数据库事务」了,所以这种方案更适用于高并发的场景。目前市面上实现该方案的「只有阿里的 RocketMQ」**。

rocketmq 事务消息

RocketMQ支持使用半消息(Half Message)机制实现分布式事务,其主要流程如下: 事务消息的生产者向RocketMQ发送半消息,半消息中包含了待处理的业务数据,但该消息并不会立即被消费者接收。 事务消息的生产者执行本地事务,如果本地事务执行成功,则向RocketMQ发送确认消息(Commit Message);如果本地事务执行失败,则向RocketMQ发送回滚消息(Rollback Message)。 如果RocketMQ接收到了确认消息,则该半消息被视为已经提交,并对外提供消费;如果RocketMQ接收到了回滚消息,则该半消息被视为已经回滚,不再对外提供消费。

最大努力通知

最大努力通知的方案实现比较简单,适用于一些最终一致性要求较低的业务。 执行流程:

  • 系统 A 本地事务执行完之后,发送个消息到 MQ;
  • 这里会有个专门消费 MQ 的服务,这个服务会消费 MQ 并调用系统 B 的接口;
  • 要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B, 反复 N 次,最后还是不行就放弃。

分布式锁

一条命令即可加锁: SET resource_name my_random_value NX EX 3

同时设置nx 和 过期时间

SET key value [EX seconds|PX milliseconds] [NX|XX]

EXseconds – 设置键key的过期时间,单位时秒
PXmilliseconds – 设置键key的过期时间,单位是毫秒
NX – 只有键key不存在的时候才会设置key的值
XX – 只有键key存在的时候才会设置key的值

解锁时需要确保my_random_value和加锁的时候一致

解锁时需要确保my_random_value和加锁的时候一致。下面的Lua脚本可以完成

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

This is important in order to avoid removing a lock that was created by another client.

过期时间如何设置

如果客户端在操作共享资源的过程中,因为长期阻塞的原因,导致锁过期,那么接下来访问共享资源就不安全。

在设置锁过期时间时,去预估业务耗时时间,那如果锁的过期时间能根据业务运行时间自动调整,那就更方便了。

参考watch dog的实现思路:

基于Redission框架,通过 watch dog 实现锁的自动续期。(watchdog的具体思路是 加锁时,默认加锁 30秒,每10秒钟检查一次,如果存在就重新设置 过期时间为30秒。)

单点 && 集群问题

如果我们的分布式锁跑在单节点的 Redis Master 节点上,那么它就存在单点故障,无法保证分布式锁的高可用。

如果增加Slave 节点用于主备切换,切换时数据可能没来得及同步到Slave上,因为主从复制是异步的

  • 在cluster 模式下 宕机 和 网络分区 都可能导致slave提升成master(如果某个master和其他节点通信故障的话)

  • 在cluster 模式下 网络分区 时 和 小分区master通信的客户端节点的写入可能会丢失

    • After a partition occurs, it is possible that in one side of the partition we have A, C, A1, B1, C1, and in the other side we have B and Z1.

      Z1 is still able to write to B, which will accept its writes. If the partition heals in a very short time, the cluster will continue normally. However, if the partition lasts enough time for B1 to be promoted to master on the majority side of the partition, the writes that Z1 has sent to B in the meantime will be lost.

RedLock算法 (不适用于cluster)

不适用于cluster,因为要往不同的master 节点set 同一个key, 这在cluster模式下 同一个key必然路由到一个master

为了应对这个情形, redis的作者antirez提出了RedLock(Distributed locks with Redis)算法,
**总体思想是尝试锁住所有节点,当有 一半以上节点被锁住就代表加锁成功
**
步骤如下(该流程出自官方文档),
假设我们有N个master节点(官方文档里将N设置成5,其实大等于3就行)

(1)获取当前时间(单位是毫秒)。
(2)轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
(3)客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
(4)如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
(5)如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。

基于zookeeper实现

ZooKeeper的分布式锁实现原理
ZooKeeper的分布式锁是基于ZooKeeper提供的有序节点(Sequential Nodes)和 Watch 机制实现的。具体实现步骤如下:

1.每个进程或节点在ZooKeeper的某个路径上创建一个有序节点,节点名称可以是一个递增的数字,也可以是其他可以排序的字符串。
2.进程或节点根据节点的顺序来竞争获取锁,获取到锁的进程或节点可以访问共享资源,其他进程或节点需要等待。
3.当有一个进程或节点释放锁时,ZooKeeper会通知等待队列中的第一个进程或节点,让其继续竞争获取锁。

因为ZooKeeper的有序节点是按照创建的顺序排序的,所以可以通过监听前一个节点的变化来实现获取锁。当一个进程或节点需要获取锁时,它会在ZooKeeper上创建一个有序节点,并获取所有有序节点中的最小值。如果当前节点是最小值,则表示该进程或节点已经获取到锁;否则,该进程或节点需要监听前一个节点的变化,等待前一个节点释放锁后再次尝试获取锁。

zookeeper相比于redis的异步复制不同点在于 当leader收到一条写入请求,会广播给所有的Follower节点,等待Follower的返回,在ZAB协议中明确了只要有超过半数的Follower节点正确的返回了ACK,才认为本次提案是successful的

  • 基于ZooKeeper的分布式锁,适用于
    • 高可靠(高可用)而并发量不是太大的场景;(如果有较多的客户端频繁的申请加锁、释放锁,对于zk集群的压力会比较大。)
    • 适用于需要公平性的场景
  • 基于Redis的分布式锁,适用于
    • 并发量很大、性能要求很高的、而可靠性问题可以通过其他方案去弥补的场景

zookeeper persistence

The ZooKeeper Data Directory contains files which are a persistent copy of the znodes stored by a particular serving ensemble. These are the snapshot and transactional log files. As changes are made to the znodes these changes are appended to a transaction log, occasionally, when a log grows large, a snapshot of the current state of all znodes will be written to the filesystem. This snapshot supercedes all previous logs.

etcd

etcdctl --endpoints=$ENDPOINTS lock mutex1  
  
# another client with the same name blocks  
etcdctl --endpoints=$ENDPOINTS lock mutex1

etcd lock 命令

Under the hood, the Lock uses a lease on a key which is revoked when the the lock is released. If the server the lock is running on dies, or the network is disconnected, etcd will time out the lock.

Bear in mind that this means that in certain rare situations (a network disconnect or wholesale etcd failure), the caller may lose the lock while operations may still be running.

Etcd 实现分布式锁的基础

Lease 机制:即租约机制(TTL,Time To Live),Etcd 可以为存储的 Key-Value 对设置租约,当租约到期,Key-Value 将失效删除;同时也支持续约,通过客户端可以在租约到期之前续约,以避免 Key-Value 对过期失效。Lease 机制可以保证分布式锁的安全性,为锁对应的 Key 配置租约,即使锁的持有者因故障而不能主动释放锁,锁也会因租约到期而自动释放。

Revision 机制:每个 Key 带有一个 Revision 号,每进行一次事务便加一,因此它是全局唯一的,如初始值为 0,进行一次 put(key, value),Key 的 Revision 变为 1,同样的操作,再进行一次,Revision 变为 2;换成 key1 进行 put(key1, value) 操作,Revision 将变为 3;这种机制有一个作用:通过 Revision 的大小就可以知道写操作的顺序。在实现分布式锁时,多个客户端同时抢锁,根据 Revision 号大小依次获得锁,可以避免 “羊群效应” (也称“惊群效应”),实现公平锁。

Prefix 机制:即前缀机制,也称目录机制,例如,一个名为 /mylock 的锁,两个争抢它的客户端进行写操作,实际写入的 Key 分别为:key1="/mylock/UUID1",key2="/mylock/UUID2",其中,UUID 表示全局唯一的 ID,确保两个 Key 的唯一性。很显然,写操作都会成功,但返回的 Revision 不一样,那么,如何判断谁获得了锁呢?通过前缀“/mylock” 查询,返回包含两个 Key-Value 对的 Key-Value 列表,同时也包含它们的 Revision,通过 Revision 大小,客户端可以判断自己是否获得锁,如果抢锁失败,则等待锁释放(对应的 Key 被删除或者租约过期),然后再判断自己是否可以获得锁。

Watch 机制:即监听机制,Watch 机制支持监听某个固定的 Key,也支持监听一个范围(前缀机制),当被监听的 Key 或范围发生变化,客户端将收到通知;在实现分布式锁时,如果抢锁失败,可通过 Prefix 机制返回的 Key-Value 列表获得 Revision 比自己小且相差最小的 Key(称为 Pre-Key),对 Pre-Key 进行监听,因为只有它释放锁,自己才能获得锁,如果监听到 Pre-Key 的 DELETE 事件,则说明 Pre-Key 已经释放,自己已经持有锁。

etcd

n order to provide high availability, etcd is run as a cluster of replicated nodes. To ensure data consistency across these nodes, etcd uses a popular consensus algorithm called Raft.

In Raft, one node is elected as leader, while the remaining nodes are designated as followers.

the leader node has the following responsibilities: (1) maintaining leadership and (2) log replication.