深入JRaft:Nacos配置中心的性能优化实践

前言

上一篇《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

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,希望这两篇文章能帮到你。


参考资料


本文完

相关推荐
我梦见我梦见我7 小时前
CentOS下安装RocketMQ
后端
Cache技术分享7 小时前
273. Java Stream API - Stream 中的中间操作:Mapping 操作详解
前端·后端
天天摸鱼的java工程师7 小时前
Docker+K8s 部署微服务:从搭建到运维的全流程指南(Java 老鸟实战版)
java·后端
Undoom7 小时前
Redis 数据库的服务器部署与 MCP 智能化交互深度实践指南
后端
法欧特斯卡雷特7 小时前
如何解决 Kotlin/Native 在 Windows 下 main 函数的 args 乱码?
后端·操作系统·编程语言
over6977 小时前
掌控 JavaScript 的 this:从迷失到精准控制
前端·javascript·面试
南囝coding7 小时前
《独立开发者精选工具》第 024 期
前端·后端
古城小栈7 小时前
性能边界:何时用 Go 何时用 Java 的技术选型指南
java·后端·golang
青春不流名7 小时前
如何在Kafka中使用SSL/TLS证书认证
分布式·kafka·ssl