《MMO 服务器开发》16.2 洪泛与连接耗尽防护

连接耗尽靠的不是带宽,而是数量:几十万条只握手、不登录、不发数据的连接就能把接入层拖垮。本节只讲怎么加固自己的服务端——每 IP 连接上限、未登录连接上限、全局上限、握手令牌桶、slowloris 的读写超时兜底与拒绝策略,并用本机 loopback 的几十条连接做真跑验证。

16.2 洪泛与连接耗尽防护

先说清楚这一节的边界:它只讲怎么保护自己的服务端不被耗尽,不涉及任何针对第三方目标的攻击手法或工具。 我们要实现的是「配额 + 限速 + 超时」三件套,验证方式是本机 loopback 上开几十条连接,看自己的接入层在超限时是否正确拒绝。

连接耗尽的可怕之处在于它几乎不花对手什么成本。一条 TCP 连接建立后,如果服务端为它分配了会话、缓冲区和 goroutine,那么对手只要「连上、然后什么都不做」,就能让服务端的资源被一点点吃掉。它不需要带宽、不需要漏洞,只需要数量。上一节我们让解析器对畸形字节安全;这一节要让它根本轮不到解析——先把不该来的连接挡在门外。

耗尽的三种打法

打法对手做什么服务端若不设防
连接数占满开满 fd 的连接,全部保持空闲accept 循环被拖慢,正常玩家连不上
未登录堆积大量连接停在「已握手、未登录」状态未登录连接吃满内存与句柄
慢速攻击(slowloris)每几十秒才发一个字节,让连接永远不超时连接永不回收,配额被长期占住

三种打法对应的防御是三组参数:每 IP 连接上限、未登录连接上限、以及握手与读写超时。前两组是「数量配额」,第三组是「时间配额」。数量配额决定「一个 IP 最多占多少」,时间配额决定「占住了但不动的东西多久被收回」。

注意「每 IP 上限」不能单独使用。对手若手里有一批 IP(或干脆用 IPv6 的一整段),按 IP 限的额度会被平摊掉,所以还必须有全局上限兜底;而「未登录上限」是专门针对第二种打法的——它比总连接上限更小,让「握手完成但迟迟不登录」的连接先被清出去。

配额与握手限速的实现

核心数据结构是一张 map[IP]*ipState,每个 IP 记三个数:当前连接数、其中未登录的数、以及握手令牌桶的剩余令牌。所有读写都在同一把互斥锁下完成,规模是「每连接一次 acquire/release」,锁竞争可以忽略。

下面这段是 L2,go test 会真起端口并真连:

// file: internal/guard/limit.go
// tier: L2
// verify: go test ./internal/guard/ -run 'TestGateEnforcesPerIPQuota|TestGateDropsSlowHandshake' -v

// Package guard 为 aegis 接入层提供连接配额、握手限速与读写超时兜底。
package guard

import (
	"errors"
	"net"
	"sync"
	"time"
)

// 拒绝原因。接入层据此决定回执文案与是否计入风控信号。
var (
	ErrIPQuotaExceeded     = errors.New("guard: per-IP connection quota exceeded")
	ErrGlobalQuotaExceeded = errors.New("guard: global connection quota exceeded")
)

// Limits 是接入层的配额参数。零值字段由 New 补齐为默认值。
type Limits struct {
	MaxConnsPerIP    int           // 单个 IP 的并发连接上限
	MaxPendingPerIP  int           // 单 IP 未登录(未完成握手/登录)连接上限
	MaxConnsGlobal   int           // 全局并发连接上限
	HandshakeBurst   int           // 单 IP 握手令牌桶容量
	HandshakeRefill  time.Duration // 单 IP 补满一个握手令牌所需的间隔
	HandshakeTimeout time.Duration // 从 accept 到握手完成的墙钟上限
	ReadTimeout      time.Duration // 单次读空闲上限(slowloris 兜底)
	WriteTimeout     time.Duration // 单次写上限
}

// Limiter 维护全局与每 IP 的连接计数,以及每 IP 的握手令牌桶。
type Limiter struct {
	lim    Limits
	mu     sync.Mutex
	global int
	perIP  map[string]*ipState
}

type ipState struct {
	conns   int
	pending int
	tokens  float64
	last    time.Time
}

// New 用给定参数构造 Limiter,未填字段取默认值。
func New(lim Limits) *Limiter {
	if lim.MaxConnsPerIP <= 0 {
		lim.MaxConnsPerIP = 64
	}
	if lim.MaxPendingPerIP <= 0 {
		lim.MaxPendingPerIP = 8
	}
	if lim.MaxConnsGlobal <= 0 {
		lim.MaxConnsGlobal = 10000
	}
	if lim.HandshakeBurst <= 0 {
		lim.HandshakeBurst = 5
	}
	if lim.HandshakeRefill <= 0 {
		lim.HandshakeRefill = 200 * time.Millisecond
	}
	return &Limiter{lim: lim, perIP: make(map[string]*ipState)}
}

// Ticket 是一条连接占用的配额凭据。Authed 在握手/登录成功后调用一次,
// 把该连接从「未登录」计数移出;Close 在连接关闭时调用一次,幂等。
type Ticket struct {
	l      *Limiter
	ip     string
	st     *ipState
	authed bool
	once   sync.Once
}

// Acquire 尝试为来自 ip 的一条新连接占用配额。
func (l *Limiter) Acquire(ip string, now time.Time) (*Ticket, error) {
	l.mu.Lock()
	defer l.mu.Unlock()

	if l.global >= l.lim.MaxConnsGlobal {
		return nil, ErrGlobalQuotaExceeded
	}
	st := l.perIP[ip]
	if st == nil {
		st = &ipState{tokens: float64(l.lim.HandshakeBurst), last: now}
		l.perIP[ip] = st
	}
	if st.conns >= l.lim.MaxConnsPerIP || st.pending >= l.lim.MaxPendingPerIP {
		return nil, ErrIPQuotaExceeded
	}
	st.conns++
	st.pending++
	l.global++
	return &Ticket{l: l, ip: ip, st: st}, nil
}

// Authed 把这条连接标记为已登录,释放它占用的「未登录」名额。
func (t *Ticket) Authed() {
	t.l.mu.Lock()
	if !t.authed {
		t.authed = true
		t.st.pending--
	}
	t.l.mu.Unlock()
}

// Close 归还配额。幂等:重复调用只生效一次。
func (t *Ticket) Close() {
	t.once.Do(func() {
		t.l.mu.Lock()
		defer t.l.mu.Unlock()
		t.st.conns--
		t.l.global--
		if !t.authed {
			t.st.pending--
		}
		if t.st.conns == 0 && t.st.pending == 0 {
			delete(t.l.perIP, t.ip)
		}
	})
}

// AllowHandshake 是每 IP 的握手令牌桶:接受一条新连接时消耗一个令牌,
// 令牌按 HandshakeRefill 的间隔补充到 HandshakeBurst 上限。返回 false
// 表示该 IP 握手太频繁,应当直接关闭连接、不做任何回执。
func (l *Limiter) AllowHandshake(ip string, now time.Time) bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	st := l.perIP[ip]
	if st == nil {
		st = &ipState{tokens: float64(l.lim.HandshakeBurst), last: now}
		l.perIP[ip] = st
	}
	if elapsed := now.Sub(st.last); elapsed > 0 {
		st.tokens += elapsed.Seconds() / l.lim.HandshakeRefill.Seconds()
		if st.tokens > float64(l.lim.HandshakeBurst) {
			st.tokens = float64(l.lim.HandshakeBurst)
		}
		st.last = now
	}
	if st.tokens < 1 {
		return false
	}
	st.tokens--
	return true
}

// Gate 把配额与超时套在一个 net.Listener 上:Accept 之后先查握手限速与配额,
// 再给连接设一个握手截止时间。任何一步失败都直接关闭,不发无谓的回执。
type Gate struct {
	Lim    *Limiter
	OnConn func(c net.Conn, tk *Ticket) // 配额通过后交给上层处理
}

// Serve 接受连接直到 listener 关闭。
func (g *Gate) Serve(ln net.Listener) error {
	for {
		c, err := ln.Accept()
		if err != nil {
			return err
		}
		go g.handle(c)
	}
}

func (g *Gate) handle(c net.Conn) {
	defer c.Close()
	host, _, err := net.SplitHostPort(c.RemoteAddr().String())
	if err != nil {
		return
	}
	now := time.Now()
	if !g.Lim.AllowHandshake(host, now) {
		return
	}
	tk, err := g.Lim.Acquire(host, now)
	if err != nil {
		return
	}
	defer tk.Close()
	if d := g.Lim.lim.HandshakeTimeout; d > 0 {
		_ = c.SetDeadline(now.Add(d))
	}
	if g.OnConn != nil {
		g.OnConn(c, tk)
	}
}

// SteadyTimeouts 给一条已完成握手的连接套上稳态的读写超时。slowloris 靠
// 「慢慢发字节」占住连接,所以读超时必须按「两次读之间的空闲」设置,
// 而不是整条连接的总时长;每收到一帧就由上层重新调用一次。
func (g *Gate) SteadyTimeouts(c net.Conn) {
	if d := g.Lim.lim.ReadTimeout; d > 0 {
		_ = c.SetReadDeadline(time.Now().Add(d))
	}
	if d := g.Lim.lim.WriteTimeout; d > 0 {
		_ = c.SetWriteDeadline(time.Now().Add(d))
	}
}

有三点设计意图值得单说。第一,拒绝时什么都不发。配额超限或握手限速命中,handle 直接 return,连接随即被 defer c.Close() 关闭。给对方回一个错误包只会把「拒绝」变成「放大器」——攻击者用极小的请求就能诱导服务端生成更大的响应。第二,Ticket 的 Authed 与 Close 都幂等,因为登录成功、读循环出错、看门狗超时可能并发触发关闭,重复归还配额会让计数变负。第三,pending 与 conns 分开记:conns 防的是总量,pending 防的是「连上但不登录」的堆积,后者阈值应显著更小。

真跑:本机 loopback 的两组断言

测试不 mock 网络:startGate 用 net.Listen("tcp", "127.0.0.1:0") 起真端口。第一组断言「每 IP 上限之外的第 N+1 条连接被立刻关闭」,第二组断言「握手期内一个字节都不发的连接会被超时断开」。下面这段是 L2:

// snippet: internal/guard/limit_test.go(节选,startGate 已略)

// TestGateEnforcesPerIPQuota 是 L2:真连本机端口,超出每 IP 上限的连接被直接关闭。
func TestGateEnforcesPerIPQuota(t *testing.T) {
	const cap = 4
	lim := New(Limits{MaxConnsPerIP: cap, MaxConnsGlobal: 1000, HandshakeBurst: 1000})
	hold := make(chan struct{})
	ln, addr := startGate(t, lim, func(c net.Conn, tk *Ticket) {
		tk.Authed()
		_, _ = c.Write([]byte{'A'}) // 写标记字节 = 这条连接被接纳
		<-hold
	})
	defer ln.Close()
	defer close(hold)

	var accepted int
	var kept []net.Conn
	for i := 0; i < cap+3; i++ {
		c, err := net.Dial("tcp", addr)
		if err != nil {
			t.Fatalf("dial %d: %v", i, err)
		}
		_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
		buf := make([]byte, 1)
		if n, err := c.Read(buf); err == nil && n == 1 && buf[0] == 'A' {
			accepted++
			kept = append(kept, c) // 必须持有引用,否则 GC 会提前关掉它
			continue
		}
		_ = c.Close() // 读到 EOF/错误 = 被拒
	}
	for _, c := range kept {
		_ = c.Close()
	}
	if accepted != cap {
		t.Fatalf("accepted = %d, want exactly %d", accepted, cap)
	}
}

第二组断言(TestGateDropsSlowHandshake)同理:HandshakeTimeout=150ms,OnConn 里先 Read 一个字节,读不到就直接返回;测试从客户端侧连上后一个字节都不发,Read 应当返回错误,且耗时落在 150ms 到 160ms 之间。它验证的正是「连接被接纳了,但迟迟不推进握手」时,SetDeadline 会把它按时收回。

实测结论

以下是本机(Go 1.26,macOS arm64)实测,同机相对量级,绝对数值不可移植:

指标实测值说明
每 IP 上限4 条接纳,第 5 条起被立即关闭20 条 loopback 连接,无一条误放
全局上限达阈值即返回 ErrGlobalQuotaExceeded纯计数测试,不碰网络
握手令牌桶突发 3 个后拒绝,100ms 后恢复 1 个HandshakeBurst=3,HandshakeRefill=100ms
slowloris 超时配置 150ms,实测 150–160ms静默客户端被断开
竞态检测go test -race 全绿计数与令牌桶无数据竞争

一个必须写进部署手册的坑:macOS 的 kern.ipc.somaxconn 默认只有 128,本节的 loopback 自测因此刻意控制在几十条连接以内。真要做千连接级压测必须错峰连接(每连一条 sleep 一下),否则内核会直接 RST 掉一部分,测出来的数字是假的。防护逻辑的正确性用几十条连接就能验证,规模压测留给第 17 章的机器人客户端。

阅读导航:上一节:16.1 协议 fuzz 与畸形包 · 下一节:16.3 封包加密与签名 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标