为什么我的实现只撑住2千用户?twelve-dollar-server-challenge性能瓶颈诊断指南:OOM、fd上限与磁盘I/O的10大经典失败模式

【免费下载链接】twelve-dollar-server-challenge

Implement one small API on SQLite in any language; we benchmark it on a $12 server (1 vCPU, 2 GB)

项目地址: https://gitcode.com/gh_mirrors/tw/twelve-dollar-server-challenge
点击查看 免费下载

twelve-dollar-server-challenge 是一个有趣的极限挑战:在一个月费 12 美元的低配服务器(1 vCPU、2 GB 内存、无 swap)上,用任意语言实现一个跑在 SQLite 之上的小型 feed API,然后用 k6 压测看它到底能扛住多少并发用户。规则很简单但约束很硬:SPEC.md 定义了 5 个接口和逐字节一致的 JSON 输出,schema.sql 的表结构固定,每个写请求必须在响应前落盘,且不允许任何跨请求缓存。

榜单最低分只有 2,000 用户(R 的 plumber2),而榜首 Rust + io_uring 实现撑到了 310,000 用户——同样一台机器,差了 150 倍。如果你压测只过了 2 千用户就"死"了,问题几乎一定出在本文总结的 10 大经典失败模式之一。LEADERBOARD.md 完整记录了 190 多个参赛实现的压测数据与崩溃原因,是本指南的全部依据。

压测是怎么打出来的:先看懂评分规则

诊断之前,先理解"死法"是如何被定义的:

项目规则说明
用户模型bench/load.jsfeed → 思考 3-7s → 看帖 → 15% 点赞 → 2% 发帖 → 空闲 5-15s,每用户一条 keep-alive 长连接
通过标准p95 < 500ms,p99 < 1s,错误率 < 1%5 分钟持续压测(hold)全部达标才算
上限探测2 分钟探针翻倍 → 二分逼近失败则每 250 用户降档重试
前置门槛test/test.sh 42 项检查全过test/run.sh 在 CI 上执行

两个关键事实:

  1. 每个用户占一个常驻 keep-alive 连接,所以"用户数"≈"并发连接数"。
  2. 直连模式(不走 Nginx)下,服务被 systemd 设置为 LimitNOFILE=65535;走 Nginx 则按 bench/nginx.conf 监听在 127.0.0.1:3000。

twelve-dollar-server-challenge 压测监控面板:实时 req/s 柱状图与 feed、post、like 请求分布

模式 1:fd 表打满——71,000 用户与"恰好 0.93% 错误率"的假象

这是榜单上最经典的陷阱:24 个实现全部精确停在 71,000 用户,错误率都是 0.93%(每次约 21,900 个超时)。原因:

  • 进程 LimitNOFILE=65535,65,535 条 keep-alive 连接占满 fd 表;
  • 多出来的用户全部等 listen 队列,直到压测器 60s 连接超时;
  • 0.93% < 1% 的错误预算,压测框架判定"通过"。

判定特征:失败时 CPU 可能只有 31%~94%(一个 Zig 服务 31% CPU 也停在同一点),req/s 在 7 万用户后完全走平,只有连接超时在增长。榜单脚注原话:"The 65,535 file-descriptor limit, not speed."

怎么破:榜首梯队(25 万+ 用户)全部做了两件事——用 io_uring 的 fixed-file slots 突破 fd 概念上限(最高 ~524k slots),以及接近上限时主动回收空闲长连接(回 Connection: close 或 RST,让客户端重连)。参考 submissions/c-iouring-fajarreza-gif/README.md 中"accept hold 1,024 slots before limit"的防洪设计。

模式 2:accept 失败 → 进程直接退出

更糟的是某些框架遇到 EMFILE(too many open files)不是报错,而是直接把引擎关掉:

  • Go gnet / fasthttp:accept: too many open files 后 gnet.Run 返回,进程退出,之后所有连接被拒(68k/70k/80k 用户下 86–90% 错误);
  • 榜单上 Go gnet 作者后来加了一条规则:"超过 55k 连接就关闭最老的空闲连接",得分从 65,000 提到 88,000。

判定特征:dmesg 无 OOM,但 journal 里进程 exit status 1 后大量 connection refused。

怎么破:监控 fd 水位,提前主动丢弃空闲连接(注意 Nginx 模式要求空闲连接至少保持 65 秒,见 SPEC.md 运行时约定)。

模式 3:accept 失败 → 无限重挂 spin,100% CPU 空转

另一种死法是 accept 错误后立即重新武装 multishot accept,事件循环在 accept 错误上死循环:

  • C++ io_uring 实现:65k 时 30% CPU 完全健康,68k 起直接 100% CPU,21–30% 请求超时;
  • 问题代码就在 submissions/cpp-iouring-edwin-2024/src/server.cpp:multishot accept 结束后无条件重新 io_uring_prep_multishot_accept,没有任何退避。

判定特征:CPU 突然飙到 100%,但 req/s 骤降,延迟暴涨。

怎么破:accept 返回负值时记录错误并退避(或降速重挂),避免忙等。

模式 4:工作线程池被空闲长连接"人质化"

这是"只撑住 2 千用户"最常见的原因之一——线程池模型 + 每连接独占一个工作线程:

  • D 语言 arsd threaded 实现:500 用户就 75–99% 超时,CPU 只有 1–2%,因为固定线程池全被空闲 keep-alive 连接占住,新请求根本拿不到线程;
  • 同作者的 hybrid CGI 版本在 22,000 后新连接全被拒,CPU 依然只有 40%。

判定特征:CPU 和内存都很低,但新连接/新请求大面积超时;p50 直接等于压测器 60s 超时。

怎么破:改用事件驱动(epoll/io_uring)或真正的非阻塞模型;若必须线程池,给空闲连接设置回收策略。

模式 5:单连接内存线性增长 → 28,000 用户 OOM kill

2 GB 内存、无 swap,每连接的内存开销是隐形杀手。榜单实录:

实现内存表现结局
Go + Gin27,000 用户时 1.6 GB28k 起被 OOM kill,后续连接全被拒
Crystal + Kemal19,000 用户时 1.6 GBOOM kill 后每 1–4 分钟重启一次
Go net/http45,000 用户时峰值 1.5 GB47k+ 全部 OOM
Rust #82 探针360k/480k 探针OOM kill(2 GB 机器)

判定特征:dmesg 出现 Out of memory: Killed process(exit 137);内存随用户数线性上涨;重启后短暂恢复再死循环。

怎么破:给每连接对象设池化/上限、复用 buffer、压测中盯 RAM(榜单每个实现都记录了 cgroup 峰值 RSS)。榜首 Rust 实现 31 万用户只用了 1.1 GB——内存效率本身就是核心竞争力。

模式 6:.NET 系运行时在 fd 上限处直接 abort

.NET 运行时对连接数有自己的天花板,超过 65,535 fd 后不是优雅降级而是进程崩溃:

  • F# Oxpecker 实现:68k/70k 用户时 .NET 以 PAL_SEHException abort(core dump),之后 86% 连接被拒;
  • C# ASP.NET Core 多个实现:80k 用户探针同样以 PAL_SEHException / "Out of memory." abort。

判定特征:journal 出现 core dump,崩溃点恰好卡在连接数突破 65,535 的瞬间。

怎么破:在 .NET 栈中,主动连接回收(模式 1 的方案)比任何 GC 调参都有效;参考 submissions/fsharp-oxpecker-delneg/Db.fs 的独立 checkpoint 线程设计,它把 iowait 压到了 1–6%。

模式 7:磁盘 I/O 瓶颈——synchronous 默认 FULL,每次提交都 fsync

榜单上有一条精确的"磁盘罪证":Java Quarkus 实现从未设置 synchronous,SQLite 默认 FULL → 每个 commit 一次 fsync,探针期间 iowait 高达 26–39%,最终只拿到 29,000 用户。

规则第 6 条要求"WAL + synchronous=NORMAL 或更强",因为 synchronous=OFF 直接违规。正确姿势:

PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;

判定特征:iowait 高企、CPU 不满、写延迟(POST)显著高于读延迟。

怎么破:WAL + NORMAL 下 commit 只是 WAL 追加、无 fsync;再配合 group commit(多请求写合入一个事务,每个响应仍等待自己的 commit,规则允许)。submissions/go-gin-lincolng4/README.md 中单连接 + 预编译语句 + pragmas 的配置清单值得对照。

模式 8:WAL auto-checkpoint 把所有请求拖住

WAL 模式下 SQLite 默认 auto-checkpoint 会同步执行:checkpoint 期间所有在途请求集体等待。c-ammarserver 的实测数据非常直观:持续写入 p50 只要 74–93 µs,但 每次 auto-checkpoint 造成 106–110 ms 的毛刺(见 submissions/c-ammarserver-AmmarkoV/README.md)。

判定特征:p99 周期性尖刺(几分钟一次),其余分位数很漂亮;压测报告里 p95 ~30ms 但 p99 ~400ms 的"断崖"。

怎么破(榜首们的标准操作):

模式 9:hold 中段的连接风暴——60–90% connection refused

压测前 2 分钟探针都好好的,5 分钟 hold 打到一半突然崩掉:

  • V epoll 实现:270k 通过探针,hold 中段开始 60% 错误,几乎全是 connection refused;
  • C io_uring 多个实现:250k–320k 时 70–78% 错误;
  • Crystal Rayo:320k 探针 85% 错误。

成因:fd/内存逼近极限 → 无法 accept 新连接 + listen 队列溢出 → 压测器不断重连 → 连接风暴自我放大,整个服务塌方。

判定特征:探针能过、hold 必挂;错误集中在 connection refused 且出现时间固定在 hold 中段。

怎么破:和模式 1 同源——扩大连接容量(fixed-file slots、SO_REUSEPORT 多监听 socket)+ 主动回收空闲连接,把"极限"推远到压测窗口够不到的地方。

模式 10:写路径悄悄失败——POST 变 EOF / 批量回滚变 500

最后一类很隐蔽:错误率 0.9% 左右,只发生在 POST,读请求 0 错误:

  • Lua / Crystal 实现:0.93–0.96% 的 EOF(连接没响应就关闭)全在 POST 上,CPU 却只有 51%——写路径(而非 CPU)才是瓶颈;
  • Go net/http 实现:空闲连接 1s 就关闭,POST 恰好落在这条刚断的 socket 上(GET 会重试、POST 不会);
  • 汇编实现:group commit 里某个批次回滚,同批次所有请求收到 HTTP 500(一次压测 34,684 个 500)。

判定特征:按接口拆解错误率——读全绿、写全红。

怎么破:空闲连接寿命 ≥ 65s(Nginx 模式);group commit 回滚时逐条重试而非整批判死;给写路径单独的超时与重试预算。

一分钟自检清单:你的 2 千用户死在哪一层?

压测失败后,按这张表逐项排查(数据来源:压测器日志 + dmesg + 榜单同款监控列):

现象指向对应模式
错误率≈0.93% 且 req/s 走平fd 表满模式 1
进程消失后海量 refusedaccept 失败 → 退出模式 2
CPU 100% 但吞吐骤降accept 错误忙等模式 3
CPU<10% 但大面积超时线程池被空闲连接占满模式 4
dmesg 出现 OOM kill单连接内存失控模式 5
core dump 在 65,535 连接处运行时自身连接上限模式 6
iowait 20%+,写延迟高每次 commit 都 fsync模式 7
p99 周期性尖刺WAL auto-checkpoint模式 8
探针过、hold 挂、refused 风暴容量极限 + 重连风暴模式 9
只 POST 报错(EOF/500)写路径缺陷模式 10

本地复现很简单:先 bash seed/make-seed.sh 生成种子库,再 k6 run -e VUS=1000 bench/load.js(VUS 逐档加大)。榜单上 31 万用户的头部实现(io_uring + SQLite B-tree 直读 + 后台 checkpoint + 连接回收)说明:2 GB 内存的机器不稀罕,稀罕的是每一层都没有短板。

小结:从 2,000 到 310,000 的路径

回头看榜单两端:2,000 用户的 R 实现(4,103 µs/req)和 0 分的 D 线程池实现,输在连接模型;28,000 用户的 Go Gin、19,000 用户的 Crystal Kemal,输在内存;29,000 用户的 Quarkus,输在磁盘。而头部梯队全部命中了四个共同点:

  1. io_uring 传输 + fixed-file slots 突破 fd 概念上限;
  2. 空闲 keep-alive 主动回收,连接数永远不触顶;
  3. WAL + NORMAL + 后台 checkpoint,写路径零毛刺;
  4. 零/低分配热路径,31 万连接也只吃 1.1 GB 内存。

按模式 1 → 10 的顺序逐层排除,你的下一个瓶颈一定在表里。

【免费下载链接】twelve-dollar-server-challenge

Implement one small API on SQLite in any language; we benchmark it on a $12 server (1 vCPU, 2 GB)

项目地址: https://gitcode.com/gh_mirrors/tw/twelve-dollar-server-challenge
点击查看 免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值