注册

十年码农内功:经历篇


分享工作中重要的经历,可以当小说来看



一、伪内存泄漏排查


1.1 背景


我们原来有一个刚用 C++ 写的业务服务,迟迟不敢上线,原因是内存泄漏问题一直解决不了。现象是,服务上线后,内存一直慢慢每隔几秒上涨4/8KB,直到服务下线。


我刚入职不久,领导让我来查这个问题,非常具有挑战性,也是领导对我的考察!


1.2 分析


心路历程1:工具分析


使用 Valgrind 跑 N 遍服务,结果中都没有发现内存泄漏,但是有很多没有被释放的内存和很多疑似内存泄漏。实在没有发现线索。


心路历程2:逐个模块排查


工具分析失败,那就挨个模块翻看代码,并且逐个模块写demo验证该模块是否有泄漏(挂 Valgrind),很遗憾,最后还是没有找到内存泄漏。


心路历程3:不抛弃不放弃


这个时候两周快过去了,领导说:“找不到内存泄漏那就先去干别的任务吧”,感觉到一丝凉意,我说:“再给我点时间,快找到了”。这样顶着巨大压力加班加点的跑Valgrind,拿多次数据结果进行对比,第一份跑 10 分钟,第二份跑 20 分钟,看看有哪些差异或异常,寻找蛛丝马迹。遗憾的是还是没有发现内存泄漏在哪。


功夫不负有心人,看了 N 份结果后,对一个队列产生了疑问,它为啥这么大,队列长度 1000 万,直觉告诉我,它不正常。


去代码中找这个队列,确实在初始化的时候设置了 1000 万长度,这个长度太大了。


1.3 定位


进队列需要把虚拟地址映射到物理地址,物理内存就会增加,但是出队列物理内存不会立刻回收,而是保留给程序一段时间(当系统内存紧张时会主动回收),目的是让程序再次使用之前的虚拟地址更快,不需要再次申请物理内存映射了,直接使用刚才要释放的物理内存即可。


当服务启动时,程序在这 1000 万队列上一直不停的进/出队列,有点像貔貅,光吃不拉,物理内存自然会一直涨,直到貔貅跑到了队尾,物理内存才会达到顶峰,开始处在一个平衡点。


图1 中,红色代表程序占用的物理内存,绿色为虚拟内存。



图1


然而每次上线还没等 到达平衡点前就下线了,担心服务内存一直涨,担心出事故就停服务了。解决办法就是把队列长度调小,最后调到了 2 万,再上线,貔貅很快跑到了队尾,达到了平衡点,内存就不再增涨。


其实,本来就没有内存泄漏,这就是伪内存泄漏。


二、周期性事故处理


2.1 背景


我们有一个业务,2019 年到 2020 年间发生四次(1025、0322、0511 和 0629)大流量事故,事故时网络流量理论峰值 3000 Gbps,导致网络运营商封禁入口 IP,造成几百万元经济损失,均没有找到具体原因,一开始怀疑是服务器受到网络攻击。


后来随着事故发生次数增加,发现事故发生时间具有规律性,越发感觉不像是被攻击,而是业务服务本身的流量瞬间增多导致。服务指标都是事故造成的结果,很难倒推出事故原因。


2.2 猜想(大胆假设)


2.2.1 发现事故大概每50天发生一次


清晰记得 2020 年 7 月 15 日那天巡检服务时,我把 snmp.xxx.InErrors 指标拉到一年的跨度,如图2 发现多个尖刺的间距似乎相等,然后我就看了下各个尖刺时间节点,记录下来,并且具体计算出各个尖刺间的间隔记录在下面表格中。着实吓了一跳,大概是 50 天一个周期。并且预测了 8月18日 可能还有一次事故。



图2 服务指标


事故时间相隔天数
2019.09.05-
2019.10.2550天
2019.12.1450天
2020.02.0149天
2020.03.2250天
2020.05.1150天
2020.06.2949天
2020.08.18预计

2.2.2 联想50天与uint溢出有关


7 月 15 日下班的路上,我在想 3600(一个小时的秒数),86400(一天的秒数),50 天,5 x 8 等于 40,感觉好像和 42 亿有关系,那就是 uint(2^32),就往上面靠,怎么才能等于 42 亿,86400 x 50 x 1000 是 40 多亿,这不巧了嘛!拿出手机算了三个数:


2^32                  = 4294967296 
3600 * 24 * 49 * 1000 = 4233600000
3600 * 24 * 50 * 1000 = 4320000000

好巧,2^32 在后面的两个结果之间,4294967296 就是 49 天 16 小时多些,验证了大概每 50 天发生一次事故的猜想。



图3 联想过程


2.3 定位(小心求证)


2.3.1 翻看代码中与时间相关的函数


果然找到一个函数有问题,下面的代码,在 64 位系统上没有问题,但是在 32 位系统上会发生溢出截断,导致返回的时间是跳变的,不连续。图4 是该函数随时间输出的折线图,理想情况下是一条向上的蓝色直线,但是在 32 位系统上,结果却是跳变的红线。


uint64_t now_ms() {
struct timeval t;
gettimeofday(&t, NULL);
return t.tv_sec * 1000 + t.tv_usec / 1000;
}


图4 函数输出


这里解释一下,问题出在了 t.tv_sec * 1000,在 32 位系统上会发生溢出,高于 32 位的部分被截断,数据丢失。不幸的是我们的客户端有一部分是 32 位系统的。


2.3.2 找到出问题的逻辑


继续追踪使用上面函数的逻辑,发现一处问题,客户端和服务端的长链接需要发Ping保活,下一次发Ping时间等于上一次发Ping时间加上 30 秒,代码如下:


next_ping = now_ms() + 30000;

客户端主循环会不断判断当前时间是否大于 next_ping,当大于时发 Ping 保活,代码如下:


if (now_ms() > next_ping) {
send_ping();
next_ping = now_ms() + 30000;
}

那怎么就出现大流量打到服务器呢?举个例子,如图3,假如当前时间是 6月29日 20:14:00(20:14:26 时 now_ms 函数返回 0),now_ms 函数的返回值超级大。


那么 next_ping 等于 now_ms() 加上 30000(30s),结果会发生 uint64 溢出,反而变的很小,这就导致在接下来的 26 秒内,now_ms函数返回值一直大于 next_ping,就会不停发 Ping 包,产生了大量流量到服务端。


2.3.3 客户端实际验证


找到一个有问题的客户端设备,把它本地时间拨回 6月29日 20:13:00,让其自然跨过 20:14:26,发现客户端本地 log 中有大量发送 Ping 包日志,8 秒内发送 2 万多个包。证实事故原因就是这个函数造成的。解决办法是对 now_ms 函数做如下修改:


uint64_t now_ms() {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return uint64_t(t.tv_sec) * 1000 + t.tv_nsec / 1000 / 1000;
}

2.3.4 精准预测后面事故时间点


因为客户端发版周期比较长,需要做好下次事故预案,及时处理事故,所以预测了后面多次事故。


时间戳(ms)16进制北京时间备注
15719580303360x16E000000002019/10/25 07:00:30历史事故时间
15762529976320x16F000000002019/12/14 00:03:17不确定
15805479649280x170000000002020/02/01 17:06:04不确定
15848429322240x171000000002020/03/22 10:08:52历史事故时间
15891378995200x172000000002020/05/11 03:11:39历史事故时间
15934328668160x173000000002020/06/29 20:14:26历史事故时间
15977278341120x174000000002020/08/18 13:17:14精准预测事故发生
16020228014080x175000000002020/10/07 06:20:01精准预测事故发生
16063177687040x176000000002020/11/25 23:22:48精准预测事故发生

2.4 总结


该事故的难点在于大部分服务端的指标都是事故导致的结果,并且大流量还没有到业务服务,就被网络运营商封禁了 IP;并且事故周期跨度大,50 天发生一次,发现规律比较困难。


发现规律是第一步,重点是能把 50 天和 uint32 的最大值联系起来,这一步是解决该问题的灵魂。



  • 大胆假设:客户端和服务端的代码中与时间相关的函数有问题;
  • 小心求证:找到有问题的函数,别写代码验证,最后通过复现定位问题;

经过不屑努力从没有头绪到逐渐缩小排查范围,最后定位和解决问题。

作者:科英
来源:juejin.cn/post/7252159509837119546

0 个评论

要回复文章请先登录注册