因为Paho是客户端SDK,无法接管连接生命周期和自定义事件循环,而gnet是异步无锁高性能网络引擎,支持精确内存控制与按需解析,适合5w+设备长连接场景。

为什么不用 Paho 而选 gnet 做 MQTT 网关底层
因为 Paho 是客户端库,本质是封装了 MQTT 协议交互逻辑的「应用层 SDK」,它依赖 net.Conn 做 I/O,无法接管连接生命周期、无法自定义事件循环、更没法做连接复用或零拷贝解析。而物联网网关要扛住 5w+ 设备长连接,必须从网络层重写——gnet 正是为此设计的异步、无锁、基于 epoll/kqueue 的高性能网络引擎。
它不实现 MQTT 协议,但给你裸的 TCP 连接和字节流;你得自己解析 CONNECT/PUBLISH/ SUBSCRIBE 报文,但也正因如此,你能精确控制内存分配、跳过冗余序列化、按需解析 header 字段(比如只读 QoS 和 Remaining Length),这对低功耗设备批量接入很关键。
- gnet 启动后只占一个 goroutine,所有连接共用 event loop,内存开销比每个连接起 goroutine 的 Paho 低一个数量级
- 报文粘包/半包必须自己处理:gnet 不自动分帧,
gnet.OnTraffic回调里拿到的是原始 []byte,得靠 MQTT Fixed Header 的 Remaining Length 字段做切分 - 没有内置 TLS 支持,若需加密,得在
gnet.OnOpen里手动包装tls.Conn,且注意 handshake 阻塞会卡住 event loop
如何用 gnet 解析 MQTT CONNECT 报文并校验 client id
MQTT v3.1.1 的 CONNECT 报文前 10 字节是固定头(第 7–8 字节为 Protocol Name 长度,第 9–10 字节为 Protocol Name 内容),真正 client id 在 variable header 末尾。gnet 不提供协议解析工具,你得手撸:
// 示例:从 buf 提取 client id(简化版,仅支持 utf-8 编码)
func parseClientID(buf []byte) (string, error) {
if len(buf) < 12 {
return "", errors.New("buffer too short for CONNECT")
}
// 跳过 protocol name ("MQTT") → 检查第 7-10 字节是否为 0x00 0x04 0x4D 0x51 0x54 0x54
if buf[6] != 0x00 || buf[7] != 0x04 || buf[8] != 0x4D || buf[9] != 0x51 || buf[10] != 0x54 || buf[11] != 0x54 {
return "", errors.New("invalid protocol name")
}
// 读取 client id length(位于 protocol level 之后,即 offset 12 开始的 2 字节)
if len(buf) < 14 {
return "", errors.New("no space for client id length")
}
idLen := int(buf[12])<<8 | int(buf[13])
start := 14
end := start + idLen
if end > len(buf) {
return "", errors.New("client id length exceeds buffer")
}
return string(buf[start:end]), nil
}
- 别直接
string(buf)全量转字符串——MQTT 报文含二进制字段,乱转会 panic - 实际部署必须加长度校验,否则恶意构造超长 client id 会导致 OOM
- OneNET 或 EMQX 等平台对 client id 有格式限制(如不能含 `/` `+` `#`),解析后需额外校验
gnet 实现多设备主题路由时怎么避免 topic 字符串频繁分配
设备上报数据时 topic 形如 device/abc123/sensor/temperature,网关要按 `/` 切分提取设备 ID。但每次 strings.Split(topic, "/") 都会 new []string,高频场景下 GC 压力大。正确做法是复用 sync.Pool 或用 bytes.IndexByte 手动找分隔符:
func extractDeviceID(topic []byte) []byte {
// 找第一个 '/' 后、第二个 '/' 前的子串(即 device/xxx 中的 xxx)
first := bytes.IndexByte(topic, '/')
if first == -1 {
return nil
}
second := bytes.IndexByte(topic[first+1:], '/')
if second == -1 {
return nil
}
return topic[first+1 : first+1+second]
}
- 返回
[]byte而非string,避免逃逸到堆上 - 如果后续要存入 map 或日志,再用
string()转一次——只在必要时转 - 订阅 topic 如
device/+/sensor/+的匹配不能用正则,要用前缀树(如github.com/muesli/go-app-paths改写)或预编译通配符规则
gnet 网关如何安全关闭并释放所有 MQTT 连接
gnet 没有类似 client.Disconnect() 的优雅退出机制——它只管网络层,断连动作得你自己触发。常见错误是直接调 gnet.Stop(),此时 TCP 连接被 kernel 强制 RST,设备端收不到 DISCONNECT 报文,可能重连风暴。
- 必须在
gnet.OnShutdown里遍历所有活跃连接,向每个 conn 写入合法 MQTT DISCONNECT 报文(2 字节:0xE0 0x00),再conn.Close() - 设备连接数多时,逐个 write 会阻塞 event loop,应改用
conn.AsyncWrite()并配gnet.WithTicker(true)定期检查写完成状态 - OneNET 平台要求设备断连前发
$sys/{pid}/{device-name}/thing/property/post/reply的 success 确认,这个 topic 必须在关闭前补发,否则平台认为上报失败
真正难的不是代码怎么写,而是当 3 万台设备同时掉线时,DISCONNECT 报文发送顺序、重试策略、以及与平台 session 清理的时序配合——这些细节文档里几乎不提,只能靠抓包反复验证。

















