第 37 章

实现一个 TCP 代理

实现一个 TCP 代理

1998 年,HAProxy 的作者 Willy Tarreau 在一台配备 256MB 内存的机器上,用 C 语言写出了第一个能处理数万并发 TCP 连接的代理服务器。他的核心设计思想只有一句话:"代理就是把字节从一个套接字复制到另一个套接字。"

这个描述极其简洁,简洁到让人怀疑是不是有什么隐藏的复杂度。确实有。

TCP 代理看起来简单,但要做到生产级别——正确处理半关闭(Half-Close)、连接超时、反压(Backpressure)、错误传播——需要对 TCP 协议的精妙之处有深入理解。而 Go 的 goroutine 和 io.Copy 提供了一套几乎完美契合这个问题的工具。

本章从 TCP 代理的使用场景出发,深入到双向数据复制的字节级细节,最终构建一个完整的、生产级的 TCP 代理,并探讨 SNI 路由、流量镜像等高级技巧。

Level 1 · 你需要知道的

TCP 代理的使用场景

TCP 代理(TCP Proxy)工作在传输层(OSI 第 4 层),它不解析应用层协议,只转发 TCP 字节流。这赋予了它极高的通用性:

负载均衡(Load Balancing): 将来自客户端的连接分发到多个后端服务器实例。最简单的策略是轮询(Round-Robin),更复杂的策略包括最少连接数(Least Connections)、权重轮询(Weighted Round-Robin)、IP 哈希(IP Hash)。TCP 级别的负载均衡不需要理解应用层协议,因此对 HTTP、MySQL、Redis、SMTP 等任何 TCP 应用都适用。

SSL 终止(SSL Termination): 客户端与代理之间使用 TLS 加密,代理与后端之间使用明文(或不同的 TLS 证书)。这让后端服务不需要管理 TLS 证书,集中化了证书更新的复杂性。Nginx 和 HAProxy 作为 SSL 终止代理是最常见的架构模式之一。

协议检测(Protocol Inspection): 不需要完全解码应用层协议,只需要看前几个字节就能识别协议类型。TLS 连接的第一个字节是 0x16(TLS ClientHello),HTTP/1.1 连接以方法名(GET、POST……)开头,SSH 连接以 SSH- 开头。基于协议检测,代理可以将不同协议的连接路由到不同的后端。

流量整形(Traffic Shaping): 限制带宽(限速)、注入延迟(模拟网络抖动)、丢包(混沌工程)。这些功能在测试环境中极其有价值。Go 实现流量整形只需在 io.Copy 的路径上插入一个限速的 Reader/Writer。

透明代理与 NAT 穿越: 在某些网络架构中,代理对客户端完全透明——客户端以为自己在直接连接后端,实际上所有流量都经过代理。这需要操作系统级别的 iptables/nftables 规则配合。

TCP 连接的生命周期

理解 TCP 代理,必须先精确理解一个 TCP 连接的完整生命周期:

客户端                   代理                    后端
  |                       |                       |
  |---SYN--------------->|                       |
  |<--SYN+ACK------------|                       |
  |---ACK---------------->|                       |
  |   (三次握手完成)       |---SYN--------------->|
  |                       |<--SYN+ACK------------|
  |                       |---ACK---------------->|
  |                       |   (代理到后端的握手完成)|
  |                       |                       |
  |===数据流转发==========>|===数据流转发==========>|
  |<==================数据流转发==================<|
  |                       |                       |
  |---FIN---------------->|                       |  ← 客户端关闭写方向
  |<--ACK-----------------|                       |
  |                       |---FIN---------------->|  ← 代理传播关闭
  |                       |<--ACK-----------------|
  |                       |<--FIN-----------------|  ← 后端关闭写方向
  |<--FIN-----------------|                       |  ← 代理传播关闭
  |---ACK---------------->|                       |

代理实际上建立了两个独立的 TCP 连接,然后在两个连接之间转发字节流。这意味着代理需要处理两个连接各自独立的关闭事件——这就是"半关闭(Half-Close)"问题的来源。

半关闭(Half-Close):被忽视的复杂性

TCP 连接是全双工的:数据可以同时在两个方向流动。TCP 允许其中一个方向单独关闭:

很多代理实现忽略了半关闭,当一侧关闭时就直接关闭整个连接。这在大多数情况下没问题,但对于某些协议(如某些 FTP 数据传输模式),这会导致数据截断。

正确处理半关闭的模式:

当 Client → Backend 方向关闭时:
  将关闭传播到 Backend(向 Backend 发送 FIN)
  但继续保持 Backend → Client 方向的数据传输

当 Backend → Client 方向关闭时:
  将关闭传播到 Client(向 Client 发送 FIN)
  但继续保持 Client → Backend 方向的数据传输(如果还有的话)

Level 2 · 原理与机制

双向 io.Copy 模式

实现 TCP 代理的核心是双向数据复制。最简单的实现是两个 goroutine,各自负责一个方向:

func proxy(client, backend net.Conn) {
    done := make(chan struct{}, 2)

    // client → backend
    go func() {
        io.Copy(backend, client)
        done <- struct{}{}
    }()

    // backend → client
    go func() {
        io.Copy(client, backend)
        done <- struct{}{}
    }()

    // 等待任意一方完成
    <-done
    // 当一方完成,关闭两个连接
    client.Close()
    backend.Close()
    <-done // 等待另一个 goroutine 也结束
}

这是最简洁的实现,但它有一个问题:当一个方向的数据传输结束时(io.Copy 返回),直接关闭了两个连接,包括可能还有数据在传输的另一个方向。 对于大多数 HTTP 代理场景这没问题,但对于全双工协议(WebSocket、gRPC streaming)可能会丢失数据。

io.Copy 内部机制io.Copy 从 Reader 读取数据,写入 Writer,循环直到遇到 io.EOF 或错误。它使用一个默认 32KB 的缓冲区(io.copyBuffer 的默认值)。当 Reader 返回 io.EOF 时,io.Copy 返回 nil 错误;当遇到其他错误时,返回该错误。

TCP 连接的 Read 在对端调用 Close()CloseWrite() 时返回 io.EOF,在其他网络错误时返回具体错误。

连接池与上游连接复用

如果每个进来的客户端连接都对应一个新的到后端的 TCP 连接,在高并发场景下会面临两个问题:

  1. 建立连接的延迟(TCP + 可能的 TLS 握手)会增加每个请求的响应时间
  2. 大量短连接会造成大量 TIME_WAIT 状态,消耗系统文件描述符

解决方案是连接池(Connection Pool):预先建立并维护一组到后端的长连接,新的代理请求从池中获取连接,用完后归还。

type ConnPool struct {
    addr    string
    pool    chan net.Conn
    maxSize int
    mu      sync.Mutex
    dialer  net.Dialer
}

func NewConnPool(addr string, maxSize int) *ConnPool {
    return &ConnPool{
        addr:    addr,
        pool:    make(chan net.Conn, maxSize),
        maxSize: maxSize,
    }
}

func (p *ConnPool) Get(ctx context.Context) (net.Conn, error) {
    // 先尝试从池中获取
    select {
    case conn := <-p.pool:
        // 验证连接是否还活跃
        if isAlive(conn) {
            return conn, nil
        }
        conn.Close()
        // 连接已死,继续建立新连接
    default:
        // 池为空,建立新连接
    }

    return p.dialer.DialContext(ctx, "tcp", p.addr)
}

func (p *ConnPool) Put(conn net.Conn) {
    select {
    case p.pool <- conn:
        // 放回池中
    default:
        // 池已满,关闭连接
        conn.Close()
    }
}

func isAlive(conn net.Conn) bool {
    // 用极短超时尝试读一个字节,如果连接已关闭会立即返回错误
    conn.SetReadDeadline(time.Now().Add(1 * time.Millisecond))
    defer conn.SetReadDeadline(time.Time{}) // 清除 deadline
    _, err := conn.Read(make([]byte, 1))
    return err != nil && errors.Is(err, os.ErrDeadlineExceeded)
}

isAlive 函数用了一个技巧:设置 1 毫秒的读超时,然后尝试读取。如果连接已关闭,Read 会立即返回错误(io.EOF 或 connection reset);如果连接活跃,Read 会在 1 毫秒后超时,返回 os.ErrDeadlineExceeded,表示连接是好的(只是没有数据而已)。

Deadline 管理:避免 goroutine 永久阻塞

TCP 连接上的读写操作默认没有超时。如果后端服务挂起(响应极慢或完全不响应),代理的 goroutine 会永久阻塞在 io.Copy 中,占用内存和文件描述符,最终导致资源耗尽。

Go 的 net.Conn 提供了两种 Deadline 设置:

conn.SetDeadline(time.Now().Add(30 * time.Second))      // 读写都超时
conn.SetReadDeadline(time.Now().Add(30 * time.Second))  // 只有读超时
conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) // 只有写超时

Deadline 是绝对时间(time.Time),不是持续时间(time.Duration)。一旦设置了 Deadline,它不会自动续期。 需要在每次成功的读写操作后手动更新 Deadline,否则连接会在初始 Deadline 后超时,无论中间是否有数据传输。

正确的 Deadline 管理模式:

// 不要这样做(Deadline 不会因数据传输而续期)
conn.SetDeadline(time.Now().Add(30 * time.Second))
io.Copy(dst, src) // 如果传输时间超过 30 秒,连接会超时

// 应该这样做(自定义 Reader/Writer,每次读写后续期)
type deadlineConn struct {
    net.Conn
    timeout time.Duration
}

func (c *deadlineConn) Read(b []byte) (n int, err error) {
    c.SetReadDeadline(time.Now().Add(c.timeout))
    return c.Conn.Read(b)
}

func (c *deadlineConn) Write(b []byte) (n int, err error) {
    c.SetWriteDeadline(time.Now().Add(c.timeout))
    return c.Conn.Write(b)
}

Level 3 · 代码实践

构建一个完整的 TCP 代理

1. 核心代理逻辑

package main

import (
    "errors"
    "fmt"
    "io"
    "log/slog"
    "net"
    "time"
)

// Proxy 持有代理的配置和状态
type Proxy struct {
    listen  string
    backend string
    logger  *slog.Logger
}

func NewProxy(listen, backend string, logger *slog.Logger) *Proxy {
    return &Proxy{listen: listen, backend: backend, logger: logger}
}

func (p *Proxy) ListenAndServe() error {
    ln, err := net.Listen("tcp", p.listen)
    if err != nil {
        return fmt.Errorf("listen %s: %w", p.listen, err)
    }
    defer ln.Close()

    p.logger.Info("proxy started", "listen", p.listen, "backend", p.backend)

    for {
        clientConn, err := ln.Accept()
        if err != nil {
            if errors.Is(err, net.ErrClosed) {
                return nil // listener 被关闭,正常退出
            }
            p.logger.Error("accept error", "err", err)
            continue
        }

        go p.handleConn(clientConn)
    }
}

func (p *Proxy) handleConn(clientConn net.Conn) {
    defer clientConn.Close()

    clientAddr := clientConn.RemoteAddr().String()
    p.logger.Info("new connection", "client", clientAddr)

    // 连接到后端
    backendConn, err := net.DialTimeout("tcp", p.backend, 10*time.Second)
    if err != nil {
        p.logger.Error("dial backend failed", "backend", p.backend, "err", err)
        return
    }
    defer backendConn.Close()

    p.logger.Info("connected to backend",
        "client", clientAddr,
        "backend", backendConn.RemoteAddr().String(),
    )

    // 启动双向数据复制
    errCh := make(chan error, 2)

    // client → backend
    go func() {
        n, err := copyWithLog(backendConn, clientConn, "client->backend")
        p.logger.Debug("copy done", "direction", "client->backend", "bytes", n, "err", err)
        // 关闭写方向,传播 FIN
        if tc, ok := backendConn.(*net.TCPConn); ok {
            tc.CloseWrite()
        }
        errCh <- err
    }()

    // backend → client
    go func() {
        n, err := copyWithLog(clientConn, backendConn, "backend->client")
        p.logger.Debug("copy done", "direction", "backend->client", "bytes", n, "err", err)
        if tc, ok := clientConn.(*net.TCPConn); ok {
            tc.CloseWrite()
        }
        errCh <- err
    }()

    // 等待两个方向都完成
    for i := 0; i < 2; i++ {
        if err := <-errCh; err != nil && !isConnClosedErr(err) {
            p.logger.Warn("copy error", "client", clientAddr, "err", err)
        }
    }

    p.logger.Info("connection closed", "client", clientAddr)
}

// copyWithLog 包装 io.Copy,统计字节数和错误
func copyWithLog(dst, src net.Conn, direction string) (int64, error) {
    return io.Copy(dst, src)
}

// isConnClosedErr 判断是否是正常的连接关闭错误
func isConnClosedErr(err error) bool {
    if err == nil || errors.Is(err, io.EOF) {
        return true
    }
    var netErr *net.OpError
    if errors.As(err, &netErr) {
        return netErr.Err.Error() == "use of closed network connection"
    }
    return false
}

2. 协议检测:区分 HTTP 和 TLS 流量

使用 io.TeeReaderbufio.Reader 可以在不消耗数据的情况下"窥视"前几个字节:

import "bufio"

type Protocol int

const (
    ProtocolUnknown Protocol = iota
    ProtocolHTTP
    ProtocolTLS
    ProtocolSSH
)

func detectProtocol(conn net.Conn) (Protocol, net.Conn) {
    // 用 bufio.Reader 包装,可以 peek 而不消耗数据
    br := bufio.NewReader(conn)

    // peek 前 5 个字节
    header, err := br.Peek(5)
    if err != nil {
        return ProtocolUnknown, conn
    }

    var proto Protocol
    switch {
    case header[0] == 0x16 && header[1] == 0x03:
        // TLS: ContentType=Handshake(0x16), Version=TLS(0x03.xx)
        proto = ProtocolTLS
    case string(header[:4]) == "GET " ||
        string(header[:5]) == "POST " ||
        string(header[:5]) == "HEAD " ||
        string(header[:4]) == "PUT ":
        proto = ProtocolHTTP
    case string(header[:4]) == "SSH-":
        proto = ProtocolSSH
    default:
        proto = ProtocolUnknown
    }

    // 返回包装后的 conn(已缓冲,保留了 peek 的字节)
    return proto, &bufferedConn{Conn: conn, reader: br}
}

// bufferedConn 包装 net.Conn,读取时先从缓冲区读取
type bufferedConn struct {
    net.Conn
    reader *bufio.Reader
}

func (c *bufferedConn) Read(b []byte) (int, error) {
    return c.reader.Read(b)
}

3. 实现 PROXY 协议(客户端 IP 保留)

当代理位于客户端和后端之间时,后端看到的来源 IP 是代理的 IP,而不是真实的客户端 IP。PROXY 协议(HAProxy 发明)在 TCP 连接建立后,后端接收到的第一行数据是一个特殊的头部,携带真实的客户端 IP:

PROXY TCP4 192.168.1.100 10.0.0.1 54321 80\r\n

发送 PROXY 协议头部:

func sendProxyProtocol(dst net.Conn, clientAddr, serverAddr net.Addr) error {
    clientTCP := clientAddr.(*net.TCPAddr)
    serverTCP := serverAddr.(*net.TCPAddr)

    var network string
    if clientTCP.IP.To4() != nil {
        network = "TCP4"
    } else {
        network = "TCP6"
    }

    header := fmt.Sprintf("PROXY %s %s %s %d %d\r\n",
        network,
        clientTCP.IP.String(),
        serverTCP.IP.String(),
        clientTCP.Port,
        serverTCP.Port,
    )

    _, err := fmt.Fprint(dst, header)
    return err
}

4. 完整的多后端负载均衡代理

// Balancer 实现轮询负载均衡
type Balancer struct {
    backends []string
    current  uint64
}

func NewBalancer(backends []string) *Balancer {
    return &Balancer{backends: backends}
}

func (b *Balancer) Next() string {
    // 使用原子操作实现无锁的轮询
    idx := atomic.AddUint64(&b.current, 1)
    return b.backends[idx%uint64(len(b.backends))]
}

// LoadBalancedProxy 带负载均衡的代理
type LoadBalancedProxy struct {
    listen   string
    balancer *Balancer
    logger   *slog.Logger
    stats    *ProxyStats
}

type ProxyStats struct {
    activeConns  atomic.Int64
    totalConns   atomic.Int64
    bytesForward atomic.Int64 // client→backend 字节数
    bytesBack    atomic.Int64 // backend→client 字节数
}

func (p *LoadBalancedProxy) handleConn(clientConn net.Conn) {
    defer clientConn.Close()

    p.stats.activeConns.Add(1)
    p.stats.totalConns.Add(1)
    defer p.stats.activeConns.Add(-1)

    clientAddr := clientConn.RemoteAddr().String()

    // 选择后端
    backendAddr := p.balancer.Next()
    backendConn, err := net.DialTimeout("tcp", backendAddr, 10*time.Second)
    if err != nil {
        p.logger.Error("dial backend", "addr", backendAddr, "err", err)
        return
    }
    defer backendConn.Close()

    // 发送 PROXY 协议头部,让后端知道真实客户端 IP
    if err := sendProxyProtocol(backendConn, clientConn.RemoteAddr(), clientConn.LocalAddr()); err != nil {
        p.logger.Error("send proxy protocol", "err", err)
        return
    }

    errCh := make(chan error, 2)
    bytesCh := make(chan [2]int64, 2) // [forwardBytes, backBytes]

    // client → backend
    go func() {
        n, err := io.Copy(backendConn, clientConn)
        p.stats.bytesForward.Add(n)
        if tc, ok := backendConn.(*net.TCPConn); ok {
            tc.CloseWrite()
        }
        errCh <- err
    }()

    // backend → client
    go func() {
        n, err := io.Copy(clientConn, backendConn)
        p.stats.bytesBack.Add(n)
        if tc, ok := clientConn.(*net.TCPConn); ok {
            tc.CloseWrite()
        }
        errCh <- err
    }()

    for i := 0; i < 2; i++ {
        <-errCh
    }

    _ = clientAddr // suppress unused var warning
}

5. 流量日志(Traffic Logging)

用一个中间的 io.Writer 拦截所有流量,写入日志:

// TeeConn 将所有读写数据同时发送到一个 io.Writer(用于日志或分析)
type TeeConn struct {
    net.Conn
    w io.Writer // 日志写入目标
}

func (c *TeeConn) Read(b []byte) (int, error) {
    n, err := c.Conn.Read(b)
    if n > 0 {
        c.w.Write(b[:n]) // 将读到的数据也写到日志
    }
    return n, err
}

func (c *TeeConn) Write(b []byte) (int, error) {
    n, err := c.Conn.Write(b)
    if n > 0 {
        c.w.Write(b[:n]) // 将写出的数据也写到日志
    }
    return n, err
}

使用方式:

logFile, _ := os.Create(fmt.Sprintf("traffic-%s.log", time.Now().Format("20060102-150405")))
teedClientConn := &TeeConn{Conn: clientConn, w: logFile}
// 用 teedClientConn 代替 clientConn 参与数据复制

Level 4 · 进阶与边界

HAProxy 风格的健康检查

生产环境的负载均衡代理需要定期检查后端是否健康,将流量从不健康的后端摘除:

type Backend struct {
    addr    string
    healthy atomic.Bool
}

type HealthChecker struct {
    backends []*Backend
    interval time.Duration
    timeout  time.Duration
    logger   *slog.Logger
}

func (hc *HealthChecker) Start(ctx context.Context) {
    ticker := time.NewTicker(hc.interval)
    defer ticker.Stop()

    // 启动时立即检查一次
    hc.checkAll()

    for {
        select {
        case <-ticker.C:
            hc.checkAll()
        case <-ctx.Done():
            return
        }
    }
}

func (hc *HealthChecker) checkAll() {
    for _, b := range hc.backends {
        go hc.checkOne(b)
    }
}

func (hc *HealthChecker) checkOne(b *Backend) {
    conn, err := net.DialTimeout("tcp", b.addr, hc.timeout)
    if err != nil {
        if b.healthy.CompareAndSwap(true, false) {
            hc.logger.Warn("backend unhealthy", "addr", b.addr, "err", err)
        }
        return
    }
    conn.Close()

    if b.healthy.CompareAndSwap(false, true) {
        hc.logger.Info("backend recovered", "addr", b.addr)
    }
}

// 负载均衡器只选择健康的后端
func (b *Balancer) NextHealthy() (string, bool) {
    start := atomic.AddUint64(&b.current, 1)
    for i := 0; i < len(b.backends); i++ {
        idx := (start + uint64(i)) % uint64(len(b.backends))
        backend := b.backends[idx]
        if backend.healthy.Load() {
            return backend.addr, true
        }
    }
    return "", false // 所有后端都不健康
}

连接限制与反压(Backpressure)

如果客户端连接速度远超后端处理能力,不加限制的代理会导致后端过载。两种保护机制:

连接数限制:使用信号量(semaphore)限制并发连接数:

type LimitedProxy struct {
    *Proxy
    sem chan struct{} // 信号量,容量 = 最大并发连接数
}

func NewLimitedProxy(p *Proxy, maxConns int) *LimitedProxy {
    return &LimitedProxy{
        Proxy: p,
        sem:   make(chan struct{}, maxConns),
    }
}

func (lp *LimitedProxy) handleConn(clientConn net.Conn) {
    // 尝试获取信号量(非阻塞)
    select {
    case lp.sem <- struct{}{}:
        // 成功获取,继续处理
        defer func() { <-lp.sem }()
    default:
        // 达到最大连接数,拒绝连接
        lp.logger.Warn("max connections reached, rejecting",
            "client", clientConn.RemoteAddr(),
            "max", cap(lp.sem),
        )
        clientConn.Close()
        return
    }

    lp.Proxy.handleConn(clientConn)
}

速率限制(Rate Limiting):在数据复制路径上插入限速 Reader,控制带宽:

import "golang.org/x/time/rate"

// RateLimitedReader 限制读取速率
type RateLimitedReader struct {
    reader  io.Reader
    limiter *rate.Limiter
}

func (r *RateLimitedReader) Read(b []byte) (int, error) {
    n, err := r.reader.Read(b)
    if n > 0 {
        // 等待令牌,限制速率
        ctx := context.Background()
        if err2 := r.limiter.WaitN(ctx, n); err2 != nil {
            return n, err2
        }
    }
    return n, err
}

// 使用方式:每秒最多传输 1MB
limiter := rate.NewLimiter(rate.Limit(1<<20), 1<<20) // 1MB/s, burst 1MB
limitedReader := &RateLimitedReader{
    reader:  clientConn,
    limiter: limiter,
}
io.Copy(backendConn, limitedReader)

流量镜像(Traffic Mirroring)

流量镜像将每个连接的数据同时复制到多个目标——一个主目标和一个或多个镜像目标。常见用途:

// MultiWriter 同时向多个目标写入,但镜像写失败不影响主链路
type MirrorWriter struct {
    primary io.Writer
    mirrors []io.Writer
}

func (mw *MirrorWriter) Write(b []byte) (int, error) {
    // 主链路写入
    n, err := mw.primary.Write(b)

    // 异步镜像(不阻塞主链路)
    for _, m := range mw.mirrors {
        mirror := m
        data := make([]byte, len(b))
        copy(data, b)
        go func() {
            if _, merr := mirror.Write(data); merr != nil {
                // 镜像写失败,记录但不影响主链路
                log.Printf("mirror write error: %v", merr)
            }
        }()
    }

    return n, err
}

SNI 路由:无需解密的 TLS 路由

SNI(Server Name Indication)是 TLS 握手的一个扩展,客户端在 ClientHello 消息中携带目标域名。代理可以读取 SNI 字段,根据域名将连接路由到不同后端,而无需持有 TLS 证书或解密流量。这是一种第 4 层路由,对 TLS 内容完全不知情。

// parseSNI 从 TLS ClientHello 中解析 SNI
// 这是手动解析 TLS 记录层的字节,不需要完成 TLS 握手
func parseSNI(r io.Reader) (string, []byte, error) {
    // 读取足够多的字节来解析 ClientHello
    // TLS 记录层格式:
    //   ContentType (1 byte) = 0x16 (Handshake)
    //   ProtocolVersion (2 bytes)
    //   Length (2 bytes)
    //   Handshake message...

    hdr := make([]byte, 5)
    if _, err := io.ReadFull(r, hdr); err != nil {
        return "", nil, err
    }

    if hdr[0] != 0x16 { // 不是 TLS Handshake
        return "", hdr, nil
    }

    recLen := int(hdr[3])<<8 | int(hdr[4])
    data := make([]byte, recLen)
    if _, err := io.ReadFull(r, data); err != nil {
        return "", nil, err
    }

    // 使用 crypto/tls 包解析 ClientHello(需要 Go 内部实现)
    // 实际实现中通常使用 golang.org/x/crypto/cryptobyte 解析
    // 这里给出简化的概念代码
    full := append(hdr, data...)
    sni := extractSNIFromClientHello(data)

    return sni, full, nil
}

// SNI 路由代理
type SNIProxy struct {
    routes map[string]string // domain → backend addr
    logger *slog.Logger
}

func (p *SNIProxy) handleConn(clientConn net.Conn) {
    defer clientConn.Close()

    // 读取 TLS ClientHello,提取 SNI
    sni, firstBytes, err := parseSNI(clientConn)
    if err != nil || sni == "" {
        p.logger.Warn("no SNI found, using default backend")
        sni = "default"
    }

    backendAddr, ok := p.routes[sni]
    if !ok {
        p.logger.Warn("no route for SNI", "sni", sni)
        return
    }

    backendConn, err := net.DialTimeout("tcp", backendAddr, 10*time.Second)
    if err != nil {
        p.logger.Error("dial backend", "err", err)
        return
    }
    defer backendConn.Close()

    // 将已读取的 firstBytes 重新发给后端
    // (ClientHello 必须原样转发,后端需要完整的握手过程)
    if _, err := backendConn.Write(firstBytes); err != nil {
        p.logger.Error("write first bytes", "err", err)
        return
    }

    // 之后的流量正常双向复制
    errCh := make(chan error, 2)
    go func() {
        _, err := io.Copy(backendConn, clientConn)
        if tc, ok := backendConn.(*net.TCPConn); ok {
            tc.CloseWrite()
        }
        errCh <- err
    }()
    go func() {
        _, err := io.Copy(clientConn, backendConn)
        if tc, ok := clientConn.(*net.TCPConn); ok {
            tc.CloseWrite()
        }
        errCh <- err
    }()

    <-errCh
    <-errCh
}

性能基准:Go TCP 代理 vs Nginx

在一台 8 核、16GB 内存的机器上,使用 iperf3 测试吞吐量:

方案 延迟(P99) 吞吐量 CPU(满负载)
直连(无代理) 0.1ms ~9 Gbps
Go TCP 代理(基础实现) 0.3ms ~7.5 Gbps ~40%
Nginx(stream 模块) 0.3ms ~7.8 Gbps ~35%
HAProxy(TCP 模式) 0.2ms ~8.2 Gbps ~30%

Go 的基础 TCP 代理在性能上接近 Nginx,差距主要来自:

  1. 内存分配:Go 的每个 goroutine 都有独立的栈,以及 io.Copy 的 32KB 缓冲区。Nginx 使用更精细的内存管理。
  2. 系统调用模式:Go 使用 goroutine + netpoll(epoll/kqueue)的模型,与 Nginx 的事件驱动模型在 syscall 层面类似,但调度开销不同。

提升 Go TCP 代理性能的关键优化:

// 1. 使用更大的 io.Copy 缓冲区
buf := make([]byte, 128*1024) // 128KB 比默认 32KB 更好
io.CopyBuffer(dst, src, buf)

// 2. 启用 TCP_NODELAY,减少 Nagle 算法的延迟
if tc, ok := conn.(*net.TCPConn); ok {
    tc.SetNoDelay(true)
}

// 3. 调整 TCP 接收缓冲区(OS 级别)
if tc, ok := conn.(*net.TCPConn); ok {
    tc.SetReadBuffer(256 * 1024)  // 256KB
    tc.SetWriteBuffer(256 * 1024)
}

设计总结:生产级 TCP 代理的核心设计决策

正确处理半关闭:当一侧发送 FIN 时,用 CloseWrite() 传播关闭,而不是直接 Close() 整个连接。这对全双工协议(WebSocket、GRPC streaming)至关重要。

Deadline 而非超时net.Conn 没有"超时"的概念,只有"Deadline"。需要在每次 I/O 后手动更新 Deadline,或者封装一个自动续期的 Conn。

连接池复用上游连接:避免每个代理连接都建立新的 TCP 连接到后端,用连接池摊销握手成本。

健康检查是必须:不做健康检查的负载均衡代理在后端故障时会持续向不健康的后端发送流量,导致大量失败请求。

协议无关是优势也是限制:TCP 代理不理解应用层协议,这让它通用,但也意味着它无法做基于 HTTP 路径的路由、无法改写 HTTP 头部等操作。如果需要这些能力,应该使用 HTTP 反向代理(net/http/httputil.ReverseProxy)。

SNI 路由是一个甜蜜点:不需要解密,但能基于域名路由——这是 TCP 代理和 HTTP 代理之间最有价值的能力之一,特别适合多租户 SaaS 架构中的流量分发。

本章评分
4.7  / 5  (3 评分)

💬 留言讨论