Apache Ratis 常用工具与 API 详解
系统梳理 Apache Ratis 的核心 API 与管理工具:RaftPeer、RaftGroup、RaftServer.newBuilder、BaseStateMachine 与 RaftClient 读写接口均附实测代码与签名核对,ratis-shell 的组信息查询、leader 转移、快照创建命令附真实回显与踩坑提示,全部步骤在 Ubuntu 22.04 环境实测通过。
Apache Ratis 常用工具与 API 详解
目标:给出一套够用的 Apache Ratis 3.3.1 开发与管理工具清单。API 部分覆盖组网、服务端构建、状态机与客户端读写;工具部分覆盖 ratis-shell 管理命令与官方 FileStore 示例脚本,全部代码与回显来自实测。
一、环境信息
| 项目 | 值 |
|---|---|
| 操作系统 | Ubuntu 22.04.5 LTS |
| JDK | OpenJDK 17.0.20.1 |
| Maven | 3.6.3 |
| Ratis 版本 | 3.3.1 |
| 演示集群 | 单机伪集群 n0/n1/n2,端口 6000-6002 |
| 组 ID | 02511d47-67ce-4664-951d-584ebac98b48 |
二、工具与 API 概览
Ratis 是内嵌库,没有独立守护进程,日常围绕三类入口工作:
| 类别 | 入口 | 用途 |
|---|---|---|
| Java API | RaftServer、RaftClient、StateMachine | 把共识能力嵌入应用 |
| 管理命令行 | ratis-shell(bin/ratis) | 查看组信息、转移 leader、触发快照 |
| 官方示例 | 发行包 examples/ | FileStore 参考实现与演练脚本 |
三、前置条件
- Maven 工程已引入依赖(版本 3.3.1):ratis-server、ratis-grpc、ratis-metrics-default,管理命令行另加 ratis-shell。
- classpath 生成(独立进程运行工具时使用):
$ mvn -B -q dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
- 3.3.1 的 RaftServer 关键签名(javap 核对):getId()、getDivision(RaftGroupId)、start()、getLifeCycleState()、close()、newBuilder()。
四、核心 API 详解
4.1 RaftPeer、RaftGroupId 与 RaftGroup
三者构成组网三元组:节点、组标识、组内成员集合。
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(group);
实测输出:
group-584EBAC98B48:[n0|127.0.0.1:6000, n1|127.0.0.1:6001, n2|127.0.0.1:6002]
要点:
- 组 ID 必须全集群一致,推荐 UUID 固定下来写入代码或配置;组 ID 变化会让旧存储目录无法识别。
- RaftPeer 的 setAddress 五段格式 name:host:port:dataStreamPort:clientPort:adminPort,只写 host:port 时其余端口自动按偏移生成。
- members 变化属动态成员变更,需走 setConfiguration,不在此展开。
4.2 RaftServer.newBuilder 构建与生命周期
服务端构建四要素:serverId、group、properties、stateMachine。
final RaftProperties props = new RaftProperties();
RaftServerConfigKeys.setStorageDir(props, Collections.singletonList(new File("/tmp/ratis/n0")));
GrpcConfigKeys.Server.setPort(props, 6000);
final RaftServer server = RaftServer.newBuilder()
.setServerId(RaftPeerId.valueOf("n0"))
.setGroup(group)
.setProperties(props)
.setStateMachine(new SimpleStateMachine())
.build();
server.start(); // 生命周期:构建、启动、运行、关闭
// ...
server.close();
启动日志(版本与修订号,每节点打印一次):
[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
4.3 状态机接口:BaseStateMachine
业务共识的核心是状态机。最简实现只需覆写 applyTransaction 与 query:
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();
final String content = entry.getStateMachineLogEntry().getLogData().toStringUtf8();
final String[] kv = content.split("=", 2);
if (kv.length == 2) {
data.put(kv[0], kv[1]);
}
updateLastAppliedTermIndex(entry.getTerm(), entry.getIndex());
return CompletableFuture.completedFuture(
Message.valueOf("OK: " + selfId + " applied index=" + entry.getIndex() + " -> " + content));
}
@Override
public CompletableFuture<Message> query(Message request) {
return CompletableFuture.completedFuture(
Message.valueOf(data.getOrDefault(request.getContent().toStringUtf8(), "(nil)")));
}
}
接口分类速查:
| 方法 | 触发点 | 返回 | 注意 |
|---|---|---|---|
| initialize | 服务启动时回调一次 | void | 捕获节点 ID、初始化存储 |
| applyTransaction | leader 与 follower 应用已提交日志 | CompletableFuture<Message> | 必须更新 lastAppliedTermIndex |
| query | 只读请求(任一节点) | CompletableFuture<Message> | 不走复制日志 |
| takeSnapshot | 触发快照时 | CompletableFuture<Long> | 返回快照索引 |
4.4 RaftClient 读写
try (RaftClient client = RaftClient.newBuilder()
.setProperties(new RaftProperties())
.setRaftGroup(group)
.build()) {
// 写:send 返回 RaftClientReply
final RaftClientReply reply = client.io().send(Message.valueOf("user=alice"));
System.out.println(reply.getMessage().getContent().toStringUtf8());
// 读:sendReadOnly 由状态机 query 处理
final RaftClientReply r = client.io().sendReadOnly(Message.valueOf("user"));
System.out.println(r.getMessage().getContent().toStringUtf8());
}
实测输出:
OK: n1 applied index=1 -> user=alice
alice
读写接口速查(javap 核对,BlockingApi 即 client.io() 的类型):
| 方法 | 语义 | 说明 |
|---|---|---|
| client.io().send(Message) | 写入并等提交 | 阻塞至多数派落盘并应用 |
| client.io().sendReadOnly(Message) | 本地读 | leader 默认线性一致读,开销低于写 |
| client.send(Message) | 异步写 | 返回 CompletableFuture<RaftClientReply> |
| RaftClientReply.getMessage() | 取响应内容 | getContent() 为 ByteString,toStringUtf8 还原 |
异常语义:请求打到 follower 时抛 NotLeaderException(携带 suggested leader);leader 刚上任未就绪抛 LeaderNotReadyException。两种情况客户端默认自动重试,实测首个请求出现过上述日志并最终成功。
4.5 集群状态查询
final RaftServer leader = servers.stream()
.filter(s -> s.getLifeCycleState().isRunning())
.filter(s -> s.getDivision(group.getGroupId()).getInfo().isLeader())
.findFirst().orElse(null);
System.out.println("leader = " + (leader == null ? "none" : leader.getId()));
实测输出:
[Demo] leader = n1
五、ratis-shell 命令详解
5.1 命令入口与调用方式
发行包脚本(推荐):
/opt/ratis/apache-ratis-3.3.1-bin/bin/ratis sh <command> [options]
无参运行可见全部命令组:
$ ./apache-ratis-3.3.1-bin/bin/ratis sh
Usage: ratis sh [generic options]
[election [transfer] [stepDown] [pause] [resume]]
[group [info] [list]]
[local [raftMetaConf]]
[peer [add] [remove] [setPriority]]
[snapshot [create]]
Maven 工程也可用 java -cp 直接调用主类 org.apache.ratis.shell.cli.sh.RatisShell(ratis-shell-3.3.1.jar 无 Main-Class 清单项,必须显式指定),两种方式回显一致。
5.2 sh group info:查看组与 leader
$ java -cp "target/classes:$(cat /tmp/cp.txt)" org.apache.ratis.shell.cli.sh.RatisShell group info \
-peers "127.0.0.1:6000,127.0.0.1:6001,127.0.0.1:6002" \
-groupid "02511d47-67ce-4664-951d-584ebac98b48"
group id: 02511d47-67ce-4664-951d-584ebac98b48
leader info: n1(127.0.0.1:6001)
[server {
id: "n0"
address: "127.0.0.1:6000"
startupRole: FOLLOWER
}
, server {
id: "n1"
address: "127.0.0.1:6001"
startupRole: FOLLOWER
}
, server {
id: "n2"
address: "127.0.0.1:6002"
startupRole: FOLLOWER
}
]
巡检脚本可直接取 leader info 行判断主节点位置。
5.3 sh group list:查询节点所属组
$ java -cp "target/classes:$(cat /tmp/cp.txt)" org.apache.ratis.shell.cli.sh.RatisShell group list \
-peers "127.0.0.1:6000,127.0.0.1:6001,127.0.0.1:6002" \
-groupid "02511d47-67ce-4664-951d-584ebac98b48" -peerId n0
The peerId n0 (server 127.0.0.1:6000) is in 1 groups, and the groupIds is: [group-584EBAC98B48]
注意定位节点必须用 -peerId;实测用 -serverAddress 127.0.0.1:6000 会报 NullPointerException: Server 127.0.0.1_6000 not found。
5.4 sh election transfer:转移 leader
$ java -cp "target/classes:$(cat /tmp/cp.txt)" org.apache.ratis.shell.cli.sh.RatisShell election transfer \
-peers "127.0.0.1:6000,127.0.0.1:6001,127.0.0.1:6002" \
-groupid "02511d47-67ce-4664-951d-584ebac98b48" -address 127.0.0.1:6000
Transferring leadership to peer n0 with address 127.0.0.1:6000
Transferring leadership initiated
转移完成后复查 group info:
group id: 02511d47-67ce-4664-951d-584ebac98b48
leader info: n0(127.0.0.1:6000)
典型场景:版本升级前把 leader 平滑挪离待重启节点。
5.5 sh snapshot create:手动触发快照
$ java -cp "target/classes:$(cat /tmp/cp.txt)" org.apache.ratis.shell.cli.sh.RatisShell snapshot create \
-peers "127.0.0.1:6000,127.0.0.1:6001,127.0.0.1:6002" \
-groupid "02511d47-67ce-4664-951d-584ebac98b48" -peerId n0
Successful take snapshot on peerId n0, the latest snapshot index is 0
注意 3.3.1 必须带 -peerId 指定目标节点,否则报 NullPointerException at RaftClientImpl.getSnapshotManagementApi(RaftClientImpl.java:324)。官方 usage:
Usage: create -peers <PEER0_HOST:PEER0_PORT,PEER1_HOST:PEER1_PORT,PEER2_HOST:PEER2_PORT> [-groupid <RAFT_GROUP_ID>] [-snapshotTimeout <timeoutInMs>] [-peerId <raftPeerId>]
Make a ratis server take snapshot
六、官方 FileStore 示例工具
发行包 examples/ 内置 FileStore 参考实现(ratis-examples-3.3.1.jar),server.sh 与 client.sh 封装了全部参数:
# 启动三副本(每实例一条命令,--storage 各自独立)
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 个 1024 字节随机文件
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
Total files written: 2
写入后对三节点存储做 md5 核验,结果一致即复制生效:
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
已知问题:3.3.1 的 filestore read 实测报 NullPointerException: target == null,读取验证用存储目录 md5 替代。
七、注意事项
- ratis-shell 的 -peers 传的是地址列表,group info 的结果显示 startupRole 均为 FOLLOWER 属正常,运行时角色以 leader info 行为准。
- Java API 与 CLI 的 -groupid 写法不同:API 用 UUID 对象,CLI 用 UUID 字符串;组 ID 前缀 group- 是显示层附加的。
- 客户端要处理 NotLeaderException 自行换节点;CLI 内部已自动重定向,无需干预。
- state machine 的 query 只反映本节点应用进度,读旧值场景须确认线性一致语义(见配置篇 read.option)。
- 发行包脚本会自动注入 OTEL agent,包布局变化时可能打印 agent 安装失败告警,不影响功能。
八、常见问题
- group info 卡住无输出:目标节点未启动或端口不通,先用 jps 与端口探活确认。
- election transfer 报 Already leader:目标节点已是 leader,无需转移。
- snapshot create 报 NullPointerException:补 -peerId 参数。
- javap 提示找不到类:RaftServerConfigKeys 在 ratis-server-api,DivisionInfo 在 ratis-server,javap 时把两个 jar 一起加入 classpath。
- 客户端重试产生重复写入:Ratis 内置 retrycache 按客户端会话去重,自研客户端保持 clientId 稳定即可。
九、参考链接
- 官网 API 文档:https://ratis.apache.org/api/
- 源码仓库:https://github.com/apache/ratis
- CLI 命令源码:https://github.com/apache/ratis/tree/ratis-3.3.1/ratis-tools/ratis-shell
- 关联文档:《Apache Ratis 构建并运行示例应用指南》《Apache Ratis 配置参数详解》