
本文详解 go 反向代理场景下连接复用的常见误区,指出 tcp 连接不可“恢复性复用”,强调断连后必须重建连接,并提供基于 `io.copy` + 双通道协调的健壮代理模式实现。
在构建 NAT 穿透类反向代理(如:内网设备通过第三方服务器暴露服务)时,一个典型架构是:
- dstNet(如 :9000):监听来自内网 NAT 设备的主动连接(即“反向隧道入口”);
- srcNet(如 :9001):监听公网客户端请求,将其流量转发至已建立的内网连接。
此时极易陷入一个关键误区:试图“复用”已关闭或出错的 net.Conn。你的日志 [DEBUG] socks: Copied 0 bytes to client 正是典型症状——io.Copy 返回 n=0, err=nil 或 use of closed network connection,说明连接已处于不可用状态,继续写入将失败或静默丢包。
❌ 错误认知:连接可“重置后复用”
Go 的 net.Conn 是一次性资源。除极少数临时错误(如 net.Error.Temporary() 返回 true 的超时、拒绝等),任何读/写错误(包括对端关闭、RST、EOF、use of closed network connection)均表示该连接已终止,无法安全恢复。强行重用会导致:
✅ 正确实践:连接即用即弃,错误即重建
应采用 “连接生命周期隔离” 原则:每个客户端会话独占一对连接(srcConn ↔ dstConn),任一端异常即整体退出,不尝试挽救旧连接。核心逻辑如下:
func Proxy(srcConn, dstConn net.Conn) {
// 使用两个 channel 同步两端关闭事件
srcDone := make(chan struct{})
dstDone := make(chan struct{})
// 并发双向转发
go copyAndClose(srcConn, dstConn, srcDone)
go copyAndClose(dstConn, srcConn, dstDone)
// 任一端关闭,立即通知另一端停止读取(优雅中断)
select {
case <-srcDone:
dstConn.CloseRead() // 阻止 dst 继续读 src
<-dstDone // 等待 dst 完全退出
case <-dstDone:
srcConn.CloseRead()
<-srcDone
}
}
// copyAndClose 封装 io.Copy + 关闭逻辑
func copyAndClose(src, dst net.Conn, done chan<- struct{}) {
_, err := io.Copy(dst, src)
if err != nil && err != io.EOF {
log.Printf("Copy error: %v", err)
}
// 关闭 src 的读端(不影响 dst 写入),触发对方 read loop 退出
if err := src.Close(); err != nil {
log.Printf("Close error: %v", err)
}
done <- struct{}{}
}⚠️ 关键注意事项
- 绝不共享 lrCh 通道复用 dstConn:原代码中 dst := 专属且新鲜的 dstConn(需在 listenDst 中按需建立并配对)。
- 监听器需持续 Accept:listenDst() 应始终运行,接受新内网连接并存入映射或队列,供 src 侧按需获取(例如使用 sync.Map 缓存活跃连接)。
- 添加连接超时与心跳:对 dstConn 设置 SetReadDeadline,避免僵尸连接占用资源;对 srcConn 可加 SetWriteDeadline 防止阻塞。
- 错误处理聚焦退出,而非重试:proxy 函数中检测到 err != nil 时,应直接 return 并让外层 goroutine 结束,由上层逻辑(如重连循环)新建连接。
总结
TCP 连接不是可重置的状态机,而是有明确生命周期的资源。反向代理的健壮性不在于“挽救失败连接”,而在于快速识别失败、干净释放资源、及时建立新连接。遵循“一请求一连接”原则,配合 io.Copy + CloseRead() 协同关闭模式,即可构建高可用、易调试的 Go 代理服务。










