引言
俗话说:好的战士,是从枪林弹雨中打出来的。好的工程师,是从沼泥坑洞中踩出来的。
在平常的开发中,人很难主动去思考深入的东西。故障,层出不穷的问题,是开发人员想要回避的却始终难以回避的事情。从正面的角度来看,错误是人类进步的阶梯。故而,每一个显现的故障和问题,也能引导人更加深入地理解系统的运行,思考一些平时很少思考的东西,是很有益的礼物。在有赞的四年里,我踩过不少坑,总结出来,期望对后来者有所启发。
导图
坑位及启示
踩坑不是目标,从踩过的坑中汲取足够的经验教训才划算。如何分析一个坑位呢 ?首先,应当从逻辑上严密地论证为什么会出现这个问题,其严密性如 1+1=2 一样无疑议;其次,带来的启示和指导是怎样的,如何去防范类似的问题。
名字覆盖出错
或许出于对同行的莫可名状的“不满”情绪,程序猿看到不太顺眼的地方,总有一种想要改掉它的冲动。但人在采取行动之前,又容易缺乏思考。因此,冲动常常招致小小的惩罚。
譬如说,我刚接手订单导出。看到报表文件名是:kdt_8fb888f9c9fad7840190d9d1531dddfc.csv 。 心想,这后面一串可真难看,商家也看不懂。为啥不改成更友好的形式呢 ? 于是,我修改成了 kdt_2020-05-02-13-49-12.csv 。 猜猜看,发生了什么 ? 不同商家的报表发生了覆盖,一个商家能下载到另一个商家的报表。一个商家的支付报表下载到了订单报表的数据。严重的数据泄露问题。嗯,吃到了来到有赞的第一个 P1 。
为什么会发生覆盖呢 ? 容易理解,报表名称没有了区分性。只要不同店铺或同一店铺的不同人在同一时刻(精确到秒)同时导出了报表,就会出现报表覆盖。加上店铺ID 是否能解决问题 ? 可以避免不同店铺的覆盖,但无法避免同一店铺不同业务的覆盖。因为这个报表名称的函数被多个业务使用。不过,故障等级至少能降低到 P4 。多思考一个细节,故障等级就能降低一大截。由此可见,后面一串数字虽然难看了点,可是起到了很强的区分度的作用。详情可阅:“因修改报表名称引发的“惨案””
启示: 不要轻易修改看不顺眼的东西。颇与那句“不要轻易修改系统遗留代码”遥相呼应。其实,改还是要改的,只是改之前,要格外审慎,评估到位。在命名的问题上,一定要加上区分度强的名字空间。不然,很容易出现覆盖,轻则程序运行不如预期,排查耗费大量时间;重则直接导致故障。这都是我经历过的。
临界分页问题
来有赞的第一个七夕节。一位客满妹纸提出了一个 jira : 订单导出重复导出同一个订单。起初,我单独导出这个订单,没有问题;导出包含这个订单的一个时段的所有订单,也没有问题;然而,进行指定条件的导出时,这个订单就是重复出现了。这可奇怪了:为什么小批量导出不重复,指定条件的导出就重复 ? 为什么这个订单重复,而其它订单不重复 ? 为什么搜索不重复,而导出重复呢 ?
经过一整天昏天暗日的排查,终于找到了原因:这个订单对应多个商品。订单导出通过 inner join 从数据库分页查询订单和商品信息,查询的SQL在分页的临界处,将这个订单分别查出了两次。而导出是按批次写入,不会做去重处理,因此最终报表里导出了两次这个订单的信息。详情可阅:“InnerJoin分页导致的数据重复问题排查”
启示:临界处很容易出问题,而且很难排查。在一对多的分页查询中,注意加 select distinct 。 如今回想起来,七夕节导出了重复的订单,这个订单的重复正意味着我与自己 ?
诱导性资损
有一个多商品订单,其中有一个商品发货了,另外两个商品没有发货。对于整个订单来说,订单状态是待发货。当时,订单导出只有一个订单状态。因此,导出了待发货状态。商家看到待发货,又将已发货的商品重新发货了一次,导致了资损。
在这个实例中,报表内容从逻辑上看是没有问题的,但报表字段设计上有点漏洞,对商家造成了误导。商家又非常较真,非说有赞的错误导致了他重复发货。最后,赔偿了这个商家。我吃到了第二个 P1。
这个故事的启示是: 现实往往不是单纯的技术逻辑。不能只活在技术的世界里。产品和系统设计需要考虑对人的影响,避免商家做出误操作。在此事发生之后,订单导出增加了一个“商品发货状态” ,也梳理了并封堵了一切可能导致商家误操作的资损潜在可能性。涉及财产问题,要敏感而敬畏。
批量处理失败
有个商家,一次性上传了 15000+ 个物流单号信息,想要发货,一直失败。系统的逻辑是:后台接收和解析商家上传的发货文件,提取出待发货的信息,每 5000 个订单号发货信息打包成一个消息后批量推送到 NSQ 消息队列中。后台有个任务脚本从NSQ消息队列中取出打包的发货消息,打散成单个的发货消息,然后循环调用 Java 发货服务化接口完成批量发货。开始以为是前端或代理导致文件上传失败,通过单步调试才发现,是一次性写入消息组件的内容过长而导致失败。
想到的第一个方案是,将 N 条发货数据切分成 m 个组打包,分组发送。尝试了不同的 N 和 m, 这样会导致多次调用推送消息接口超时,不稳定。查看推送消息的接口代码,发现现有使用的方法是 push 单次推送。咨询消息同学,有一个批量消息推送接口 bulkPush 。使用批量推送接口后,就没有问题了。
批量发货失败的另一个事例是批量发货阻塞:“批量发货阻塞启示:深挖系统薄弱点”。
关于批量发货,有个值得提及的点:有时,商家发现批量发货后系统登记的运单号与发货文件里的不一致或漏掉了某个订单的发货,还提供了发货文件。后来,对原始发货文件内容加了日志后发现,上传时商家要么重复上传了同一个订单号的不同运单号,要么就没有相关订单号的运单号信息。可见,对原始文件内容打日志后,对解决此类纠纷是很有用的。
启示:在处理小批量数据时,简单起见,往往会循环调用单个的接口。这样,很容易导致总调用开销超时。批量数据处理,就应该提供批量接口来处理。
被遗忘的杀手
在做第一期周期购项目时,遇到了一个奇怪的问题:一个 N 期次的周期购订单,仅当 N 次全部发货完成后,订单状态才会变成已发货。然而,在 QA 环境,仅仅发了一次货,订单状态就变成了已发货。
临近上线时间,排查这个问题,排查到心理快崩溃。调试过程:打日志 -> 单步调试 -> 排除干扰 -> 直连确认 -> 地毯式搜索,终于找到罪魁祸首:藏在不起眼的角落里的一个任务悄悄修改了订单状态。详情可阅: “被遗忘的杀手”
这个故事的启示是: 作为系统业务处理的某个环节的任务脚本,往往是极容易被忽视的。忽视的后果是:要么新需求漏改了,要么不小心把不该改的改掉了。
阻塞之痛
很久以前,订单导出还是 PHP 的时代。商家发起一个导出请求,推送一个导出请求消息。后台接收这个请求消息进行处理。每台机器的导出进程只有 8 个。 只要有若干个大流量(1-2w+)的 订单导出,就能把订单导出服务搞出问题。
深切于阻塞之痛,和大数据同学进行了一次合作,将订单导出迁到了 Java + ES + HBase 的技术栈。大数据同学在这个重构项目里功不可没。借助 Java 的多线程和大数据技术,再经历若干次迭代优化,订单导出浴火重生,一举攻克了阻塞的难题。现在,150w+ 的订单量导出不在话下,不少订单导出都在 25-50w+ 之间,8w 订单量导出只要几分钟。
这个故事的启示是:如果技术方案有阻塞的固有瓶颈,那么系统迟早会阻塞。唯有技术重构,才能重生。
关联软件导致的困惑
为了跨平台以及不受导出订单数量限制,订单导出的报表采用了 csv 格式 (excel 有最大行限制)。商家用 Excel 打开 csv 格式的文件后,出现了一些神奇的现象。你能猜到背后的内容吗 ?
科学计数法的物流单号和支付流水号。 嗯,这个还能理解。商品编码是 Feb-76 是什么鬼 ? 2 月 76 日 ? 负数的电话号码 ?猜猜看。
科学计数法,是因为 excel 打开长数字时,会默认转成可读的科学计数法,但对于要处理原始运单号的系统来说,科学计数法可不友好,会导致发货失败;商品编码 Feb-76 的背后内容是 76-2 ,嗯,我不知道 excel 这个默认转换是哪位大神写出来的;负数的电话号码,是因为原始内容被 excel 识别成了数学计算式,默默地做了次算术。
这个例子的启示是:系统不是孤岛。 订单导出的报表常常用 Excel 来查看和操作。即使订单导出没有问题,与 Excel 联用时也会产生困惑。 解答此类疑惑,也是在职责范围内的。不仅要关注自己的系统,还要关注关联的系统。
连续大流量将系统打挂
一切初始事物是自由无限制的。直到有一天被巨大的流量打懵。2018年4月16日,订单导出跪了。几乎接近于崩溃,导出接口响应非常慢,以至于前端直接报错。最后只能通过重启服务器解决。事后排查发现: 当时有多个大流量导出,都在几十万的订单量导出之间,导出机器不多,访问 Hbase 集群大量超时,线程被 hang 住,最终无力支撑。
大流量是导致系统崩溃的一大杀手。在有了一定系统基础的互联网企业里,除了影响面评估不足导致的功能型故障,大流量导致的性能型故障也出尽风头。限制一段时间的请求数量和流量、设置合理的超时,弱依赖降级,是必备手段;将不同用途的线程池(提交任务和拉取数据)隔离;消除耗时操作,比如用 BatchGet 替代 Scan ,消减不必要的 IO 访问等。详情可阅: “订单导出应对大流量订单导出时的设计问题”
经历过一次大流量的洗礼,一个工程师才会走向真正的成熟。
“内存杀手”大对象
对于响应敏感型应用,尤其调用量很大的底层服务,Full GC 是导致系统不稳定的重要原因之一。而大对象,则是容易造成 FullGC 的潜行者。
大对象,通常是一对多的关系导致。比如一个订单有 50 个商品,或者像周期购订单,可以有 100 期次的配送发货。再加上订单列表会一次拉取 20 个订单,如果 20 个订单都是大量商品订单或者都是发过几十期次的配送,那么总数据量大小会很大,容易引起底层服务订单详情 GC,从而引发更大范围的“业务地震”。详情可阅:“记一起Java大对象引起的FullGC事件及GC知识梳理”
启示: 大对象,是需要谨防的另一种引发不稳定的因素。如何应对呢 ?一次调用的大对象数量限制,比如一次只能拉取 m 个周期购订单;每个周期购只拉取最近 N 期次的配送信息;大对象打散分到不同批次调用;综合考虑多个系统之间的协作和影响。
过于自信的疏忽
即使是比较资深的工程师,如果对代码过于自信,而缺乏基本的测试,也会导致问题。这不,为了快速满足业务方的一个需求,我只改了一行代码,没有单测和回归测试就上战场了,结果分页搜索订单失效了。
this.from = (page-1) * size 改成了
if (this.from == null) {
this.from = (page-1) * size
}
最直接的启示是: 单测和回归测试是保证不出低级问题的基本手段。引申一下:安全来源于意识到危险的存在。如果不能意识到危险的存在,就很容易出问题。比如,我第一次用水果削皮器,就把手指削掉了,光荣挂彩。为什么那么多次拿刀都没事,偏偏一个小小的削皮器就搞掉了我的小指甲 ? 因为我压根儿就没意识到,削皮器还有这种危险!
更“宿命”的一个结论是:在写下代码的一刻,命运就已经决定了。是顺利上线还是等着相会故障,早已在代码写下的那一刻就确定了。因为代码执行是精确而确定的,没有一点随机性。想要安全上线,反复多 check 代码吧,对每一行改动都要推敲,是否会产生负面的影响。
乱序消息同步的不一致性
订单状态不一致,不一致,不一致,…… 发生了三次故障。多表同时更新的乱序消息同步在机房切换下的固有问题。实质是,为了高可用的缘故,主备机房存在信息冗余,且主备机房之间同步存在延迟。机房切换过程中,同一个订单的读和写分别在不同的机房,读操作就容易导致读取到旧的信息。原理类似:一个写线程更新了数据库而未更新缓存,而一个读线程读到了缓存里的旧内容。多表同步的原理可见:“多表同步 ES 的问题”
启示是:
不能一次只前进一步。俗话说,事不过三。第一次故障,知道了有这么个原因,增加了批量扫描修复工具,加了对比后的自动补偿,但由于 QA 环境无法复现主备读写不一致的情形,只是对补偿环节做了测试。第二次故障时,发现由于新老数据的版本号是相同的,从而导致新的数据无法写入最终存储,自动补偿未能生效。第三次故障时,才深入思考和梳理了整个过程,做了更多优化。
综合考虑,消除可能导致不一致的场景。 前两次故障,都是发货时更新交易订单表然后立即更新交易商品表导致的。接受商品表消息后,读到了老的订单状态并写入最终存储。实际上,所需商品表的搜索字段在下单时就已经确定并同步了,在交易商品表更新时根本不需要再次同步。因此,我去掉了更新商品表的同步。这样,彻底避免了发货时可能导致的不一致(无法避免下单时可能的不一致)。这体现了“奥卡姆剃刀”原理:如无必要,勿增实体。 尤其增加的多余实体还会带来潜在风险。
更有效更及时的自动补偿机制。修复同步的方法很早就有了,就是更新交易表一次。之所以开始不用,是因为担心一旦短时间有大量的订单状态不一致,就会大量更新交易表,短时大量的 binlog 消息可能会下游造成影响。此外,也没确定系统该如何交互。在优化补偿机制时,发现其他的方案要么在某种场景下难以实现补偿,要么存在更新出错的风险。因此,找了一个合适的地方,将更新交易表的自动补偿机制添加上去了。第二次故障发生时,其实最新的自动补偿机制已经生效了,但是得过 10 分钟后才生效。因此,自动补偿还必须更及时。
墨菲定律
如果事情可能发生,那么迟早会发生。墨菲定律一般形容不太好的事情即使小概率也会发生。退款业务,一向是个业务量比较恒定的业务。如果退款量太大,只能说明不对劲,而不是正常的业务状态。因此,退款单的同步采用了顺序队列,足够所需。
不过奇葩的事情总会有。商家借疫情的东风,做万人团活动,但最终力所不及,库存不足,系统短时间大量退款,导致退款同步短时间无法处理这么多退款消息,消息处理延迟,最终导致故障。
这是一个常规业务量在特殊场景下触发成大流量的场景,超过系统原来的设计所能承载的负荷。怎么看待这个事情呢 ? 一方面,系统设计理应能够容纳 10-20 倍的常规业务量,以备任何可能的特殊情况,而这也会付出更大的成本。这是一个成本与收益的衡量。能够接受怎样的成本和负荷。
此外,退款同步采用顺序队列实际上是一种保守而通用的策略,避免任何情况下的多表同步的不一致。而实际上,退款状态的同步和发货状态的同步是互斥的。也就是说,退款的时候无法发货。要么退款前发货,要么退款后发货。因此,退款单同步退款状态和发货状态,不需要顺序队列。改为非顺序队列后,退款单同步的吞吐量提升了几十倍,不必再担心大退款量的问题了。 这说明:在具备通用方案解决问题的同时,也要根据具体问题的特殊性来设计更简单的方案。
墨菲定律2
PHP 实现的电子卡券导出,走到了异常分支。异常分支有行打日志的代码编译不通过。结果导致电子卡券导出任务进程始终起不来。详情可阅: “遗留问题,排雷会炸,不排也会炸!”
这个事例指出的问题是:很多开发同学不会在意异常分支,异常测试往往是一个空白。而一旦系统走到了异常分支,未料到的情况就发生了。
直接的启示是:不要忽略异常分支的测试。起码要保证编译能通过吧(尤其动态语言不具备强类型校验时) ! 其次,与那句“不要轻易修改系统的遗留代码”的箴言相反,如果不去排除,系统埋下的地雷会“定时爆炸”。颇符合好莱坞法则:Don't call me, I'll call you。
引申的结论是:主动排除系统里的坑。预防胜于治疗。一个故障的发生,既有实际的损失,又需要为故障复盘耗费大量的精力。有这样的时间,为什么不去深挖系统里的坑,及时填平呢 ? 开发与调试也是同样的道理: 与其花费大量时间去调试,为何不花费这些时间使得程序更加严谨健壮呢 ?
局部次要失败导致了整体失败
这种情形屡见不鲜。 曾几何时,有个神奇的字段传给前端的值为 null ,整个页面加载不出来了;曾几何时,有个订单的商品太多,循环单个调用接口调用超时了,整个页面加载不出来了;曾几何时,列表页有个代付订单因故查不到抛异常,整个买家列表页加载不出来了。
在处理整体流程时,需要评估局部失败是否可以接受。是快速失败,还是可以让整体流程走下去 ? 查询详情时,如果是某个次要信息因故获取不到或某个弱依赖出错,是不应该影响整体的输出的。这是对系统健壮性的基本要求。能够做到系统健壮性,时时放在心上,才能更快地成长为合格的工程师。
盲区
人总有留意不到的地方。盲区是那些容易导致问题却能让人毫无察觉无从防范的地方。
比如隐式依赖。如下代码所示,为什么 isItemIncludingAha 能够运行 ? map .toString 的返回字符串不是 JSON 串啊 ! 对代码的追踪发现,在某个遥远的地方,将 extraMap 设置为了 JSONObject ,这才使得程序能够正常运行。真是杰出的超距作用啊 !如果有人把 extraMap 又设置成了 Map ,那就等着线上故障吧。
Map<String, Object> extraMap; // 声明
Boolean isItemIncluded = isItemIncludingAha(extraMap.toString());
private Boolean isItemIncludingAha(String extra) {
JSONObject itemExtra = JSONObject.parseObject(extra);
return itemExtra.containsKey("aha") && "1".equals(itemExtra.get("aha"));
}
比如脏数据。表字段 item.promotionInfo 通常是一个 json 串 或者空串。因此,我先判断非空,然后用 json 库来解析它,再获取 json 串的某个字段的值。我认为 json 解析出来的应该非 null 了。然而, 对于某种类型订单,promotionInfo 的值为 null ! 这就导致 json 解析出来的是 null ,而后面的方法调用就报了 NPE 。NPE 真是 Java 开发者的如影随形的好朋友。幸好,我使用了 try-catch 捕获并暂时隔离了这位好朋友。尽管部分开发同学认为 try-catch 有点“脏”,但它确实阻止了一次线上故障的发生。为了保卫线上,也是不遗余力了。此外,我还有点疑惑:写下大段大段的 if-elif-else 不觉得脏,为了保护线上不出意料之外的问题,写个 try-catch 反而觉得“脏” 了 ?两三行的 try-catch 与一次可能会引发实际损失的未预料的故障,孰轻孰重 ?
对于盲区,多个心眼总是好的。 try-catch 是防身法宝之一 。
小结
踩坑处处有,行路要谨慎。 本文主要分享了一些自己在有赞做订单管理业务期间经历过的故障、踩过的坑。于我而言,这些经历见证了我逐步成熟的过程,引发的思考也很有价值。安全来源于意识到危险的存在。 这篇文章期望让你明白系统的危险可能来源于哪里,并有意识地做好防范。