一个Golang实现的内部API的系统优化与实践
作者 fiisio | 发布于 2016-12-09
Go 内存泄漏垃圾回收系统优化

      上次已经谈了golang程序中CPU占用过多排查和优化的办法,此后也是这个API系统通过了QA的功能测试,基本上符合了需求,但是接着就面临一个严重的问题————在面对上百万的终端数据量时请求失败,原因是该系统依赖的v1的基础查询和缓存模块设计之初照着满足10W终端的量来要求,导致数量级上升后程序的性能严重跟不上,不得不回归重构。但这次遇到了更多更复杂的坑。。。

      此次优化更多的是算法和Go使用技巧的优化,包括简化递归算法的设计,数据结构的精简,缓存以及数据的复用共享,内存暴增的排查和解决,GC与内存分配的优化等等,我将通过几个真实的场景问题来介绍。

  • 1.数据库缓存快速加载优化————API系统开启后需要快速的将100W终端的详细信息以及它们组成的10W分组信息从Pg数据库中查询加载出来,但是速度特别慢100W数据需要大概1分钟以上,10W分组需要二十多分钟才能完全load下来?
  • 2.精简递归算法提高查询效率————通过gid获取该分组的所有子孙分组以及从顶层到该层的分组路径,当查询gid=1时需要60s+,如何优化?并且一旦有一个分组的信息改变可能需要递归所有相关的分组缓存信息,如何做到?
  • 3.golang程序的内存占用不是你看到的内存实际占用————通过gid获取全部的终端信息,请求查询gid=1时返回数据大概32Mb,上百并发时16G内存的服务器一下子占用了其中的12G+,如何降低?

  • 4.channel长度的陷阱

    1.数据库缓存快速加载优化

          100W的数据一次查询下来缓存肯定会出现不可预期的问题,数据量太大,所以我们采用切分策略,每次只取2000条左右,大概要取50次,ok,这没问题。问题出在如何切分以及下次从哪里开始查询,原先代码如下:

      var minId int ShovelSize = 2000 if err := Pg.Get(&minId, "SELECT MIN(id) FROM client"); err != nil { return err } // 得到最小的id lastSyncedID := minId for { var thisQueryResultCount = 0 // 然后查询id>lastSyncedID的数据 rows, err := Pg.Queryx(fmt.Sprintf(`SELECT id,mid,gid FROM client WHERE id >= %d ORDER BY id ASC LIMIT %d; `, lastSyncedID, ShovelSize)) /.../ // 每次lastSyncedID+2000 lastSyncedID += ShovelSize for rows.Next() { thisQueryResultCount++ ... err = rows.StructScan(&c) api.clientMgr.ReplaceInto(&c) // lastSyncedID = c.Id +1 } if thisQueryResultCount == 0 { break } }

          从上面的代码可以看出问题所在,当这100W数据id是连续的,每次取2000条然后每次lastSyncedID加2000没问题,一旦出现断了的id,就会出现重复的查询,而插入缓存的代码还要判断是否目前存在了该信息,存在了还要覆盖或者删除再添加,只要出现一个id中断要重复50次左右,出现100个中断将重复50000次的删除写入操作。所以需要修改两个地方,一个是lastSyncedID = c.Id +1,即随着每次查询更新最后面的id,不再盲目的直接增加2000,这样我们保证了每次查出来的信息都是与之前没有重复的,另一个是将Replace直接用Add替换,因为可以放心的直接插入不用再像之前那样判断很多项是否存在,然后再插入。这样100W的终端数据缓存速度在几秒钟就完成啦。

          影响初始化速度的另一个因素是每次加入一条分组信息要回归所有的子分组和父分组的children信息,更新数据,10W条分组要递归10W次,导致10W数据要20+分钟才能加载完,这些数据本质上是不必要的。下面的递归算法也是影响的一个因素。

    2.精简递归算法提高查询效率

          递归算法是一种直接或者间接调用自身函数或者方法的算法。实质是把问题分解成规模缩小的同类问题的子问题,然后递归调用方法来表示问题的解。递归是一门伟大的艺术,使得程序的正确性更容易确认,而不需要牺牲性能,但这需要我们能将复杂的问题抽象出最简单的数据模型来实现,使用不当不仅效率大大下降,有时候还会出现意外的崩溃。之前刷ACM题的时候就和同学经常说大部分的算法题都可以用二叉树和递归解决,真实场景确实也如此。

          这个问题是这样的,10W分组的数据,每个分组带有分组的id以及父分组的id,还有分组信息的更新时间等一些信息,程序原先的方法是缓存下来所有的分组信息,并一一计算它所有的children的id,一旦有一个分组出现修改要回滚计算所有的children分组和父分组,计算量还是非常大的,试想分组id如果是1,将重新递归计算10W分组信息。。。

          原先的递归算法代码如下:

      groupTbl map[int]*RawGroupinfo type GroupCalcResult struct { Children []int Path []int OutputJson []byte } func getAllchildrenbyid(id int, children []int, recursiveChildrenNum int, maxRecursive int) ([]int, int) { isEnd := 0 recursiveChildrenNum++ if recursiveChildrenNum <= (maxRecursive + 1) { allFirstInfo := gm.groupTbl for childrenkey, childrenvalue := range allFirstInfo { if childrenvalue.Pid == id { children = append(children, childrenkey) children, isEnd = getAllchildrenbyid(childrenkey, children, recursiveChildrenNum, maxRecursive) } } } else { isEnd = 1 } return children, isEnd }

          可以看到这个递归使用上没有什么问题,在数据量不大的情况下问题同样可以解决,但是有一点,就是关注的信息太多,返回值太多,而这些值对于我们问题本身并没有什么必要用处,我们关注的就是id之间的父子关系,并且递归过程还出现了map读写冲突的bug,而这些读写是不必要的,也不是我们问题的关键。最初我以为是递归算法本身的问题,我照着上面的结构撸了个多叉树期望在算法效率上解决这个问题,经过测试我发现关注的信息太多并没有明显的优势。经过仔细分析,其实我们关注的无非就是int型的id以及id之间的上下级关系嘛

          我们抽象下这个数据结构,10W分组的数据,结构如下:

      1:[2,3,4,5,6,7,8,9] 2:[11,12,13,14,15,16,...] 11:[110,111,112...] ....

          首先,我们不能时时刻刻维护这一个数组来保存着子子孙孙,只需要关注自己的儿子,不然怎么管的过来,还有就是对雨没用的每个分组的附加信息在我们的关系结构中根本不需要,试想我们知道一个人和自己的关系,还需要带上他穿多少衣服,今天吃了多少吗?两个数据结构就可以维护我们的分组信息,10W的分组详细信息:map[gid]GidInfo和分组的父子信息map[id][]int

      // 某个组id的children查询 func childrenSearch(groupFamily map[int][]int, data []int) []int { res := make([]int, 0) for _, v := range data { res = append(res, v) res = append(res, childrenSearch(groupFamily, groupFamily[v])...) } return res }

          10W分组的id查询效率从之前的60s+变成了0.1s

    3.golang程序的内存占用不是你看到的内存实际占用

          一个请求的返回数据32M,当同时有上百个这样的请求发起时内存暴增12G,当时的我是崩溃的,一遍遍的过代码,看pprof的内存数据,始终没有发现内存泄漏的问题,后来才发现,这个问题其实不是我看到的问题或者说问题是为何内存长时间占用不释放,并发请求过来内存暴增其实是正常的,因为毕竟一个请求就32M,好几百个请求同时过来不暴增才怪.

          这里其实是Golang内存分配认识的一个坑:为了保证程序内内存的连续,Golang会申请一大块内存(据说只写一个hello, world可能都会占用100M+内存)。当用户的程序申请的内存大于之前预申请的内存时,runtime会进行一次GC,并且将GC的阈值翻倍。也就是说,之前是超过4M时进行GC,那么下一次GC就是超过8M才进行。我们内存暴增的原因,就是访问量过大导致内存申请,并且GC阈值也一下子变大,回收频率变低,同时GC还预测以后可能还会需要这块大内存为了优化内存分配就一直不释放给系统啦。而且Golang采用了一种拖延症策略,即使是被释放的内存,runtime也不会立刻把内存还给系统,而是在自己不需要并且系统需要的情况下才回还给系统。这就导致了内存降不下来,一种内存泄漏的假象。所以难怪我一直找不到内存泄漏原因

          找到原因之后,解决方案是定时手动还给系统内存,debug.FreeOSMemory(),不过这个请慎用,最好不用,最好的解决方案是:

  • 1.尽量减少对象创建
  • 2.尽量做到变量复用,很多采用共用buffer来减少内存分配
  • 3.如果局部变量过多,可以把这些变量放到一个大结构体里面,这样扫描的时候可以只扫描一个变量,回收掉它包含的很多内存

    4.channel长度的陷阱

          之前写C/C++时经常坚守的一个规则是在使用数组时,如果要遍历要首先获取到数组的长度,然后再去遍历它,而不是直接在for循环中求长度,因为一旦数组有变化会导致不可预期的bug,但通常直接写在循环中是可以的,所以很多人会习惯这个写法。一般的程序还好,但是在golang中最好不要这样,因为在并发中这样写非常容易出错,例如:

      if subChain, ok := psgrRegister[strings.Join(psgr, `-`)]; ok { // 此处应该注意不能直接在循环里面使用len(),因为一旦close一个chan这个数字就变了,会导致严重bug BROADCASTLOOP: for i := 0; i < len(subChain); i++ { select { case waiter := <-subChain: waiter <- msg close(waiter) // 一旦close chan的长度会减少1 default: break BROADCASTLOOP } } close(subChain) }

          可以看到每当close一个chan原先的chan数组元素个数就减少了1,所以在并发请求过来后发现最后的几个请求结果是null,就是因为在这里提前结束了。

    总结

          从系统优化的这些问题可以看出:

  • 1.在一个系统初始化的时候要尽量以最快的速度完成初始化,例如缓存和数据准备,不要将初始化和业务逻辑混在一起
  • 2.系统架构设计最初要以高规格要求或者说高于生产环境要求,例如数据量和并发请求量的标准,否则后期会非常麻烦,会让你有种重写的冲动。
  • 3.真实的业务场景需要的算法没有我们想象的那么复杂,大多都可以用最基本的递归,排序,搜索算法解决,关键是在于数据结构的设计和数据模型的抽象
  • 4.任何一门语言了解语言的底层很重要,不仅能让你出高性能的代码,在遇到问题时也能快速想到原因所在
  • 5.面对一个项目首先要了解需要实现的业务或者功能,在写代码的时候要关注的是数据和算法逻辑,不能一味的写业务代码不可自拔
  • 6.坚持最基本的代码规范会让自己避开很多坑