
用 net.Conn 包裹底层连接实现可控延迟
Go 标准库没有内置“加延迟的 TCP 连接”,但你可以用 net.Conn 接口自己包一层。关键不是改协议栈,而是拦截 Read 和 Write 调用,在真正调用底层连接前塞个 time.Sleep。
常见错误是直接在 http.Client 层加 Timeout——那只是请求超时,不是模拟网络延迟;还有人试图用 runtime.Gosched() 或空循环,结果压根不生效,因为没阻塞 I/O 路径。
- 必须实现完整的
net.Conn接口(哪怕只代理部分方法),否则http.Transport会 panic - 延迟应作用于每次
Read/Write,不是连接建立阶段;否则 HTTP pipelining 或长连接场景下效果失真 - 别在
Close里 sleep,容易卡死连接池回收
示例片段:
type DelayConn struct {
net.Conn
readDelay time.Duration
writeDelay time.Duration
}
func (d *DelayConn) Read(b []byte) (n int, err error) {
time.Sleep(d.readDelay)
return d.Conn.Read(b)
}
丢包不能靠随机 return 0, io.EOF
简单在 Read 里按概率 return 0, io.EOF 看似能模拟丢包,但实际会让上层 TCP 连接立即断开,无法复现“数据段丢失但连接仍存活”的真实行为。真正的丢包发生在 IP 层,对应用层来说,它表现为 Read 阻塞超时,或 Write 后对方迟迟没 ACK。
- 更合理的方式:对指定比例的
Read调用,不 sleep、不返回、也不调用底层Read,而是挂起 goroutine,等超时后才返回io.Timeout - 丢包率需和延迟联动——高延迟下丢包更容易触发重传,单纯丢包却不延时,HTTP 客户端可能秒重试,掩盖容错逻辑缺陷
- 注意
http.Transport的ResponseHeaderTimeout和ExpectContinueTimeout,它们会影响你丢包后的可观测行为
用 http.Transport 的 DialContext 注入自定义连接
想让 HTTP 客户端走你的延迟+丢包连接,不能动 http.Client,得换掉底层拨号器。标准做法是配置 http.Transport 的 DialContext 字段,让它返回你的 *DelayConn 实例。
立即学习“go语言免费学习笔记(深入)”;
- 别漏掉
tls.Config的GetConfigForClient或NextProtos配置,HTTPS 场景下 TLS 握手也会被你加的延迟影响 - 如果测试 gRPC,要替换的是
grpc.WithDialer,不是 HTTP transport;gRPC 默认用 HTTP/2,丢包对流控的影响比 HTTP/1 更敏感 - 并发量大时,每个连接都 Sleep 会吃光 goroutine,建议用带缓冲的 channel 控制延迟队列,而不是无限制启 goroutine
真实环境里,SetDeadline 比 time.Sleep 更危险
很多人用 conn.SetDeadline 模拟超时,结果发现延迟不准、甚至连接提前关闭。因为 SetDeadline 是系统级 socket 选项,一旦触发,底层连接状态就变了,后续 Read 可能直接返回 syscall.EAGAIN,而你的包装层根本来不及干预。
- 坚持用纯用户态 sleep 控制延迟节奏,把 deadline 逻辑留给上层业务或测试框架
- Linux 下可验证:用
tcpdump抓包看 SYN/ACK 时间戳,对比你的 sleep 值——若偏差 >5ms,说明调度或 GC 干扰了,该上runtime.LockOSThread()锁住 M/P - 容器环境里,cgroup CPU quota 会导致 sleep 实际耗时翻倍,务必在目标环境中实测,别信本地开发机数据
丢包和延迟从来不是独立参数,它们共同塑造重传行为、拥塞窗口变化和应用层重试节奏。调试时盯着三次握手时间、第一个 FIN 的间隔、以及 Go runtime 的 goroutine block profile,比看平均延迟数字管用得多。










