JRaft–基础–03–实现细节解析之高效的线性一致读
1、线性一致读
所谓线性一致读,一个简单的例子就是在 t1 的时刻我们写入了一个值,那么在 t1 之后,我们一定能读到这个值,不可能读到 t1 之前的旧值(想想 java 中的 volatile 关键字,说白了线性一致读就是在分布式系统中实现 java volatile 语义)
如上图 Client A、B、C、D 均符合线性一致读,其中 D 看起来是 stale read(脏读),其实并不是,D 请求横跨了 3 个阶段,而读可能发生在任意时刻,所以读到 1 或 2 都行
2、实现线性一致读
重要:接下来的讨论均基于一个大前提,就是业务状态机的实现必须是满足线性一致性的,简单说就是也要具有 java volatile 的语义
2.1、怎么实现
- 要实现线性一致读,首先我们简单直接一些,是否可以直接从当前 leader 节点读?
- 仔细一想,这显然行不通,因为你无法确定这一刻当前的 “leader” 真的是 leader,比如在网络分区的情况下,它可能已经被推翻王朝却不自知
- 最简单易懂的实现方式:
-
同 “写” 请求一样,“读” 请求也走一遍 raft 协议(raft log)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6AOS3qoY-1677998584462)(./image1/9.png)] -
这一定是可以的,但性能上显然不会太出色,走 raft log 不仅仅有日志落盘的开销,还有日志复制的网络开销,另外还有一堆的 raft “读日志” 造成的磁盘占用开销,这在读比重很大的系统中通常是无法被接受的
-
2.2、ReadIndex Read 方式
2.2.1、这是 raft 论文中提到的一种优化方案,具体来说:
- Leader 将自己当前 log 的 commitIndex 记录到一个 local 变量 ReadIndex 里面
- 接着向 followers 发起一轮 heartbeat,如果半数以上节点返回了对应的 heartbeat response,那么 leader 就能够确定现在自己仍然是 leader(证明了自己是自己)
- Leader 等待自己的状态机执行,直到 applyIndex 超过了 ReadIndex,这样就能够安全的提供 Linearizable Read 了,也不必管读的时刻是否 leader 已飘走
- 思考:为什么等到 applyIndex 超过了 ReadIndex 就可以执行读请求?
- Leader 执行 read 请求,将结果返回给 Client
2.2.2、通过ReadIndex,也可以很容易在 followers 节点上提供线性一致读
- Follower 节点向 leader 请求最新的 ReadIndex
- Leader 执行上面前 3 步的过程(确定自己真的是 leader),并返回 ReadIndex 给 follower
- Follower 等待自己的 applyIndex 超过了 ReadIndex
- Follower 执行 read 请求,将结果返回给 client(JRaft 中可配置是否从 follower 读取,默认不打开)
2.2.3、ReadIndex小结
相比较于走 raft log 的方式,ReadIndex 省去了磁盘的开销,能大幅度提升吞吐,结合 JRaft 的 batch + pipeline ack + 全异步机制,三副本的情况下 leader 读的吞吐可以接近于 RPC 的吞吐上限
延迟取决于多数派中最慢的一个 heartbeat response,理论上对于降低延时的效果不会非常显著
2.3、Lease Read
-
Lease read 与 ReadIndex 类似,但更进一步,不仅省去了 log,还省去了网络交互。它可以大幅提升读的吞吐也能显著降低延时
-
基本的思路:
- leader 取一个比 election timeout 小的租期(最好小一个数量级),在租约期内不会发生选举,这就确保了 leader 不会变,所以可以跳过 ReadIndex 的第2步,也就降低了延时。可以看到 Lease read 的正确性和时间是挂钩的,因此时间的实现至关重要,如果时钟漂移严重,这套机制就会有问题
-
实现方式:
- 定时 heartbeat 获得多数派响应,确认 leader 的有效性(在 JRaft 中默认的 heartbeat 间隔是 election timeout 的十分之一)
- 在租约有效时间内,可以认为当前 leader 是 raft group 内的唯一有效 leader,可忽略 ReadIndex 中的 heartbeat 确认步骤(2)
- Leader 等待自己的状态机执行,直到 applyIndex 超过了 ReadIndex,这样就能够安全的提供 Linearizable Read 了
2.4、更进一步:Wait Free
-
到目前为止 lease 省去了 ReadIndex 的第 2 步(heartbeat),实际上还能再进一步,继续省去第 3 步
-
我们想想前面的实现方案的本质是什么:
- 首先我们定义两个状态:日志状态(log_state)和状态机状态(st_state),Leader 的 log_state 反映了当前 raft group 最新的数据状态,因为所有的写请求一定都先记录在 raft log 中
- 当 leader 接收到 read_request 那一刻,以 log_state 作为逻辑时间参考点,等到 st_state 追上之前记录 log_state 时,显然 read_request 那个时间点的所有数据已经全部应用到状态机,自然是能保证线性一致读了(只要你的业务状态机能保证可见性)
- 总结起来即是等待当前节点的状态机达到了接收 read_request 那一刻的时间点相同甚至更新的状态(applyIndex >= commitIndex)
-
通过以上分析可以看到 applyIndex >= commitIndex 的约束其实很保守,本质上我们只要保证当前时刻,当前节点状态机一定是最新即可
-
那么问题来了,leader 节点的状态机能保证一定是最新的吗?
- 首先 leader 节点的 log 一定是最新的,即使新选举产生的 leader,它也一定包含全部的 commit log,但它的状态机却可能落后于旧的 leader
- 不过等到 leader 成功应用了自己当前 term 的第一条 log 之后,它的状态机就一定是最新的
- 所以可以得出结论:当 leader 已经成功应用了自己 term 的第一条 log 之后,不需要再取 commitIndex,也不用等状态机,直接读,一定是线性一致读
-
小结:可以想象,Wait Free 机制将最大程度的降低读延迟
3、在 JRaft 中发起一次线性一致读请求的代码展示:
// KV 存储实现线性一致读
public void readFromQuorum(String key, AsyncContext asyncContext) {
// 请求 ID 作为请求上下文传入
byte[] reqContext = new byte[4];
Bits.putInt(reqContext, 0, requestId.incrementAndGet());
// 调用 readIndex 方法, 等待回调执行
this.node.readIndex(reqContext, new ReadIndexClosure() {
@Override
public void run(Status status, long index, byte[] reqCtx) {
if (status.isOk()) {
try {
// ReadIndexClosure 回调成功,可以从状态机读取最新数据返回
// 如果你的状态实现有版本概念,可以根据传入的日志 index 编号做读取
asyncContext.sendResponse(new ValueCommand(fsm.getValue(key)));
} catch (KeyNotFoundException e) {
asyncContext.sendResponse(GetCommandProcessor.createKeyNotFoundResponse());
}
} else {
// 特定情况下,比如发生选举,该读请求将失败
asyncContext.sendResponse(new BooleanCommand(false, status.getErrorMsg()));
}
}
});
}