Redis自身不会导致Go堆内存碎片,真正元凶是Go程序中高频分配小对象(如JSON解析、Buffer扩容、结构体逃逸等)引发的span级碎片;HeapSys - HeapIdle - HeapReleased >100MB即表明存在严重span碎片,需通过sync.Pool复用、size class收敛、避免逃逸等手段优化。
go 程序里用 redis 本身不会直接导致 go 堆内存碎片,但如果你在 redis 操作过程中高频分配小对象(比如反复 new 结构体、make([]byte, n)、解析响应),这些分配行为会触发 go 运行时的 span 级碎片积累——这才是真正在“涨 rss 却不释放”的元凶。
看懂 memstats 里的关键差值:HeapSys - HeapIdle - HeapReleased
这个差值反映的是被卡住、无法归还 OS 的内存页总量。只要它持续 >100MB,基本可判定是 span 碎片主导的内存膨胀,而不是 Redis 自身的内存泄漏。
-
HeapSys是向操作系统申请的总内存(RSS) -
HeapIdle是当前未被使用的 span 内存页 -
HeapReleased是已通知 OS 归还的页数(注意:不是所有 Idle 都能 Released) - 真正卡住的是那些 部分对象存活 的 span ——哪怕只剩 1 个 byte 活着,整个 8KB span 就不能回收
pprof heap 分析要盯 runtime.mallocgc 下游的分配源头
别只看 topN 函数,重点查 runtime.mallocgc 调用栈里是否大量来自:
-
encoding/json.(*Decoder).Decode→ 解析 Redis 返回的 JSON 字符串时频繁make([]byte, ...) -
strings.Builder.Grow或bytes.Buffer.Grow→ 拼接 key、序列化 value 时动态扩容 - 结构体字面量如
&struct{a int; b string}{}→ 编译器可能逃逸到堆,且 size class 不稳定 - Redis 客户端库内部(如
github.com/go-redis/redis/v9)的cmd.NewCmd、scan迭代器等临时对象
sync.Pool 复用必须成对且初始化,否则等于没用
很多团队加了 sync.Pool 但 RSS 依旧涨,问题出在两个细节:
- Get 后没清零或重置字段:
p := pool.Get().(*MyStruct); p.Field = 0; p.Data = p.Data[:0]—— 否则下次 Get 可能拿到脏数据,业务出错后被迫重建对象 - Put 前没确认对象生命周期结束:
go func(){ defer pool.Put(p) }()是典型错误,goroutine 可能还在读写 p - Pool 对象 size 必须收敛:比如统一用
[64]byte而非[]byte切片,避免跨 size class(如 32B 和 64B span)分散占用 - 不要把 Redis 连接、Client 实例塞进 Pool —— 它们本就是长生命周期对象,Pool 适合高频创建销毁的小结构
Redis 侧的内存碎片和 Go 堆碎片是两回事,别混着调
Redis 自身的 mem_fragmentation_ratio 高(比如 >1.5),只说明 jemalloc 分配器内部有碎片,不影响 Go 进程 RSS;而 Go 的 HeapSys 持续上涨,说明的是 Go mheap 管理的 span 被钉住。两者指标来源、修复手段、影响范围完全不同。
- 查 Redis 碎片:执行
INFO memory,看mem_fragmentation_ratio - 查 Go 碎片:
runtime.ReadMemStats+go tool pprofheap profile +GODEBUG=gctrace=1观察 scvg 行 - Redis 碎片靠
CONFIG SET activedefrag yes或升级 jemalloc;Go 碎片靠对象复用、size class 收敛、避免逃逸
最容易被忽略的一点:快照恢复、批量写 chunk、并发 scan key 这类操作,会在短时间内集中触发大量不规则小对象分配——它们不一定会出现在业务主路径的 pprof 里,但会显著拉高 span 碎片率。盯住这些“辅助路径”的分配模式,比优化主逻辑更有效。











