图解 Fluss(三):读写全链路 ------ 一次写入、一次扫描、一次点查
阅读本文你将了解: 一条记录从客户端到落盘要经过哪些步骤、Exactly-Once 是怎么保证的、列裁剪为什么能把网络传输降两个数量级、以及 Lookup Join 的三级缓存如何让维表关联做到亚毫秒级。
配套图表:
seq-01-write-path、seq-02-read-path、seq-03-pk-lookup难度:⭐⭐⭐ | 适合人群:要做性能调优与 Exactly-Once 方案设计的开发
一、场景:一个实时大屏的三个请求
某电商平台要做一个实时大屏,每秒处理 50 万条订单事件。这个大屏背后有三个完全不同的数据请求:
请求 A:订单写入
sql
INSERT INTO orders SELECT * FROM kafka_source; -- 50 万 QPS
要求:不能丢、不能重复。这是写入路径。
请求 B:大屏聚合
sql
SELECT SUM(amount), COUNT(*) FROM orders WHERE event_time > NOW() - INTERVAL '5' MINUTE;
orders 表有 180 个字段,但这个查询只用 2 个。这是扫描路径,要求高吞吐、低延迟。
请求 C:订单详情关联用户
sql
SELECT o.order_id, u.user_name, u.city
FROM orders o
LEFT JOIN user_profile FOR SYSTEM_TIME AS OF o.proc_time AS u
ON o.user_id = u.user_id;
每条订单都要查一次用户表,QPS 和订单写入一样高。这是点查路径,要求极低延迟。
三条路径,三种完全不同的优化思路。 这一篇我们逐条跟踪。
二、图 1:PK 表写入路径

整条链路分三个阶段,图里用分隔线标得很清楚。
2.1 阶段一:元数据定位
Client -> Coordinator : GetTableMetadata(tablePath)
Coord --> Client : TableDescriptor + Bucket 映射
Client -> Client : 计算 bucketId = hash(pk) % bucket.num
Client -> Client : 定位 bucket 的 Leader TabletServer
注意这个阶段只在初始化时执行一次,不是每条记录都问一次 Coordinator。客户端拿到 TableDescriptor 后会缓存:
java
// 客户端侧的逻辑(简化示意)
// TableBucket 的真实路径:org.apache.fluss.metadata.TableBucket
public void write(RowData row) {
// 1. 从本地缓存取表元数据(首次或失效时才请求 Coordinator)
TableDescriptor descriptor = metadataCache.get(tablePath);
// 2. 计算 bucket(本地计算,零网络开销)
int bucketId = BucketingFunction.hash(descriptor.getBucketKeys(), descriptor.getBucketNum(), row);
// 等价于:MurmurHash3.hash(primaryKey) % bucket.num
// 3. 从缓存的路由表找 Leader(首次或 Leader 变更时才请求 Coordinator)
ServerNode leader = routingCache.get(new TableBucket(tableId, bucketId));
// 4. 直连 TabletServer 写入
client.send(new WriteRecordRequest(leader, bucketId, batch, acks));
}
这个设计是 Fluss 高吞吐的前提之一:Coordinator 不在数据路径上。如果每条记录都要问一次 Coordinator"我该写到哪",50 万 QPS 会把 Coordinator 打爆。
三个关键缓存:
| 缓存 | 内容 | 失效时机 |
|---|---|---|
| 表元数据缓存 | Schema、bucket.num、分区信息 | 表结构变更(Coordinator 主动推送) |
| 路由缓存 | bucketId → Leader TabletServer 地址 | Leader 变更(请求返回 NOT_LEADER 时刷新) |
| 连接池 | 到各 TabletServer 的 TCP 长连接 | 连接断开 |
2.2 阶段二:写入(先 WAL 后 RocksDB)
图中两个 group 块标出了两次写入:
java
// TabletServer 侧处理 WriteRecordRequest(简化)
public CompletableFuture<WriteRecordResponse> handleWrite(WriteRecordRequest request) {
int bucketId = request.getBucketId();
// ---- 写入 LogTablet (WAL) ----
LogTablet logTablet = logManager.getOrCreateLogTablet(tablePath, bucketId);
LogAppendInfo appendInfo = logTablet.append(request.getBatch());
// 内部:检查是否需要 Roll Segment(段写满 1GB 或超过时间阈值)
// 返回新记录的起始 offset
// ---- 写入 KvTablet (RocksDB) ----
if (tableDescriptor.hasPrimaryKey()) {
KvTablet kvTablet = kvManager.getOrCreateKvTablet(tablePath, bucketId);
for (Record record : request.getBatch()) {
kvTablet.put(record.getKey(), record.getValue());
// 内部:先写 RocksDB 自身 WAL,再写 MemTable
// 如果配置了 merge-engine,这里会先读旧值再合并
}
}
// ---- 等待副本确认 ----
return waitForAcks(appendInfo.getBaseOffset() + appendInfo.getCount() - 1, request.getAcks());
}
图右侧的 note 总结了顺序保证:
写入顺序保证:
1. 先写 WAL (LogTablet) 保证持久性
2. 再写 RocksDB (KvTablet) 提供查询能力
3. acks=all 时等待所有 ISR 确认后才返回
为什么这个顺序不能反? 上一篇已经答过:LogTablet 是参与副本复制的共享 WAL,RocksDB 的本地 WAL 不是。反过来的话,Follower 就没有办法追平数据。
2.3 阶段三:副本同步与 High Watermark
Leader -> Follower : Replicate(batch, offset)
Follower -> Follower : 追加到本地 LogTablet
Follower --> Leader : ACK(offset)
Leader -> Leader : 等待所有 ISR 确认
Leader -> Leader : advanceHighWatermark(offset)
Leader --> Client : 写入成功
ISR(In-Sync Replicas) 是能够跟上 Leader 的副本集合。High Watermark(HW)的定义是:
HW = min(所有 ISR 副本的 LEO)
其中 LEO(Log End Offset)是每个副本的日志末端 offset。
举例说明 HW 的作用:
LEO HW
Leader: [0 1 2 3 4 5 6] → 7 4
Follower1:[0 1 2 3 4 5] → 6 -
Follower2:[0 1 2 3 4] → 5 -
HW = min(7, 6, 5) = 5
HW 的意义:offset < HW 的记录,已经至少被所有 ISR 副本确认,消费者一定能读到它,即使 Leader 立刻宕机也不会丢。
这就是 acks 参数的语义:
| acks | 行为 | 持久性 | 延迟 |
|---|---|---|---|
acks=0 |
写入 Leader 内存即返回 | 最低,可能丢 | 最低 |
acks=1 |
写入 Leader 的 LogTablet 即返回 | Leader 宕机可能丢 | 低 |
acks=all |
等待所有 ISR 确认 | 最高 | 较高 |
生产环境推荐 acks=all + min.insync.replicas=2:
properties
# 客户端配置
client.request.acks: all
# 服务端配置:ISR 最少要有 2 个副本,否则拒绝写入
tablet-server.min.insync.replicas: 2
这个组合很重要 :如果只配
acks=all而不配min.insync.replicas,当 ISR 只剩 Leader 一个时,acks=all会退化成acks=1------你以为数据有三副本,实际上只有一份。
2.4 Exactly-Once 是怎么来的?
图里最后一行写着 写入成功 (Exactly-Once)。这需要两层机制叠加:
第一层:Flink Sink 的两阶段提交
java
class FlussSinkCommitter implements SinkCommitter {
// Checkpoint 触发时调用:预提交,数据已写入但对外不可见
public List<CommitRequest> prepareCommit() { /* ... */ }
// 所有并行 writer 都预提交成功后调用:真正提交,数据对外可见
public void commit(List<CommitRequest> commits) { /* ... */ }
}
第二层:服务端的幂等写入
java
class WriterStateManager {
// 每个 Writer (producer) 有唯一的 writerId 和递增的 sequence number
// 服务端记录每个 writerId 的最后 sequence:
// - seq == lastSeq + 1 → 正常接收
// - seq <= lastSeq → 重复写入,直接返回成功(幂等)
// - seq > lastSeq + 1 → 数据丢失,返回错误
}
两层叠加的效果:
场景:Flink Checkpoint 失败后从上次成功的 checkpoint 恢复,重放一批数据
传统 Kafka Sink:这批数据会被重复写入 → 下游看到重复
Fluss Sink: 两阶段提交保证预提交的数据没有 commit,
重放时 sequence number 相同,服务端识别为重复写入并丢弃
→ 下游只看到一次
配置方式:
sql
-- Flink SQL
SET 'execution.checkpointing.mode' = 'EXACTLY_ONCE';
SET 'execution.checkpointing.interval' = '30s';
CREATE TABLE orders_sink (...) WITH (
'connector' = 'fluss',
'bootstrap.servers' = 'coordinator:9123',
'sink.delivery-guarantee' = 'exactly-once' -- 默认值
);
2.5 请求 A 的答案
回到开头的"订单写入 50 万 QPS":
| 优化点 | 效果 |
|---|---|
| 元数据本地缓存 | Coordinator 零压力,不在数据路径上 |
| bucket 路由本地计算 | 无额外网络往返 |
| 顺序写 WAL | 磁盘顺序 IO,单盘可到 500MB/s+ |
| 批量追加 | 攒批减少 RPC 次数 |
acks=all + min.insync.replicas=2 |
不丢数据 |
调优参数:
properties
# 客户端批量参数
client.writer.batch.size: 1MB # 攒批大小
client.writer.linger.ms: 10 # 最多等 10ms 攒批
client.writer.buffer.memory: 64MB # 客户端缓冲区
三、图 2:流式读取路径

3.1 关键:FetchRequest 带着"投影列"和"过滤条件"
注意图里第一条消息:
Client -> Server : FetchRequest(tabletId, offset, projectedColumns, predicates)
这是 Fluss 和 Kafka 最本质的区别之一。 Kafka 的 FetchRequest 只有 topic + partition + offset,服务端只能把完整的字节流吐给你;而 Fluss 的请求里带了:
projectedColumns:我只要这几列predicates:我的 WHERE 条件
服务端因此可以做两件优化,图中用两个 group 块标出。
3.2 优化一:列裁剪(Column Projection)
Log -> Arrow : 读取 Arrow RecordBatch (全列)
Arrow -> Arrow : 通过 TransferPair 零拷贝投影,只保留 projectedColumns
Arrow --> Log : 投影后的 RecordBatch (仅需要的列)
为什么能"零拷贝"? 因为底层数据是 Arrow 列式格式。
Arrow 的内存布局是按列连续存储的:
行式(Kafka / 传统):
row1: [id|name|age|city|amount|...]
row2: [id|name|age|city|amount|...]
→ 要读第 2 列,必须把每一行的完整字节都读出来
列式(Arrow):
col_id: [1, 2, 3, ...] ← 连续内存块 A
col_name: ['a','b','c', ...] ← 连续内存块 B
col_age: [20, 25, 30, ...] ← 连续内存块 C
→ 要读第 2 列,直接引用内存块 B,不需要碰 A 和 C
"投影"的本质就是:把不需要的列的 FieldVector 引用丢掉,只保留需要的。Arrow 的 TransferPair 机制让这个操作是 O(1) 的引用转移,不产生任何数据拷贝:
java
class ColumnProjector {
public VectorSchemaRoot project(VectorSchemaRoot full, int[] projectedColumns) {
// 对每个需要的列:
// TransferPair pair = vector.getTransferPair(allocator);
// pair.transfer(); ← 零拷贝,只是转移 buffer 所有权
// result.add(pair.getTo());
// 不需要的列:直接 close() 释放
}
}
3.3 优化二:谓词下推(Predicate Pushdown)
Log -> Log : 应用 WHERE 过滤条件,对 Arrow Vector 向量化过滤
这一步是向量化执行:不是一行一行判断,而是对一个 Arrow Vector 批量判断,生成一个 selection vector(位图)。
java
// 伪代码:向量化过滤
public SelectionVector applyPredicate(FieldVector amountVector, Predicate pred) {
// 传统:for each row: if (row.amount > 100) keep
// 向量化:一次性处理 1024 个值,生成 bitmask
// long[] bitmask = new long[1024 / 64];
// for (int i = 0; i < 1024; i++) {
// if (amountVector.get(i) > 100) bitmask[i/64] |= (1L << (i%64));
// }
}
向量化过滤的好处:
- CPU cache 友好:连续内存访问
- 可被 JIT 向量化:现代 JVM 能把这种循环编译成 SIMD 指令
- 分支预测友好:没有 per-row 的 if 分支跳转
3.4 效果量化
图中 note 给出了一个非常直观的数字:
列裁剪 + 谓词下推在服务端完成:
200 列的表只读 2 列 → 网络传输减少 99%
WHERE 过滤在 TabletServer 执行 → 减少无效数据传输
我们算一下那个"180 字段,只用 2 个"的大屏查询:
| 传统方案(Kafka) | Fluss | |
|---|---|---|
| 网络传输 | 50万 QPS × 2KB/行 = 1 GB/s | 50万 QPS × 20B = 10 MB/s |
| 反序列化 CPU | 180 字段全解析 | 只解析 2 列 |
| Flink TM 内存 | 完整对象,GC 压力大 | 只有 2 列的对象 |
| 过滤位置 | Flink 侧(数据已经传过来了) | TabletServer 侧 |
网络传输降低 99%,这个量级的差距足以让一个跑不动的作业变成跑得很轻松。
3.5 客户端侧的收尾
Client -> Client : Arrow → Flink RowData 转换
Client -> Client : 更新 Checkpoint offset
第二步是 Flink 容错的关键:offset 随 Checkpoint 一起持久化。Flink 的 Checkpoint 完成后,offset 才被提交;故障恢复时从 Checkpoint 里的 offset 重新消费。
3.6 请求 B 的答案
sql
SELECT SUM(amount), COUNT(*) FROM orders
WHERE event_time > NOW() - INTERVAL '5' MINUTE;
Flink 优化器会把 SUM(amount) 用到的列推导出来,生成 projectedColumns = [amount, event_time],把 event_time > ... 下推为 predicate。TabletServer 只返回这两列的过滤后数据。
验证方法------看 Flink 的执行计划:
sql
EXPLAIN SELECT SUM(amount), COUNT(*) FROM orders
WHERE event_time > NOW() - INTERVAL '5' MINUTE;
如果看到 Source 上有 projectedFields 或类似的提示,说明列裁剪生效了。
四、图 3:PK Lookup 路径

4.1 三级优化链
图下方的 note 总结了 Lookup 的性能优化链:
性能优化链:
1. LRU 缓存命中 → 零网络开销
2. Bloom Filter → 快速排除不存在的 key
3. RocksDB LSM → 亚毫秒级点查询
这三级的成本递减、命中率递减,构成了一个典型的缓存层次结构:
第 1 级:LRU 缓存(Flink TaskManager 本地堆内存)
成本:~100 ns
命中率:取决于维表大小和缓存容量,热点数据可到 80%+
第 2 级:Bloom Filter(TabletServer 内存)
成本:~1 μs(只判断"可能存在"还是"一定不存在")
作用:对于不存在的 key,直接返回 null,避免 RocksDB 查询
第 3 级:RocksDB LSM 查询
成本:~100 μs ~ 1 ms
路径:MemTable → Immutable MemTable → L0 → L1 → ... → L6
4.2 第一级:LRU 缓存
java
class FlussLookupFunction extends LookupFunction {
private final Cache<RowData, RowData> lookupCache; // Guava/Caffeine LRU
private final FlussConnection connection;
public void eval(Object... joinKeys) {
RowData key = toRowData(joinKeys);
RowData cached = lookupCache.getIfPresent(key);
if (cached != null) {
collect(cached); // 命中,零网络开销
return;
}
// 未命中,走下面的远程查询
// ...
lookupCache.put(key, value); // 回填缓存
}
}
配置:
sql
CREATE TABLE user_profile (
user_id BIGINT,
user_name STRING,
city STRING,
PRIMARY KEY (user_id) NOT ENFORCED
) WITH (
'bucket.num' = '16',
'lookup.cache.max-rows' = '100000', -- 最多缓存 10 万行
'lookup.cache.ttl' = '1h' -- 缓存 1 小时过期
);
调优要点:
| 参数 | 影响 | 建议 |
|---|---|---|
lookup.cache.max-rows |
命中率 vs 内存 | 维表小(< 100万行)可以全缓存;大表按热点比例设 10%-20% |
lookup.cache.ttl |
数据新鲜度 | 维表更新频繁 → 设短(分钟级);静态维表 → 设长或不设 |
缓存一致性的坑 :如果维表在 Fluss 里被更新了,Flink 侧的 LRU 缓存不会自动失效,要等 TTL 过期。所以维表更新频率高且要求强一致的场景,TTL 不能设太长。
4.3 第二级:Bloom Filter
Server -> Bloom : mayContain(key)
Bloom --> Server : 不存在 (快速返回) → 直接返回 null
Bloom --> Server : 可能存在 → 继续查 RocksDB
Bloom Filter 的特点是:可能误报"存在",但绝不会误报"不存在"。
- 返回"不存在" → 100% 确定这个 key 没有,可以安全返回 null
- 返回"可能存在" → 大概率存在,需要真的查一次 RocksDB
上一篇讲过 BLOOM_BITS_PER_KEY = 10.0,这个配置下假阳性率约 1%。也就是说:
查 10000 个不存在的 key:
- 约 9900 个被 Bloom Filter 拦截 → 零磁盘 IO
- 约 100 个假阳性 → 白跑一次 RocksDB 查询,但返回 null
这个优化对于"大量 key 命不中"的场景效果拔群。比如风控场景,大部分用户不在黑名单里,Bloom Filter 能拦掉 99% 的无谓查询。
4.4 第三级:RocksDB LSM 查询
Kv -> Kv : 查 MemTable → Immutable → L0~L6 SST
RocksDB 的读路径(图中标注的顺序):
1. Active MemTable(内存跳表,最新写入) ~ 100 ns
2. Immutable MemTable(正在 flush 的) ~ 100 ns
3. Block Cache(读缓存) ~ 1 μs
4. L0 SST 文件(可能有重叠,要查多个) ~ 10-100 μs
5. L1 ~ L6 SST 文件(每层有序,二分查找) ~ 10-100 μs/层
读放大问题 :LSM 树的层级越多,查询可能要读的文件越多。这就是为什么 BLOCK_CACHE_SIZE 和 Bloom Filter 都很重要------它们能把大部分查询挡在第 4 步之前。
4.5 请求 C 的答案
sql
SELECT o.order_id, u.user_name, u.city
FROM orders o
LEFT JOIN user_profile FOR SYSTEM_TIME AS OF o.proc_time AS u
ON o.user_id = u.user_id;
图下方的 note 也标注了这条 SQL:
对应 SQL:
LEFT JOIN dim FOR SYSTEM_TIME AS OF o.time
LRU 缓存降低服务端查询压力
性能对比:
| 方案 | 单次关联延迟 | 50万 QPS 下的表现 |
|---|---|---|
| Redis 维表 | 0.5 - 1 ms(网络 RTT) | 需要 Redis 集群,且仍受网络限制 |
| HBase 维表 | 2 - 10 ms | 扛不住,需要大集群 |
| Flink 双流 Join | 无网络,但状态巨大 | 状态 TB 级,Checkpoint 慢 |
| Fluss PK Lookup | 缓存命中 ~0.1μs,未命中 ~0.5ms | 热点命中率高时整体 P99 < 1ms |
核心优势:Fluss 把维表数据存在 TabletServer 本地,配合 LRU + Bloom + RocksDB 三级优化,把"远程查询"变成了"大部分时候的本地内存查询"。
五、动手验证
5.1 观察列裁剪的效果
sql
-- 建一张宽表
CREATE TABLE wide_table (
id BIGINT,
c01 STRING, c02 STRING, ... c50 STRING, -- 50 个 STRING 字段
amount DECIMAL(18,2)
) WITH ('bucket.num' = '8');
-- 只查 2 列
EXPLAIN SELECT id, amount FROM wide_table WHERE amount > 100;
在 TabletServer 的 metrics 里观察:
bash
curl http://tablet-server:9125/metrics | grep -i "bytes_out"
对比全表扫描和只查 2 列的网络字节数,差距应该在两个数量级。
5.2 验证 Lookup 缓存命中率
bash
curl http://tablet-server:9125/metrics | grep -i lookup
关注指标:
fluss_lookup_cache_hit_count
fluss_lookup_cache_miss_count
fluss_lookup_cache_hit_ratio ← 目标 > 0.8
fluss_lookup_remote_latency_p99 ← 目标 < 1ms
5.3 验证 Exactly-Once
sql
-- 开启 Checkpoint
SET 'execution.checkpointing.interval' = '10s';
SET 'execution.checkpointing.mode' = 'EXACTLY_ONCE';
-- 写入并观察
INSERT INTO orders_sink SELECT * FROM source;
手动杀掉 Flink 作业,从 Checkpoint 恢复,检查目标表的数据条数是否有重复:
sql
SELECT COUNT(*), COUNT(DISTINCT order_id) FROM orders_sink;
-- 两者相等 → 无重复
六、生产实践要点
6.1 写入调优清单
properties
# 客户端
client.request.acks: all
client.writer.batch.size: 1MB
client.writer.linger.ms: 10
client.writer.compression.type: lz4 # 压缩,牺牲 CPU 换带宽
# 服务端
tablet-server.min.insync.replicas: 2
log.segment.size: 1GB
log.flush.interval.messages: 10000 # 攒多少条刷盘
log.flush.interval.ms: 1000
6.2 读取调优清单
properties
# 增大 fetch 批次,减少 RPC 往返
client.scanner.fetch.max-bytes: 64MB
client.scanner.fetch.max-wait-ms: 500
# 服务端 Arrow 内存池
arrow.allocator.memory.limit: 2GB
6.3 Lookup 调优清单
sql
-- 维表设计的黄金法则:
-- 1. bucket.num 要足够大,避免热点 bucket
-- 2. 开启缓存,并设置合理 TTL
-- 3. 维表字段不要太多(投影只取需要的列)
CREATE TABLE dim_user (
user_id BIGINT,
user_name STRING,
city STRING,
PRIMARY KEY (user_id) NOT ENFORCED
) WITH (
'bucket.num' = '64', -- 足够分散
'lookup.cache.max-rows' = '500000', -- 缓存 50 万行
'lookup.cache.ttl' = '30min' -- 30 分钟过期
);
6.4 常见反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
acks=0 追求低延迟 |
数据可能丢 | 用 acks=all + 批量写入来降延迟,而不是降低持久性 |
| Lookup 缓存 TTL 设成 1 天 | 维表更新后读到脏数据 | 按维表更新频率设 TTL,或改用双流 Join |
| 用 PK 表存纯日志数据 | 存储成本翻 2.5 倍 | 纯追加场景用 Log 表 |
bucket.num 设成 1 |
完全无法并行,单 bucket 成为瓶颈 | 至少设为 TabletServer 数的 2-4 倍 |
七、排障手册
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 写入延迟突然升高 | ISR 收缩触发 acks=all 等待 |
检查 Follower 是否有 GC 或网络问题;`curl .../metrics |
写入报 NOT_LEADER_FOR_BUCKET |
Leader 刚切换 | 客户端会自动刷新路由重试;如果持续报错,检查 Coordinator 是否在频繁 Rebalance |
| 读取吞吐上不去 | 列裁剪没生效 | 检查 Flink 执行计划;确认谓词可下推(不支持 UDF 过滤下推) |
| Lookup P99 高 | 缓存命中率低 | 检查 lookup_cache_hit_ratio;调大 max-rows 或缩小维表 |
| 数据出现重复 | Checkpoint 没开或两阶段提交没生效 | 确认 execution.checkpointing.mode = EXACTLY_ONCE 和 sink.delivery-guarantee = exactly-once |
| 数据丢失 | min.insync.replicas 未配置 |
补上配置;检查 ISR 最小副本数 |
八、小结
三条路径,三种优化思路:
| 路径 | 核心优化 | 关键机制 |
|---|---|---|
| 写入 | 元数据本地缓存 + 顺序写 WAL | bucket 路由本地计算、ISR 复制、两阶段提交 + 幂等写入 |
| 扫描 | 服务端列裁剪 + 谓词下推 | Arrow 列式格式、TransferPair 零拷贝投影、向量化过滤 |
| 点查 | 三级缓存层次 | LRU 本地缓存 → Bloom Filter 快速排除 → RocksDB LSM |
三句话记住:
- 写入 :Coordinator 不在数据路径上,先 WAL 后 RocksDB,
acks=all+min.insync.replicas=2是持久性的底线。 - 扫描:请求里带投影列和谓词,服务端做列裁剪和过滤,宽表场景网络传输能降 99%。
- 点查:LRU → Bloom → RocksDB 三级优化,把"远程查询"变成"大部分时候的本地内存查询"。
下一篇,我们关注集群本身:启动选举、副本状态机、以及扩缩容时的数据迁移。