给自己写的分布式事务管理器(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%------往返少了,按往返计价的开销自然跟着缩水,反过来也印证了前面的判断。
几条能带走的
- 跨实现的压测,先怀疑自己的脚本。 我错了三次,每次的错误都足以让结论反过来。
- 看数字的"形状"。 每 500ms 正好 48 笔、跟并发无关的 5500 req/s------过于整齐的数就是人造常数,去找那个常数是谁。
- 量对指标。 Redis 单线程,我一开始盯命令数,实际主导的是往返数。这两个在"减少一次调用"时会一起降,容易混淆;而"把三条命令合进一个脚本"能把它们分开------命令降了、往返没降,吞吐就不动。
- 优化前先确认瓶颈还在原地。 我修完 Nagle 之后没有重新确认瓶颈,直接接着做命令数优化,白花了一轮。
- 别信自己没验过的对照组。 "快 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 的对照组。数字都能自己跑一遍------跑之前记得先看那三条前置检查,否则大概率测的是你自己的脚本。