Debug 调试技巧

调试/bug 定位/debug

调试也是个很重要的问题,不可能保证代码没bug,要命的是有时候写代码完成功能的时间还没调试的时间多,编码不要过度求快,逻辑正确更重要。 复现是排错的第一步,之后通过各种方式确定原因(访问日志、邮件报的异常记录)等,通过走查代码、断点调试(二分法等)确定错误位置,确定好错误原因了就好改了。修复后最好反思下问题的原因、类型等,哪些地方可以改进,争取下次不犯一样的错,慢慢减少错误才能越来越高效。

尽量写出对自己也对其他人负责的代码,上边费了牛劲都是在阐述这个显而易见但是没多少人严格遵守的东西。用动态语言写大型项目维护起来要稍麻烦, 很多新手写代码不注重可维护性,甚至自己写的代码回头自己看都一脸懵逼,问了一句这代码TM是干啥的? 一开始的负责会为以后协作和维护带来极大便利(当然你想干两天就走让其他人擦屁股就当我没说)。 最后,很多东西我也在摸索,上面的玩意你就当小白的踩坑记录,随着理解和经验的加深我会不定期更新本篇内容。另外我发现网上大部分是教程性的东西,对于python相关的工程性的东西很少,我很疑惑难道大部分公司的python项目都写得相当规范?没人吐槽?反正我是踩过坑,希望看到过本章的人能把python代码质量重视起来。

如何定位和修复 bug:复现->定位->修复->验证->复盘。大胆假设,小心求证;思路不通,换个角度。定位需要找到 bug 出现时候的上下文信息,可以用 log,sentry,kibana 日志系统等查看。确认之后通过走查代码、断点调试等方式寻找代码逻辑错误。

  • 第一步是复现,偶尔才复现的代码是很难排查错误的。如果不好复现但是有 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。
  • 升级后出问题。是否有完善的功能测试和单元测试保证回归没有问题?升级代码修改了哪些部分?降级之后能否复现?
  • 服务超载。重点关注指标 cpu/io/memory/磁盘/log/连接数 是否被打满,是否无法继续正常服务,如果是服务器负载问题也会导致服务失败,不一定是主逻辑代码有问题(当然也有可能是连接池使用不当导致)。
  • 是否是缓存的问题?缓存数据过期了么?缓存是否一致呢?能否清理缓存解决?测试环境禁用一下缓存看看表现如何(笔者之前改完代码一直 debug 没生效结果发现是缓存还在导致一直是旧数据)
  • 是否是配置的问题?配置的时候参数填写的是否正确,有没有去掉多余的无用的空白符?比如笔者遇到过对比配置结果手动填写的配置多了空格导致比对失败
  • 服务之间的依赖关系如何?有没有分布式链路追踪,哪一步调用关系出了问题?是否是没有降级,有没有碰到雪崩?服务间有没有循环调用?
  • 监控报警。各种服务指标监控是否有报警?报警是否正常?如果没有及时监控到是否可以增加相关指标的报警?

发现并且修复问题之后,我们需要通过之前的 bug 来涨涨记性,如何避免类似的问题再犯,养成良好的编码和思维习惯。

  • 重视静态检查/编译器/IDE 开发工具的缺陷提示,尽量连 warning 提示都不要留,及时修复缺陷,保证高质量的代码可以有效减少 bug 产生。
  • 不要死磕,一个法子不行换一个。死磕可能会耗费太长时间并且容易进入死胡同(思维定势),在一个大型复杂系统中定位 bug 原因是对技术、经验、毅力、灵感、心理素质的很大考验,休息一会甚至睡一觉醒来可能就解决了。
  • 极难排查和复现的 bug 可以无限期搁置,bug 永远修不完的
  • 找到 bug 修复以后增加相应单元测试用例,这样对回归测试非常有利,同时避免重复犯一样的错误。tricky 的地方要加上注释。
  • 修复原因而非现象。你要排查出来真正导致 bug 的原因,而不是仅仅通过魔改代码修复了不合理现象。又比如仅仅依赖重启解决内存泄露等问题,而不去排查真正泄露的原因(当然可能排查起来很艰难)
  • 真的是代码的问题么?还是非代码因素:比如代码是否正确部署上线等(比如之前脑残查一个 bug 无解最后发现是部署系统失败部署到线上压根没成功,还是老代码,根本没起作用)。如果实在没发现代码级别错误,单测也比较完善,可能就要考虑下非代码因素。
  • 配置/环境问题。是否是因为配置而非代码逻辑 bug 导致的,线上/测试/开发环境 的配置是否正确,是否脑子抽了写串了,比如测试环境的配置写到了正式环境(这种看似低级的错误笔者在工作中就遇到过)
  • 建立个人 bug 清单和上线核对清单,避免再次出现犯过的错误。你的每一个错误都应该自己用一个笔记软件或者小本本记录下来,避免再次犯错(小心被扣工资)。上线之前检查日志等级,进程数设置是否正确,建立核对清单,养成好的思维习惯
  • bug 总结:建立错误检查表(核对清单),哪些可以避免的记录下来,防止以后再犯。(团队的知识财富)。比如笔者在关闭一个 bug 单的时候会注明 bug 产生的原因和修复方式,而不是修复完成之后就不长记性了
  • 流程自动化。凡是可以自动化的就自动化,依赖人的行为反而是最容易出错的。脚本一旦编写通过之后就可以无限次正确使用,远比人为操作可靠。

大多数 bug 都可以通过复杂度控制、设计复审、代码审查、代码静态分析、单测/功能测试等找出来,我们可以综合利用以上手段尽量减少代码缺陷,大幅减少给代码擦屁股的时间。

常见的 bug 类型

打算记录一下自己犯过和见过别人犯过的一些常见 bug 类型,尽量避免重复犯错,笔者会长期不定期更新这个错误列表,不断吸取自己 和别人的经验。笔者这里也强烈建议你自己整理一个文件,专门用来记录你曾经犯下的错误并引以为戒,争取不要重复之前的 bug。

需求理解错误:

  • 需求理解不一致。业务开发中很常见的一个问题,产品/开发/测试理解不一致导致实现被当成 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 会拷贝每一个元素值,需要通过下标或者指针修改
  • 影子变量。很多语言同名的局部作用域变量会隐藏外部作用域变量,最好不要命名重复,否则可能不是期望结果

网络问题

  • 请求超时。网络请求的 client(http/rpc) 是否有设置超时,比如有些 go 的 client 需要显式自己传进去超时参数,否则可能导致 block
  • 连接池打满。连接池应该是服务共享的(单例),而不是每个请求都要去创建连接池导致打满连接池。
  • 长短连接。注意有些需要长连接的场景,可以避免频繁建立 tcp 握手的开销。(http keepalive)

RPC/Web 框架

  • 请求参数限制。比如一般 rpc 请求会限制每次请求的最大的参数个数,如果一次性请求太多可能需要分批并发请求
  • debug 模式。注意线上一定要关闭掉 debug 方式防止泄露关键信息。很多框架在 debug 模式下会显示一些关键信息,可能会被利用
  • 序列化协议版本问题。client/server 序列化的方式是否一致?

数据库问题

  • 查询参数非法。查询数据库的时候可能因为一些不合理参数导致数据库慢查询,比如一次查询太多导致慢查询。可以在入口处做一下限制。比如限制limit 大小
  • 查询参数类型不匹配。注意如果传入类型不对,可能导致数据库没法利用索引导致慢查询,注意查询的参数类型和数据库类型匹配
  • 连接池跳涨。除了不当使用连接池之外,如果是启动了大量的服务容器也可能有这个问题,注意限制单服务连接池的大小
  • 连接池过大。连接池数量设置太大效率反而可能降低,应该根据实际压测结果设置一个比较合理的值,并非越大越好
  • 主写从读。很多采用最终一致性模型,但是对于一些对时延敏感的场景要考虑是否会有主从延迟问题

并发问题

  • 线程安全。如果不是线程安全的操作(原子操作),应该通过加锁等方式做数据同步。比如 go 里边如果多个 goroutine 并发读写 map 程序会出错(lock/sync.Map)。利用好 race detector。 但是有些语言有 GIL 可以保证内部数据结构的一些原子操作,这个时候可以不用加锁,所以要区分不同编程语言决定。
  • goroutine泄露。确保你的 goroutine 可以完成退出,防止大量未执行结束的 goroutine 堆积

依赖库问题

  • 依赖版本是否一致。笔者曾经因为开发工具的自动 import 引入了错误的包版本导致一个挺难查的 bug(vendor 和 gopath 下不同的redigo 版本), 要小心因为不同版本导致的一些隐蔽的难查的 bug。

日志错误

  • 日志级别错误。线上使用了 debug 级别,可能导致日志打满,如果没有滚动日志可能会导致服务器磁盘打满。一定要注意不同环境日志级别,推荐集中式日志收集系统
  • 日志参数错误。日志语句对应的占位符要和传参的个数一致,类型要匹配,比如本来是数字的使用了 "%s" 而不是 "%d"
  • 缺少必要信息。如果是为了 debug 加上的日志一定要有足够的上下文信息帮助排查问题,同时也要注意日志不要泄露敏感数据(密码等)

错误/异常处理

  • 不要忽略任何一个错误/异常。除非你有 100% 的把握可以忽略,否则至少要在发生错误或者异常的地方加上日志,否则出问题之后错误被吞掉极难排查。笔者这个地方吃过亏,吞掉了错误导致排查困难

配置错误

  • 配置环境写错。看起来是一个很傻的错误,但是其实还挺常见,注意不同环境配置是否对的上,别把测试的写到正式环境了。启动服务时打印配置看看
  • 服务启动命令是否写错。有些服务依赖命令行启动的时候容易写错参数,建议通过配置文件的形式传进去。
  • 配置字符串是否有多余空白符。笔者也被这个小问题坑过,手动编辑的时候人工加上了空白符导致我比对出错,注意配置参数都要去掉空白符

字符串问题

  • 比对字符串。单元测试的时候注意比对的字符串可能因为多了空格的问题没法直接比对。注意可以去掉空格之后对比,笔者曾经因为不 同字符串就多了一个空格比对失败,被坑过

分布式系统问题

  • 分布式锁。分布式服务对于需要数据同步的操作可以使用分布式锁,注意分布式锁的超时问题
  • 时钟漂移。如果代码强依赖时间戳在不同的服务器上可能因为时钟差距导致问题,可以采用适当取整对齐时钟。
  • 分布式数据库。注意有些分布式数据库插入数据之后不会返回主键

基础组件问题

  • redis版本和集群模式。使用云 redis 的时候之前因为使用了 lua 脚本,但是测试环境和线上使用了不同的 redis 集群版本,发现测试 环境测试一直没问题,但是一到线上就不起作用。建议保持线上和测试环境的基础组件版本一致。