“怎么又出bug了?”
导语|凡是可能出错的事,就一定会出错。
调试/bug 定位/debug
第一步是复现,偶尔才复现的代码是很难排查错误的。如果不好复现但是有 sentry 之类的记录工具也是极好的,sentry 会记录当前栈信息和变量信息,非常有利于排错。
走查代码。使用 pylint 等静态检测工具排除低级错误(你应该把它集成到开发工具里)。
看提交日志。最近代码的修改记录,是否是别人的代码引入了 bug。是否可以回滚到上一个可用部署解决呢?(注意一旦一个新的上线出问题,应该先回滚部署而不是回滚代码)
看日志,各种日志(logging, nginx),看 sentry 异常信息。很多框架或者工具都有 debug 模式,打开 debug 模式可以获取到更多有用信息(但是要注意线上慎用 debug 级别日志)
加日志。如果已有的日志没能排查出来关键信息,可以适当增加 debug 日志记录更充分的数据。比如关键函数的输入和输出,关键rpc调用/数据库查询/第三方库调用/重要数据结构的输入和输出等。
问同事,问源码作者(脸皮要厚),让同事帮忙 review 审查代码。有时候人有思维定势,你自己看不出来的别人可能一眼就看出来了。多学习一下高手解决问题的流程和思路
借助搜索引擎。很多问题 google/stackoverflow/github 上都可以搜到,善用搜索引擎解决问题。
小黄鸭调试法,桌子上放个小黄鸭(小黄鸡儿也行),然后尝试从头到尾给它讲解有问题的代码段,说不定就在你给它代码描述过程中发现了问题。
断点调试。看变量值,控制变量法调试。二分法(分而治之)排查代码位置,快速试错定位。比如一个地方很有隐秘的错误,但是又不好快速确定位置,我们就可以用二分加断点的方式快速定位到具体哪一块出了问题。
使用调试器(命令行or IDE 调试工具)。ipdb/pdb 断点配合 python 一些内置方法比如
print/vars/locals/pprint
等断点调试,使用 curl/chrome 开发者工具/mitmproxy 等调试请求。代码异常可以通过import traceback; traceback.print_exc()
打印出来。日志比对/输入输出对拍。在重构系统的时候,首先保持原有系统和重构之后代码的正确性。可以通过比对日志,比对输入和输出值的方式确保正确
功能对拍。可以用不同的语言、框架等实现同一个功能,看看是否是因为某些框架的 bug 导致,比如一个框架没问题,另一个有问题就可以断定是该框架实现本身有问题。
排除法。不断记录灵感/想法/可能的原因等,做排除法,缩小问题范围,说不定就可以发现 bug 的藏身点。
依赖库bug。一般经过广泛使用的第三方库是可以信赖的,但是公司自己造的轮子(尤其是文档和单测都没有的),还是有可能出 bug 的。有可能是依赖而非自己代码逻辑 bug。
升级后出问题。是否有完善的功能测试和单元测试保证回归没有问题?升级代码修改了哪些部分?降级之后能否复现?
服务超载。重点关注指标 cpu/io/memory/磁盘/log/连接数 是否被打满,是否无法继续正常服务,如果是服务器负载问题也会导致服务失败,不一定是主逻辑代码有问题(当然也有可能是连接池使用不当导致)。
是否是缓存的问题?缓存数据过期了么?缓存是否一致呢?能否清理缓存解决?测试环境禁用一下缓存看看表现如何(笔者之前改完代码一直 debug 没生效结果发现是缓存还在导致一直是旧数据)
是否是配置的问题?配置的时候参数填写的是否正确,有没有去掉多余的无用的空白符?比如笔者遇到过对比配置结果手动填写的配置多了空格导致比对失败
是否是特殊输入问题?偶现的问题可能是由某些特殊的输入参数代码未处理导致的,能否通过日志或者构造某些特殊的输入复现?
服务之间的依赖关系如何?有没有分布式链路追踪,哪一步调用关系出了问题?是否是没有降级,有没有碰到雪崩?服务间有没有循环调用?
监控报警。各种服务指标监控是否有报警?报警是否正常?如果没有及时监控到是否可以增加相关指标的报警?
硬件问题。硬件问题出现较少,但是一旦监控出现单个机器请求异常基本上可以断定是机器硬件问题,需要及时排查和剔除异常机器
重视静态检查/编译器/IDE 开发工具的缺陷提示,尽量连 warning 提示都不要留,及时修复缺陷,保证高质量的代码可以有效减少 bug 产生。
善用工具。比如进程/cpu/内存/io/fd/流量/proc等,可以通过日志/监控/pdb/gdb/strace/pstack/ps/pmap/top/iostat/netstat/tcpdump/prof 等多种工具定位和排查
不要死磕,一个法子不行换一个。死磕可能会耗费太长时间并且容易进入死胡同(思维定势),在一个大型复杂系统中定位 bug 原因是对技术、经验、毅力、灵感、心理素质的很大考验,休息一会甚至睡一觉醒来可能就解决了。
极难排查和复现的 bug 可以无限期搁置,bug 永远修不完的
找到 bug 修复以后增加相应单元测试用例,这样对回归测试非常有利,同时避免重复犯一样的错误。tricky 的地方要加上注释。
修复原因而非现象。你要排查出来真正导致 bug 的原因,而不是仅仅通过魔改代码修复了不合理现象。又比如仅仅依赖重启解决内存泄露等问题,而不去排查真正泄露的原因(当然可能排查起来很艰难)
真的是代码的问题么?还是非代码因素:比如代码是否正确部署上线等(比如之前脑残查一个 bug 无解最后发现是部署系统失败部署到线上压根没成功,还是老代码,根本没起作用)。如果实在没发现代码级别错误,单测也比较完善,可能就要考虑下非代码因素。
配置/环境问题。是否是因为配置而非代码逻辑 bug 导致的,线上/测试/开发环境 的配置是否正确,是否脑子抽了写串了,比如测试环境的配置写到了正式环境(这种看似低级的错误笔者在工作中就遇到过)
建立个人 bug 清单和上线核对清单,避免再次出现犯过的错误。你的每一个错误都应该自己用一个笔记软件或者小本本记录下来,避免再次犯错(小心被扣工资)。上线之前检查日志等级,进程数设置是否正确,建立核对清单,养成好的思维习惯
bug 总结:建立错误检查表(核对清单),哪些可以避免的记录下来,防止以后再犯。(团队的知识财富)。比如笔者在关闭一个 bug 单的时候会注明 bug 产生的原因和修复方式,而不是修复完成之后就不长记性了
流程自动化。凡是可以自动化的就自动化,依赖人的行为反而是最容易出错的。脚本一旦编写通过之后就可以无限次正确使用,远比人为操作可靠。
《软件调试修炼之道》 笔者比较推荐的一本书,告诉你正确的思维方式
《调试九法》
《Python ipdb 调试大法[视频]》 笔者经常在服务器上进行命令行调试,一些技巧
需求理解错误
需求理解不一致。业务开发中很常见的一个问题,产品/开发/测试理解不一致导致实现被当成 bug,一定好沟通好互相阐述确保需求理解一致。
代码错误
拼写错误。不要笑,这个错误其实很常见,推荐打开编辑器的拼写检查,可以消除一些类似问题。
类型错误。在动态语言和弱类型语言当中比较常见的一种错误(动态语言确实更容易出 bug),可以借助类型强转,type hint 工具。
资源没有关闭。打开的文件/IO流/连接等资源一定要关闭,防止资源泄露。go 的 defer 和 python 的 with 最好用上
深浅拷贝问题。不同语言可能又不同的拷贝模型,确定你的参数是深拷贝还是浅拷贝,能否修改,修改了之后是否有副作用。
数组越界错误。注意涉及到数组的时候使用的下标是否会越界。越界了 python 抛出异常,go 直接 panic 掉,并且 go 不支持负数下标
参数校验。一般来自用户的输入都要假设参数可能是错误甚至是恶意参数,后台必须要进行类型、大小、范围、长度、边界、空值等进行检查,防止恶意参数导致服务出问题
参数单位是否匹配。比如 go 需要时间的参数 time.Duration 有没有乘以对应的 time.Second/MilliSecond 等。
参数顺序不对。如果函数参数太多可能导致看走眼顺序写错了,所以强烈建议如果参数太多,封装成对象或者一个结构体传参。
路径错误。编写一些脚本需要处理文件的时候,推荐使用绝对路径比较不容易出错。
空值错误。比如直接赋值一个 go 里边声明的 map 会 panic,你需要先给 map make 一个值,很多 go 新手会重复犯这个错(go slice 却可以直接声明之后 append)
零值和空值。有时候我们根据业务来区分零值(一个类型的初始化值)和空值 (None/nil等),注意处理上的细微区别。
闭包问题。循环里闭包引用的是最后一个循环变量的值,需要注意一下,很多语言都有类似问题,可以通过临时变量或者传参的方式避免
遍历修改列表问题。一边遍历,一边修改可能会使得迭代器失效而出错,最好不要遍历的时候修改列表。
遍历修改元素值问题。这一点 go 和 python 表现不同,go 比如你去循环一个
[]Struct
是无法修改每个元素的,go 会拷贝每一个元素值,需要通过下标或者指针修改影子变量(shadow)。很多语言同名的局部作用域变量会隐藏外部作用域变量,最好不要同名冲突,否则可能不是期望结果。建议使用go vet/go-nyet 之类的静态检查工具检查
数值截断错误。注意强制类型转换是否会发生截断,损失精度,结果是否符合期望。
数值范围:注意前端 javascript(设计缺陷) 无法表示完整的 int64,传给前端需要用 string 替换 int64 (被坑过)
解引用空指针。是否引用了空指针的值导致直接 panic?比如 go 里边直接对一个 nil map 赋值 panic。指针有没有 nil 检查
内存泄露。有没有循环引用?有没有全局变量值一直增长没有释放?有没有多个对象底层引用的其实是同一块内存始终无法释放(比如直接赋值)?
网络请求超时。一切网络client(http/rpc/mysql/redis请求等) 都应该设置合理的超时参数,比如有些 go 的 client 需要显式自己传进去超时参数,否则可能导致 block
连接池打满。连接池应该是服务共享的(单例),而不是每个请求都要去创建连接池导致打满连接池。请检查 client 的连接池和超时参数设置是否合理。
长短连接使用不当。注意有些需要长连接的场景,可以避免频繁建立 tcp 握手的开销。(http keepalive)
接口限制。接口请求参数有没有进行限制,一次请求的数据量是否太大,有没有加上分页参数,日志会不会一次打印太多导致 IO 压力大
请求参数限制。比如一般 rpc 请求会限制每次请求的最大的参数个数,如果一次性请求太多可能需要分批并发请求
debug 模式。注意线上一定要关闭掉 debug 方式防止泄露关键信息。很多框架在 debug 模式下会显示一些关键信息,可能会被黑客利用
序列化协议版本问题。client/server 序列化的方式是否一致?版本是否一致?不同的版本之间有时候可能会有一些微妙的 bug
查询参数非法。查询数据库的时候可能因为一些不合理参数导致数据库慢查询,比如一次查询太多导致慢查询。可以在入口处做一下限制。比如限制limit 大小
查询参数类型不匹配。注意如果传入类型不对,可能导致数据库没法利用索引导致慢查询,注意查询的参数类型和数据库类型匹配
慢查询:没有索引,索引设计不合理可能导致慢查询问题,有没有慢查询监控?
连接池跳涨。除了不当使用连接池之外,如果是启动了大量的服务容器也可能有这个问题,注意限制单服务连接池的大小
连接池过大。连接池数量设置太大效率反而可能降低,应该根据实际压测结果设置一个比较合理的值,并非越大越好
主写从读。很多采用最终一致性模型,但是对于一些对时延敏感的场景要考虑是否会有主从延迟问题
字符集问题。注意如果字符串需要存一些特殊的 emoji 表情符号,需要使用 utf8mb4 字符集。
请求放大。不要在循环语句里边请求数据库或者 redis(除非你明确知道你在干什么?),使用批量请求并限制每次请求个数,防止打挂数据库
SQL注入。尽量不要使用直接拼接 sql 的方式,比较容易出现 sql 注入。使用 orm 或者一些第三方库可以有效减少注入问题
数据加密。敏感数据是否加密存储,不要明文直接存储用户的敏感信息,比如电话、用户密码等,一旦泄露数据十分危险
线程安全。如果不是线程安全的操作(原子操作),应该通过加锁等方式做数据同步。比如 go 里边如果多个 goroutine 并发读写 map 程序会出错(lock/sync.Map)。利用好 race detector。但是有些语言有 GIL 可以保证内部数据结构的一些原子操作,这个时候可以不用加锁,所以要区分不同编程语言决定。
goroutine泄露。确保你的 goroutine 可以完成退出(比如没有死循环,没有channel block住),防止大量未执行结束的 goroutine 堆积。通过上报 go 的 runtime goroutine 数量指标可以发现
死锁问题。锁的粒度对不对?锁有没有正确加锁和释放锁?加锁和释放锁的类型是否匹配(Lock/Unlock, Rlock/Runlock()),次数是否匹配?
依赖版本是否一致。笔者曾经因为开发工具的自动 import 引入了错误的包版本导致一个挺难查的 bug(vendor 和 gopath 下不同的redigo 版本), 要小心因为不同版本导致的一些极其隐蔽的 bug。最好通过包管理工具锁定依赖的第三方库版本
升级服务出问题。升级有时候可以解决一些 bug,但是也可能引入新 bug?能否通过回退到上一个版本解决(比如git checkout 到一个历史提交)?是否详细看过升级日志(release notes),修改了哪些东西?是兼容升级还是不兼容升级?
清理无用依赖。对于不用的依赖也有可能引入问题,不用的依赖最好清理掉,比如
go mod tidy
或者清理掉 pythonrequirements.txt
日志级别错误。线上使用了 debug 级别,可能导致日志打满,如果没有滚动日志可能会导致服务器磁盘打满。一定要注意不同环境日志级别,推荐集中式日志收集系统
日志参数错误。日志语句对应的占位符要和传参的个数一致,类型要匹配,比如本来是数字的使用了
"%s"
而不是"%d"
缺少必要信息。如果是为了 debug 加上的日志一定要有足够的上下文信息、关键参数帮助排查问题,同时也要注意日志不要泄露敏感数据(比如密码等)
日志过大:除了注意日志等级,还要注意是否输出了过大的日志导致磁盘 IO 飙升,适当精简日志量,或者提升线上日志等级只打印异常和ERROR。线上一定要关闭 DEBUG 日志
危险操作记录。对于一些修改数据的危险操作,比如一些后台管理系统等,一定要加上日志记录,方便排查问题
不要忽略任何一个错误/异常。除非你有 100% 的把握可以忽略,否则至少要在发生错误或者异常的地方加上日志,出问题之后错误被吞掉会极难排查。笔者这个地方吃过亏,吞掉了错误导致排查困难
集中收集。一般搭建 sentry(异常、错误收集);ELK(集中式日志收集)来进行集中收集,方便针对异常、日志进行聚合和搜索。否则散布在各个服务器上很难排查问题
配置环境写串。看起来是一个很傻的错误,但是其实还挺常见,注意不同环境配置是否对的上,别把测试的写到正式环境了。启动服务时打印配置看看
服务启动命令是否写错。有些服务依赖命令行启动的时候容易写错参数,建议通过配置文件的形式传进去。
配置字符串是否有多余空白符。笔者也被这个小问题坑过,手动编辑的时候人工加上了空白符导致我比对出错,注意配置参数都要去掉空白符
比对字符串。单元测试的时候注意比对的字符串可能因为多了空格的问题没法严格比对。注意可以去掉空格之后对比,笔者曾经因为不 同字符串就多了一个空白符比对失败查了好久,被坑过。比对字符串特征而不是直接对比字符串
分布式系统问题
分布式锁。分布式服务对于需要数据同步的操作可以使用分布式锁,注意分布式锁的超时问题(本身是否高可用)
时钟倾斜(clock skew)。如果代码强依赖时间戳在不同的服务器上可能因为时钟差距导致问题,可以采用适当取整对齐时钟。有一些第 三方库允许一定的时间差容忍(比如乘以一个误差因子)。https://github.com/dgrijalva/jwt-go/issues/383
时钟同步出错。笔者最近碰到的问题,云服务机器时钟出问题了,导致我一些服务鉴权带上时间戳参数的失败了。
分布式数据库。注意有些分布式数据库插入数据之后不会返回主键。可以用分布式 id 生成器(snowflake算法)指定主键
缓存问题
超高热点 key:对于微博/直播之类的应用,比如明星出轨或者热门直播等,可能有某些热点的 key 集中到单台 redis 上导致压力过大(看一下 redis 热点 key 统计方便排查问题),可以考虑再加一层进程内缓存。比如使用 go-cache 等进程内缓存库。编写代码的时候应该注意到可能发生这种热点 key 的问题(测试环境压测+观察热点 key),应当谨慎使用 redis,充分利用进程缓存/key hash是有效的方案。
redis版本和集群模式。使用云 redis 的时候之前因为使用了 lua 脚本,但是测试环境和线上使用了不同的 redis 集群版本,发现测试 环境测试一直没问题,但是一到线上就不起作用。建议保持线上和测试环境的基础组件版本一致。
系统调用结果缓存。之前发日志获取本机 ip 的时候没有缓存下来,导致大量系统调用,类似结果可以放到缓存或者全局变量
脚本编写问题
先用日志替换写操作。需要跑一些脚本的时候,可能会修改数据库,如果脚本直接修改了数据并且脚本有 bug 可能就会导致数据异常并很难回滚。建议所有的写操作写替换成日志打印出来,确认无误之后再去执行,更加保险。
数据备份。用脚本操作重要数据之前建议先备份一份,防止操作出错无法恢复。
服务构建问题
版本检查。go/python 版本是否一致
环境检查。环境变量,或者构建参数、 go env 等是否一致
后台服务
自动拉起。如果服务因为严重错误退出了(比如 go panic 了,python 未捕获异常进程退出了),能否快速拉起服务?
异地部署。是否已经做到了两地三机房?一个机房挂了之后,服务能否正常继续工作
数据不一致。如果程序在关键流程中退出了,是否会导致数据不一致的问题?有方法修复么?是幂等操作么?比如交易系统定期对账
自动扩容。如果突然请求量上去了,服务能否在短时间之内快速扩容应对压力?
快速回滚部署。如果线上出了问题,能否快速回滚到上一个可用的稳定版本保证服务可以继续稳定执行?回滚是否会有不兼容情况,导致其他依赖你的服务不正常?
拆分部署。对于一些特别核心的接口,可以分开部署。防止其他接口有问题了,造成核心服务不稳定。(一个项目的接口重要性不同)
qps监控。有没有监控服务每个接口的 qps?有没有监控接口的成功失败率?返回码?
响应时间。每个接口请求的响应时间有没有做监控?TP90, TP99 分别是多少?
链路追踪。微服务中各种系统互相调用,有没有用 open-tracing 之类的进行链路追踪?
业务监控。使用 Grafana 之类的监控系统对关键业务数据进行打点监控,防止某些业务异常
失败报警。关键接口、服务挂了,机器负载高了有没有及时发送报警提醒?
异常上报。区分于日志,异常一般是发生了比较严重的错误,业界有比如 sentry 这种集中式异常收集平台来上报异常,一般除了无法 避免的网络问题之外,大部分异常都是需要开发者修复的。
熔断降级
熔断保护。对于核心服务,如果流量短时间暴增,能否监控到并且正常处理。如果下游服务打挂了,能否熔断保护,应当确保调用其他 rpc 服务加上熔断器保护。
柔性降级。柔性可用是在有损服务价值观支持下的方法,重点在于实际上会结合用户使用场景,根据资源消耗,调整产品策略, 设计几个级别不同的用户体验场景,保证尽可能成功返回关键数据,并正常接受请求,绝不轻易倒下。简言之就是保证关键接口兜底策略
压力测试。上线之前有没有预估过最高 qps 然后做过压力测试并且监控各个基础组件和下游服务的压力和稳定性?能否应对突发的流量
混沌测试。如果随机停掉一些依赖服务,你的服务会有问题么?有没有类似混沌测试保证接口没问题?
接口限流。是匀速限流(leaky bucket 漏桶算法)还是可以允许突发流量(token bucket 令牌桶算法)?限流之后是丢弃还是降级(fallback)?
频率限制。对于一些用户相关接口有没有针对用户操作进行频率限制(比如借助 redis 限制操作频率)?如果接口被恶意刷量了如何处理?
服务自查
上线之前请阅读以上内容,详细检查自己的服务是否有缺陷
参考
50 Shades of Go: Traps, Gotchas, and Common Mistakes for New Golang Devs
《开发更高质量的服务》
《Go101 内存内泄漏场景》
初级阶段
熟悉常用的工具,搞清楚自己面对的问题,是真的搞清楚,不怕麻烦。
在搞清楚面对问题的前提下,靠清晰的逻辑思维,大胆假设,多做验证。
到了这个阶段,直觉是非常关键的!尤其是面对疑难Bug的时候。此时每次面对的Bug都不一样,也都不见得有固定的套路和模式。