时间、时钟与事件排序
为什么我们需要对事件进行排序?
示例:Facebook
- 移除老板为好友(或拉黑)
- 发布“我的老板是最糟的,我需要一份新工作!”
- 不希望顺序搞错了!
- 这些事件怎么会顺序错乱呢?
为什么事件会顺序错乱?
- 数据不是存储在一个服务器上——实际上有10万+服务器
- 隐私设置和帖子分开存储
- 大量数据副本:数据中心的副本、缓存、跨数据中心复制、边缘缓存
- 我们如何一致地更新所有这些东西?
我们如何对事件进行排序?
物理时钟:同步
- 基于信标的方法
- 指定带有GPS/原子钟的服务器作为主服务器
- 主服务器定期广播时间
- 客户端接收广播,重置它们的时钟
- 这个方法效果如何?
网络延迟
- 网络延迟是不可预测的,有一个下限
方法 #2:基于询问的协议
- 客户端查询服务器
- 时间 = T1 + (T2-T0)/2
- 多次采样,平均多个服务器的结果;排除异常值
- 考虑时钟速率偏差
- NTP, PTP
我们如何在没有物理时钟的情况下对事件进行排序?
先行关系
- 捕获事件之间的逻辑(因果)依赖关系
- (非自反的)部分排序:→
- a →/ a
- 如果 a → b,则非 b → a
- 如果 a → b 且 b → c,则 a → c
分布式系统中的先行关系
- 进程
- 消息
- 事件
- 先行规则:
- 在一个进程内,a 在 b 之前,则 a → b
- 如果 a = 发送(M),且 b = 接收(M),则 a → b
- 传递性:如果 a → b 且 b → c,则 a → c
Lamport 论文
示例 → 意味着什么?
- a → b 意味着“b 可能受到 a 的影响”
- 那 a /→ b 呢?这意味着 b → a 吗?
- a /→ b 且 b /→ a:事件是并发的
- 事件并发意味着什么?
- 关键洞见:
- 没人能分辨出 a 或 b 先发生!
逻辑时钟
- 为事件分配时间戳的方法
- 保留先行关系
- 目标:如果 a → b,则 C(a) < C(b)
- 时钟条件:
- 如果 a 和 b 在同一进程 i 上,且 a 在 b 之前,则 Ci(a) < Ci(b)
- 如果 a = 进程 i 发送 M,且 b = 进程 j 接收 M,则 Ci(a) < Cj(b)
- 如何实现这样的时钟?
实现逻辑时钟
- 保持一个本地时钟 T
- 事件发生时增加 T
- 在所有消息上发送时钟值作为 Tm
- 接收消息时:T = max(T, Tm) + 1
使用逻辑时钟形成全序
- 新的关系:=>
- 如果 C(a) < C(b),那么 a => b
- 如果 C(a) == C(b) 呢? 示例平局决断:进程ID
- 如果 a -> b,那么 a => b
- 反过来呢?
互斥
确保共享资源一次只被一个进程访问,防止数据冲突和不一致。
- 使用时钟实现锁:每个消息都带有一个时间戳,用于确定请求的顺序。
- 目标:
- 一次只有一个进程持有锁
- 按请求顺序授予锁
- 请求进程最终获得锁
- 假设:
- 按顺序的点对点消息传递
- 没有故障
互斥实现-概念
- 每条消息携带一个时间戳 Tm
- 三种消息类型:
- 请求(广播):当一个进程想要访问共享资源时,它会向所有其他进程广播一个带时间戳的请求消息。
- 释放(广播):当进程完成对共享资源的访问后,它会广播一个释放消息,表示资源现在可用。
- 确认(收到时):当一个进程收到请求消息时,它会回复一个确认消息。
- 每个节点的状态:
- 一个按 Tm 排序的请求消息队列
- 它从每个节点收到的最新时间戳
互斥实现-过程
- 收到请求时:
- 记录消息时间戳
- 将请求加入队列
- 发送一个确认
- 收到释放时:
- 记录消息时间戳
- 从队列中移除对应的请求
- 收到确认时:
- 记录消息时间戳
互斥实现-锁
- 要获取锁:
- 向所有人发送请求,包括自己
- 当满足以下条件时获得锁:
- 我的请求在我的队列头部,且
- 我已经从每个人那里收到了更高时间戳的消息
- 所以我的请求必须是最早的
- 释放锁:
- 向所有人发送释放,包括自己
示例1
- A、B 和 C 同时请求访问文件。
- A 的请求时间戳最早,所以它的请求在所有进程的队列头部。
- A 从 B 和 C 那里收到确认消息。
- A 获得锁,开始访问文件。
- A 完成访问后,发送释放消息,其他进程更新队列。
- 接下来,B 或 C 根据时间戳顺序获得锁。
示例2
在一个分布式系统中,三个服务器(或节点)S1、S2、S3使用基于时间戳的方法来处理互斥锁请求。每个服务器维护一个队列来记录其他服务器的锁请求,并根据时间戳来排序这些请求。这个过程涉及到时间戳的更新和队列的调整。
初始状态
- 所有服务器(S1、S2、S3)的时间戳初始设置为0。
- S1的队列中有一个请求:
S1@0
(S1在时间戳0的请求)。 - 每个服务器还记录了它从其他服务器收到的最高时间戳(S1max, S2max, S3max),初始都设置为0。
第一步:S2 发送请求
-
S2 发送请求:S2 将其时间戳更新为1,并向 S1 和 S3 发送一个请求(
request@1
),表示 S2 想要在时间戳1获得锁。 -
更新状态:
- S2:更新自己的时间戳为1。但队列和最大时间戳记录暂时不变,因为它还没有收到来自 S1 和 S3 的回复。
- S1 和 S3:在收到 S2 的请求后,它们需要将这个请求加入自己的队列,并更新与 S2 相关的最大时间戳记录。
第二步:队列更新和时间戳记录
-
S2 更新队列:
- S2 将自己的请求(
S2@1
)加入队列,队列变为[S1@0, S2@1]
。这表示 S2 现在等待在 S1 后获得锁。
- S2 将自己的请求(
-
S1 和 S3 更新队列和时间戳:
- 当 S1 和 S3 收到 S2 的请求后,它们将
S2@1
添加到各自的队列中,队列变为[S1@0, S2@1]
。 - 同时,S1 更新其记录的 S2 的最大时间戳为1(
S2max:1
),因为它刚刚收到了来自 S2 时间戳为1的请求。 - S3 也做类似的更新,将其记录的 S2 的最大时间戳更新为1(
S2max:1
)。
- 当 S1 和 S3 收到 S2 的请求后,它们将
第三步:S2发送ack@3到S1和S3
- S2 发送一个带时间戳3的确认消息(ack@3)给 S1 和 S3。此时S2的时间戳仍为1。
- S1 和 S3 收到 ack@3 后,将它们的时间戳更新为3(因为收到的时间戳3比它们当前的时间戳2大)。
S2 更新队列和时间戳
- S2 将时间戳更新为5,并将
S1max
和S3max
都更新为3。这可能是因为它收到了来自 S1 和 S3 的响应,说明它们知道的最大时间戳是3。
S1 发送 release@4 给 S2 和 S3
- S1 发送一个带时间戳4的释放消息(release@4)给 S2 和 S3,并将自己的时间戳更新为4。
- S2 和 S3 收到这个消息后,不会立即改变它们的时间戳,因为它们的当前时间戳已经比4大了。
最终更新
- S2 将时间戳更新为6,并从队列中移除
S1@0
,同时更新S1max
为4(因为S1的最新已知时间戳是4)。 - S1 将队列中的
S1@0
移除,只保留S2@1
。 - S3 将时间戳更新为5(可能是因为接收到另一个节点的消息),并在队列中保留
S2@1
,同时更新S1max
为4。
Lamport 时钟的问题?
- 如果 a → b,则 C(a) < C(b)
- 反之是否成立:如果 C(a) < C(b) 则 a → b?
- 不,它们也可以是并发的
- 如果我们使用 Lamport 时钟作为全局顺序,我们将引入一些不必要的排序约束
更好的逻辑锁
- 一个使得反向也成立的锁
- 如果 C(a) < C(b),那么 a → b
- 注意仍然必须有并发事件
- 有时既不是 C(a) < C(b) 也不是 C(b) < C(a)
- 向量时钟!
向量时钟
- 时钟是一个向量 C,长度等于节点数
- 例如,对于一个3节点系统是 (0, 0, 0)
- 在节点 i 上,每个事件递增 C[i]
- 节点 0 (3, 5, 2);事件后:(4, 5, 2)
- 在节点 i 收到带有时钟 Cm 的消息时:
- 递增 C[i]
- 对于每个 j != i
- C[j] = max(C[j], Cm[j])
- 示例:
- 节点 0 (4, 5, 2) 收到消息 (2, 7, 0):(5, 7, 2)
向量时钟-比较
- 逐元素比较向量
- 如果对于某些 i, j,Cx[i] < Cy[i] 且 Cx[j] > Cy[j]
- Cx 和 Cy 是并发的
- 如果对于所有 i,Cx[i] <= Cy[i],并且存在 j 使得 Cx[j] < Cy[j]
- Cx 在 Cy 之前发生