Debug 调试神技

凡是可能出错的事就一定会出错。 - 墨菲定律

调试/bug 定位/debug

调试也是个很重要的问题,不可能保证代码没bug,要命的是有时候写代码完成功能的时间还没调试的时间多,编码不要过度求快,逻辑正确更重要。 复现是排错的第一步,之后通过各种方式确定原因(访问日志、邮件报的异常记录)等,通过走查代码、断点调试(二分法等)确定错误位置,确定好错误原因了就好改了。修复后最好反思下问题的原因、类型等,哪些地方可以改进,争取下次不犯一样的错,慢慢减少错误才能越来越高效。 比如像是知乎、字节等公司都有统一的事故通报平台,隐去涉密信息后发送到全公司让大家复盘学习,尽量避免同样的bug问题再次在其他团队重复出现给公司带来损失。

如何定位和修复 bug:复现->定位->修复->验证->复盘。大胆假设,小心求证;思路不通,换个角度。定位需要找到 bug 出现时候的上下文信息,可以用 log,sentry,kibana 日志系统等查看。确认之后通过走查代码、断点调试等方式寻找代码逻辑错误。 如果是比较复杂的系统,之间的交互比较多,可以边调试边记录,把关键的流程调用、系统交互逻辑、关键点的日志记录到文档里,整体梳理,对于排查比较难以解决的 bug 也有帮助。

  • 及时止损。出现问题第一步应该及时排查线上代码、配置、AB实验等有无变更,还有上下游的代码或者配置有无变更,如果有应该及时回滚止损。一般来说突发的线上问题大部分都是变更导致的,应该先排查变更并且及时回滚止损。

  • 复现,偶尔才复现的代码是很难排查错误的。如果不好复现但是有 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。

  • 升级后出问题。是否有完善的功能测试和单元测试保证回归没有问题?升级代码修改了哪些部分?降级之后能否复现?

  • 同样的代码/服务别人没问题你的有问题。你们运行的版本一致么?运行环境、环境变量一致吗?依赖第三方库的版本一致么?一般总会有一个不一致导致的问题。建议锁定编程语言、第三方库、运行环境版本并保持一致。

  • 服务一会有问题一会没有。是不是有的机器服务未更新或者灰度失败导致只有部分机器更新了?

  • 偶现问题。之前碰到过绑定测试环境出现问题,结果请求偶现会打到线上导致表现不一致的问题,比较坑而且不好排查(内部的平台问题)。通过链路查询得到的 ip 地址可以锁定那些偶现请求是否正确打到了你的联调环境

  • 只有个别的数据有问题。检查一些这些数据的字段配置是否和其他业务一致,对比一下大概率是配置字段不同导致的

  • 服务超载。长期稳定运行的服务突然出问题一般不太可能是业务逻辑 bug,重点关注指标 cpu/io/memory/磁盘/log/连接数 是否被打满,是否无法继续正常服务,如果是服务器负载问题也会导致服务失败,不一定是主逻辑代码有问题(当然也有可能是连接池使用不当导致)。

  • 是否是缓存的问题?缓存数据过期了么?缓存是否一致呢?能否清理缓存解决?测试环境禁用一下缓存看看表现如何(笔者之前改完代码一直 debug 没生效结果发现是缓存未失效导致一直是旧数据)

  • 是否是配置的问题?配置的时候参数填写的是否正确,有没有去掉多余的无用的空白符?别傻傻地把测试写到正式或者正式写到测试环境。比如笔者遇到过对比配置结果手动填写的配置多了空格导致比对失败

  • 是否是特殊输入问题?偶现的问题可能是由某些特殊的输入参数代码未处理导致的,能否通过日志或者构造某些特殊的输入复现?

  • 服务之间的依赖关系如何?有没有分布式链路追踪,哪一步调用关系出了问题?是否是没有降级,有没有碰到雪崩?服务间有没有循环调用?

  • 监控报警。各种服务指标监控是否有报警?报警是否正常?如果没有及时监控到是否可以增加相关指标的报警?

  • 硬件问题。硬件问题出现较少比如某个机器宕机,但是大规模部署的服务还挺常见的,一旦监控出现某个机器请求异常基本上可以断定是机器硬件问题,需要及时排查和剔除异常机器

  • 负载均衡问题。可能表现为服务时而可用时而不可用,或者因为不同机器服务代码不一样表现不一致(不是期望的灰度逻辑)

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

  • 重视静态检查/编译器/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 产生的原因和修复方式,而不是修复完成之后就不长记性了

  • 流程自动化。凡是可以自动化的就自动化,依赖人的行为反而是最容易出错的。脚本一旦编写通过之后就可以无限次正确使用,远比人为操作可靠。

bug主要来源于粗心(比如拼写错误)、认知偏差(比如错误理解和使用API)、系统复杂度的增加等。 可以通过复杂度控制、设计复审、代码审查、代码静态分析、单测/功能测试等找出来,我们可以综合利用以上手段尽量减少代码缺陷,大幅减少给代码擦屁股的时间。

常见的 bug 类型

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

需求理解错误:

  • 需求理解不一致。业务开发中很常见的一个问题,产品/开发/测试理解不一致导致实现被当成 bug,不明确的地方一定要沟通好互相阐述确保需求理解一致再去开发,防止返工。

代码错误:

  • 拼写错误。不要笑,这个错误其实很常见,推荐打开编辑器的拼写检查和相同词高亮,可以消除一些类似问题。还有就是直接 copy 类似代码然后忘记改一些小细节也容易出问题而且不好排查(少复制粘贴)

  • 类型错误。在动态语言和弱类型语言当中比较常见的一种错误(动态语言确实更容易出 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 之类的静态检查工具检查

  • 空数据和nil。注意在 go 里空数据指针比如 &SomeStruc{} 和 nil 序列化的结果是不同的,注意序列化之后的差别

函数(复杂度)问题

  • 循环调用。在一些复杂场景中,a 函数调用了b 函数,b 函数里边又因为某些条件调用了 a 函数导致循环调用,可能导致 cpu 飙高,严重的可能打垮下游服务。

  • 调用放大。一次请求链路中可能会多次请求同一个函数导致请求放大(重复调用或者循环里调用等)。go 语言可以利用 context(WithValue) 来缓存结果,防止一次调用链路中的重复请求。不要在for循环里做网络请求或者特别重的逻辑,防止打垮下游 (适合只读或者读写场景,注意不能是读-写-读) (参考:https://github.com/ag9920/go-ctxcache)

  • 破坏协议约定。比如之前约定是参数错误不抛出异常,而修改之后抛出了异常,可能导致调用方业务逻辑有问题。

  • 强依赖与弱依赖。对于强依赖代码如果调用下游出错应该返回错误(比如再让用户重试),而弱依赖可以根据业务场景设计合理的默认返回降级值(兜底值+打印日志)。

  • 导出和非导出。go 语言通过大小写决定函数/结构体字段是否是非导出的,对于内部使用的函数、字段应该严格使用小写不要导出,隐藏细节方便之后修改。否则一旦导出被外部使用之后重构起来就会更加困难

  • 超长函数。控制复杂度,尽量不要写太长的函数。超长的函数最好及时重构降低复杂度

  • 冗余代码。不用的代码应及时删除,很多人会感觉将来会用到,实际上大概率将来也不会用到,及使用到通过版本库可以很容易找回来。但是如果不删代码将来会非常难以修改

参考:

数值错误

  • 数值截断错误。注意强制类型转换是否会发生截断,损失精度,结果是否符合期望。如果需要精确数值,比如银行存款、电商交易可以 用定点数或者整数。

  • 数值范围越界:注意前端 javascript(设计缺陷) 无法表示完整的 int64,传给前端需要用 string 替换 int64 (被坑过好几次, 有些序列化协议会自动给你把int64转成string处理)

  • 浮点数比较:浮点数不能直接用等号比较,应该是比较两个数的差值小于指定范围

内存问题

  • 解引用空指针。是否引用了空指针的值导致直接 panic?比如 go 里边直接对一个 nil map 赋值 panic。指针有没有 nil 检查(一些嵌套的结构体指针可能忘记检查是否是nil就访问导致panic,比如访问a.b.c 但是b是 nil)

  • 内存泄露。有没有循环引用?有没有全局变量值一直增长或者被引用没有释放?有没有多个对象底层引用的其实是同一块内存始终无法释放(比如直接赋值)?

  • OOM(out of memory): 比如使用了本地缓存需要严格控制本地缓存的使用量大小(过期和剔除策略),防止内存持续增长进程被杀掉影响服务稳定性。

网络问题

  • 网络请求合理超时。一切网络client(http/rpc/mysql/redis请求等) 都应该设置合理的超时参数,比如有些 go 的 client 需要显式自己传进去超时参数,否则可能导致 block 或者下游大量超时导致线程堆积。超时时间可以参考 P99等响应时间合理设置

  • 连接池打满。连接池应该是服务共享的(单例),而不是每个请求都要去创建连接池导致打满连接池。请检查 client 的连接池和超时参数设置是否合理。

  • 长短连接使用不当。注意有些需要长连接的场景,可以避免频繁建立 tcp 握手的开销。(http keepalive)

  • 接口限制。接口请求参数有没有进行限制,一次请求的数据量是否太大,有没有加上分页参数,日志会不会一次打印太多导致 IO 压力大

  • 带宽打满。比如 redis 有比较大的 key 可能导致并发请求的时候打满带宽,可以扩容带宽同时限制 redis 的热 key 和大 key。

  • 幂等问题。调用下游服务成功了,但是因为网络问题没拿到结果调用端认为失败了又进行重试,可能会造成数据不一致。可以用带有过期时间的缓存/版本号等来做幂等。

RPC/Web 框架

  • 请求参数限制。比如一般 rpc 请求会限制每次请求的最大的参数个数,如果一次性请求太多可能需要分批并发请求

  • debug 模式。注意线上一定要关闭掉 debug 方式防止泄露关键信息。很多框架在 debug 模式下会显示一些关键信息,可能会被黑客利用

  • 序列化协议版本问题。client/server 序列化的方式是否一致?版本是否一致?不同的版本之间有时候可能会有一些微妙的 bug

  • 调用重试。由于超时或者服务抖动可能需要重试,注意重试次数、间隔时间(线性、随机、指数退避)等问题,避免重试风暴

参考:

兼容性问题

  • 新特性版本号兼容。对于客户端新上线的需求,是否限制了特定的平台和版本号才能展示或者下发(ab实验是否过滤了老版本),防止老版本无法处理导致崩溃

  • 协议文件兼容。一般线上会同时跑很多版本的 App,修改协议要慎重,错误修改协议严重可能导致老版本 App 不可用甚至崩溃(只加新字段,别改老字段)

    • 对于 json 等格式应当只增加新字段,不要修改和删除老字段,防止老版本解析失败。同样也不应该修改老字段的含义或者功能!(笔者遇到过因为一个之前定义的list长度变化导致的代码问题)

    • 对于 Ptotocol buffers,Thrift 等协议,之后新增的字段必须是可选的或者具有默认值。(旧代码不会写入require字段导致检查失败)

    • 同样 PB, Thrift 协议也不建议删除老字段,如果必须删除只能删除可选字段,而且不能再次使用相同的标签号。不要修改之前已有 的required字段为optional的或者删除required字段,否则就会出现版本不兼容问题,老客户端访问新服务会出现参数错误。不确定的情况建议用 optional 字段。

    • json 无法表示 64 位数字,如果后台需要传递 64 位 id 给客户端,必须使用 string 类型,否则会被截断!超过 9007199254740992 就会有精度丢失问题。 在序列化golang的 map[string]interface{} 也会有类似问题,拿到的float64再转成int64就会丢精度。你会发现pb一些序列化框架会自动把int64序列化成string类型防止丢精度

    • 如果返回的结构体是一个 list,最好外层包一下不要直接返回一个裸的 list,否则后续要增加字段就没法在 list 上加了

数据库问题

  • 查询参数非法。查询数据库的时候可能因为一些不合理参数导致数据库慢查询,比如一次查询太多导致慢查询,非法 id 透传到了数据库层。可以在入口处做一下限制和严格校验,比如限制limit 大小,过滤不合法 id

  • 查询参数类型不匹配。注意如果传入类型不对,可能导致数据库没法利用索引导致慢查询,比如字符串类型传了一个int, 注意查询的参数类型和数据库类型匹配(尤其是动态语言)

  • 慢查询:没有索引,索引设计不合理可能导致慢查询问题,有没有慢查询监控? 对于分布式数据库,有没有使用分片键查询?一定要有数据库慢查询的监控告警,新需求上线放量期间注意观察数据库的负载

  • 连接池跳涨。除了不当使用连接池之外,如果是启动了大量的服务容器也可能有这个问题,注意限制单服务连接池的大小

  • 连接池过大。连接池数量设置太大效率反而可能降低,应该根据实际压测结果设置一个比较合理的值,并非越大越好

  • 连接未及时释放。如果协程里有事务处理代码 panic 了并且没有 recover,即使协程退出了,但是依旧占用DB连接且不会主动释放,可能导致连接泄露

  • 字符集问题。注意如果字符串需要存一些特殊的 emoji 表情符号,需要使用 utf8mb4 字符集。

  • 请求放大。不要在for循环等语句里边做网络请求比如访问数据库、redis、rpc 调用等(除非你明确知道你在干什么?有及时退出条件和请求间隔么),使用批量请求并限制每次请求个数,防止打挂数据库(批量优先于并发)

  • SQL注入。尽量不要使用直接拼接 sql 的方式,比较容易出现 sql 注入。使用 orm 或者一些第三方库可以有效减少注入问题

  • 数据加密。敏感数据一开始就要加密存储,不要明文直接存储用户的敏感信息,比如电话、用户密码等,一旦泄露数据十分危险

  • 数据误删。笔者还真遇到了因为别人渗透测试误删了线上数据库重要数据导致服务大量出错,一定要做好数据库备份

  • 主从延迟。写后即读场景中读取的时候没有读到写入的数据可能是主从复制延迟过高,可以通过读取主库(确保读取量级不会压垮主库或影响写入速度),写缓存读缓存(注意缓存本身也有主从延迟问题)、消息队列冗余信息等方式处理。最好可以从业务角度设计尽量不依赖主从延迟

  • 字段类型问题:

    • 自增类型作为主键应该选择 BIGINT,目前很多大业务int容易超过最大范围。每张表都应该设置一个主键(可以用snowflake等算法生成,会暴露出去的 id 不要直接用连续自增数字防止被遍历)

    • 涉及到金钱比如余额等,推荐用整数类型(单位用分)而不是DECIMAL 类型,性能更好而且存储更紧凑,同时避免了计算精度问题

    • 时间字段建议使用 DATETIME,时区问题可以在前端或者服务端转换。(int不容易看出来具体时间,TIMESTAMP最大只能到2038年)

并发问题

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

  • goroutine泄露。确保你的 goroutine 可以完成退出(比如没有死循环,没有channel block住),防止大量未执行结束的 goroutine 堆积。通过上报 go 的 runtime goroutine 数量指标可以发现

  • 死锁问题。锁的粒度对不对?锁有没有正确加锁和释放锁?加锁和释放锁的类型是否匹配(Lock/Unlock, Rlock/Runlock()),次数是否匹配?

  • 并发操作导致资损。可能由于客户端错误的并发请求导致一些涉及到发放资金的操作会并发执行,如果后台没有加分布式锁可能导致并发请求都会成功,从而有超发带来资金损失

  • 并发修改丢失。对于一些非原子性操作比如 CAS 操作修改缓存,并发场景可能导致数据不符合期望。使用 lua 脚本/redis原生结构/分布式锁/请求排队等方式保证原子性修改

  • 并发修改panic。go 语言对于同一个变量的并发修改会导致 panic 可以通过加锁解决。不同协程对于同一个结构体的不同成员变量并发操作不会造成 panic,但是由于cpu cache line的原因会有一定的性能问题

资金损失问题(资损)

  • 并发操作资损。未拦截并发操作,导致多发或者超发。使用分布式锁等方式拦截,注意临界区的粒度

  • 重复操作资损。比如重试操作导致重复发放(失败重试、消息队列重新消费等)。使用 CAS 版本号、幂等id设计等避免重复操作资损。

  • 幂等设计。比如对于奖励发放即使重试应该使用之前同一个幂等 id 来避免重复发放。

依赖库问题

  • 依赖版本是否一致。笔者曾经因为开发工具的自动 import 引入了错误的包版本导致一个挺难查的 bug(vendor 和 gopath 下不同的redigo 版本), 要小心因为不同版本导致的一些极其隐蔽的 bug。最好通过包管理工具锁定依赖的第三方库版本; 还要注意 IDE 工具自动导入的包对不对

  • 能否升级解决。有些知名的库或者编程语言(go/python)等都是开源并且不断迭代的,在一些旧版本出现的隐蔽的bug直接可以升级解决(可以搜索提交记录和 issue等看修复的问题记录)

  • 升级服务出问题。升级有时候可以解决一些 bug,但是也可能引入新 bug?能否通过回退到上一个版本解决(比如git checkout 到一个历史提交)?是否详细看过升级日志(release notes),修改了哪些东西?是兼容升级还是不兼容升级?

  • 清理无用依赖。对于不用的依赖也有可能引入问题,不用的依赖最好清理掉,比如 go mod tidy 或者清理掉 python requirements.txt

日志错误

  • 日志级别错误。线上使用了 debug 级别,可能会产生大量日志,如果没有滚动日志可能会导致服务器磁盘打满。一定要注意不同环境日志级别,推荐集中式日志收集系统。 线上应该只打印重要的 info 和 error 级别日志,或者不重要的日志也可以使用一定采样率打印。遇到过几次对方服务把日志打满服务不可用的情况

  • 日志参数错误。日志语句对应的占位符要和传参的个数一致,类型要匹配,比如本来是数字的使用了 "%s" 而不是 "%d"

  • 缺少必要信息。如果是为了 debug 加上的日志一定要有足够的上下文信息、关键参数帮助排查问题,同时也要注意日志不要泄露敏感数据(比如密码等)

  • 日志过大:除了注意日志等级,还要注意是否输出了过大的日志导致磁盘 IO 飙升,适当精简日志量,不要打印过大的结构体(json等/请求体/透传参数等),或者提升线上日志等级只打印异常和ERROR。线上一定要关闭 DEBUG 日志

  • 危险操作记录。对于一些修改和删除数据的危险操作,比如一些后台管理系统等,一定要加上日志记录,方便排查问题和找到误操作人

错误/异常处理

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

  • 集中收集。一般搭建 sentry(异常、错误收集);ELK(集中式日志收集)来进行集中收集,方便针对异常、日志进行聚合和搜索。否则散布在各个服务器上很难排查问题

配置错误

  • 配置环境写串。看起来是一个很傻的错误,但是其实还挺常见,注意不同环境配置是否对的上,别把测试的写到正式环境了。启动服务时打印配置看看

  • 服务启动命令是否写错。有些服务依赖命令行启动的时候容易写错参数,建议通过配置文件的形式传进去。

  • 配置字符串是否有多余空白符。笔者也被这个小问题坑过,手动编辑的时候人工加上了空白符导致我比对出错,注意配置参数都要去掉空白符。应该在前端用户输入阶段就过滤掉不合法字符

  • 配置安全。不要硬编码到配置文件或者代码文件 git 仓库里,涉及到密码的配置应该使用统一的配置中心,防止代码仓库泄露秘钥等风险。

  • 框架/编程语言配置。很多web/rpc框架的线程数、golang 容器的 GOMAXPROCS(uber-go/automaxprocs) 配置是否合理可能影响程序性能

  • 配置校验。人为的业务数据配置经常出现数据范围、类型等写错的情况,或者多加了一些错误的分隔符等,关键数据需要配置系统或者业务代码做一下校验,防止资金损失

  • 配置变更。修改配置之后应该现在测试和预发布环境进行验证,确认无误后再修改线上环境配置。故障平台统计显示很大一部分问题都是线上变更导致

字符串问题

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

  • split空字符串。py/go split(s, sep) 一个空字符串得到的是一个长度为 1 且第一个元素是空字符串的数组,而非空数组。

分布式系统问题

分布式系统中可能会碰到的问题:

  • 网络中的数据包可能会丢失、重新排序、重复递送或任意延迟(超时)

  • 时钟只是尽其所能地近似(时钟回拨等)

  • 节点可以暂停(例如,由于垃圾收集)或随时崩溃:检测和剔除故障节点(负载均衡);失败转移(主从)

常见的业务开发可能会碰到的坑:

  • 分布式锁。分布式服务对于需要数据同步的操作可以使用分布式锁,注意分布式锁的超时问题(本身是否高可用)。Redission 实现比较完善

  • 时钟倾斜(clock skew)。如果代码强依赖时间戳在不同的服务器上可能因为时钟差距导致问题,可以采用适当取整对齐时钟。有一些第 三方库允许一定的时间差容忍(比如乘以一个误差因子)。https://github.com/dgrijalva/jwt-go/issues/383

  • 分布式数据库。注意有些分布式数据库插入数据之后不会返回主键。可以用分布式 id 生成器(snowflake算法)指定主键作为 shard key

  • 时钟同步出错(ntp同步问题)。笔者最近碰到的问题,云服务机器时钟出问题了,导致我一些服务鉴权带上时间戳参数的失败了。依赖 时间的比如 snowflake算法 如果出现时钟回拨可能会产生重复 id。

  • 日历时钟与单调时钟。(参考《设计数据密集型应用》第八章-分布式系统的麻烦)

    • Time-of-day clock(日历时钟) : 返回从 epoch(UTC 时间 1970 年 1 月 1 日午夜)开始的秒数(可能回拨)。需要从 NTP(网络时间协议) 服务器同步信息。linux的 clock_gettime(CLOCK_REALTIME) 或者 java System.currentTimeMillis()。日历时钟无法用来测量经过时间

    • Monotoinc clock: 经常用来衡量时间间隔(time interval),例如超时或者服务器响应时间,保证不会回跳,但是单调钟的绝对值无意义。linux的 clock_gettime(CLOCK_MONOTONIC) 或者 java System.nanoTime()

    • python3 的 time.monotonic() 方法和go 1.9 之后的 time 包使用了单调时钟

    • bwmarrin/snowflake 包使用了 go 的 time 包解决时钟漂移的问题,参考 https://github.com/bwmarrin/snowflake/pull/18

缓存(redis)问题

缓存在大幅提升性能的同时也会带来很多问题,比如缓存一致性等。一致性问题在开发和测试中(尤其多级缓存)会带来很多困难,而且一 旦数据结构变更,处理缓存失效逻辑也会变得复杂。所以如无必要,不要轻易引入缓存,笔者之前碰到过因为数据结构改变的缓存 bug 就很难处理。

  • 超高热点 key:对于微博/直播之类的应用,比如明星出轨或者热门直播等,可能有某些热点的 key 集中到单台 redis 上导致压力过大(看一下 redis 热点 key 统计方便排查问题),可以考虑再加一层进程内缓存。比如使用 go-cache 等进程内缓存库。 编写代码的时候应该注意到可能发生这种热点 key 的问题(测试环境压测+观察热点 key),应当谨慎使用 redis,充分利用进程缓存/key hash是有效的方案。或者写多个 key 然后每次获取随机取一个。

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

  • 系统调用结果缓存。比如一些日志库获取本机 ip 的时候没有缓存下来,导致大量系统调用,类似结果可以放到缓存或者全局变量

  • 并发修改缓存。比如存了一个kv值是json数据结构但是却有并发修改,可能会造成数据丢失。使用分布式锁、redis 原生结构、redis+lua 脚本等保证原子性修改

  • 多级缓存。如果上游缓存了调用结果,那么当前代码修改后可能不起作用。联调或者排查问题的时候记住多级缓存的场景可能带来的问题

  • 缓存一致性。无论是先更新缓存再更新数据库,或者先更新数据库再失效缓存,并发场景都不能保证完全一致。推荐先更新数据库,再 删除缓存出现缓存不一致概率最小,也是目前最常用的一种方案(Cache Aside 旁路模式)

  • 热 key 和大 key。热key 一般通过本地缓存或者哈希分片的方式解决,大 key 一般也应该尽量从业务上避免,可以拆分或者写数据库做冷热分离

  • 老 key 和长 key。如果是作为缓存的key一定要设置过期时间防止永久驻留(根据业务预估失效期)。缓存key的名字也应该尽量短,本身也占用空间

  • redigo: 注意go的一个常用 redis 库如果查询不到 key 会返回 redis.ErrNil,需要和其他的 err 做区分。一般来说go里查不到和返回错误是需要业务上做不同的处理

  • redis cluster 集群错误:有时候要实现 redis lua 原子操作,对于 redis cluster,操作的所有key必须在一个slot上(或者可以指定hash tag 落到同一个 slot),否则返回错误信息。 同理 redis cluster 下 mset/mget/pipeline 等都需要操作同一个 slot,腾讯云 redis 在 proxy 层给你实现了,可以直接批量操作。

  • 请求放大。业务变更或者代码逻辑错误(比如 for 循环里请求 redis 等) 都有可能打垮缓存服务,缓存组件要有及时的利用率、请求量等告警。优先使用redis 提供的批量操作

  • 缓存失效(缓存污染)。如果新上线的代码修改了数据结构导致和已有缓存的数据结构不同,那么上线的代码必须设计好失效机制让老的缓存数据先失效(或者代码可以兼容),否则有严重的业务问题(如果缓存失效期比较短问题倒是不大):

    • 上线期间灰度部署新老代码都在跑,老代码会读到新的缓存数据结构导致现有逻辑可能出问题(比如字段含义改变甚至不兼容的时候)

    • 上线之后新代码读取还没失效的老的缓存数据,也可能会导致现有逻辑有问题(比如新加的字段读不到)

    • 稳妥的开发和上线方式:

      1. 只新增字段,不要修改数据结构老字段或者改变其含义(类型、长度等)。 这样保证灰度期间老的代码逻辑不会影响

      2. 新上线代码判断获取的缓存有没有新字段,如果没有新字段则认为是过期缓存,删除对应的缓存数据并回源重建。这样保证新代码没有影响

      3. 或者通过给key增加配置版本号的机制,来控制失效缓存。比如上线后缓存污染了,通过配置增加缓存key版本号自动失效旧缓存重建新缓存

参考:

消息队列问题

  • Kafka 只能保证单个分区有序。如果要保证有序可以使用单个分区(丧失吞吐性,不推荐);指定消息key为业务id,保证同一个业 务 id 的消息发到同一个分区保证有序,从而保持因果一致性(推荐)

脚本编写问题

  • 先用日志替换写操作。需要跑一些脚本的时候,可能会修改数据库,如果脚本直接修改了数据并且脚本有 bug 可能就会导致数据异常并很难回滚。 建议所有的写操作写替换成日志打印出来,确认无误之后再去执行,更加保险。

  • 数据备份。用脚本操作重要数据之前建议先备份一份,防止操作出错无法恢复。或者操作之前导出数据,之后出问题再用于恢复

服务构建问题

  • 版本检查。go/python 版本是否一致

  • 环境检查。环境变量,或者构建参数、 go env 等是否一致

数据问题

  • 资源隔离。不同环境下比如测试和正式环境的资源一定要严格隔离开,防止数据污染。

后台服务

  • 自动拉起。如果服务因为严重错误退出了(比如 go panic 了,python 未捕获异常进程退出了),能否快速拉起服务?

  • 异地部署。是否已经做到了两地三机房?一个机房挂了之后,服务能否正常继续工作

  • 数据不一致。如果程序在关键流程中退出了,是否会导致数据不一致的问题?有方法修复么?是幂等操作么?比如交易系统定期对账

  • 自动扩容。如果突然请求量上去了,服务能否在短时间之内快速扩容应对压力?

  • 快速回滚部署。如果线上出了问题,能否快速回滚到上一个可用的稳定版本保证服务可以继续稳定执行?回滚是否会有不兼容情况,导致其他依赖你的服务不正常?

  • 拆分部署。对于一些特别核心的接口,可以分开部署。防止其他接口有问题了,造成核心服务不稳定。(一个项目的接口重要性不同)

服务监控(监控三板斧:度量指标+告警、链路追踪、日志)

  • qps监控。有没有监控服务每个接口的 qps?有没有监控接口的成功失败率?返回码?

  • 响应时间。每个接口请求的响应时间有没有做监控? TP90, TP99 分别是多少?

  • 链路追踪。微服务中各种系统互相调用,有没有用 open-tracing 之类的进行链路追踪?

  • 业务监控。使用 Grafana 之类的监控系统对关键业务数据进行打点监控,防止某些业务异常

  • 失败报警。关键接口、服务挂了,机器负载高了有没有及时发送报警提醒?

  • 异常上报。区分于日志,异常一般是发生了比较严重的错误,业界有比如 sentry 这种集中式异常收集平台来上报异常,一般除了无法 避免的网络问题之外,大部分异常都是需要开发者修复的。

写完代码之后检查一下该加的日志有没有加,该上报的指标有没有上报,错误能否及时捕捉并且上报到平台上。

熔断降级

  • 容量预估。对于请求量级比较大的服务,上线之前各个基础组件、下游服务等应该有合理的容量预估,需要扩容的是否提前扩容充分。

  • 熔断保护。对于核心服务,如果流量短时间暴增,能否监控到并且正常处理。如果下游服务打挂了,能否熔断保护,应当确保调用其他 rpc 服务加上熔断器保护。

  • 柔性降级。柔性可用是在有损服务价值观支持下的方法,重点在于实际上会结合用户使用场景,根据资源消耗,调整产品策略, 设计几个级别不同的用户体验场景,保证尽可能成功返回关键数据,并正常接受请求,绝不轻易倒下。简言之就是保证关键接口兜底策略

  • 压力测试。上线之前有没有预估过最高 qps 然后做过压力测试并且监控各个基础组件和下游服务的压力和稳定性?对于有突发流量的场景也应该做好压测及时发现问题(比如依赖下游无法及时扩容导致大量请求失败)

  • 混沌测试。如果随机停掉一些依赖服务,你的服务会有问题么?有没有类似混沌测试保证接口没问题?

  • 接口限流。是匀速限流(leaky bucket 漏桶算法)还是可以允许突发流量(token bucket 令牌桶算法)?限流之后是丢弃还是降级(fallback)?如果是新增接口需要配置限流么?

  • 频率限制。对于一些用户相关接口有没有针对用户操作进行频率限制(比如借助 redis 限制操作频率)?如果接口被恶意刷量了如何处理?

想一下,如果你的服务接口突然 qps 暴增了几十甚至上百倍(比如类似微博热点推送,直播间涌入,轮询接口等),你的服务能扛得住么? 各种基础组件 mysql/redis 等会挂掉么?如果扛不住能够限流降级保证服务依然可用么?) (很多场景不能保证一定可以及时扩容,基础设施不能保证一定能够扩容成功,这个时候需要从代码框架层面考虑熔断降级) 笔者之前就因为疏忽,一个接口短时间 qps 翻了几百倍导致接口大量失败。

运营事故(经验发现不少 bug 或者事故来源于错误的运营配置)

  • 用户填写数据校验。对于关键用户配置数据,加上数据格式、类型、范围检查,比如配置中奖概率、商品价格等,防止填错导致重大财务损失。

  • 配置多人审核。比如推送数据,应该建立多人审批流机制,多人审核都无误之后才允许给用户推送数据

  • 配置验证。修改配置后应该在测试环境验证逻辑没有问题之后再发布线上,禁止未经测试直接线上发布

上线部署(超过一半的事故来自于线上代码、配置等的变更)

  • 依赖方检查。依赖的下游服务、实验策略等是否都已经上线。如果是 AB 实验应该上线后回归没问题再开实验放量

  • 配置检查。需要上线提前配置的服务是否都已经配置完成,比如网关配置、业务配置、框架配置、熔断限流配置等(预期高 qps 的接口要配置)等。仔细检查正式和测试环境的配置不要写串了!

  • 小流量观察,分阶段观察。无论是部署服务或者配置还是 AB 实验放量,都应该先小流量观察,每个放量阶段(小流量-10%-50%-100%)应该至少观察 10 分钟。包括后台服务的线上错误日志、请求监控、客户端的崩溃率、基础组件的稳定性、容器的利用率、业务监控告警等,确保没问题再慢慢全量。 笔者碰到过后台放量过快导致客户端偶现 bug 崩溃率上升的问题(上线只注意到了后台监控,没想到客户端也可能出问题)。

  • 规范上线。不要在业务流量高峰期、或者上游突然开始放量的时候部署服务,严重的时候可能有雪崩的风险(滚动重启期间扛不住流量雪崩)

  • 变更回滚。如果小流量上线观察期间发现有问题应第一时间回滚及时止损,之后再去排查问题并且复盘。

  • 粒度控制。每次应该只上线一个代码合并(需求或者问题修复),不要多个功能一起上(哪怕只是几行修改最好都分开提交单独上线)。否则一旦出问题掺杂多个提交将难以回滚处理

  • 上线顺序。先上代码还是先上配置。如果先上配置,会有一些新的配置先下发了会不会有问题?如果先上代码读不到新的配置是否有问题?建议在技术文档里规范好上线顺序严格执行,尤其涉及到多方上线依赖顺序的。 笔者遇到一个比较坑的问题是,先上了配置,结果代码里有修改数据配置默认值的操作,代码还没上导致这个默认配置字段没有被修改,下发给端上引起了问题。

服务自查

  • 上线之前请阅读以上内容,详细检查自己的服务是否有缺陷

参考