【免费下载链接】twelve-dollar-server-challenge
Implement one small API on SQLite in any language; we benchmark it on a $12 server (1 vCPU, 2 GB)
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.js | feed → 思考 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 上执行 |
两个关键事实:
- 每个用户占一个常驻 keep-alive 连接,所以"用户数"≈"并发连接数"。
- 直连模式(不走 Nginx)下,服务被 systemd 设置为
LimitNOFILE=65535;走 Nginx 则按 bench/nginx.conf 监听在127.0.0.1:3000。
模式 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 + Gin | 27,000 用户时 1.6 GB | 28k 起被 OOM kill,后续连接全被拒 |
| Crystal + Kemal | 19,000 用户时 1.6 GB | OOM kill 后每 1–4 分钟重启一次 |
| Go net/http | 45,000 用户时峰值 1.5 GB | 47k+ 全部 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_SEHExceptionabort(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 的"断崖"。
怎么破(榜首们的标准操作):
- 关自动 checkpoint(
wal_autocheckpoint=0),改为后台线程定期跑sqlite3_wal_checkpoint_v2(PASSIVE):Ada 实现每 250ms 一次(submissions/ada-native-visseranalytics/README.md),p99 从 376ms 降到 302ms; - 或 fork 一个独立的 checkpoint 子进程,主事件循环永不被 checkpoint 阻塞(submissions/asm-epoll-unorig-username/README.md)。
模式 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 |
| 进程消失后海量 refused | accept 失败 → 退出 | 模式 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,输在磁盘。而头部梯队全部命中了四个共同点:
- io_uring 传输 + fixed-file slots 突破 fd 概念上限;
- 空闲 keep-alive 主动回收,连接数永远不触顶;
- WAL + NORMAL + 后台 checkpoint,写路径零毛刺;
- 零/低分配热路径,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)
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考




