gorm.PrepareStmt模式使用不当问题查询

一、背景

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中。

gorm.PrepareStmt模式使用不当问题查询_第1张图片

在触发了几次 RefreshAllPermission 后,直接在图1断点处打上断点,发现db.Stmts有大量的SQL缓存
gorm.PrepareStmt模式使用不当问题查询_第2张图片

查看 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,再次尝试,对应对象已经没了,调用多次后内存没有明显变化。详细的解决方案可以看下文结论中的解决方案

三、结论

根因

  1. xx服务DB配置开启了 PrepareStmt,也就是 PrepareStmt 配置为 true,gorm会缓存查询的SQL
  2. 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缓存

你可能感兴趣的:(数据结构,golang,mysql,数据库)