我把同一个压测做错了三次,第四次才发现真正的瓶颈

给自己写的分布式事务管理器(Rust,对标 Go 的 DTM)做压测,本来以为是半天的活。

结果前三版的结论全是错的。不是代码有 bug,是压测脚本一直在测自己。

这篇记录那三次翻车,以及修完之后看到的真实瓶颈------它跟我一开始的假设正好相反。

第一次:卡在 157 笔/秒,纹丝不动

第一版压测跑出来:不管协调器开几个推进 worker,端到端吞吐都是 157 笔/秒,1 个 worker 是它,16 个还是它。

"并行没生效"是最自然的解释。我去查了抢占逻辑、查了连接池、查了锁------都没问题。

真正的线索在完成曲线里:

ini 复制代码
t= 1.36s  done=128  (+48)
t= 1.86s  done=176  (+48)
t= 2.36s  done=224  (+48)
t= 2.86s  done=272  (+48)

每 500ms 正好 48 笔,一次不多一次不少。 真实系统不会这么整齐。这种刻度感只可能来自某个固定的时间常数。

48 笔/500ms = 96 笔/秒,每个 worker 12 笔/秒,也就是每笔事务 83ms。而一笔事务要调两次业务接口。

83 ÷ 2 ≈ 40ms。

40ms 是 Linux 延迟 ACK 的典型值。 这是 Nagle 算法的经典症状:http.server 的响应分两次 write(先头后体),开着 Nagle 时第二段要等对端的延迟 ACK 才发得出去。

python 复制代码
class Busi(BaseHTTPRequestHandler):
    disable_nagle_algorithm = True   # 就这一行

改完:96 → 3216 笔/秒,33 倍。

真实的业务服务(Go / Java / nginx)默认就是 TCP_NODELAY,所以这纯粹是压测脚本的伪影。不修的话,等于给被测系统栽赃。

第二次:压测客户端自己顶到了天花板

修完 Nagle,我开始比较两种事务模式:SAGA(每笔 1 个客户端请求)和二阶段消息(每笔 2 个:prepare + submit)。

结果二阶段消息"慢 45%"。看起来很合理------多一次往返嘛。

差点就这么写进文档了。临了做了个对照实验:让压测客户端去打协调器的 /health,一个什么都不做的端点。

yaml 复制代码
并发 50 : 5729 req/s
并发 100: 5517 req/s
并发 200: 5153 req/s

~5500 req/s,跟并发数无关。 这是 Python GIL 的天花板,不是被测系统的。

而 SAGA 那组测出来是 6083 笔/秒 = 6083 req/s,二阶段消息是 4190 笔/秒 = 8380 req/s------两边都撞在客户端上限,"慢 45%"完全是假象。

把提交端改成多进程(8 个进程各自一份 GIL)之后,SAGA 的提交阶段从 6083 涨到 18769 笔/秒

这是同一个错误的第二次出现:业务服务、完成判定、提交端,三处都是单进程 Python,我修了前两处就以为完事了。

第三次:某几行结果凭空消失

批量扫参数的时候,输出里偶尔会少几行。不报错,就是没了。

追进去发现协调器起不来,日志里一句 Address already in use。但用 ss 去查,那个端口空空如也。

关键在这:

bash 复制代码
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768	60999

我给协调器选的端口是 36700 ------正好落在内核的临时端口范围里。压测时客户端要建海量外连,其中一条随机分到 36700 作为本地端口,监听就绑不上了。

而且这个坑只在对端连接 churn 大的时候才现形。用连接池的客户端几乎不触发,每次新建连接的客户端一触即发。

端口挪到 26700,问题消失。

拿 DTM 做对照,第一次的结论完全反了

自己跟自己比说明不了什么,于是我把 DTM v1.19 拉起来,同机、同一个 Redis、同一个业务服务、同一个压测客户端。

第一轮结果:DTM 1500 笔/秒,我这边 7600。

好看得可疑。去翻 DTM 的日志------5667 条 connection reset by peer

python 复制代码
class ThreadingHTTPServer:
    request_queue_size = 5    # socketserver 的默认值

accept 队列只有 5。 用连接池的客户端几乎不受影响(长连接建一次用很久),而 DTM 不复用连接、每次调用都新建,队列一满内核直接 RST。

拿这个去对比两个实现,等于按"客户端复不复用连接"给分,跟事务协调没有半点关系。

python 复制代码
request_queue_size = 4096

改完 DTM 是 9600 笔/秒,零错误。差 6 倍,全是脚本的锅。

我差点公开发布一个"快 5 倍"的结论。

修完之后:真正的瓶颈跟我想的相反

harness 干净了,才能看清系统本身。用的存储是 Redis(跑秒杀那类尖峰场景),它是单线程的,所以先量它的 CPU:

yaml 复制代码
稳态 Redis CPU: 94.2%   (单线程,100% 即打满)

打满了。那就数一下每笔事务打了多少次 Redis:

sql 复制代码
     8.0  hget
     7.0  hset
     6.0  evalsha
     5.0  zadd
     4.0  hgetall
     4.0  exists
     ...
----
    40.0  合计

一笔只有一个正向步骤的事务,打了 40 次 Redis。 对照 DTM 的实现(它的 storage/redis/redis.go 我逐个方法读了一遍),同样形状大约 23 次。

于是我做了一轮"减命令数":把 EXISTS + 两次 HGET 的开头合并成一次 HMGET(读到 nil 就等于键不存在,EXISTS 根本不用单发);把三次调用合成一个 Lua 脚本;把分支状态从 JSON blob 里拆出来(改状态从"读-解析-改-编码-写"变成一条 HSET)。

40 → 25 条,砍掉 37%。

吞吐涨了多少?

yaml 复制代码
msg  1 步   11541 → 12540   +8%
saga 2 步    8642 →  7648   -11%

+8%,还有一项倒退了。

而且 Redis CPU 仍然是 90%。命令少了 37%,CPU 没降------说明每条命令的平均成本变高了 。换句话说,主导 CPU 的不是"脚本里跑了几条命令",而是每次网络往返的固定开销:协议解析、系统调用、EVALSHA 的脚本启动。

我那个"命令数和吞吐近似线性"的模型是错的。

真正有效的那一刀

既然按往返计价,就该去数往返。一笔事务的往返是:

markdown 复制代码
1. 建事务(提交请求里)
2. 抢占                    ← 这个
3. 读分支列表
4. 调业务接口后写分支状态
5. 再读分支列表
6. 落终态

第 2 步是每笔事务固定要付 的:提交方写完事务就返回,推进器得再 lock_one_due 抢一次才能推它。

DTM 没有这一步------它在 submit 请求里同步把事务推完,不入队。这也解释了为什么 SAGA 那栏我输给它:SAGA 只有一次客户端请求,这个固定成本摊不薄。

但同步推完意味着客户端要一直等。有没有办法既省掉抢占,又不阻塞提交?

有。在建事务的那条写入里顺便把租约占在自己手上

rust 复制代码
g.owner = driver.owner.clone();
g.next_cron_time = now() + driver.lease;   // 占坑
if store.create_global(&g, &branches).await? {
    // 写成功 == 抢到了,直接 spawn 出去推,提交立刻返回
    tokio::spawn(async move { driver.process(&g).await });
}

写成功就等于抢到了,零额外往返。而且推进是 spawn 出去的,提交仍然立刻返回。

安全性上它没引入新东西:next_cron_time 被推到租约之后,推进器的抢占查询(WHERE next_cron_time <= now)就看不见这笔事务了。进程要是在"写完"和"推完"之间挂了,租约到期后别的实例接手------这跟"推进器抢到之后崩了"是同一种情形。

效果:

内联前 内联后
SAGA 2 步 7630 13883(+82%)
msg 1 步 12371 18580(+62%)

对比一下三轮优化的性价比:

做了什么 收益
抠 Redis 命令数(40→25) +8%
省掉抢占往返(SAGA) +82%
省掉抢占往返(msg) +62%

往返数才是那个量级的东西。

顺带一提,这一刀还消掉了另一个现象:以前吞吐会随库里的存量数据下滑(Postgres 空库 3424 笔/秒,堆了 4 万笔历史之后只剩 777,差 4.4 倍),因为抢占那条查询要扫索引、而死元组堆积得比 autovacuum 收得快。现在抢占不在正常路径上,空库 5147、存量 4 万笔 5036,在噪声内。

最终对照

同机、同一个 Redis(host 网络)、同一个业务服务、同一个压测客户端,2 万笔,三次取中位数:

模式 dtmrs DTM v1.19
二阶段消息,1 个正向步骤 18580 笔/秒 9853 +89%
SAGA,2 个步骤 13883 笔/秒 9208 +51%

报数时必须一起说的 :DTM 默认 UpdateBranchSync: 0(分支状态异步落盘),上表已经改成 1 对齐,实测差别不大;两边的 nofile 都调到了 1048576;机器是 20 核的 i7-12700,数据库都在本机。

还有一个容易忽略的变量:存储的网络路径 。docker 用 -p 发布端口比 --network host 慢 11%(每次往返多约 8µs,累积起来)。这个差距在优化之前是 36%------往返少了,按往返计价的开销自然跟着缩水,反过来也印证了前面的判断。

几条能带走的

  1. 跨实现的压测,先怀疑自己的脚本。 我错了三次,每次的错误都足以让结论反过来。
  2. 看数字的"形状"。 每 500ms 正好 48 笔、跟并发无关的 5500 req/s------过于整齐的数就是人造常数,去找那个常数是谁。
  3. 量对指标。 Redis 单线程,我一开始盯命令数,实际主导的是往返数。这两个在"减少一次调用"时会一起降,容易混淆;而"把三条命令合进一个脚本"能把它们分开------命令降了、往返没降,吞吐就不动。
  4. 优化前先确认瓶颈还在原地。 我修完 Nagle 之后没有重新确认瓶颈,直接接着做命令数优化,白花了一轮。
  5. 别信自己没验过的对照组。 "快 5 倍"和"快 89%"的差别,就是一次翻日志。

项目是 dtmrs,Rust 写的分布式事务管理器,Apache-2.0。支持 SAGA / TCC / 二阶段消息 / XA / workflow 五种模式,存储可跑 sqlite / Postgres / MySQL / Redis。

比较特别的一点是协调器可以当库链进你自己的进程 ,分支直接是进程内函数,不用单独部署服务;再往外通过 C ABI 给 Python / Node / JVM 用。这个形态 Go 结构上做不了(c-shared 会把整个运行时拖进宿主进程)。

压测脚本在 bench/bench.py,上面那些坑都写在文件头注释里了,包括怎么复现 DTM 的对照组。数字都能自己跑一遍------跑之前记得先看那三条前置检查,否则大概率测的是你自己的脚本。

相关推荐
番茄炒鸡蛋加糖1 小时前
专项2:项目性能优化&可量化指标
性能优化
ai_coder_ai2 小时前
事件驱动架构(EDA)在分布式业务系统中的应用
分布式·架构
程序员爱钓鱼2 小时前
Rust 生命周期详解:生命周期标注、约束与真实项目应用
后端·面试·rust
MoonBit月兔2 小时前
MoonBit 受邀亮相 SPLASH/ISSTA 2026,与 Java、Rust、Eiffel、D 语言核心贡献者同场交流
开发语言·后端·rust
Source.Liu2 小时前
【A11】项目架构设计笔记
rust
x-cmd3 小时前
用 Rust 打造 AI 时代的 SQL:把重复任务变成可执行文件
数据库·人工智能·sql·ai·容器·rust·workflow
蓝胖的四次元口袋4 小时前
分布式知识梳理(4)
分布式
书香门第4 小时前
系统设计练习 - 分布式黑名单服务(design a distributed denylist service)
分布式·系统架构·系统设计
江畔柳前堤11 小时前
大语言模型分布式训练:从并行策略到万卡工程的系统梳理
人工智能·分布式·深度学习·算法·目标检测·机器学习·语言模型