前言
上一篇《Nacos到底是AP还是CP?一文说清楚》中,我们讲了:
- Nacos为什么既是AP又是CP
- 微服务注册为什么用AP(Distro)
- 配置中心为什么用CP(Raft)
- Raft的核心原理
这一篇我们深入JRaft的实现细节,看看Nacos配置中心是如何做到高性能、高可用的。
如果你想成为架构师、做分布式中间件开发、或者只是想深入理解Raft,这篇文章值得一读。
一、Raft协议的细节问题
上一篇我们讲了Raft的三大机制:Leader选举、日志复制、Term机制。这些是Raft的核心,但实际实现中还有很多细节问题需要处理。
1.1 为什么只能提交当前term的日志?
这是Raft最微妙的地方,也是最容易出bug的地方。
场景重现
markdown
初始状态:3节点集群,term=1
时间线:
T1时刻:Node1是Leader(term=1)
- Node1写入日志[1]:"SET x=100"
- 复制给Node2成功
- 复制给Node3失败(网络慢)
- 此时:Node1和Node2都有日志[1],过半了
T2时刻:Node1宕机
- 还没来得及提交日志[1]
- 此时日志[1]的状态:未提交但已过半
T3时刻:选举,term变为2
- Node3当选为Leader(它的日志是空的)
- Node3写入自己的日志[2]:"SET y=200"
- 还没复制给其他节点,Node3也宕机了
T4时刻:选举,term变为3
- Node1恢复了,重新当选Leader
- Node1发现:
* 自己有日志[1],已经过半了(Node1和Node2)
* 问题:能直接提交日志[1]吗?
错误的做法
ini
如果Node1直接提交日志[1]:
1. Node1标记日志[1]为已提交
2. 客户端读取,看到x=100
3. Node1又宕机了
4. Node3恢复,重新当选(term=4)
5. Node3的日志[2]会覆盖日志[1]
6. 客户端再读取,x=100不见了
问题:已提交的数据丢失了!
正确的做法
css
Node1不能直接提交日志[1],而是:
1. Node1先写一条当前term(term=3)的新日志[3]
"SET z=300"
2. 复制日志[3]给所有Follower
3. 等日志[3]过半确认
4. 提交日志[3]
5. 此时,日志[1]和[2]也自动算提交了
(因为它们在日志[3]之前)
为什么这样安全?
- 因为Node1已经提交了term=3的日志
- 任何能当选的新Leader,term必须≥3
- 而term≥3的Leader,必须有日志[1][2][3]
(否则拿不到过半选票)
- 所以日志[1]不会丢失
代码实现
java
public void updateCommitIndex() {
// 1. 收集所有节点的进度
List<Long> indices = new ArrayList<>();
indices.add(logStorage.getLastIndex()); // Leader自己
for (String peer : peers) {
indices.add(matchIndex.get(peer)); // 每个Follower
}
// 2. 排序,取中位数(过半)
Collections.sort(indices);
long majorityIndex = indices.get(indices.size() / 2);
// 3. 关键检查:只能提交当前term的日志
if (majorityIndex > commitIndex) {
LogEntry entry = logStorage.getEntry(majorityIndex);
if (entry != null && entry.getTerm() == currentTerm) {
// 只有当前term的日志才能提交
commitIndex = majorityIndex;
logger.info("commitIndex更新: {} -> {}", commitIndex, majorityIndex);
} else {
logger.info("日志[{}]虽然过半,但term={}不是当前term={},不提交",
majorityIndex, entry.getTerm(), currentTerm);
}
}
}
1.2 prevLogIndex和prevLogTerm的一致性检查深入
上一篇我们讲了一致性检查的原理,这里看看具体的冲突处理流程。
Leader如何处理Follower的拒绝?
冲突处理流程
sequenceDiagram
participant L as Leader
participant F as Follower
Note over L: nextIndex[F]=4
L->>F: AppendEntries
prevLogIndex=3, prevLogTerm=2
entries=[4,5] Note over F: 检查本地日志[3] F->>F: 本地日志[3].term=1,不匹配 F->>F: 删除日志[3]及之后的所有日志 F-->>L: 拒绝,success=false Note over L: nextIndex[F]=3 L->>F: AppendEntries
prevLogIndex=2, prevLogTerm=1
entries=[3,4,5] Note over F: 检查本地日志[2] F->>F: 本地日志[2].term=1,匹配 F->>F: 追加日志[3,4,5] F-->>L: 接受,success=true Note over L: nextIndex[F]=6
prevLogIndex=3, prevLogTerm=2
entries=[4,5] Note over F: 检查本地日志[3] F->>F: 本地日志[3].term=1,不匹配 F->>F: 删除日志[3]及之后的所有日志 F-->>L: 拒绝,success=false Note over L: nextIndex[F]=3 L->>F: AppendEntries
prevLogIndex=2, prevLogTerm=1
entries=[3,4,5] Note over F: 检查本地日志[2] F->>F: 本地日志[2].term=1,匹配 F->>F: 追加日志[3,4,5] F-->>L: 接受,success=true Note over L: nextIndex[F]=6
Follower的处理代码
java
public AppendEntriesResponse handleAppendEntries(AppendEntriesRequest req) {
lock.lock();
try {
// 1. term检查
if (req.getTerm() < currentTerm) {
return new AppendEntriesResponse(currentTerm, false);
}
// 2. 转为Follower,重置心跳
if (req.getTerm() >= currentTerm) {
becomeFollower(req.getTerm());
resetElectionTimeout();
}
// 3. 一致性检查(关键)
if (req.getPrevLogIndex() > 0) {
LogEntry localPrev = logStorage.getEntry(req.getPrevLogIndex());
if (localPrev == null) {
// 本地没有这条日志,说明落后了
return new AppendEntriesResponse(currentTerm, false);
}
if (localPrev.getTerm() != req.getPrevLogTerm()) {
// term不匹配,删除冲突的日志
logStorage.truncateFrom(req.getPrevLogIndex());
return new AppendEntriesResponse(currentTerm, false);
}
}
// 4. 追加日志
if (req.getEntries() != null && !req.getEntries().isEmpty()) {
for (LogEntry entry : req.getEntries()) {
LogEntry existing = logStorage.getEntry(entry.getIndex());
if (existing == null) {
// 新日志,直接追加
logStorage.append(entry);
} else if (existing.getTerm() != entry.getTerm()) {
// 冲突,删除旧的,追加新的
logStorage.truncateFrom(entry.getIndex());
logStorage.append(entry);
}
// 如果index和term都相同,说明是重复的,跳过
}
}
// 5. 更新commitIndex
if (req.getLeaderCommit() > commitIndex) {
commitIndex = Math.min(req.getLeaderCommit(), logStorage.getLastIndex());
}
return new AppendEntriesResponse(currentTerm, true);
} finally {
lock.unlock();
}
}
1.3 Leader如何追踪Follower的进度?
Leader需要维护两个索引:
nextIndex和matchIndex
java
// Leader当选时初始化
public void becomeLeader() {
state = LEADER;
leaderId = nodeId;
long lastLogIndex = logStorage.getLastIndex();
for (String peer : peers) {
// nextIndex:下一条要发送的日志位置
// 初始化为Leader最后一条日志+1(乐观假设)
nextIndex.put(peer, lastLogIndex + 1);
// matchIndex:已知Follower复制到的最高位置
// 初始化为0(保守假设)
matchIndex.put(peer, 0L);
}
}
复制成功时的更新
java
public void handleAppendEntriesSuccess(String peer, AppendEntriesResponse resp) {
if (!resp.isSuccess()) {
return;
}
// 如果发送了日志[100-200],且成功了
long lastSentIndex = 200;
// 更新进度
nextIndex.put(peer, lastSentIndex + 1); // 下次从201开始发
matchIndex.put(peer, lastSentIndex); // 已确认复制到200
// 重新计算commitIndex
updateCommitIndex();
}
复制失败时的回退
java
public void handleAppendEntriesFailure(String peer) {
long currentNextIndex = nextIndex.get(peer);
// 简单回退:每次失败往回退一格
long newNextIndex = Math.max(1, currentNextIndex - 1);
nextIndex.put(peer, newNextIndex);
// 立即重试
sendAppendEntries(peer);
}
二、JRaft的性能优化
理解了Raft协议,我们看看JRaft(Nacos用的实现)如何做性能优化。
2.1 Pipeline批量复制
问题:传统的串行复制太慢
markdown
场景:Leader要复制10000条日志给Follower
串行方式:
1. 发送日志[1-100]
2. 等待响应(100ms)
3. 发送日志[101-200]
4. 等待响应(100ms)
5. ...
6. 发送日志[9901-10000]
7. 等待响应(100ms)
总耗时:100次 × 100ms = 10秒
优化:Pipeline并发传输
css
Pipeline方式:
1. 发送日志[1-100](不等响应)
2. 发送日志[101-200](不等响应)
3. 发送日志[201-300](不等响应)
...
256. 发送日志[25501-25600]
→ 此时有256个批次同时在传输(in-flight)
当批次1响应回来:
→ 立即发送批次257
→ 保持256个批次在飞
总耗时:约1秒(主要受网络带宽限制)
性能提升:10倍
Replicator的实现
java
public class Replicator implements Runnable {
// 正在传输中的批次
private final Deque<Inflight> inflights = new ArrayDeque<>();
// Pipeline深度(最多多少批同时在飞)
private int maxInflight = 256;
// 每批日志数量
private int batchSize = 100;
@Override
public void run() {
while (isRunning()) {
// 持续发送,直到Pipeline满
while (inflights.size() < maxInflight && hasMoreLogs()) {
sendNextBatch();
}
// 短暂休眠,避免CPU空转
Thread.sleep(1);
}
}
private void sendNextBatch() {
// 1. 获取要发送的日志
long startIndex = nextIndex;
long endIndex = startIndex + batchSize - 1;
List<LogEntry> entries = logStorage.getEntries(startIndex, endIndex);
// 2. 构造请求
AppendEntriesRequest request = buildRequest(entries);
// 3. 记录in-flight
Inflight inflight = new Inflight(startIndex, endIndex, System.currentTimeMillis());
inflights.add(inflight);
// 4. 异步发送(不阻塞)
rpcService.sendAsync(peer, request, new Callback() {
@Override
public void onSuccess(AppendEntriesResponse response) {
// 移除in-flight
inflights.remove(inflight);
if (response.isSuccess()) {
// 成功,推进nextIndex
nextIndex = endIndex + 1;
matchIndex = endIndex;
} else {
// 失败,回退重试
nextIndex = Math.max(1, startIndex - 1);
}
}
@Override
public void onFailure(Throwable t) {
inflights.remove(inflight);
// 网络失败,回退重试
nextIndex = Math.max(1, startIndex - 1);
}
});
}
}
Pipeline的流控
java
// 防止发送过快,压垮Follower或网络
public class Replicator {
// 使用信号量限流
private final Semaphore inflightSemaphore = new Semaphore(256);
private void sendNextBatch() {
try {
// 获取许可(如果已经有256个在飞,会阻塞)
inflightSemaphore.acquire();
// 发送日志
rpcService.sendAsync(peer, request, new Callback() {
@Override
public void onComplete() {
// 释放许可
inflightSemaphore.release();
}
});
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
2.2 批量写入和Group Commit
问题:每条日志都fsync太慢
diff
fsync的成本:
- 机械硬盘:5-10ms
- SSD:1-2ms
如果每条日志都fsync:
- 100条日志 → 100次fsync
- 耗时:100-200ms(SSD)或500-1000ms(机械盘)
优化:批量写入
java
public class LogManager {
// 待写入的日志缓冲区
private final List<LogEntry> buffer = new ArrayList<>();
// 等待写入完成的Future列表
private final List<CompletableFuture<Void>> waiters = new ArrayList<>();
public CompletableFuture<Void> appendEntry(LogEntry entry) {
CompletableFuture<Void> future = new CompletableFuture<>();
synchronized (buffer) {
buffer.add(entry);
waiters.add(future);
// 累积到32条或超时100ms,批量刷盘
if (buffer.size() >= 32 || timeout(100)) {
flushBuffer();
}
}
return future;
}
private void flushBuffer() {
if (buffer.isEmpty()) {
return;
}
try {
// 1. 一次性写入多条日志
logStorage.appendEntries(buffer);
// 2. 一次fsync
logStorage.sync();
// 3. 通知所有等待者
for (CompletableFuture<Void> waiter : waiters) {
waiter.complete(null);
}
} finally {
buffer.clear();
waiters.clear();
}
}
}
效果:
diff
批量写入前:
- 32条日志 → 32次fsync
- 耗时:32-64ms
批量写入后:
- 32条日志 → 1次fsync
- 耗时:1-2ms
性能提升:16-32倍
2.3 动态调整复制速度
问题:Follower落后时固定速度太慢
diff
场景:Follower落后10000条日志
固定参数:
- batchSize = 100
- maxInflight = 64
- interval = 50ms
计算:
- 每50ms发送100条
- 每秒发送2000条
- 追上需要:5秒
优化:根据落后程度动态调整
java
public class Replicator {
private int batchSize = 100;
private int maxInflight = 64;
private int sendInterval = 50;
// 每次复制成功后,重新评估
private void adjustReplicationSpeed() {
long leaderLastIndex = node.getLastLogIndex();
long followerIndex = this.matchIndex;
long lag = leaderLastIndex - followerIndex;
if (lag == 0) {
// 完全同步,心跳模式
batchSize = 1;
maxInflight = 1;
sendInterval = 50;
logger.debug("节点{}完全同步,切换到心跳模式", peer);
} else if (lag < 100) {
// 轻微落后,正常模式
batchSize = 100;
maxInflight = 128;
sendInterval = 10;
logger.debug("节点{}轻微落后{}条,正常复制", peer, lag);
} else if (lag < 10000) {
// 严重落后,快速追赶模式
batchSize = 1024; // 增大批量
maxInflight = 256; // 增加Pipeline深度
sendInterval = 0; // 立即发送,不等待
logger.warn("节点{}严重落后{}条,启动快速追赶模式", peer, lag);
} else {
// 落后太多,发送快照
logger.warn("节点{}落后{}条,切换到快照传输", peer, lag);
sendSnapshot();
}
}
}
效果对比:
diff
场景:落后10000条日志
固定速度:
- 5秒追上
动态加速:
- 检测到落后
- 自动切换到快速模式
- 1秒追上
性能提升:5倍
2.4 快照传输
问题:日志太多,复制慢
diff
场景:
- Leader日志:[10001-20000]
- Follower日志:[1-100]
- 前10000条日志已被压缩成快照
问题:
- Leader没有日志[101-10000]了
- 无法通过日志复制追上
解决:传输快照
java
public class Replicator {
private void sendSnapshot() {
// 1. 获取最新快照
Snapshot snapshot = node.getSnapshot();
// 快照包含:
// - lastIncludedIndex: 10000(快照包含的最后一条日志)
// - lastIncludedTerm: 5
// - data: 状态机的完整数据(如所有配置)
// 2. 分块传输(避免单次RPC太大)
long offset = 0;
int chunkSize = 1024 * 1024; // 每块1MB
while (offset < snapshot.size()) {
byte[] chunk = snapshot.read(offset, chunkSize);
InstallSnapshotRequest request = new InstallSnapshotRequest();
request.setTerm(currentTerm);
request.setLeaderId(nodeId);
request.setLastIncludedIndex(snapshot.getLastIncludedIndex());
request.setLastIncludedTerm(snapshot.getLastIncludedTerm());
request.setOffset(offset);
request.setData(chunk);
request.setDone(offset + chunk.length >= snapshot.size());
InstallSnapshotResponse response = rpcService.installSnapshot(peer, request);
if (!response.isSuccess()) {
logger.error("快照传输失败,重试");
return;
}
offset += chunk.length;
}
// 3. 快照传输完成,继续从lastIncludedIndex+1发送增量日志
nextIndex = snapshot.getLastIncludedIndex() + 1;
matchIndex = snapshot.getLastIncludedIndex();
logger.info("快照传输完成,从index={}继续发送增量日志", nextIndex);
}
}
Follower处理快照
java
public InstallSnapshotResponse handleInstallSnapshot(InstallSnapshotRequest req) {
lock.lock();
try {
// 1. term检查
if (req.getTerm() < currentTerm) {
return new InstallSnapshotResponse(currentTerm, false);
}
// 2. 写入快照数据到临时文件
snapshotWriter.write(req.getOffset(), req.getData());
// 3. 如果是最后一块
if (req.isDone()) {
// 加载快照到状态机
stateMachine.loadSnapshot(snapshotWriter.getFile());
// 更新元数据
lastApplied = req.getLastIncludedIndex();
commitIndex = req.getLastIncludedIndex();
// 清理被快照覆盖的日志
logStorage.truncateBefore(req.getLastIncludedIndex());
logger.info("快照加载完成,lastApplied={}", lastApplied);
}
return new InstallSnapshotResponse(currentTerm, true);
} finally {
lock.unlock();
}
}
效果:
diff
不用快照:
- 传输10000条日志
- 耗时:几分钟
使用快照:
- 传输快照文件(假设50MB)
- 传输1000条增量日志
- 耗时:几秒
性能提升:数十倍
三、配置中心的实现细节
上一篇我们讲了配置中心为什么用CP模式,这里看看具体的实现细节。
3.1 配置写入的实现代码
客户端发布配置的处理流程:
java
public class ConfigController {
@Autowired
private RaftCore raftCore;
@PostMapping("/configs")
public Result publishConfig(@RequestBody ConfigInfo configInfo) {
try {
// 1. 构造Raft日志
Log log = Log.newBuilder()
.setGroup("naming_persistent_service_v2")
.setOperation("PUT")
.setKey(buildKey(configInfo))
.setData(ByteString.copyFrom(serialize(configInfo)))
.build();
// 2. 提交给Raft组(阻塞等待)
CompletableFuture<Response> future = raftCore.commit(
"naming_persistent_service_v2",
log
);
// 3. 等待过半确认(超时5秒)
Response response = future.get(5000, TimeUnit.MILLISECONDS);
if (response.getSuccess()) {
return Result.success("配置发布成功");
} else {
return Result.fail("配置发布失败: " + response.getErrMsg());
}
} catch (TimeoutException e) {
return Result.fail("配置发布超时,集群可能不可用");
} catch (Exception e) {
logger.error("配置发布异常", e);
return Result.fail("配置发布异常: " + e.getMessage());
}
}
private String buildKey(ConfigInfo configInfo) {
return configInfo.getDataId() + "@@" + configInfo.getGroup();
}
}
3.2 Raft层的处理
java
public class RaftCore {
// 每个Raft组对应一个Node
private final Map<String, Node> nodes = new ConcurrentHashMap<>();
public CompletableFuture<Response> commit(String group, Log log) {
Node node = nodes.get(group);
if (node == null) {
throw new IllegalStateException("Raft组不存在: " + group);
}
// 检查是否是Leader
if (!node.isLeader()) {
// 转发给Leader
String leader = node.getLeaderId();
return redirectToLeader(leader, log);
}
// Leader处理
CompletableFuture<Response> future = new CompletableFuture<>();
// 1. 写入本地日志
long index = node.appendLog(log);
// 2. 触发日志复制(异步)
node.triggerReplication();
// 3. 等待提交
node.waitForCommit(index, future);
return future;
}
}
Node的核心实现:
java
public class Node {
private final LogStorage logStorage;
private final StateMachine stateMachine;
private final Map<String, Replicator> replicators;
private volatile long commitIndex = 0;
private volatile long lastApplied = 0;
// 等待提交的Future
private final Map<Long, CompletableFuture<Response>> waitingCommits =
new ConcurrentHashMap<>();
public long appendLog(Log log) {
lock.lock();
try {
// 构造LogEntry
LogEntry entry = new LogEntry();
entry.setIndex(logStorage.getLastIndex() + 1);
entry.setTerm(currentTerm);
entry.setType(EntryType.OP_DATA);
entry.setData(log.toByteArray());
// 写入日志(会触发批量写入优化)
logStorage.appendEntry(entry);
return entry.getIndex();
} finally {
lock.unlock();
}
}
public void triggerReplication() {
// 通知所有Replicator线程
for (Replicator replicator : replicators.values()) {
replicator.wakeup();
}
}
public void waitForCommit(long index, CompletableFuture<Response> future) {
if (index <= commitIndex) {
// 已经提交了,直接返回
future.complete(Response.success());
return;
}
// 注册等待
waitingCommits.put(index, future);
// 设置超时
executor.schedule(() -> {
CompletableFuture<Response> f = waitingCommits.remove(index);
if (f != null) {
f.complete(Response.fail("提交超时"));
}
}, 5000, TimeUnit.MILLISECONDS);
}
// 当commitIndex更新时调用
public void onCommitIndexUpdate(long newCommitIndex) {
long oldCommitIndex = this.commitIndex;
this.commitIndex = newCommitIndex;
// 应用到状态机
for (long i = lastApplied + 1; i <= newCommitIndex; i++) {
LogEntry entry = logStorage.getEntry(i);
if (entry != null) {
stateMachine.apply(entry);
lastApplied = i;
}
}
// 通知等待的Future
for (long i = oldCommitIndex + 1; i <= newCommitIndex; i++) {
CompletableFuture<Response> future = waitingCommits.remove(i);
if (future != null) {
future.complete(Response.success());
}
}
}
}
Nacos的配置数据存储在三个地方:
1. Raft日志
lua
目录:/nacos/data/protocol/raft/log/
文件:
- log-000001.log
- log-000002.log
- log-000003.log
格式:二进制,记录所有写操作
作用:
- 用于日志复制
- 用于故障恢复
- 用于快照生成
2. 快照
diff
目录:/nacos/data/protocol/raft/snapshot/
文件:
- snapshot-000100.zip
- snapshot-000200.zip
内容:
- 配置数据的完整副本
- lastIncludedIndex
- lastIncludedTerm
触发时机:
- 每10000条日志生成一次
- 或手动触发
作用:
- 加速启动恢复
- 减少日志占用空间
- 用于快照传输
3. Derby数据库
diff
目录:/nacos/data/derby-data/
表:
- config_info:配置内容
- config_tags_relation:配置标签
- config_info_aggr:聚合配置
- config_info_beta:灰度配置
作用:
- 快速查询
- 支持SQL查询
- Web控制台展示
3.3 启动恢复流程
java
public void startRecover() {
// 1. 加载快照
Snapshot snapshot = loadLatestSnapshot();
if (snapshot != null) {
stateMachine.loadSnapshot(snapshot);
lastApplied = snapshot.getLastIncludedIndex();
commitIndex = snapshot.getLastIncludedIndex();
logger.info("加载快照完成,lastApplied={}", lastApplied);
}
// 2. 重放快照之后的增量日志
long startIndex = lastApplied + 1;
long lastLogIndex = logStorage.getLastIndex();
for (long i = startIndex; i <= lastLogIndex; i++) {
LogEntry entry = logStorage.getEntry(i);
if (entry != null && i <= commitIndex) {
// 重放已提交的日志
stateMachine.apply(entry);
lastApplied = i;
}
}
logger.info("日志重放完成,lastApplied={}", lastApplied);
// 3. 启动Raft组
raftNode.start();
logger.info("Nacos配置中心启动完成");
}
四、生产环境最佳实践
4.1 部署架构
单机房部署(3节点)
diff
适用场景:
- 测试环境
- 小规模生产(用户量<10万)
部署方式:
Node1: 192.168.1.1:8848
Node2: 192.168.1.2:8848
Node3: 192.168.1.3:8848
容错能力:
- 容忍1个节点故障
- 同时2个节点故障会不可用
风险:
- 机房级故障导致不可用
双机房部署(5节点)
diff
适用场景:
- 中大规模生产(用户量>10万)
- 对可用性要求高
部署方式:
主机房(3节点):
Node1: 192.168.1.1:8848
Node2: 192.168.1.2:8848
Node3: 192.168.1.3:8848
备机房(2节点):
Node4: 192.168.2.1:8848
Node5: 192.168.2.2:8848
容错能力:
- 容忍2个节点故障
- 单个机房故障仍可用
注意:
- 主机房3节点,过半在主机房
- 如果主机房故障,需要手动切换
三机房部署(5节点,推荐)
diff
适用场景:
- 核心系统
- 金融、电商等关键业务
部署方式:
机房A(2节点):
Node1: 192.168.1.1:8848
Node2: 192.168.1.2:8848
机房B(2节点):
Node3: 192.168.2.1:8848
Node4: 192.168.2.2:8848
机房C(1节点):
Node5: 192.168.3.1:8848
容错能力:
- 容忍任意1个机房故障
- 容忍2个节点故障
优势:
- 任意1个机房故障,剩余4个节点过半
- 无需手动切换,自动故障转移
4.2 配置优化
yaml
# application.properties
# Raft选举超时(根据网络延迟调整)
nacos.core.protocol.raft.data.election_timeout_ms=5000
# 快照生成间隔(根据配置变更频率调整)
nacos.core.protocol.raft.data.snapshot_interval_secs=1800
# 批量大小(根据网络带宽调整)
nacos.core.protocol.raft.data.max_entries_size=1024
# Pipeline深度(根据内存大小调整)
nacos.core.protocol.raft.data.max_replicator_inflight_msgs=256
# 日志保留时间(根据磁盘空间调整)
nacos.core.protocol.raft.data.log_retention_hours=72
# 异步刷盘(性能优化,但有丢数据风险)
nacos.core.protocol.raft.data.sync=false
# JVM参数
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
4.3 监控告警
关键指标
java
// 暴露的监控指标
GET /nacos/v1/raft/state
{
"self": "192.168.1.1:8848",
"term": 12345,
"leader": "192.168.1.1:8848",
"raftState": "LEADER",
"peers": [
{
"ip": "192.168.1.2:8848",
"raftState": "FOLLOWER",
"lastLogIndex": 100000,
"matchIndex": 99999,
"lag": 1,
"healthy": true,
"nextReplicateTime": 50
},
{
"ip": "192.168.1.3:8848",
"raftState": "FOLLOWER",
"lastLogIndex": 95000,
"matchIndex": 95000,
"lag": 5000,
"healthy": true,
"warning": "lag too much"
}
]
}
告警规则
markdown
1. Leader缺失
条件:集群没有Leader超过30秒
级别:P0(致命)
影响:配置无法写入
处理:检查网络、重启节点
2. 节点不健康
条件:Follower心跳超时超过1分钟
级别:P1(严重)
影响:容错能力下降
处理:检查节点状态,准备替换
3. 节点落后
条件:lag > 10000条
级别:P2(警告)
影响:恢复时间可能过长
处理:检查网络、磁盘IO
4. 写入延迟高
条件:P99延迟 > 500ms
级别:P2(警告)
影响:用户体验差
处理:检查CPU、网络、磁盘
5. 日志增长过快
条件:1小时内日志增长 > 100000条
级别:P3(提示)
影响:磁盘空间
处理:检查是否有配置变更异常
4.4 故障处理
场景1:单节点宕机
markdown
现象:
- 某个Follower宕机
- 集群仍有Leader
- 配置读写正常
处理:
1. 不要立即操作(可能只是重启)
2. 观察1-2小时
3. 如果未恢复,检查原因:
- 硬件故障?
- 进程崩溃?
- 网络隔离?
4. 确认无法恢复后,启动备用节点
5. 新节点会自动同步数据
注意:
- 不要手动切换Leader
- 不要删除宕机节点的数据
场景2:Leader频繁切换
markdown
现象:
- Leader每隔几分钟就换一次
- 日志大量选举记录
- 配置写入失败
原因分析:
1. 网络不稳定
- 检查:ping、traceroute
- 处理:优化网络、增大election_timeout
2. Leader负载过高
- 检查:CPU、内存、GC日志
- 处理:扩容、优化JVM参数
3. 磁盘IO慢
- 检查:iostat、iotop
- 处理:换SSD、优化fsync策略
4. 时钟漂移
- 检查:ntpdate
- 处理:配置NTP同步
场景3:脑裂风险
diff
场景:
网络分区,集群分为两部分
- 区域A:Node1
- 区域B:Node2、Node3
Raft的处理:
- 区域A:1/3,无法过半,拒绝写入
- 区域B:2/3,过半,Node2或Node3当选Leader
结果:
- 只有区域B能写入配置
- 不会出现两个Leader
- 网络恢复后,区域A自动同步
预防措施:
- 跨机房部署
- 至少3个可用区
- 不要2个机房各50%节点
五、总结
核心要点
1. Raft的细节问题
diff
只能提交当前term的日志:
- 防止已提交数据丢失
- 通过写新日志间接提交旧日志
一致性检查(prevLogIndex/prevLogTerm):
- 递归保证日志序列完全一致
- 发现冲突立即截断重建
Term机制:
- 防止脑裂
- 区分新老Leader
- 拒绝过期请求
2. JRaft的性能优化
diff
Pipeline批量复制:
- 256批同时传输
- 性能提升10倍
批量写入:
- 32条日志一次fsync
- 性能提升16-32倍
动态加速:
- 根据落后程度自适应
- 性能提升5-50倍
快照传输:
- 跳过大量历史日志
- 性能提升数十倍
3. 生产环境实践
diff
部署架构:
- 测试环境:3节点单机房
- 生产环境:5节点三机房
- 核心系统:7节点跨地域
配置优化:
- 根据场景调整参数
- 批量大小、Pipeline深度
- 快照间隔、日志保留
监控告警:
- Leader状态
- 节点健康
- 复制延迟
- 写入性能
写在最后
JRaft通过一系列工程优化,在保证Raft协议正确性的前提下,大幅提升了性能:
- Pipeline让日志复制快了10倍
- 批量写入让fsync开销降低了32倍
- 动态加速让落后节点追赶快了50倍
- 快照传输解决了大规模数据同步问题
这些优化不改变Raft的核心算法,而是在实现层面做文章。这也是分布式系统工程实践的精髓:
理论保证正确性,工程追求性能。
如果你在做分布式中间件开发、或者想深入理解Raft,希望这两篇文章能帮到你。
参考资料
- Raft论文:In Search of an Understandable Consensus Algorithm
- SOFAJRaft源码:github.com/sofastack/s...
- Nacos官方文档:nacos.io/zh-cn/docs/...
- etcd的Raft实现:github.com/etcd-io/etc...
本文完