fix(dns): server 层查询并发化,消除队头阻塞 (issue #223) - #233
Conversation
UDP 主循环此前把 handle_dns_request(...).await 内联在 recv_from 分支里, 一条撞上慢上游的查询(config.timeout_ms 默认 3000ms)没返回之前 recv_from 根本不会被 poll,所有后续查询全部排队 —— 最坏吞吐 ≈ 1/上游 RTT。 用户感知为「部分网站突然打不开、过几秒恢复」。 改成与 proxy.rs(#76)一致的分发模型: - 主循环只做 recv_from + 分发,每个查询交给独立 task; - 请求字节用 bytes::Bytes 拷贝出栈上 4 KiB 缓冲(对齐 proxy 的 P-R5 / P-R4), 消除「原 buffer 被慢上游 await 阻塞住」的问题; - Semaphore 限流(MAX_CONCURRENT_CLIENT_QUERIES = 1024,与 proxy 对齐), try_acquire_owned 发生在 spawn **之前**,且刻意放在拷贝 / 读 resolver 之前 —— 命中上限时那些 per-query 开销全是白做的; - JoinSet 管理在途 task,shutdown 走 abort_all + drain:stop() 立即返回, 不被最长 3000ms 的上游超时拖住(见 AGENTS.md 的 exit cleanup 契约), 同时避免 detached task 对着已 drop 的 socket 刷无意义 warn; - resolver_slot 的读提到 spawn 之前,从「每 task 一次读锁」降为「每包一次」, hot-swap 逐查询生效的语义不变。 server 层与 proxy 不同,不需要 per-query 临时 socket:socket 只用于无状态的 send_to(没有 connect() + recv 配对),共享并发发送是安全的。 顺带去掉 build_answer_response 的 answer 双重 clone(参数改收 &Record, clone 只发生在 add_answer 内部那一次),cache-miss 的 answer 查询每次省一次 Record 分配。 测试: - test_dns_server_no_head_of_line_blocking —— 已在修复前的串行实现上验证过 会失败(fast 查询耗时 1.204s,阈值 500ms),修复后为毫秒级; - test_dns_server_concurrency_capped —— 洪水下断言 available_permits() 归零; - test_dns_server_stop_aborts_inflight —— 断言 stop() 在 500ms 内返回。 flood 测试里 client socket 用完即丢,不持有 2048 个 fd。本测试 fd 峰值 ~1026(主要来自 hickory 为每个在途查询开的上游 socket),与已有的 proxy 并发测试同量级(后者峰值 ~3074,长期 CI 绿)。 cargo fmt / clippy --workspace --all-targets -D warnings / 全量测试(606)全绿。
Review 结论:可合并(2 个 P3 小项 + 2 个观察,均不阻塞)已在本地验证(PR 分支的临时 worktree 中): 已验证的关键点
非阻塞发现1. [P3] recv 错误路径与 shutdown 分支不对称。 2. [P3] permit 耗尽的 3. [观察] fd 压力从 1 变成 ~1026。 4. [观察] 总结修复方向、实现、测试三者都对:并发化模型与 proxy 层(#76)一致,已知取舍(cache stampede 不做 single-flight、上限不进 DnsConfig、不加 backoff)都在 PR 里明说了且合理。建议合并;第 1、2 点可以作为合并前顺手的小修或后续 issue。 |
Closes #223
问题
mhost-dns/src/server.rs的 UDP 主循环把handle_dns_request(...).await内联在recv_from分支里 —— 一条查询完成前recv_from不会被 poll。上游解析超时默认 3000ms(
DnsConfig.timeout_ms),所以一条慢上游查询期间所有后续查询排队等待;最坏情况吞吐 ≈ 1/上游 RTT,系统 DNS 解析近乎停摆。用户感知为「部分网站突然打不开、过几秒恢复」,尤其在某个上游 DNS 抖动时。proxy 层早在 #76 就做了 per-query socket + spawn + 并发信号量,server 层没有对应模型。
改动
主循环改成与
proxy.rs一致的分发模型:Vec<u8>(跨 await 借用)[u8; 4096]+Bytes::copy_from_sliceSemaphore,上限 1024(对齐 proxy)JoinSet,shutdown 时abort_all要点:
try_acquire_owned),且刻意放在拷贝 / 读 resolver 之前 —— 命中上限时那些 per-query 开销全是白做的。同 proxy 的模型。resolver_slot的读提到 spawn 之前,从「每 task 一次读锁」降为「每包一次」(只是原子 ref bump)。hot-swap 逐查询生效的语义不变。abort_all+ drain,stop()立即返回,不被最长 3000ms 的上游超时拖住 —— 这条在应用退出 / 关闭 DNS 模式的路径上尤其重要(见 AGENTS.md 的 exit / DNS cleanup 契约)。顺带消除了 detached task 对着已 drop 的 socket 刷无意义 warn 的问题。build_answer_response的 answer 双重 clone(参数改收&Record,clone 只发生在add_answer内部那一次),cache-miss 的 answer 查询每次省一次Record分配。为什么不需要 proxy 那套 per-query 临时 socket:proxy 需要是因为它
connect()后要在同一个 socket 上recv回包,并发 query 会互相把回包读串。server 层是「收包 → 处理 →send_to原地址」的无状态发送,没有 recv 配对,共享同一个 socket 并发send_to是安全的。代码注释里记了这个差异。验证
核心回归测试在修复前的串行实现上验证过会失败:
正好是预测的 ~1200ms;修复后是毫秒级。(做法:
git checkout HEAD -- server.rs回到旧主循环,套上测试,跑完再恢复。)新增测试:
test_dns_server_no_head_of_line_blocking—— mock upstream 按名字分流,slow.example.com睡 1200ms、其他立即应答;用通知通道确定性等到 slow 查询确实打到上游(不靠 sleep 猜,否则 server 还没收包就发 fast 会假通过),再断言 fast 查询 500ms 内返回。test_dns_server_concurrency_capped—— 黑洞 upstream + 洪水,断言available_permits()归零且 server 不 panic。test_dns_server_stop_aborts_inflight—— 断言stop()在 500ms 内返回且is_running() == false。CI 门槛(均本地跑通):
三个新测试连跑 8 次稳定。flood 测试 client socket 用完即丢(不持有 2048 个 fd);本测试 fd 峰值 ~1026,主要来自 hickory 为每个在途查询开的上游 socket,与已有的
test_proxy_concurrency_capped同量级(后者峰值 ~3074,长期 CI 绿)。已知取舍
DnsConfig(避免动 serde 与前端,控制在 issue 范围内)。recv_from出错仍按现状向上传播终止 loop,未加 proxy 那样的 backoff,保持现有错误语义。🤖 Generated with Claude Code