|
| 1 | +# DNS 性能修复开发计划 (dns-perf-fix) |
| 2 | + |
| 3 | +**基于**: `.plan/dns_perf_rootcause.md` |
| 4 | +**分支**: `dns_fix` |
| 5 | +**目标**: 将 DNS 并发 200 的成功率从 ~66% / 15s 提升至 ~100% / <2s |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 执行总则 |
| 10 | + |
| 11 | +1. 所有任务**严格串行**:Tn 未通过,不开始 Tn+1 |
| 12 | +2. 每个任务必须:代码实现 + 任务级测试 + 测试记录 |
| 13 | +3. 每个里程碑(M1/M2)全部任务通过后,执行里程碑回归测试 |
| 14 | +4. 任一测试失败:立即停止,修复重测,直至通过 |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## 里程碑 M1 — 正确性修复 (P0 + P1) |
| 19 | + |
| 20 | +### T1: 删除 dnsForwarderCache(P0-1) |
| 21 | + |
| 22 | +**文件**: `control/dns_control.go` |
| 23 | +**问题**: 缓存已关闭的 DnsForwarder 对象;下次命中缓存即使用死连接 |
| 24 | + |
| 25 | +**变更**: |
| 26 | +1. 删除 `DnsController` 结构体中的 `dnsForwarderCacheMu`, `dnsForwarderCache`, `dnsForwarderLastUse` 字段 |
| 27 | +2. 删除常量 `maxDnsForwarderCacheSize = 128`(仅在 evict 函数中使用) |
| 28 | +3. 删除 `evictDnsForwarderCacheOneLocked()` 方法 |
| 29 | +4. 删除 `dnsForwarderKey` 类型(仅用于缓存 key) |
| 30 | +5. 删除构造函数中对这三个字段的初始化 |
| 31 | +6. 改写 `dialSend()` 中的缓存逻辑 → 直接 `newDnsForwarder()` + `defer forwarder.Close()` |
| 32 | +7. 删除 `var connClosed bool` 变量及相关的 `defer` 和 `connClosed = true` 赋值 |
| 33 | + |
| 34 | +**修改后 dialSend 关键代码**: |
| 35 | +```go |
| 36 | +forwarder, err := newDnsForwarder(upstream, *dialArgument) |
| 37 | +if err != nil { |
| 38 | + return err |
| 39 | +} |
| 40 | +defer forwarder.Close() |
| 41 | + |
| 42 | +respMsg, err = forwarder.ForwardDNS(ctxDial, data) |
| 43 | +// ... 错误处理 / fallback 不变 |
| 44 | +``` |
| 45 | + |
| 46 | +**测试**: |
| 47 | +- 更新 `TestEvictDnsForwarderCacheOneLocked` → 改为 `TestDialSendCreatesNewForwarder`,验证每次 dialSend 都创建新 forwarder |
| 48 | +- 运行 `go test -race -v -run 'TestIsTimeoutError|TestTcpFallback|TestEvict|TestSendStream|TestDialSend' $(go list ./control/... | grep -v kern/tests)` |
| 49 | +- 预期: PASS, 无 DATA RACE |
| 50 | + |
| 51 | +--- |
| 52 | + |
| 53 | +### T2: DNS 请求绕过 per-src 串行队列(P1-1 + P2-2) |
| 54 | + |
| 55 | +**文件**: `control/control_plane.go` |
| 56 | +**问题**: |
| 57 | +- DNS 请求在 per-src-IP 串行队列中顺序处理,同源 IP 的 200 个请求排队 |
| 58 | +- `UdpTaskQueueLength=128` 溢出时静默丢弃,200 并发 → 72 个请求丢失 |
| 59 | + |
| 60 | +**变更**: |
| 61 | +在 `control_plane.go` 的 UDP 读循环中,对 DNS 包(`isDns == true`)改为 `go` goroutine 直接处理,不经过 `EmitTask`: |
| 62 | + |
| 63 | +```go |
| 64 | +// 在 EmitTask 的 task 函数内部,handlePkt 返回 dnsController.Handle_() 之前 |
| 65 | +// handlePkt 已经区分 isDns,直接调用 dnsController.Handle_() |
| 66 | +// 因此只需在 control_plane.go 中识别 DNS 包并 go handlePkt 即可 |
| 67 | +``` |
| 68 | + |
| 69 | +**实现策略**: 由于 `handlePkt` 在 task 内部才能解析 DNS,最简单的方案是: |
| 70 | + |
| 71 | +**方案**: 在 `EmitTask` 的 lambda 中,先快速检查 port == 53 / isDns,若是 DNS 则 `go c.handlePkt(...)` 而非在当前 queue goroutine 中同步处理。 |
| 72 | + |
| 73 | +```go |
| 74 | +DefaultUdpTaskPool.EmitTask(convergeSrc.String(), func() { |
| 75 | + // ... setup ... |
| 76 | + if pktDst.Port() == 53 || pktDst.Port() == 5353 { |
| 77 | + // DNS: do not block the task queue, handle concurrently |
| 78 | + go func() { |
| 79 | + if e := c.handlePkt(...); e != nil { |
| 80 | + c.log.Warnln("handlePkt(dns):", e) |
| 81 | + } |
| 82 | + }() |
| 83 | + return |
| 84 | + } |
| 85 | + if e := c.handlePkt(...); e != nil { |
| 86 | + c.log.Warnln("handlePkt:", e) |
| 87 | + } |
| 88 | +}) |
| 89 | +``` |
| 90 | + |
| 91 | +注意:`RetrieveOriginalDest` 需要在 goroutine 外调用(已在 task 内),所以 `pktDst` 需要先解析再判断。 |
| 92 | + |
| 93 | +**测试**: |
| 94 | +- 新增 `TestUdpTaskPoolDNSBypass` 单元测试:构造 100 个并发 DNS 任务(模拟同一 src IP),验证全部执行而非被丢弃 |
| 95 | +- 运行 `go test -race -v -run 'TestUdpTask' $(go list ./control/... | grep -v kern/tests)` |
| 96 | +- 预期: PASS, 无 DATA RACE |
| 97 | + |
| 98 | +--- |
| 99 | + |
| 100 | +### T3: 传播 context(带超时)从 handle_() 到 dialSend(P1-3) |
| 101 | + |
| 102 | +**文件**: `control/dns_control.go` |
| 103 | +**问题**: `handle_()` 传递 `context.Background()` 给 `dialSend()`,导致: |
| 104 | +1. 请求无法被外部取消 |
| 105 | +2. 200 个并发请求各持有独立的不可取消 8s timeout,累积资源消耗 |
| 106 | + |
| 107 | +**变更**: |
| 108 | +在 `handle_()` 中,为 `dialSend` 调用添加超时 context: |
| 109 | + |
| 110 | +```go |
| 111 | +// handle_() 末尾,替换原 dialSend 调用 |
| 112 | +dialCtx, dialCancel := context.WithTimeout(context.Background(), DnsNatTimeout) |
| 113 | +defer dialCancel() |
| 114 | +return c.dialSend(dialCtx, 0, req, data, dnsMessage.Id, upstream, needResp) |
| 115 | +``` |
| 116 | + |
| 117 | +`DnsNatTimeout = 17s` 是当前 DNS 请求的最长生命周期,作为 dialSend 的外层 deadline 合适。`dialSend` 内部再用 `context.WithTimeout(ctx, DefaultDialTimeout=8s)` 作为上游连接超时,这是正确的嵌套关系。 |
| 118 | + |
| 119 | +**测试**: |
| 120 | +- 新增 `TestHandle_ContextPropagatesToDialSend`:验证当 ctx 带短超时时,dialSend 能感知并提前返回 |
| 121 | +- 运行相关测试 |
| 122 | + |
| 123 | +--- |
| 124 | + |
| 125 | +### T4: AnyfromPool — 将 socket 创建移出全局写锁(P1-4) |
| 126 | + |
| 127 | +**文件**: `control/anyfrom_pool.go` |
| 128 | +**问题**: `ListenPacket` 在持有全局 write lock 期间执行,高并发响应串行化 |
| 129 | + |
| 130 | +**变更**: |
| 131 | +"先创建,再锁定写入,若竞争则关闭多余的" 模式(optimistic create): |
| 132 | + |
| 133 | +```go |
| 134 | +func (p *AnyfromPool) GetOrCreate(lAddr string, ttl time.Duration) (conn *Anyfrom, isNew bool, err error) { |
| 135 | + p.mu.RLock() |
| 136 | + af, ok := p.pool[lAddr] |
| 137 | + if ok { |
| 138 | + af.RefreshTtl() |
| 139 | + p.mu.RUnlock() |
| 140 | + return af, false, nil |
| 141 | + } |
| 142 | + p.mu.RUnlock() |
| 143 | + |
| 144 | + // Create socket OUTSIDE the lock (parallel creation is ok; we'll deduplicate) |
| 145 | + newAf, createErr := p.createAnyfrom(lAddr, ttl) |
| 146 | + if createErr != nil { |
| 147 | + return nil, true, createErr |
| 148 | + } |
| 149 | + |
| 150 | + p.mu.Lock() |
| 151 | + if af, ok = p.pool[lAddr]; ok { |
| 152 | + // Lost the race; close what we just created |
| 153 | + p.mu.Unlock() |
| 154 | + _ = newAf.UDPConn.Close() |
| 155 | + return af, false, nil |
| 156 | + } |
| 157 | + p.pool[lAddr] = newAf |
| 158 | + p.mu.Unlock() |
| 159 | + return newAf, true, nil |
| 160 | +} |
| 161 | +``` |
| 162 | + |
| 163 | +**注意**: 这会在竞争时多创建一个 socket 然后立即关闭,但消除了持锁 ListenPacket 的串行化。 |
| 164 | + |
| 165 | +**测试**: |
| 166 | +- 并发调用 `GetOrCreate` 相同地址 N 次,验证只有一个 socket 存活,无 data race |
| 167 | +- `go test -race -v -run 'TestAnyfromPool' $(go list ./control/... | grep -v kern/tests)` |
| 168 | + |
| 169 | +--- |
| 170 | + |
| 171 | +### Milestone M1 回归测试 |
| 172 | + |
| 173 | +```bash |
| 174 | +go test -race -v -run '.' $(go list ./control/... | grep -v 'control/kern/tests') |
| 175 | +``` |
| 176 | + |
| 177 | +预期: 所有测试 PASS,`ok github.com/daeuniverse/dae/control`, 无 DATA RACE |
| 178 | + |
| 179 | +--- |
| 180 | + |
| 181 | +## 里程碑 M2 — 测试覆盖补全 |
| 182 | + |
| 183 | +### T5: 为删除的 evictDnsForwarderCacheOneLocked 补充替代测试 |
| 184 | + |
| 185 | +**变更**: T1 删除了 `evictDnsForwarderCacheOneLocked`,相关测试也需同步删除/替换。 |
| 186 | +新增 `TestNewForwarderCreatedEachCall` 验证每次 `dialSend` 都是新建对象(通过 mock forwarder 工厂计数)。 |
| 187 | + |
| 188 | +### T6: UdpTaskPool — 验证 DNS 不阻塞非 DNS 任务 |
| 189 | + |
| 190 | +验证 DNS 任务绕过队列后,同一 src IP 的后续非 DNS 包仍然按序处理。 |
| 191 | + |
| 192 | +--- |
| 193 | + |
| 194 | +## 文件变更清单 |
| 195 | + |
| 196 | +| 文件 | 变更类型 | 任务 | |
| 197 | +|------|----------|------| |
| 198 | +| `control/dns_control.go` | 删除缓存字段/方法,改写 dialSend,修复 ctx 传播 | T1, T3 | |
| 199 | +| `control/control_plane.go` | DNS 包 go-dispatch | T2 | |
| 200 | +| `control/anyfrom_pool.go` | optimistic socket creation | T4 | |
| 201 | +| `control/dns_improvement_test.go` | 删除/替换旧 evict 测试,新增 T1/T3/T4 测试 | T1,T3,T4 | |
| 202 | + |
| 203 | +--- |
| 204 | + |
| 205 | +## 预期指标(修复后) |
| 206 | + |
| 207 | +| 指标 | 修复前 | 修复后预期 | |
| 208 | +|------|--------|-----------| |
| 209 | +| 成功率 (concurrency=200) | ~66% | ~100% | |
| 210 | +| 响应时间/round | ~15s | <500ms(缓存命中)/ <2s(上游正常)| |
| 211 | +| DATA RACE | 0(已验证) | 0 | |
| 212 | +| 队列溢出丢包 | 72/200 | 0 | |
| 213 | + |
| 214 | +--- |
| 215 | + |
| 216 | +*创建时间: 2026-02-17* |
0 commit comments