前言
复盘最近碰到的bug,记录下日常处理异常的方法。
一、定位异常手段
1.看日志
(1)单个服务日志
每个服务的日志,可以通过配置日志格式+日志滚动存在服务器上,运维小伙伴指定保存在对应路径。登录堡垒机,用tail\less\awk\sed等linux命令查看,常用:
cd log-path
tail -f -n 200 xxx.log
less xxx.log | grep key-word
参考:Linux 文本处理三剑客:grep、sed 和 awk
(2)统一日志平台
微服务架构,不同服务都有日志,各个日志文件散落在不同目录,所以出现了统一日志平台。
其次,基于调用链跟踪,每个日志记录会标记调用的traceId和spanId,可以用traceId来找到一次调用涉及的全部日志。
开源的日志架构有如 elk(Elasticsearch+Logstash+Kibana),我们内部用的是阿里云日志平台。所以在日志平台中查询异常:
- 时间+服务+error
2.看接口
有些异常比较好重现,接口重新调用之后,日志里面可以看到和请求相关的traceId。用traceId在日志平台直接搜索,可以看到完整的调用上下文。
当然,服务可能没有打印日志或者打印的日志不够用,这时候有几种处理方式:
- (1)看bug是否本地、测试环境、预生产能复现,能复现的话可以单步调试或者加日志,这种方式需要为加日志发版;
- (2)不能复现的,又想加日志,可以试下使用 arthas;
3.看数据
上面接口会在数据库、缓存、搜索库有对应记录,还有对应记录的操作历史,可以在数据工具查看,也可以在管理后台观察对应数据。
二、bug分类
这里讨论下常见的一些bug.
(1)NPE、空数组、空集合
除了臭名昭著的NPE,日常还会碰到空数组和空集合被访问的问题,一般是数据出问题了,预期有值但是产生了空,访问[0]或者get(0)就会有问题。
修复:
- 数据修复:有些是数据问题,这种可以试下数据修复;
- 代码修复:加个if-null判断,用Optional,或者用断言返回业务异常。
注意:可以用arthas线上修复,不过在生产上有点危险,其次要记得合并代码。
预防:
- 静态检测spotBug找bug
- chatGpt 单元测试
(2)OOM
gc行为目前是通过grafana看,不过这块一般很少动。出现OOM,目前我的一般做法是用MAT看dump文件(使用MAT定位OOM异常代码)。下面是导致OOM的几次总结:
大查询
一次数据查询加载数据太大。处理:
- 减少join查询,改成多次查询,在java里map处理;
- limit分页;
条件丢失
ORM 框架会处理查询条件,但是因为条件数据为null,条件就没了,如果全部条件都没了,会导致查询条件为空,加载全表。结果就是数据库io升高,程序的内存也被耗尽。
select * from user where age=?1 and name=?2;
-- 代码可能会将age=null和name=''的处理成
select * from user;
修复方式:
- 异常数据处理,但是风险还在;
- null 不查询,提前终止;
预防:
- 查询加上limit分页
- 单元测试+边界测试
反序列化
有个接口返回了一个大的json,其实也不大,但是下游OOM了,因为部分服务给的内存不多。分析接口的作用:数据{后台用户信息,用户全部权限信息},下游只用到前面部分,目前是提供2个接口,新加一个只返回用户数据的接口。
(3)慢查询
慢查询不算异常,但是对使用方来说有点不能用了。优化手段:
- 合适的index:这个可以观察或者用explain看查询sql找到要加的index,我们内部用的阿里云数据库,阿里云数据库有优化建议,不过一般不直接使用,会稍作调整。
- 避免回表:日常使用select * 会返回全部字段,如果接口只返回用到的字段,可以直接使用合适的索引命中数据。
- 避免索引失效:in、order by、like 、范围查询等可能会导致索引失效,limit 很大也可能有问题。这种可以试下修改sql的写法优化。
- 架构优化:反范式,冗余部分字段,还有一些计算结果存入库。
- 缓存:jvm caffine缓存+redis缓存
(4)大量数据操作
有些操作是很耗时的,比如调用数据库、调用外部接口服务,因为有网络调用,在循环里面操作就更慢了。优化方式:
- 数据库交互改成批量操作,比如查询,用可以索引的查询条件in,再map。多条update可以改成批量。insert into 可以改成批量,或者合成一个sql。
- 接口查询,如果没有依赖,可以用线程池+future/CompletableFuture,控制返回可以用join/future.get/countdownlatch,比较粗暴的手段是parallelStream。
- 架构调整:有些查询是没必要的,或者说应该下沉到数据中台,而不是在某个服务里面去做一些累死人的操作。
- 批量改成增量:部分操作是在设计的时候采用了拉取数据的模式,其实可以通过事件(主动mq、canal之类的解析binlog)推送出去,变成分散的增量式的操作。
(5)事务丢失
spring声明式事务是会丢失的,根本原因是spring的代理行为是产生了一个代理类,而方法直接调用不会使用代理方法。这种可以改成bean调用来恢复代理:
service A{
@事务
methodA(){
methodB(); // 这里会丢失methodB事务
}
@事务
methodB(){
}
}
service A{
serviceB
@事务
methodA(){
serviceB.methodB();
}
}
service B{
@事务
methodB(){
}
}
当然,这样改有点大,可以手动管理事务。
(6)时序和数据一致性问题
部分事务还没提交,但是状态被使用了。这种有2个做法:
- 扩大事务的范围,或者修改事务的隔离级别。
- 确保线性一致性:监听事务提交事件,@TransactionalEventListener的使用和实现原理
还有一种是跨应用或者mq消息先发出来了,这种涉及数据一致性了。
- 减少修改:jpa#save 这种ORM调用会重新更新全部数据字段,多写个接口,只更新变化的部分。
- 分布式事务: 我们内部目前很少用分布式事务,目前主要是利用日志、业务异常记录,做人工补偿。
- 状态机+幂等调用+重试:状态不符合,调用失败;重试k次,状态符合,更新成功。对于重要业务,上死信,不重要业务,记录结果,不做重试。
(7)异步状态共享问题
多线程比较麻烦的问题是状态共享,可以用深拷贝或者threadLocal,做对象数据分发,避免共享。还有一种是避免修改,如果是只读就不存在差异问题。
(8)this is a feature
这种需要倒逼产品修改了。请尽情battle~
三、思考
记住一个点:
程序是一个状态机。
- 蒋炎岩老师
不管是单体用用还是微服务架构应用,都是一个状态机或者是n个状态机组合的状态机。而状态来自数据库(包括结构化、非结构化的)、用户输入。
状态机有初始化状态,某个时刻的状态。日志、监控、数据库记录是观察状态机的手段。一个可观察或者说足够透明的系统,是可以追溯记录的变化的。微服务一般使用同一日志平台+调用链来观察整个系统。
所以,一个完整的系统治理,在开发、测试、维护阶段,都涉及状态的处理。而机器是不会骗人的,一切不正常的状态,都是超出了预期而已。我们要敬畏代码,做好开发测试工作,防患于未然才能减少bug。
四、总结
少写代码,bug就少了。
怎么可以少写代码,又能把事做成?
修炼自己!