1. 信令服务器概述
1.1 什么是信令服务器
在 WebRTC P2P 音视频通信中,信令服务器 是连接建立前必不可少的"中间人"。
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 信令服务器的作用 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
生活中的类比:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 想象两个人想要打电话,但他们不知道对方的电话号码: │
│ │
│ 1. 他们需要一个"电话簿"来查找对方 │
│ 2. 他们需要一个"接线员"来帮忙建立连接 │
│ 3. 一旦电话接通,他们就可以直接通话,不再需要接线员 │
│ │
│ 信令服务器 = 电话簿 + 接线员 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
WebRTC 中的角色:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 用户 A 用户 B │
│ │ │ │
│ │ 1. 注册上线 │ │
│ │ ───────────────> 信令服务器 │ │
│ │ │ │
│ │ 2. 查询 B 是否在线 │ │
│ │ ───────────────> 信令服务器 │ │
│ │ <─────────────── "B 在线" │ │
│ │ │ │
│ │ 3. 发送呼叫请求 │ │
│ │ ───────────────> 信令服务器 ──────────────────>│ │
│ │ │ │
│ │ 4. 交换 SDP 和 ICE 候选者 │ │
│ │ <──────────────────────────────────────────────>│ │
│ │ (通过信令服务器中转) │ │
│ │ │ │
│ │ ═══════════════════════════════════════════════│═══════════════════════════════│
│ │ 5. P2P 连接建立,直接传输音视频 │ │
│ │ <══════════════════════════════════════════════>│ │
│ │ (不再经过信令服务器) │ │
│ ▼ ▼ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
1.2 信令服务器的核心职责
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 信令服务器核心职责 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 用户管理 │
│ - 用户注册/注销 │
│ - 在线状态管理 │
│ - 用户列表查询 │
│ 通俗理解:维护"谁在线"的名单 │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 2️⃣ 消息转发 │
│ - 呼叫请求转发 │
│ - SDP 交换 │
│ - ICE 候选者交换 │
│ 通俗理解:帮双方"传话" │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 3️⃣ 连接保持 │
│ - 心跳检测 │
│ - 超时清理 │
│ - 断线重连支持 │
│ 通俗理解:确认双方"还活着" │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 4️⃣ 配置下发 │
│ - STUN/TURN 服务器配置 │
│ - 客户端参数配置 │
│ 通俗理解:告诉客户端"去哪里找对方" │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
1.3 信令服务器不做什么
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 信令服务器不做什么 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
❌ 不传输音视频数据
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 音视频数据走 P2P 直连,不经过信令服务器 │
│ 通俗理解:接线员只帮你接通电话,不会听你们聊天 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
❌ 不参与媒体协商
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ SDP 的内容由客户端生成,信令服务器只负责转发 │
│ 通俗理解:接线员不管你们说什么语言,只负责传话 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
❌ 不存储通话内容
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 信令服务器不记录任何通话内容 │
│ 通俗理解:接线员不会录音 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
2. 技术选型
2.1 传输协议选择
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 传输协议对比 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 协议 │ 优点 │ 缺点 │ 适用场景 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ TCP │ 可靠传输、有序、支持长连接 │ 开销稍大 │ ✅ 本项目选择 │
│ WebSocket│ 实时性好、浏览器原生支持 │ 需要HTTP升级、服务器开销大 │ Web端首选 │
│ UDP │ 低延迟、开销小 │ 不可靠、需要自己实现可靠性 │ 游戏/直播 │
│ HTTP │ 简单、兼容性好 │ 实时性差、开销大 │ 兼容性方案 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
本项目选择 TCP 的原因:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1. 信令消息必须可靠送达(SDP、ICE 候选者不能丢失) │
│ │
│ 2. 长连接场景,TCP 连接建立后可持续复用 │
│ │
│ 3. 客户端是 Android 原生应用,TCP 支持完善 │
│ │
│ 4. 实现简单,Go 语言标准库支持完善 │
│ │
│ 通俗理解:信令消息很重要,必须保证送达,TCP 最可靠 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
2.2 消息格式选择
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 消息格式对比 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 格式 │ 优点 │ 缺点 │ 适用场景 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ JSON │ 可读性好、调试方便、跨语言 │ 体积稍大、解析稍慢 │ ✅ 本项目选择 │
│ Protobuf│ 体积小、解析快、强类型 │ 可读性差、需要定义proto │ 高性能场景 │
│ XML │ 标准化、工具支持好 │ 冗余多、解析慢 │ 企业级应用 │
│ 二进制 │ 最小体积、最快解析 │ 调试困难、兼容性差 │ 极致性能 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
本项目选择 JSON 的原因:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1. 信令消息量不大,JSON 的性能完全够用 │
│ │
│ 2. 开发调试方便,可以直接看到消息内容 │
│ │
│ 3. Go 和 Kotlin 都有成熟的 JSON 库 │
│ │
│ 4. 扩展性好,新增字段不影响旧版本 │
│ │
│ 通俗理解:信令消息不多,JSON 够用,还方便调试 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
2.3 消息分帧方案
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ TCP 消息分帧方案 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
TCP 是字节流协议,需要解决"粘包"问题:
问题:什么是粘包?
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 发送方发送: │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 消息 A │ │ 消息 B │ │ 消息 C │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ 接收方可能收到: │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 消息 A + 消息 B + 消息 C(粘在一起了) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ 通俗理解:就像多封信被装在同一个信封里,需要知道每封信有多长 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
解决方案:长度前缀法(本项目采用)
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 消息格式: │
│ ┌──────────────────┬────────────────────────────────────────────────┐ │
│ │ 4 字节长度头 │ N 字节 JSON 数据 │ │
│ │ (Big Endian) │ (消息内容) │ │
│ └──────────────────┴────────────────────────────────────────────────┘ │
│ │
│ 读取流程: │
│ 1. 先读取 4 字节,解析出消息长度 N │
│ 2. 再读取 N 字节,得到完整消息 │
│ │
│ 通俗理解:先看信封上写的"信件长度",再按长度读取 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
本项目实现:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // protocol.go │
│ │
│ const ( │
│ LengthHeaderSize = 4 // 长度头固定 4 字节 │
│ MaxMessageLength = 10000 // 单条消息最大 10KB │
│ ) │
│ │
│ // 解析数据包 │
│ func ParseTcpPacket(data []byte) (*TcpPacket, error) { │
│ jsonLength := binary.BigEndian.Uint32(data[:4]) │
│ jsonData := data[4 : 4+jsonLength] │
│ return &TcpPacket{JsonLength: int(jsonLength), JsonData: jsonData}, nil │
│ } │
│ │
│ // 打包数据 │
│ func (p *TcpPacket) ToBytes() []byte { │
│ result := make([]byte, 4+p.JsonLength) │
│ binary.BigEndian.PutUint32(result[:4], uint32(p.JsonLength)) │
│ copy(result[4:], p.JsonData) │
│ return result │
│ } │
└─────────────────────────────────────────────────────────────────────────────────────────┘
3. 架构设计
3.1 整体架构
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 信令服务器架构 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ 配置文件 │
│ config.json │
└────────┬────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ main.go │
│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 1. 初始化日志 │ │
│ │ 2. 加载配置 │ │
│ │ 3. 设置优雅关闭 │ │
│ │ 4. 初始化 Worker Pool │ │
│ │ 5. 启动信令服务器 (TCP) │ │
│ │ 6. 启动 STUN/TURN 服务器 │ │
│ │ 7. 启动健康检查服务器 │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
│
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ TCP 监听器 │ │ STUN/TURN │ │ 健康检查 │
│ (3480端口) │ │ (3478/3479) │ │ (8080端口) │
└────────┬────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 连接处理流程 │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Accept │───>│ 读取长度头 │───>│ 读取消息体 │───>│ Worker Pool │ │
│ │ 新连接 │ │ (4字节) │ │ (N字节) │ │ 异步处理 │ │
│ └──────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 消息路由分发 │ │
│ │ │ │
│ │ register ──────>│ │
│ │ heartbeat ─────>│ │
│ │ call_request ──>│ │
│ │ webrtc_sdp ────>│ │
│ │ ... │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
3.2 核心数据结构
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 核心数据结构 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
1️⃣ 客户端信息 (Client)
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ type Client struct { │
│ ID string // 客户端唯一标识 │
│ Username string // 用户名 │
│ Conn net.Conn // TCP 连接 │
│ PublicIP string // 公网 IP │
│ PublicPort int // 公网端口 │
│ LastActivity time.Time // 最后活动时间 │
│ Stats ClientStats // 统计信息 │
│ } │
│ │
│ 通俗理解:每个在线用户的"档案" │
└─────────────────────────────────────────────────────────────────────────────────────────┘
2️⃣ 消息结构 (Message)
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ type Message struct { │
│ Type string `json:"type"` // 消息类型 │
│ SenderID string `json:"sender_id"` // 发送者 ID │
│ ReceiverID string `json:"receiver_id"` // 接收者 ID │
│ Data string `json:"data"` // 消息内容 │
│ } │
│ │
│ 通俗理解:信封上的"寄件人"、"收件人"、"信件内容" │
└─────────────────────────────────────────────────────────────────────────────────────────┘
3️⃣ 全局状态管理
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ var ( │
│ gClients = make(map[string]*Client) // 用户ID -> 客户端信息 │
│ gClientsMux sync.RWMutex // 读写锁 │
│ gConnToUsername = make(map[string]string) // 连接地址 -> 用户ID │
│ gCurrentConnections int64 // 当前连接数 │
│ ) │
│ │
│ 通俗理解:服务器的"通讯录"和"计数器" │
└─────────────────────────────────────────────────────────────────────────────────────────┘
3.3 消息类型定义
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 消息类型定义 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 消息类型 │ 方向 │ 用途 │ 通俗理解 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ register │ 客户端 → 服务器 │ 用户注册上线 │ "我来了" │
│ register_response │ 服务器 → 客户端 │ 注册响应 │ "欢迎" │
│ heartbeat │ 客户端 → 服务器 │ 心跳检测 │ "我还活着吗?" │
│ heartbeat_response │ 服务器 → 客户端 │ 心跳响应 │ "活着呢" │
│ call_request │ 客户端 ↔ 客户端 │ 呼叫请求 │ "想和你通话" │
│ call_stop │ 客户端 ↔ 客户端 │ 挂断请求 │ "挂了" │
│ webrtc_sdp │ 客户端 ↔ 客户端 │ SDP 交换 │ "这是我的媒体信息" │
│ webrtc_ice_candidate │ 客户端 ↔ 客户端 │ ICE 候选者交换 │ "这是我的网络地址" │
│ query_online_users │ 客户端 → 服务器 │ 查询在线用户 │ "谁在线?" │
│ get_stun_turn_config │ 客户端 → 服务器 │ 获取 STUN/TURN 配置 │ "给我服务器地址" │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
4. 关键实现
4.1 连接管理
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 连接管理实现 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
关键点:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 连接数限制 │
│ - 检查当前连接数是否超过最大值 │
│ - 超过则拒绝新连接 │
│ 通俗理解:房间满了就不让进 │
│ │
│ 2️⃣ 读取超时设置 │
│ - 每次读取前设置超时 │
│ - 超时后继续等待(不立即断开) │
│ 通俗理解:给客户端一点时间,别太着急 │
│ │
│ 3️⃣ 异步消息处理 │
│ - 消息处理不阻塞主循环 │
│ - 使用 Worker Pool 提高并发 │
│ 通俗理解:收信和拆信分开做,效率更高 │
│ │
│ 4️⃣ 资源清理 │
│ - defer 确保连接关闭 │
│ - 清理客户端映射 │
│ 通俗理解:走的时候把门带上 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
4.2 用户注册
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 用户注册实现 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
关键点:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 重复登录处理 │
│ - 同一用户 ID 只能有一个连接 │
│ - 新连接会踢掉旧连接 │
│ 通俗理解:一个账号只能在一处登录 │
│ │
│ 2️⃣ 并发安全 │
│ - 使用互斥锁保护共享数据 │
│ - defer 确保锁一定释放 │
│ 通俗理解:大家排队办事,别抢 │
│ │
│ 3️⃣ 双向映射 │
│ - gClients: 用户 ID → 客户端信息 │
│ - gConnToUsername: 连接地址 → 用户 ID │
│ 通俗理解:既能按名字找人,也能按地址找人 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
4.3 消息转发
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 消息转发实现 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
关键点:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 读写锁使用 │
│ - 查找用读锁,不阻塞其他读取 │
│ - 修改用写锁,保证数据一致 │
│ 通俗理解:读的时候大家一起来,写的时候排队 │
│ │
│ 2️⃣ 错误处理 │
│ - 目标不在线时通知发送方 │
│ - 不要让发送方一直等待 │
│ 通俗理解:找不到人要说一声,别让人干等 │
│ │
│ 3️⃣ 消息原样转发 │
│ - 不修改消息内容 │
│ - 保持消息完整性 │
│ 通俗理解:只负责传话,不偷看内容 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
4.4 心跳机制
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 心跳机制实现 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
关键点:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 心跳间隔配置 │
│ - 客户端每 90 秒发送一次心跳 │
│ - 服务器超时时间 300 秒 │
│ 通俗理解:每隔一段时间确认一下"还活着吗" │
│ │
│ 2️⃣ 定时清理 │
│ - 每 30 秒检查一次超时客户端 │
│ - 清理断开连接的客户端 │
│ 通俗理解:定期打扫房间,清理不用的东西 │
│ │
│ 3️⃣ 统计记录 │
│ - 记录心跳次数 │
│ - 用于监控和调试 │
│ 通俗理解:记录谁来了多少次,方便排查问题 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
4.5 Worker Pool
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ Worker Pool 实现 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
为什么需要 Worker Pool?
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 问题:每个连接一个 goroutine 处理消息 │
│ - 10000 个连接 = 10000 个 goroutine │
│ - goroutine 数量过多会消耗大量内存 │
│ - 调度开销大 │
│ │
│ 解决:使用固定数量的 worker 处理消息 │
│ - 100 个 worker 处理所有消息 │
│ - 控制并发数量 │
│ - 减少内存和调度开销 │
│ │
│ 通俗理解:雇 100 个员工处理 10000 个客户的需求,而不是每个客户配一个员工 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
关键点:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 固定 worker 数量 │
│ - 配置文件中设置 worker_pool_size │
│ - 避免无限创建 goroutine │
│ 通俗理解:控制员工数量,别招太多 │
│ │
│ 2️⃣ 任务队列容量 │
│ - 队列容量 = worker 数量 × 10 │
│ - 缓冲高峰期的任务 │
│ 通俗理解:排队区要够大,高峰期别排不下 │
│ │
│ 3️⃣ 队列满时的策略 │
│ - 队列满时直接启动新 goroutine 执行 │
│ - 不阻塞消息接收 │
│ 通俗理解:忙不过来就临时加人 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
5. 注意事项与避坑指南
5.1 并发安全
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 并发安全避坑指南 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 1:忘记加锁
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // 错误写法 │
│ func handleMessage(msg Message) { │
│ client := gClients[msg.SenderID] // 危险!可能并发读写 │
│ client.LastActivity = time.Now() │
│ } │
│ │
│ // 正确写法 │
│ func handleMessage(msg Message) { │
│ gClientsMux.Lock() │
│ defer gClientsMux.Unlock() │
│ client := gClients[msg.SenderID] │
│ client.LastActivity = time.Now() │
│ } │
│ │
│ 通俗理解:共享的东西要先上锁再动,不然会打架 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 2:死锁
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // 错误写法:持锁调用其他需要锁的函数 │
│ func handleRegister(conn net.Conn, msg Message) { │
│ gClientsMux.Lock() │
│ defer gClientsMux.Unlock() │
│ sendMessage(conn, response) // sendMessage 内部也要获取锁!死锁! │
│ } │
│ │
│ // 正确写法:先释放锁再调用 │
│ func handleRegister(conn net.Conn, msg Message) { │
│ gClientsMux.Lock() │
│ // ... 修改数据 ... │
│ gClientsMux.Unlock() // 先释放锁 │
│ sendMessage(conn, response) // 再发送消息 │
│ } │
│ │
│ 通俗理解:别在房间里锁门,然后又想去外面拿钥匙 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 3:锁粒度过大
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // 错误写法:整个处理过程都持锁 │
│ func handleMessage(conn net.Conn, msg Message) { │
│ gClientsMux.Lock() │
│ defer gClientsMux.Unlock() │
│ // 解析消息(不需要锁) │
│ // 查找客户端(需要锁) │
│ // 发送消息(不需要锁,可能很慢) │
│ } │
│ │
│ // 正确写法:只在必要时持锁 │
│ func handleMessage(conn net.Conn, msg Message) { │
│ // 解析消息(不需要锁) │
│ gClientsMux.RLock() │
│ client := gClients[msg.SenderID] │
│ gClientsMux.RUnlock() │
│ sendMessage(conn, response) │
│ } │
│ │
│ 通俗理解:别一直占着厕所,用完赶紧出来 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
5.2 资源泄漏
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 资源泄漏避坑指南 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 1:连接未关闭
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // 错误写法:异常时连接未关闭 │
│ func handleConnection(conn net.Conn) { │
│ for { │
│ data := make([]byte, 1024) │
│ _, err := conn.Read(data) │
│ if err != nil { return } // 连接未关闭! │
│ process(data) │
│ } │
│ } │
│ │
│ // 正确写法:使用 defer 确保关闭 │
│ func handleConnection(conn net.Conn) { │
│ defer conn.Close() // 无论如何都会关闭 │
│ for { /* ... */ } │
│ } │
│ │
│ 通俗理解:走的时候记得关门 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 2:goroutine 泄漏
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // 错误写法:goroutine 永远不会退出 │
│ func startHeartbeat(conn net.Conn) { │
│ go func() { │
│ for { │
│ time.Sleep(30 * time.Second) │
│ sendHeartbeat(conn) // 连接关闭后还在发送! │
│ } │
│ }() │
│ } │
│ │
│ // 正确写法:使用 context 或 done channel 控制 │
│ func startHeartbeat(ctx context.Context, conn net.Conn) { │
│ go func() { │
│ ticker := time.NewTicker(30 * time.Second) │
│ defer ticker.Stop() │
│ for { │
│ select { │
│ case <-ticker.C: sendHeartbeat(conn) │
│ case <-ctx.Done(): return // 收到退出信号 │
│ } │
│ } │
│ }() │
│ } │
│ │
│ 通俗理解:员工下班了要让他回家,别一直干活 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 3:map 并发读写
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // Go 的 map 不是并发安全的,并发读写会 panic │
│ │
│ // 解决方案 1:使用互斥锁 │
│ var m = make(map[string]string) │
│ var mu sync.Mutex │
│ func set(key, value string) { │
│ mu.Lock() │
│ m[key] = value │
│ mu.Unlock() │
│ } │
│ │
│ // 解决方案 2:使用 sync.Map(适合读多写少) │
│ var m sync.Map │
│ func set(key, value string) { m.Store(key, value) } │
│ │
│ 通俗理解:共享的白板要轮流用,不然会打架 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
5.3 消息处理
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 消息处理避坑指南 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 1:消息验证不充分
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // 错误写法:直接使用未验证的消息 │
│ func handleMessage(msg Message) { │
│ target := gClients[msg.ReceiverID] // ReceiverID 可能为空! │
│ sendMessage(target.Conn, msg) │
│ } │
│ │
│ // 正确写法:验证消息字段 │
│ func isValidMessage(msg *Message) bool { │
│ if msg.Type == "" { return false } │
│ if len(msg.SenderID) > 100 { return false } // 防止超长字符串 │
│ if len(msg.Data) > 100000 { return false } // 防止超大消息 │
│ return true │
│ } │
│ │
│ 通俗理解:收到的信要先检查,别什么信都收 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 2:消息长度限制
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // 必须限制消息长度,防止内存耗尽攻击 │
│ const MaxMessageLength = 10000 // 10KB │
│ │
│ func handleConnection(conn net.Conn) { │
│ lengthBuf := make([]byte, 4) │
│ conn.Read(lengthBuf) │
│ messageLength := binary.BigEndian.Uint32(lengthBuf) │
│ │
│ if messageLength > MaxMessageLength { │
│ conn.Close() // 拒绝超大消息 │
│ return │
│ } │
│ data := make([]byte, messageLength) │
│ conn.Read(data) │
│ } │
│ │
│ 通俗理解:别收太大的包裹,仓库装不下 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 3:粘包处理错误
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // 错误写法:假设一次 Read 能读完一条消息 │
│ func handleConnection(conn net.Conn) { │
│ data := make([]byte, 1024) │
│ n, _ := conn.Read(data) // 可能只读了半条消息! │
│ process(data[:n]) │
│ } │
│ │
│ // 正确写法:按长度精确读取 │
│ func readMessage(conn net.Conn) ([]byte, error) { │
│ lengthBuf := make([]byte, 4) │
│ if _, err := io.ReadFull(conn, lengthBuf); err != nil { return nil, err } │
│ length := binary.BigEndian.Uint32(lengthBuf) │
│ data := make([]byte, length) │
│ if _, err := io.ReadFull(conn, data); err != nil { return nil, err } │
│ return data, nil │
│ } │
│ │
│ 通俗理解:按信封上写的长度读,别多读也别少读 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
5.4 性能优化
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 性能优化避坑指南 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 1:频繁创建对象
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // 错误写法:每次都创建新 buffer │
│ func sendMessage(conn net.Conn, msg Message) { │
│ data, _ := json.Marshal(msg) // 每次分配内存 │
│ buf := make([]byte, 4+len(data)) // 每次分配内存 │
│ } │
│ │
│ // 正确写法:使用 sync.Pool 复用对象 │
│ var bufferPool = sync.Pool{ │
│ New: func() interface{} { return new(bytes.Buffer) }, │
│ } │
│ func sendMessage(conn net.Conn, msg Message) { │
│ buf := bufferPool.Get().(*bytes.Buffer) │
│ defer bufferPool.Put(buf) │
│ buf.Reset() │
│ // ... 使用 buf ... │
│ } │
│ │
│ 通俗理解:别用一次就扔,洗洗还能用 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 2:阻塞操作在主循环
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // 错误写法:消息处理阻塞接收新连接 │
│ func handleConnection(conn net.Conn) { │
│ for { │
│ msg := readMessage(conn) │
│ processMessage(msg) // 如果这里很慢,会阻塞接收下一条消息 │
│ } │
│ } │
│ │
│ // 正确写法:使用 Worker Pool 异步处理 │
│ func handleConnection(conn net.Conn) { │
│ for { │
│ msg := readMessage(conn) │
│ gWorkerPool.Submit(func() { processMessage(msg) }) │
│ } │
│ } │
│ │
│ 通俗理解:收信和拆信分开做,别堵着 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 3:连接数限制
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // 必须限制最大连接数,防止资源耗尽 │
│ func acceptConnections(listener net.Listener) { │
│ for { │
│ conn, _ := listener.Accept() │
│ if atomic.LoadInt64(&gCurrentConnections) >= gConfig.Server.MaxConnections { │
│ serverLog("Connection rejected: maximum connections reached") │
│ conn.Close() │
│ continue │
│ } │
│ atomic.AddInt64(&gCurrentConnections, 1) │
│ go handleConnection(conn) │
│ } │
│ } │
│ │
│ 通俗理解:房间满了就不让进,不然会挤爆 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
5.5 系统配置
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 系统配置避坑指南 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 1:文件描述符限制
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 问题:Linux 默认文件描述符限制是 1024 │
│ 每个连接占用一个文件描述符 │
│ 10000 个连接需要 10000 个文件描述符 │
│ │
│ 解决:修改系统限制 │
│ # 查看当前限制 │
│ ulimit -n │
│ │
│ # 临时修改(重启失效) │
│ ulimit -n 100000 │
│ │
│ # 永久修改 /etc/sysctl.conf │
│ fs.file-max = 1000000 │
│ net.core.somaxconn = 65535 │
│ │
│ 通俗理解:把门开大点,不然人多进不来 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
❌ 坑 2:TCP 参数调优
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ # /etc/sysctl.conf 添加 │
│ │
│ # 开启 TCP 快速回收 │
│ net.ipv4.tcp_tw_reuse = 1 │
│ │
│ # 减少 TIME_WAIT 时间 │
│ net.ipv4.tcp_fin_timeout = 30 │
│ │
│ # 增加连接队列长度 │
│ net.core.somaxconn = 65535 │
│ │
│ # 增加 TCP 缓冲区 │
│ net.core.rmem_max = 16777216 │
│ net.core.wmem_max = 16777216 │
│ │
│ 通俗理解:优化交通规则,让车跑得更快 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
6. 监控与运维
6.1 健康检查
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 健康检查实现 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
本项目实现了独立的健康检查服务器:
使用方式:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ curl http://localhost:8080/health │
│ │
│ 响应: │
│ { │
│ "status": "ok", │
│ "online_clients": 150, │
│ "current_connections": 150, │
│ "total_connections": 1520, │
│ "uptime": "2h30m15s" │
│ } │
└─────────────────────────────────────────────────────────────────────────────────────────┘
6.2 统计报告
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 统计报告实现 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
输出示例:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ ========== Statistics Report ========== │
│ Client: user001 | Duration: 1h30m │
│ Messages: Sent=150 | Received=148 │
│ Heartbeat: Sent=60 | Received=60 │
│ Call requests: Sent=5 | Received=3 │
│ Server Statistics | Online clients: 150 | Total connections: 1520 │
│ ====================================== │
└─────────────────────────────────────────────────────────────────────────────────────────┘
6.3 优雅关闭
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 优雅关闭实现 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
关键点:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 先停止接受新连接 │
│ 通俗理解:关门不让进了 │
│ │
│ 2️⃣ 等待现有请求处理完成 │
│ 通俗理解:让里面的人办完事再走 │
│ │
│ 3️⃣ 关闭资源 │
│ 通俗理解:关灯锁门 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
7. 配置说明
7.1 配置文件详解
json
{
"server": {
"name": "tcp 服务器",
"port": 3480, // 信令服务器端口
"max_connections": 1000000, // 最大连接数
"worker_pool_size": 100 // Worker Pool 大小
},
"client": {
"heartbeat_interval_seconds": 90, // 心跳间隔
"inactive_timeout_seconds": 300 // 超时时间
},
"stun": {
"name": "stun服务器",
"enabled": true,
"server": "47.106.189.19", // STUN 服务器地址
"port": 3478
},
"turn": {
"name": "turn服务器",
"enabled": true,
"server": "47.106.189.19", // TURN 服务器地址
"port": 3479,
"username": "videocall",
"password": "turn123456"
}
}
7.2 配置参数说明
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 配置参数说明 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 参数 │ 说明 │ 建议值 │ 影响 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ max_connections │ 最大连接数 │ 根据服务器配置 │ 超过会拒绝连接 │
│ worker_pool_size │ Worker 数量 │ CPU 核心数 × 10 │ 影响并发处理能力 │
│ heartbeat_interval │ 心跳间隔 │ 60-120 秒 │ 太短增加负载 │
│ inactive_timeout │ 超时时间 │ 心跳间隔 × 3 │ 太短会误杀连接 │
│ stun.enabled │ 是否启用 STUN │ true │ 影响 NAT 穿透 │
│ turn.enabled │ 是否启用 TURN │ true │ 保底连通性 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
8. 参考资料
8.1 本项目相关文件
| 文件路径 | 说明 |
|---|---|
| main.go | 服务器入口,启动各组件 |
| protocol.go | 协议定义,消息结构 |
| client.go | 客户端管理,注册/心跳 |
| message.go | 消息处理,路由分发 |
| worker_pool.go | Worker Pool 实现 |
| config.go | 配置加载 |
| stun_turn.go | STUN/TURN 服务器 |
本系列文章:
【P2P音视频通信系统】之方案架构详解
【P2P音视频通信系统】之呼叫完整时序图
【P2P音视频通信系统】之STUN服务详解
【P2P音视频通信系统】之TURN 服务详解
【P2P音视频通信系统】WebRTC 之 SDP 详解
【P2P音视频通信系统】WebRTC 之 ICE 详解
【P2P音视频通信系统】WebRTC ICE 候选类型详解:对等反射候选者(Peer Reflexive Candidate)
【P2P音视频通信系统】之信令服务器详解