Apache Ratis 构建并运行示例应用指南
以可运行的完整源码演示 Apache Ratis 3.3.1 的构建与运行:单机多实例伪集群、自定义状态机、leader 选举日志解读、写入与读取验证、故障容错演练,并实测官方 FileStore 示例与三节点复制一致性核验,命令与回显全部真实可复现。
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 |
| JDK | OpenJDK 17.0.20.1 |
| Maven | 3.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 核验复制结果。
十一、参考链接
- 官网示例页:https://ratis.apache.org/examples/
- 源码仓库:https://github.com/apache/ratis
- 官方下载目录:https://downloads.apache.org/ratis/3.3.1/
- 安装与依赖说明:《Apache Ratis 安装指南》
- 配置项详解:《Apache Ratis 配置参数详解》