databend-meta 升级:写入从 110 QPS 到 1.7 万 QPS
上一篇测 databend-meta 的 musl 和 GNU 构建时,我顺手把 1.2.820 也跑了一遍。820 是 2025 年 9 月的 nightly。跑出来的数字让我一度以为脚本写错了:并发从 16 加到 1536,写入吞吐一直在 110 QPS 左右,延迟跟着并发一起涨。同一台机器上,1.2.956 在 1024 并发时是 17,511 QPS。
脚本没问题。我用官方镜像在 820 和 956 之间做了二分,问题出在 1.2.896 之前 MetaWorker 里的一个循环:它一次只处理一个请求。
先说结论:写入并发高的环境,databend-meta 至少要升到 1.2.896,能用新版本就直接用新版本。按兼容性文档,query 还停在 820 的话,也可以先单独升级 meta。
测试方法
和上一篇一样。用 Databend 仓库里的 scripts/benchmark/meta/meta-cluster-bench.py 在一台机器上起 3 个节点,走完整的 Raft 复制。负载是递增 key 的 upsert_kv,每轮 60 秒,从空数据开始。快照阈值调到 10 亿,基本不触发快照。两个版本用同一份压测客户端 databend-metabench,32 并发每个版本跑 5 轮,其他并发各跑 3 轮。
两个二进制都是 musl 静态构建:
| 项目 | 1.2.820 | 1.2.956 |
|---|---|---|
| 日期 | 2025-09-25 | 2026-10-27 |
| 链接方式 | musl static PIE | musl static PIE |
| 链接器 | mold 2.40.4 | LLD 22.1.6 |
| Rust 工具链 | 1.88.0-nightly | 1.100.0-nightly |
测试结果
QPS 和 p99 都取各轮的中位数。两个版本所有轮次都没有报错。
| 并发 | 820 QPS | 956 QPS | 956 / 820 | 820 p99 | 956 p99 |
|---|---|---|---|---|---|
| 16 | 130.2 | 1,826.6 | 14 倍 | 192.6 ms | 12.9 ms |
| 32 | 111.5 | 1,907.2 | 17 倍 | 420.1 ms | 48.8 ms |
| 64 | 109.9 | 3,853.9 | 35 倍 | 707.3 ms | 27.1 ms |
| 128 | 111.8 | 6,950.3 | 62 倍 | 1.47 s | 34.0 ms |
| 256 | 110.1 | 11,162.3 | 101 倍 | 2.68 s | 52.9 ms |
| 512 | 110.4 | 15,176.9 | 137 倍 | 5.24 s | 101.1 ms |
| 1024 | 111.2 | 17,511.0 | 157 倍 | 10.48 s | 250.6 ms |
| 1536 | 113.2 | 17,209.5 | 152 倍 | 15.22 s | 533.4 ms |
820 一共跑了 26 轮。除了 16 并发的前两轮是 154.9 和 130.2,其余都在 107 到 118 QPS 之间。并发翻了 96 倍,吞吐没动。
820 卡在哪
吞吐不随并发变化,最常见的原因是请求在某个地方排队,一次只处理一个。820 的其他数据也都对得上这个解释。
延迟基本就是排队时间。每秒只能处理 110 个请求的话,N 个并发请求平均要等前面 N 个处理完。照这个算,16 并发的 p50 应该是 145 ms,1536 并发是 14 秒;实测是 139 ms 和 13.4 秒。
机器很闲。整机 CPU 不到 10%,3 个节点加起来只用了 1.2 个逻辑核左右。磁盘每秒 225 次 flush,每次 2 ms 左右,利用率 67%。
写入没有合并。拿 256 并发的第 1 轮来看,node1 在 60 秒里处理了 6,800 个请求左右,Raft 日志的 append 和 save_committed 次数跟请求数几乎一样,磁盘 flush 次数大约是请求数的两倍。每个请求都单独写一次日志、单独刷盘。
110 QPS 是怎么算出来的
请求串行处理时,吞吐就是 1 ÷ 单个请求的耗时。为了把这个耗时拆开,我又对 820 跑了几组 20 秒的实验,分别改并发数、节点数,以及数据目录放在 NVMe 还是内存盘(tmpfs)上:
| 实验 | 节点数 | 并发 | 存储 | QPS | 每个请求耗时 |
|---|---|---|---|---|---|
| A | 3 | 1 | NVMe | 160.4 | 6.2 ms |
| B | 3 | 64 | NVMe | 169.5 | 5.9 ms |
| C | 1 | 64 | NVMe | 490.5 | 2.0 ms |
| D | 3 | 64 | 内存盘 | 547.0 | 1.8 ms |
| E | 1 | 64 | 内存盘 | 10,846.7 | 0.09 ms |
表里的“每个请求耗时”是用 1 ÷ QPS 算的。这组实验和正式测试不在同一时段跑,所以 NVMe 上是 160 到 170 QPS,不是 110。
A 和 B 说明并发不起作用:1 个并发和 64 个并发的吞吐差不多。E 组既不刷盘也不复制,一个请求只要 0.09 ms,但 64 并发时 p50 还是 5.76 ms,正好约等于 64 × 0.09 ms,请求照样在排队。
剩下几组用来拆耗时。C 比 E 多出 1.9 ms 左右,这是一次刷盘的时间,单独测这块盘写 4 KiB 再 fsync,p50 是 1.96 ms。D 比 E 多出 1.7 ms 左右,这是三节点复制的开销,这里走的还是本机回环网络。把 Leader 刷盘、Follower 刷盘和复制往返加起来,大约 6 ms,和 A、B 对得上。两次刷盘在流程上不一定完全串行,但三个节点共用一块盘,刷盘本来就要互相排队。
这块盘是消费级 NVMe,没有掉电保护,fsync 的 p99 能到 58 ms。所以单个请求的耗时会在 6 到 9 ms 之间来回变,对应 110 到 170 QPS。
换好一点的硬件也高不了多少。D 组刷盘几乎不花时间,三节点也只有 550 QPS 左右;真正跨机部署,还要再加网络往返。我估计 820 三节点在一般服务器上的写入上限是几百 QPS,这个数没有实测,但不管什么硬件,加并发都不会让它变快。
用官方镜像二分
历史 nightly 的 Release 页面上已经找不到二进制,好在 Databend 国内镜像仓库 registry.databend.cn/public/databend-meta 还留着历史 nightly 的镜像,可以匿名拉,这次要用的版本都拉到了。我从镜像里取出 databend-meta 和 databend-metactl,每个版本跑一轮 64 并发、20 秒:
| 版本 | 日期 | QPS | p50 | p99 |
|---|---|---|---|---|
| 1.2.820 | 2025-09-25 | 161.7 | 345 ms | 3,359 ms |
| 1.2.880 | 2026-02-08 | 172.7 | 356 ms | 392 ms |
| 1.2.890 | 2026-03-22 | 135.8 | 486 ms | 523 ms |
| 1.2.895 | 2026-04-12 | 123.6 | 490 ms | 1,789 ms |
| 1.2.896 | 2026-04-13 | 4,749.4 | 12 ms | 22 ms |
| 1.2.897 | 2026-04-19 | 4,443.8 | 12 ms | 24 ms |
| 1.2.899 | 2026-04-22 | 4,908.0 | 12 ms | 22 ms |
| 1.2.900 | 2026-04-22 | 4,614.1 | 12 ms | 23 ms |
镜像里是 glibc 动态链接的二进制,测试时长也和正式测试不同,这张表只用来看版本之间有没有跳变,绝对值不要和前面比。895 到 896 差了 38 倍,位置很清楚。
895 和 896 之间只有 3 个提交,其中两个升级了 databend-meta 依赖:
| 提交 | 内容 |
|---|---|
| #19703 | 升级到 databend-meta v260312.6.0,带入 rotbl 0.2.10,改进快照块读取的并发 |
| #19704 | 升级到 databend-meta v260312.7.0 |
v260312.7.0 里有一个提交 6bc6f54,标题是 parallelize MetaWorker request processing,和前面看到的现象完全对得上。rotbl 改的是快照数据的读取,而这次测试从空数据开始,也基本不触发快照,所以我认为原因是 6bc6f54。
那个循环
MetaWorker 从队列里取请求,交给 meta 节点执行。820 里是这么写的:
pub async fn run(mut self) {
while let Some(box_fn) = self.worker_rx.recv().await {
(box_fn)(self.meta_node.clone()).await;
}
// ...
}
取一个请求,await 到它执行完,再取下一个。写请求的“执行完”要走完一整轮 Raft 提交,所以任何时候都只有一个写请求在处理,吞吐被卡死在 1 ÷ 单次提交耗时。
6bc6f54 改成每个请求起一个独立任务,用信号量限制最多 4096 个同时执行(代码有删节):
const MAX_IN_FLIGHT: usize = 4096;
pub async fn run(mut self) {
let semaphore = Arc::new(Semaphore::new(Self::MAX_IN_FLIGHT));
while let Some(box_fn) = self.worker_rx.recv().await {
let permit = semaphore.clone().acquire_owned().await.expect("semaphore closed");
let meta_node = self.meta_node.clone();
RT::spawn(
async move {
(box_fn)(meta_node).await;
drop(permit);
},
Some("meta-request".to_string()),
);
}
// ...
}
一共改了 19 行。多个写请求能同时进入 Raft 以后,日志模块可以把它们合并成一次写入、一次刷盘,刷盘的成本就摊到了多条写入上。
后面的版本在这条路径上还有优化。2026 年 5 月的 #19861 升级依赖后用上了 raft-log 的 WAL group commit,会稍等一下,把更多写入攒到一次刷盘里。仓库里的基准报告显示,2 节点集群上吞吐又提高了 10% 到 13%,1024 并发时平均一次刷盘合并 99 条写入左右。
6bc6f54 的提交说明里还提到,串行时读请求也得排在慢写入后面,改完之后他们测的读请求 p50 从 29 ms 降到了 226 µs。
升级
写入并发高的环境,至少升到 1.2.896。更建议直接上新版本,group commit 这些后续优化也一起拿到。
兼容性可以查仓库里的 src/meta/ver/compat_docs/。820 的 query 兼容 1.2.677 及以上的 meta;文档里 1.2.896 和最新收录的 1.2.926 到 1.2.929 的 meta,都兼容 1.2.676 及以上的 query,956 自己报告的最低客户端版本也是 1.2.676。所以先只升级 meta、query 暂时不动,版本上是可以的。meta 节点之间,文档里最新的 1.2.929 要求对端不低于 1.2.818,820 满足;956 还没收录进文档,滚动升级前要再确认一下。
820 和 956 的磁盘数据格式都是 V004。生产环境升级前还是要先备份,在测试环境把滚动升级演练一遍。
几点说明
这次只测了小 KV 写入,没测读请求,也没测真实的元数据操作。3 个节点和压测客户端在同一台机器上,共用一块消费级 NVMe,没有跨机网络,所以 110 QPS 只代表这台机器;“吞吐不随并发增长”这一点和硬件无关。
820 和 956 的正式测试隔了几个小时才跑,不是交替进行的。这台机器上我见过的状态漂移在 1.7 倍左右,同一时段各跑一次 16 并发的冒烟测试也差了 8.6 倍,远小于 14 到 157 倍的差距。两个二进制的链接器和工具链也不一样,但二分结果已经说明,主要的跳变来自 896 的代码改动。
二分时每个版本只跑了一轮 20 秒,只用来找跳变的位置。我也没有单独编译只包含 #19703 的版本来排除 rotbl 的影响,6bc6f54 读请求的那组数据我同样没有复测。
附录:二分探测命令
拉取指定版本的镜像,取出二进制:
TAG=v1.2.896-nightly
IMG=registry.databend.cn/public/databend-meta:$TAG
BIN=/data/meta-bisect/bin/$TAG
docker pull "$IMG"
mkdir -p "$BIN"
C=$(docker create "$IMG")
docker cp "$C:/usr/bin/databend-meta" "$BIN/"
docker cp "$C:/usr/bin/databend-metactl" "$BIN/"
docker rm "$C"
# 每个版本使用同一份压测客户端
cp /path/to/databend-metabench "$BIN/"
在 Databend 仓库根目录执行一轮探测:
python3 scripts/benchmark/meta/meta-cluster-bench.py \
--node-bin "$BIN" --node-label "$TAG" --node-count 3 \
--work-dir "$(mktemp -d)/cluster" --port-base 29101 \
--workload-rpc upsert_kv \
--workload-clients 64 --workload-client-pool-size 64 \
--workload-ops 1000000 --workload-run-secs 20 --repeat 1
测试机的硬件和系统配置见上一篇。
💬 社区支持
有问题与我们的团队联系:Slack微信:82565387

