Skip to content

Commit 72e025a

Browse files
authored
Merge pull request #5 from MaurUppi/dns_fix
fix(dns): remove dead forwarder cache, async DNS dispatch, bounded context, optimistic socket creation
2 parents e7a651c + 79d29aa commit 72e025a

File tree

8 files changed

+914
-136
lines changed

8 files changed

+914
-136
lines changed

.github/workflows/dns-race.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ on:
99
- ".github/workflows/dns-race.yml"
1010
push:
1111
branches:
12-
- main
1312
- dns_fix
1413
paths:
1514
- "control/**/*.go"

.plan/dns_perf_rootcause-dev.md

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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

Comments
 (0)