Apache Ratis 构建并运行示例应用指南

以可运行的完整源码演示 Apache Ratis 3.3.1 的构建与运行:单机多实例伪集群、自定义状态机、leader 选举日志解读、写入与读取验证、故障容错演练,并实测官方 FileStore 示例与三节点复制一致性核验,命令与回显全部真实可复现。

Apache RatisApache Ratis2026/10/12

Apache Ratis 构建并运行示例应用指南

目标:从零构建一个最小可运行的 Apache Ratis 3.3.1 示例:单机三个 RaftServer 组成伪集群,实现最简状态机,验证 leader 选举、客户端写入读取与故障容错;并实测官方发行包自带的 FileStore 示例。所有命令与回显均来自实测。

一、环境信息

项目值
操作系统Ubuntu 22.04.5 LTS(jammy)
内核6.8.0-138-generic,x86_64,12 核
演示账号root
JDKOpenJDK 17.0.20.1
Maven3.6.3
Ratis 版本3.3.1
工程目录/root/ratis-demo
伪集群端口127.0.0.1:6000、6001、6002

二、前置条件

  • 已安装 openjdk-17-jdk 与 maven(见《Apache Ratis 安装指南》),java -version 回显 17.0.20.1、mvn -version 回显 3.6.3。
  • Maven Central 可直连;如网络受限可配置阿里云镜像。
  • 依赖坐标(groupId 均为 org.apache.ratis):ratis-server、ratis-grpc、ratis-metrics-default,版本 3.3.1。注意 3.x 必须显式引入 ratis-metrics-default,否则启动时抛 NoClassDefFoundError: MetricRegistries。

三、工程结构

/root/ratis-demo
├── pom.xml
└── src
    └── main
        └── java
            └── demo
                ├── RatisDemo.java
                └── SimpleStateMachine.java

四、完整 pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>demo</groupId>
    <artifactId>ratis-demo</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.apache.ratis</groupId>
            <artifactId>ratis-server</artifactId>
            <version>3.3.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.ratis</groupId>
            <artifactId>ratis-grpc</artifactId>
            <version>3.3.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.ratis</groupId>
            <artifactId>ratis-metrics-default</artifactId>
            <version>3.3.1</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>2.0.18</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <mainClass>demo.RatisDemo</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

五、核心示例源码

5.1 最简状态机:SimpleStateMachine.java

复制日志条目格式约定为 key=value,applyTransaction 时写入内存 Map,query 时按键读取:

package demo;

import org.apache.ratis.proto.RaftProtos.LogEntryProto;
import org.apache.ratis.protocol.Message;
import org.apache.ratis.protocol.RaftGroupId;
import org.apache.ratis.protocol.RaftPeerId;
import org.apache.ratis.server.RaftServer;
import org.apache.ratis.server.storage.RaftStorage;
import org.apache.ratis.statemachine.TransactionContext;
import org.apache.ratis.statemachine.impl.BaseStateMachine;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;

public class SimpleStateMachine extends BaseStateMachine {
    private final Map<String, String> data = new ConcurrentHashMap<>();
    private RaftPeerId selfId;

    @Override
    public void initialize(RaftServer server, RaftGroupId groupId, RaftStorage storage) throws IOException {
        super.initialize(server, groupId, storage);
        this.selfId = server.getId();
    }

    @Override
    public CompletableFuture<Message> applyTransaction(TransactionContext trx) {
        final LogEntryProto entry = trx.getLogEntry();
        // 复制日志的 LogData 即客户端发送的原始消息内容
        final String content = entry.getStateMachineLogEntry().getLogData().toStringUtf8();
        final String[] kv = content.split("=", 2);
        if (kv.length == 2) {
            data.put(kv[0], kv[1]);
        }
        final long term = entry.getTerm();
        final long index = entry.getIndex();
        updateLastAppliedTermIndex(term, index);
        return CompletableFuture.completedFuture(
                Message.valueOf("OK: " + selfId + " applied index=" + index + " -> " + content));
    }

    @Override
    public CompletableFuture<Message> query(Message request) {
        final String key = request.getContent().toStringUtf8();
        final String value = data.getOrDefault(key, "(nil)");
        return CompletableFuture.completedFuture(Message.valueOf(value));
    }
}

要点:

  • 继承 BaseStateMachine,只覆写 applyTransaction 与 query 两个方法,即可获得一个可复制、可读写的状态机。
  • 必须调用 updateLastAppliedTermIndex 同步应用进度,否则快照与重启恢复不可用。
  • getServer() 在 3.x 返回 CompletableFuture,节点自身 ID 在 initialize 回调中捕获。

5.2 主程序:RatisDemo.java

package demo;

import org.apache.ratis.client.RaftClient;
import org.apache.ratis.conf.RaftProperties;
import org.apache.ratis.grpc.GrpcConfigKeys;
import org.apache.ratis.protocol.Message;
import org.apache.ratis.protocol.RaftClientReply;
import org.apache.ratis.protocol.RaftGroup;
import org.apache.ratis.protocol.RaftGroupId;
import org.apache.ratis.protocol.RaftPeer;
import org.apache.ratis.protocol.RaftPeerId;
import org.apache.ratis.server.RaftServer;
import org.apache.ratis.server.RaftServerConfigKeys;

import java.io.File;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;

public class RatisDemo {

    public static void main(String[] args) throws Exception {
        // 1) 定义 Raft 集群:3 个 RaftPeer
        final RaftPeer[] peers = new RaftPeer[]{
                RaftPeer.newBuilder().setId(RaftPeerId.valueOf("n0")).setAddress("127.0.0.1:6000").build(),
                RaftPeer.newBuilder().setId(RaftPeerId.valueOf("n1")).setAddress("127.0.0.1:6001").build(),
                RaftPeer.newBuilder().setId(RaftPeerId.valueOf("n2")).setAddress("127.0.0.1:6002").build()
        };
        final RaftGroup group = RaftGroup.valueOf(
                RaftGroupId.valueOf(UUID.fromString("02511d47-67ce-4664-951d-584ebac98b48")), peers);
        System.out.println("[Demo] RaftGroup = " + group);

        // 2) 逐个构建并启动 3 个 RaftServer
        final List<RaftServer> servers = new CopyOnWriteArrayList<>();
        final String[] ids = {"n0", "n1", "n2"};
        final int[] ports = {6000, 6001, 6002};
        for (int i = 0; i < ids.length; i++) {
            final RaftProperties props = new RaftProperties();
            RaftServerConfigKeys.setStorageDir(props, Collections.singletonList(new File("/tmp/ratis/" + ids[i])));
            GrpcConfigKeys.Server.setPort(props, ports[i]);
            final RaftServer server = RaftServer.newBuilder()
                    .setServerId(RaftPeerId.valueOf(ids[i]))
                    .setGroup(group)
                    .setProperties(props)
                    .setStateMachine(new SimpleStateMachine())
                    .build();
            servers.add(server);
        }
        for (RaftServer server : servers) {
            server.start();
        }
        System.out.println("[Demo] 3 RaftServers started, waiting for leader election ...");

        // 3) 等待 leader 选举完成
        RaftServer leader = null;
        for (int i = 0; i < 100 && leader == null; i++) {
            Thread.sleep(500);
            for (RaftServer s : servers) {
                if (s.getLifeCycleState().isRunning() && s.getDivision(group.getGroupId()).getInfo().isLeader()) {
                    leader = s;
                    break;
                }
            }
        }
        System.out.println("[Demo] leader = " + (leader == null ? "none" : leader.getId()));

        // 4) RaftClient 发送写入 + 读取
        try (RaftClient client = RaftClient.newBuilder()
                .setProperties(new RaftProperties())
                .setRaftGroup(group)
                .build()) {
            for (String kv : Arrays.asList("user=alice", "city=hangzhou", "lang=java")) {
                final RaftClientReply reply = client.io().send(Message.valueOf(kv));
                System.out.println("[Demo] send '" + kv + "' -> "
                        + reply.getMessage().getContent().toStringUtf8());
            }
            for (String key : Arrays.asList("user", "city", "lang")) {
                final RaftClientReply reply = client.io().sendReadOnly(Message.valueOf(key));
                System.out.println("[Demo] query '" + key + "' -> "
                        + reply.getMessage().getContent().toStringUtf8());
            }
        }

        // 5) 容错验证:关闭一个 follower,再写入/读取
        RaftServer follower = null;
        for (RaftServer s : servers) {
            if (!s.getId().equals(leader.getId())) {
                follower = s;
                break;
            }
        }
        System.out.println("[Demo] kill follower " + follower.getId() + " ...");
        follower.close();
        Thread.sleep(2000);

        try (RaftClient client = RaftClient.newBuilder()
                .setProperties(new RaftProperties())
                .setRaftGroup(group)
                .build()) {
            final RaftClientReply reply = client.io().send(Message.valueOf("status=degraded"));
            System.out.println("[Demo] send after-kill 'status=degraded' -> "
                    + reply.getMessage().getContent().toStringUtf8());
            for (String key : Arrays.asList("user", "status")) {
                final RaftClientReply r = client.io().sendReadOnly(Message.valueOf(key));
                System.out.println("[Demo] query after-kill '" + key + "' -> "
                        + r.getMessage().getContent().toStringUtf8());
            }
        }

        // 6) 清理剩余服务
        for (RaftServer s : servers) {
            if (s.getLifeCycleState().isRunning()) {
                s.close();
            }
        }
        System.out.println("[Demo] done.");
    }
}

六、编译与启动

6.1 编译

$ cd /root/ratis-demo && mvn -B clean compile
(首次运行从 Maven Central 下载依赖)
[INFO] BUILD SUCCESS

6.2 设置 JVM 堆内存

MAVEN_OPTS="-Xms256m -Xmx512m" mvn -B exec:java

exec:java 模式下示例运行在 Maven JVM 中,MAVEN_OPTS 直接生效。如需独立进程,可先生成 classpath 再用 java 启动:

mvn -B -q dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
java -Xms256m -Xmx512m -cp "target/classes:$(cat /tmp/cp.txt)" demo.RatisDemo

6.3 启动伪集群

$ MAVEN_OPTS="-Xms256m -Xmx512m" mvn -B exec:java -Dexec.cleanupDaemonThreads=false
(退出码 0,日志节选见下节)

另一个终端核验进程与堆参数:

$ jps -v | grep -v -E "Jps|Launcher"
8467 ServeDemo -Xms256m -Xmx512m

七、运行结果解读

7.1 程序输出

[Demo] RaftGroup = group-584EBAC98B48:[n0|127.0.0.1:6000, n1|127.0.0.1:6001, n2|127.0.0.1:6002]
[Demo] 3 RaftServers started, waiting for leader election ...
[Demo] leader = n1
[Demo] send 'user=alice' -> OK: n1 applied index=1 -> user=alice
[Demo] send 'city=hangzhou' -> OK: n1 applied index=3 -> city=hangzhou
[Demo] send 'lang=java' -> OK: n1 applied index=5 -> lang=java
[Demo] query 'user' -> alice
[Demo] query 'city' -> hangzhou
[Demo] query 'lang' -> java
[Demo] kill follower n0 ...
[Demo] send after-kill 'status=degraded' -> OK: n1 applied index=7 -> status=degraded
[Demo] query after-kill 'user' -> alice
[Demo] query after-kill 'status' -> degraded
[Demo] done.

7.2 Leader 选举日志

三个节点同时启动 pre-vote 选举,n1 胜出(节选):

[n1@group-584EBAC98B48-LeaderElection3] INFO org.apache.ratis.server.RaftServer$Division - n1@group-584EBAC98B48: changes role from CANDIDATE to LEADER at term 1 for changeToLeader
[n1@group-584EBAC98B48-LeaderElection3] INFO org.apache.ratis.server.RaftServer$Division - n1@group-584EBAC98B48: change Leader from null to n1 at term 1 for becomeLeader, leader elected after 948ms
[grpc-default-executor-3] INFO org.apache.ratis.server.RaftServer$Division - n0@group-584EBAC98B48: change Leader from null to n1 at term 1 for APPEND_ENTRIES, leader elected after 996ms
[grpc-default-executor-5] INFO org.apache.ratis.server.RaftServer$Division - n2@group-584EBAC98B48: change Leader from null to n1 at term 1 for APPEND_ENTRIES, leader elected after 994ms

版本信息每个节点打印一次,可确认实际运行的 Ratis 版本:

[demo.RatisDemo.main()] INFO org.apache.ratis.server.RaftServer - Starting Apache Ratis Server -- RaftServerProxy n0
[demo.RatisDemo.main()] INFO org.apache.ratis.server.RaftServer -                version: 3.3.1
[demo.RatisDemo.main()] INFO org.apache.ratis.server.RaftServer -               revision: 379c90a00291252beffa81c6d10d47ac0d015dce

7.3 关于日志 index 跳号

输出中 index 为 1、3、5、7。首次写入时 leader 尚未就绪,客户端收到 FAILED 后自动重试(日志可见 NotLeaderException、LeaderNotReadyException),重试与去重会使部分日志索引空占,属预期行为,不影响最终一致。

7.4 容错验证

n0 关闭时打印正常关停日志,leader 侧持续重试连接:

[demo.RatisDemo.main()] INFO org.apache.ratis.server.RaftServer - n0: close
[n0-impl-thread1] INFO org.apache.ratis.server.RaftServer$Division - n0@group-584EBAC98B48: shutdown
[grpc-default-executor-0] WARN org.apache.ratis.grpc.server.GrpcLogAppender - n1@group-584EBAC98B48->n0-GrpcLogAppender: Follower failed (request=null, errorCount=1); keep nextIndex (7) unchanged and retry.

期间新写入 status=degraded 仍成功(index=7),历史 key 读取不受影响:3 副本下关闭 1 个 follower 后仍有 2/3 多数派,集群继续服务。

7.5 存储目录

每个节点在配置的存储目录下生成组目录与复制日志:

/tmp/ratis/n0/02511d47-67ce-4664-951d-584ebac98b48/current/
├── log_0-0
├── log_inprogress_1
├── raft-meta
└── raft-meta.conf

八、官方发行包示例:FileStore

官方二进制包(apache-ratis-3.3.1-bin.tar.gz,解压后 91M)自带 FileStore 示例,无需写代码即可体验三副本复制。

8.1 启动三个服务端

逐个执行(每条一个终端,也可后台运行;JAVA_TOOL_OPTIONS 限堆):

# 实例 1(n0)
cd /opt/ratis/apache-ratis-3.3.1-bin/examples
JAVA_TOOL_OPTIONS="-Xms256m -Xmx512m" bin/server.sh filestore server \
  --id n0 --peers "n0:localhost:6000,n1:localhost:6001,n2:localhost:6002" \
  --storage /tmp/filestore/n0
# 实例 2(n1)
cd /opt/ratis/apache-ratis-3.3.1-bin/examples
JAVA_TOOL_OPTIONS="-Xms256m -Xmx512m" bin/server.sh filestore server \
  --id n1 --peers "n0:localhost:6000,n1:localhost:6001,n2:localhost:6002" \
  --storage /tmp/filestore/n1
# 实例 3(n2)
cd /opt/ratis/apache-ratis-3.3.1-bin/examples
JAVA_TOOL_OPTIONS="-Xms256m -Xmx512m" bin/server.sh filestore server \
  --id n2 --peers "n0:localhost:6000,n1:localhost:6001,n2:localhost:6002" \
  --storage /tmp/filestore/n2

注意 --peers 的五段格式 name:host:port:dataStreamPort:clientPort:adminPort,只写前三段时其余端口按偏移自动生成。启动日志确认角色:

Picked up JAVA_TOOL_OPTIONS: -Xms256m -Xmx512m
2026-10-12 04:46:53 INFO  RaftServer$Division:400 - n0@group-6F7570313233: changes role from      null to FOLLOWER at term 0 for startAsFollower

进程核验(jps -l 三个示例进程):

$ jps -l
10018 /opt/ratis/apache-ratis-3.3.1-bin/examples/lib/ratis-examples-3.3.1.jar
10053 /opt/ratis/apache-ratis-3.3.1-bin/examples/lib/ratis-examples-3.3.1.jar
10091 /opt/ratis/apache-ratis-3.3.1-bin/examples/lib/ratis-examples-3.3.1.jar

8.2 loadgen 写入

$ cd /opt/ratis/apache-ratis-3.3.1-bin/examples
$ bin/client.sh filestore loadgen --numFiles 2 --size 1024 --numClients 1 \
    --peers "n0:localhost:6000,n1:localhost:6001,n2:localhost:6002" --storage /tmp/loadgen-client
Found /opt/ratis/apache-ratis-3.3.1-bin/examples/lib/ratis-examples-3.3.1.jar
Starting Async write now
Total files written: 2

若首个请求落在非 leader 节点,客户端会按 NotLeaderException 中的 suggested leader 自动重定向,最终成功。

8.3 复制一致性核验

loadgen 在客户端目录 /tmp/loadgen-client 生成两个文件名,同时状态机把文件内容复制到三节点存储。对同一文件在三个节点分别做 md5:

$ for n in n0 n1 n2; do find /tmp/filestore/$n -type f -name "file-654a7e8a-2378-454c-a17e-0a357b7c4686-0" -exec md5sum {} \;; done
36210cb5a0968876183da67427d9e141  /tmp/filestore/n0/n0/file-654a7e8a-2378-454c-a17e-0a357b7c4686-0
36210cb5a0968876183da67427d9e141  /tmp/filestore/n1/n1/file-654a7e8a-2378-454c-a17e-0a357b7c4686-0
36210cb5a0968876183da67427d9e141  /tmp/filestore/n2/n2/file-654a7e8a-2378-454c-a17e-0a357b7c4686-0

三节点 md5 一致,复制生效。

8.4 read 子命令的已知问题

3.3.1 的 filestore read 实测报错,属官方示例缺陷,读取验证可用 8.3 的存储目录核验替代:

Exception in thread "main" java.lang.NullPointerException: target == null

九、注意事项

  • 伪集群端口 6000-6002 必须空闲,且 storage 目录首次启动前为空;复用旧目录但组不匹配会报 Failed to FORMAT。
  • exec:java 默认随 Maven 生命周期退出,长驻演示建议用 java -cp 独立进程或 nohup 后台运行。
  • slf4j-simple 输出到 stderr,重定向时用 2>&1 合并;无日志绑定则看不到选举日志。
  • 演示堆内存 256-512MB 足够;生产按状态机数据量与并发另行评估。
  • 三副本是 Raft 多数派的最小可用配置,写入性能随副本数增加而下降,属共识协议代价。

十、常见问题

  • 启动抛 NoClassDefFoundError: MetricRegistries:3.x 缺 ratis-metrics-default 依赖,补齐即可。
  • 首次写入报 NotLeaderException 或 LeaderNotReadyException:请求先到 follower 或 leader 尚未就绪,客户端自动重试,无需处理;若自定义客户端须监听 NotLeaderException 换节点重发。
  • mvn exec:java 卡在等待选举:确认 6000-6002 端口无残留进程(jps 核验),必要时清理 /tmp/ratis 后重跑。
  • 选举日志看不到:检查 classpath 是否有 slf4j 绑定;发行包脚本自带 log4j 配置,可直接看到 INFO 日志。
  • filestore read 报 NullPointerException:3.3.1 官方示例缺陷,用存储目录 md5 核验复制结果。

十一、参考链接