一、背景
xx服务内存持续上涨。内存占用10%以内,在QPS无明显变化的前提下,内存占用50%左右。
dump了一下heap内存,发现主要是 InitUserCacheRefresh 任务代码占用
正常来说,dao层查完数据库之后,对象应该会释放,最终被gc回收,但这里 InitUserCacheRefresh 代码里的对象长期持有引用,占用内存达400M+,感觉发生了内存泄露,所以排查下。
核心代码逻辑
该代码主要用于权限服务刷新用户权限缓存,在服务启动时会初始化50个协程通过 chan 等待用户权限刷新任务,刷新任务由 RefreshAllPermission/RefreshUserPermission 接口触发
// 使用伪码减少逻辑理解成本
func InitUserCacheRefresh() {
ctx := context.Background()
for i := 0; i < 50; i++ {
go func() {
// ...
updateUserCache(ctx)
}()
}
}
func updateUserCache(ctx context.Context) {
db := client.GetDB(ctx)
for {
task := <-qpsChan
a := dao.SelectXX(ctx, db, xxx)
// TODO
b := dao.SelectXXX(ctx, db, xxx)
// TODO
redis.GetClient().Set(xxx, xxx)
}
}
二、排查
一开始怀疑是 updateUserCache 方法内 db 变量在查询完结果后,有可能还持有结果引用,而 for 循环导致 db 变量一直无法回收,引用无法释放,导致内存泄露。
尝试本地复现:按照线上的逻辑写了个类似的代码尝试复现,但没有复现出来
给gorm提交oncall,咨询了下相关用法会不会导致数据引用无法释放,但也没结论
于是尝试在xx服务测试环境复现,部署服务后尝试调用几次 RefreshAllPermission 后dump内存,发现和线上基本一致,也持有 gorm 的很多对象(事情已经成功了百分之八十)。直接找到占用内存最大的对象 PreparedStmtDB,查看查询走到的逻辑(图1),prepare方法会优先在db.Stmts这个map中看存不存在对应query(SQL),如果存在就直接返回,如果不存在会创建一个新的放到这个map中。
在触发了几次 RefreshAllPermission 后,直接在图1断点处打上断点,发现db.Stmts有大量的SQL缓存
查看 PreparedStmtDB.Stmts 字段是一个 map,缓存SQL和对应的Stmt
type PreparedStmtDB struct {
Stmts map[string]Stmt
PreparedSQL []string
Mux *sync.RWMutex
ConnPool
}
看图2 感觉 PreparedStmtDB.Stmts 对象无限增长,没有清理策略,看 prepare_stmt.go 代码
只有在 close 的时候,以及err != nil的时候会清理
func (db *PreparedStmtDB) Close() {
db.Mux.Lock()
defer db.Mux.Unlock()
for _, query := range db.PreparedSQL {
if stmt, ok := db.Stmts[query]; ok {
delete(db.Stmts, query)
go stmt.Close()
}
}
}
func (db *PreparedStmtDB) QueryContext(ctx context.Context, query string, args ...interface{}) (rows *sql.Rows, err error) {
stmt, err := db.prepare(ctx, db.ConnPool, false, query)
if err == nil {
rows, err = stmt.QueryContext(ctx, args...)
if err != nil {
db.Mux.Lock()
defer db.Mux.Unlock()
go stmt.Close()
delete(db.Stmts, query)
}
}
return rows, err
}
所以感觉已经真相大白,只有在DB配置PrepareStmt为true情况会缓存SQL,尝试把DB的这个置为false,再次尝试,对应对象已经没了,调用多次后内存没有明显变化。详细的解决方案可以看下文结论中的解决方案
三、结论
根因
- xx服务DB配置开启了 PrepareStmt,也就是 PrepareStmt 配置为 true,gorm会缓存查询的SQL
- dao层使用的SQL没有使用预占符,而是通过 fmt.Sprintf 拼接查询,SQL中某个id或其他查询条件不一样就会导致gorm生成的SQL也不一样,gorm会将这些SQL都缓存下来,且没有容量上线和清理机制(使用map缓存),导致占用了大量内存。
解决方案
方式1
gorm 修复,缓存SQL的map改成LRU,设置容量,达到容量值时淘汰缓存的SQL。
方式2
xx服务更改DB配置,关闭PrepareStmt模式,将 PrepareStmt 配置改为 false。但PrepareStmt模式可以大幅提高SQL查询性能,建议在单独sql处使用。
方式3
xx服务的查询,动态SQL改成预占符
// 建议
Where(fmt.Sprintf("%v.role_status = ?", roleEntityTableName), 1)
// 不建议
Where(fmt.Sprintf("%v.role_status = %d", roleEntityTableName, 1))
部署服务,再看断点处数据,缓存的SQL只有预占符的SQL,没有带id参数的,只有五条,不会占用大量内存
倾向选择方式3,也建议SQL使用预占符,而不是通过 fmt.Sprintf 拼接。另外也给gorm提交,反馈后续会修复,将缓存SQL的map改为LRU缓存