“内存泄漏”是开发者最害怕的问题之一,尤其是在高并发、高负载的线上环境中。它往往不易察觉,却能悄悄吞噬系统的性能,最终导致应用崩溃或响应变慢。你是否曾在项目上线后遇到过性能下降或宕机的问题,而问题根源竟然是内存泄漏?本篇文章将带你深入分析线上内存泄漏的排查思路,帮你快速定位并解决这一隐患。
在现代互联网应用中,内存泄漏的排查已不再是一个简单的任务。线上系统通常复杂且动态,如何在不影响生产环境的情况下,定位并修复内存泄漏问题呢?你是不是也遇到过系统内存逐渐增加,甚至最终导致崩溃的情况?别着急,接下来我们一起探讨排查内存泄漏的有效方法。
内存泄漏的常见表现
- 内存逐渐增加:应用程序在长时间运行后,占用的内存不断增加,且无法释放。
- 频繁的 GC(垃圾回收):虽然 GC 不断回收,但堆内存依然无法被清理。
- 系统卡顿或崩溃:内存泄漏严重时,系统可能因为内存耗尽导致性能下降、响应变慢,甚至宕机。
内存泄漏排查
背景了解:告知 线上 room_work 运行一段时间内存就会慢慢往上涨,8G内存吃掉了4G。。
思路
1.大概捋一下项目中有通过常驻内存操作实现业务逻辑的代码
cpu火焰图
直接本地环境全部启动之后,开始使用三个手机进入dji房间,进行所有功能疯狂乱点,生成cup火焰图,但讲真看不出来啥,才发现应该才内存才对
内存调用
上代码 (go2cache)
// 1. new一个go2cache的 server,这个对象负责将数据库的数据建立2层缓存(redis, memory),批量查内存优先级高
func NewServer(redis string, codec Codec) *Server {
memory := newMemoryCache(codec) // 内存子对象
redisCacheServer := newRedisCache(redis, memory, codec)
db := newDbCache(redisCacheServer, codec)
return &Server{
redis: redisCacheServer,
db: db,
codec: codec,
}
}
// 2. 着重看内存子对象
func newMemoryCache(codec Codec) *memoryCache {
config := bigcache.DefaultConfig(time.Second * 60)
config.Shards = 1024 // 1024个分片,bigcache这个组件,没了节约内存,将我们的数据是按byte存入内存的
config.MaxEntrySize = 1024 * 16
config.HardMaxCacheSize = 500
cache, _ := bigcache.NewBigCache(config)
return &memoryCache{
cache: cache,
codec: codec,
}
}
func NewBigCache(config Config) (*BigCache, error) {
return newBigCache(config, &systemClock{})
}
func newBigCache(config Config, clock clock) (*BigCache, error) {
// 重点来了~
func initNewShard(config Config, callback onRemoveCallback, clock clock) *cacheShard {
bytesQueueInitialCapacity := config.initialShardSize() * config.MaxEntrySize
maximumShardSizeInBytes := config.maximumShardSizeInBytes()
if maximumShardSizeInBytes > 0 && bytesQueueInitialCapacity > maximumShardSizeInBytes {
bytesQueueInitialCapacity = maximumShardSizeInBytes
}
return &cacheShard{
hashmap: make(map[uint64]uint32, config.initialShardSize()),
hashmapStats: make(map[uint64]uint32, config.initialShardSize()),
entries: *queue.NewBytesQueue(bytesQueueInitialCapacity, maximumShardSizeInBytes),
entryBuffer: make([]byte, config.MaxEntrySize+headersSizeInBytes),
onRemove: callback,
isVerbose: config.Verbose,
logger: newLogger(config.Logger),
clock: clock,
lifeWindow: uint64(config.LifeWindow.Seconds()),
statsEnabled: config.StatsEnabled,
}
}
// NewBytesQueue initialize new bytes queue.
// capacity is used in bytes array allocation
// When verbose flag is set then information about memory allocation are printed
func NewBytesQueue(capacity int, maxCapacity int, verbose bool) *BytesQueue {
return &BytesQueue{
array: make([]byte, capacity),
capacity: capacity,
maxCapacity: maxCapacity,
headerBuffer: make([]byte, binary.MaxVarintLen32),
...
for i := 0; i < config.Shards; i++ {
tail:leftMarginIndex,
head:leftMarginIndex,
rightMargin:leftMarginIndex,
verbose:verbose,
}
最终内存好用结果打印
我们都是怎么使用的
经过分析发现 /Flock-Server/rpc/server/internal/room/worker/base/base.go@Init 方法会在每次rid new一个work的时候被初始化一次, 了解下房间业务,发现房间是一个街区一个房间的,所以。。。
解决方式
方式一
// newMemoryCache 使用较小的内存做缓冲
func newMemoryCache(codec Codec) *memoryCache {
config := bigcache.DefaultConfig(time.Second * 60)
//config.Shards = 1024
config.Shards = 10 // 改成系统默认的10个分片就行了,或者咱们的业务就别用内存了,直接对接redis
config.MaxEntrySize = 1024 * 16 // 16KB
config.HardMaxCacheSize = 500
cache, _ := bigcache.NewBigCache(config)
return &memoryCache{
cache: cache,
codec: codec,
}
}
然后把
func (r *RoomWorkerBase) Init() {
r.Ctx = context.TODO()
r.ServerCache = go2cache.NewServer(consts.RedisRoom, cache.RoomCodec{})
r.I18n = i18n.NewI18n()
r.I18n.SetLanguage("en")
r.msgCh = make(chan interface{}, 500)
r.stopCh = make(chan interface{})
r.SyncHdl = make(map[reflect.Type]SyncHandler)
r.ASyncHdl = make(map[reflect.Type]AsyncHandler)
r.SyncPbHdl = make(map[protoreflect.Descriptor]SyncProtoHandler)
r.ASyncPbHdl = make(map[protoreflect.Descriptor]AsyncProtoHandler)
r.CmdHdl = make(map[string]CmdHandler)
r.timers = make(map[string]*time.Timer)
r.CommonCache = cache.NewRoomCommon()
r.RegisterHandlers()
}
方式二
直接把内存的二级缓存拿掉,让go2cache直接对接redis, 这种方式代码业务方无感知,只需要该go2cache内部
来个demo重现看看
func main() {
var m runtime.MemStats
asas := map[string]interface{}{}
for i := 0; i < 20; i++ {
asas[fmt.Sprintf("%d", i)] = go2cache.NewServer(consts.RedisRoom, RoomCodec{})
fmt.Println(fmt.Sprintf("第 %d: 次循环\n", i))
runtime.ReadMemStats(&m)
fmt.Printf("%d M\n", m.Alloc/1024/1024)
time.Sleep(time.Second * 7)
}
}
对其他全局对象使用的一些思考
golang里面的map充当全局对象使用的时候, 要时刻提醒自己,这个map在被delete的时候,内存不会被gc的,只会被打tag,需要定时迁移新的map,才能是老的map里面被tag的内存对象被回收。。。
// ClearTimer 清除定时器
func (r *RoomWorkerBase) ClearTimer(key string) {
g.Log().Debugf("ClearTimer Key: %s", key)
timer, ok := r.timers[strings.ToUpper(key)]
if !ok {
return
}
timer.Stop()
delete(r.timers, strings.ToUpper(key)) // TODO delete map 不会释放内存。只会打标记
}
room.Check()
go func() {
defer func(room base.RoomWorkerItf) {
serv.mutex.Lock()
_ = cache.DelRoomWorkerId(rid, wid)
delete(serv.rooms, room.GetWid()) // TODO delete map 只是打标记 不会真正释放内存
serv.mutex.Unlock()
serv.wg.Done()
}(room)
room.Run()
}()
serv.rooms[wid] = room
return room, nil
总结
go tool pprof --alloc_space http://127.0.0.1:6064/debug/pprof/heap 对所有内存对象的监控打印,其中包括被GC的
go tool pprof http://127.0.0.1:6064/debug/pprof/heap 等价与 go tool pprof --inuse_space http://127.0.0.1:6064/debug/pprof/heap 对活跃内存对象打印,不包活会被GC掉的对象
top -pid 1123 //对具体的pid进行top命令监控
ps -ef | grep worker 等价于 ps -aux | grep nginx 根据进程名称查看进程的pid及启动信息
通过端口查出pid,进而查出进程的启动命令等信息
lsof -i :9527 //查看端口的pid
ps -ef | grep 12321 //根据pid 查出进程启动信息
随着应用规模的不断扩大,尤其是在微服务架构下,线上环境中的内存泄漏问题日益严重。为了保证服务的持续稳定,开发和运维团队需要密切配合,建立完善的内存监控和日志分析机制。现代化的监控工具和诊断工具,给我们提供了更高效的排查方式,但也要求我们在实践中不断积累经验和总结。
线上内存泄漏是一种渐进式的隐性问题,往往难以在早期察觉。然而,通过合理的工具和排查思路,我们完全可以在生产环境中高效定位并解决问题。内存管理是开发者不可忽视的一项基础能力,而通过监控和分析,提升系统的稳定性和健壮性,才是最终目标。
“内存泄漏是性能杀手,排查它,正是高效开发的必修课。把内存管理做好,才能让系统走得更远。”