当前位置: 首页 > Linux

踩坑#2:Go服务被锁

时间:2023-04-06 07:09:35 Linux

然后挖坟,说说我去年踩的另一个坑。前方低能量那是去年七月的一天。我正在工位搬砖,透过落地玻璃,五道口的夕阳照耀着宇宙的中心,突然听到一阵骚动。D同学反映交通严重异常。点击告警组,一连串的异常就在眼前:【规则】:“流量波动过大(严重)”【告警上下文】:变化:-70.38%值班人员:D(不是我)告警方式:电话&LarkalarmURL:Alarmdetailspage然后点击alarmdetailspage可以看到:EOD解释:在字节跳动中,我们有一个基于OpenTSDB的metrics平台(时间序列数据库),用于收集和查询各种监控指标。图中某流量源的请求QPS在短时间内从7k下降到2k,触发了告警规则。除了流量来源,我们在这个指标中还有机房、服务器IP等其他标签;通过这些标签,我们发现所有流量源的QPS都在直线下降,所有机房的QPS都在直线下降。很多机器的QPS已经降到0了,但是有的机器还在正常接待客户。对于异常的机器,通过监控可以看出它们的CPU占用率急剧下降到一个很低的值(大约2.5%):所以这个线索可以让我们把问题缩小到某台机器上。进一步排查如下:ssh可以正常登录,看来操作系统没有问题。通过lsof可以看到进程还在监听服务端口,但是没有响应请求。top看到进程排在第一位,占99.8%cpu,有点奇怪注:这是40核的物理机,99.8%cpuintop表示进程占1核,与前面的CPU监控呼应.PIDUSERRES%CPUCOMMAND316user96849799.8./service1root37960.0虽然systemd还没有定位到问题,但至少已经将问题缩小到了进程层面。既然是go程序的问题,当然要搬出神器pprof,可惜很快就会失败——因为这个进程还没有响应任何请求。此时,距离减压时出现故障已经过去了15分钟。对于广告业务,请求无法正常处理,对应真金白银的损失。但这种情况以前从未发生过,病根一时半会找不到,大家压力都很大。在这种情况下,有经验的车手都不会忘记箱底的大招——重启大法。由于线上大面积机器出现异常,我们随机找了一台机器重新启动流程,很快,这台机器开始正常接待客户。于是我们留下几台出现异常的机器(现场预留备查),将其从服务注册中移除,其他出现异常的机器全部重启,暂时恢复业务。但是病根还没有治好,问题随时可能复发,还得继续。前面说过,在top中,D项目的进程CPU使用率为100%,占满了一个CPU。这是一个奇怪的现象,说明它不是简单地陷入了死锁,而是在重复执行一些任务,也就是说如果我们知道它在运行什么,就可能找到原因。这时候老司机W同学献上了linux的perf命令$sudoperftop幸福来的太突然了,连函数名都给出来了,送命题送子题[doge]看了一眼代码中,这个GetXXX是一个二分查找函数,根据输入的价格,查询对应价格区间的相关配置,如下所示:typeItemstruct{leftintrightintvalueint}varconfig=[]*项目{{0,10,1},{10,20,2},{20,100,8},{100,1<<31,10},}funcGetXXX(priceint)int{开始,结束:=0,len(config)mid:=(start+end)/2formid>=0&&mid=config[mid].right{start=mid+1}else{returnconfig[mid].value}mid=(start+end)/2}return-1}据说这么简单的代码已经在网上跑了至少几个月。应该没有错误。按照胡氏柯南的教导,排除了所有的不可能,只有一个道理,那就是:投入的价格是负数。负价?这让我想起了中国银行的某款理财产品……当然,这只是一个推论。胡适在日记中反省自己,告诉我们要大胆假设,认真求证。其实锤打也很简单。查了上游数据库,发现确实有负价。那么上游系统就有bug了。通过日志记录发现,价格确实在17点44分被改成了负数。检查代码,确认WebUI和对应的后台代码有合法性验证,但是缺少提供的API,最终导致了事故的发生。既然找到了根本原因,那么修复就很容易了:修复API代码,增加合法性检查,修复数据库无效数据,在GetXXX(防御性编程)中增加无效价格检测,填坑-灌装业务。坑还没到:为什么死循环会导致进程卡死?按照常识调度推理,一个线程(或goroutine)不应该阻塞其他线程的执行。比如运行下面的代码,可以看到进程并没有卡住,第一个for循环确实会不断的输出i的值。variint64=0funcmain(){gofunc(){for{fmt.Println(atomic.LoadInt64(&i))time.Sleep(time.Millisecond*500)}}()for{atomic.AddInt64(&i,1)}}由此可见,“卡住”的原因并没有这么简单。这么简单的代码无法重现之前的问题。需要将无限循环放在复杂的场景中才能重现。比如在前面提到的D工程的代码中加入,简单粗暴,直接见效。通过添加GODEBUG环境变量:$GODEBUG="schedtrace=2000,scheddetail=1"./service,可以看到有一条线索:gcwaiting=1SCHED2006ms:gomaxprocs=64idleprocs=0threads=8spinningthreads=0idlethreads=5runqueue=0gcwaiting=1nmidlelocked=0stopwait=1sysmonwait=0这样就形成了一个闭环:gc需要STW,但是这个goroutine是死循环,不能被打断,而其他goroutine已经被打断等待gc去完成,最终导致了这种情况。实锤可以在上述代码中手动触发gc:现象与网上一模一样。variint64=0funcmain(){gofunc(){for{fmt.Println(atomic.LoadInt64(&i))time.Sleep(time.Millisecond*500)runtime.GC()//手动触发GC}}()for{atomic.AddInt64(&i,1)}}但这还没有结束——上述现象似乎并不符合go所宣称的“抢占式调度”!实际上,Go实现的是一个CooperatingScheduler。一般来说,协同调度器需要线程(准确的说是coroutines)主动让出CPU来实现调度(runtime.Gosched(),对应python/java中的yield),但是Go的runtime会在扩栈的同时判断是否空间,检测G的抢占标志,从而实现一个看起来像抢占调度的调度器。还有一个小问题——上面代码中不是调用了atomic.AddInt64吗?这个很简单,通过gotoolcompile-Smain.go可以看到,AddInt64已经内联了;但只需在此处添加fmt.Println即可中断工作(试试看?)。(内联的AddInt64)最后,如果你发现之前的代码无法重现——那你一定是用的是go1.14+。这个版本实现了基于SIGURG信号的抢占式调度,不用再害怕死循环/密集计算killgc(但代价是死循环带来的性能下降更难排错),还有正在做的同学对此感兴趣的推荐学习《Go语言原本:6.7 协作与抢占》。简单总结一下:二分查找可能会造成死循环;在go1.13及以下,死循环/密集计算会导致调度问题;特别是在gc的情况下,进程可能会被锁住;在Linux下,可以使用perftop做profiling;学了这么多,感觉没用?来穿山甲吧,百万QPS,各种酸题等着你。~发货链接~发货研发工程师(上海)https://job.toutiao.com/s/J8D...高级广告研发工程师(北京)https://job.toutiao.com/s/J8D...欢迎参考链接如何定位golang进程挂死的bug关于Go1.14,你必须知道的性能提升和新特性Go语言原著:6.7协作与抢占