3个小 bug,直接损失近千万😭

0x0. 背景介绍

再过几天就是 1024 程序员节了,提前祝广大程序员工友们节日快乐,少写 bug,早点下班回家,不熬夜,尽量 delay 秃头的上线时间😭

上篇文章《因为月薪过高,我的工资发放失败了。。。》中,我分享了中行的骚操作导致我收不到工资的故事。简单的说,就是中行的码农老哥上线了一个 bug,误伤了普通用户,将正常的银行卡标记为风险账户导致入账失败。

这个 bug 看似没有带来实际损失,但是浪费了客户、客服、柜台人员的大量时间,这些都是成本。更重要的是,中行损失了潜在的高净值客户,某网友撰文吐槽此事,试图搞个大新闻,居然获得了几万的阅读。万一读者里有未来的首富,发誓不跟中行做生意,中行怎么也得损失几个小目标吧🐶。

作为码农,我们和 bug 的相处时间可能比另一半都多,毕竟咱们就是以写 bug 为生。写代码赚大钱的故事,大家见的多了,尤以逼乎和卖卖为甚。可能是大多数开发离钱太远,亦或是因为家丑不可外扬,网上鲜有人分享因为 bug 亏大钱的事故。

恰好,我做过日入过亿的大项目,脸皮也足够厚,本文分享3个我亲身经历的简单 bugs,简单到只需几秒钟就能修复。但是它们带来了巨额的损失,达到千万之多。

对了,上文有老哥留言说我废话太多了,这里稍微解释下,我的个人简介里有写的:

本人主业是讲段子,副业才是写 bug

所以,为了避免文章过于枯燥,本文,我依然会按照自己的风格,用「废话」的方式来回答:

bug 产生的原因是什么?为什么没测试出来?给用户带来了什么影响?如何修复?耗时多久?如何避免?

如前所述,都是非常简单的 bug,并没有什么深度和难度,只想看干货的老哥,恕难满足,超市里应该有:

声明1:本文内容,毫无虚构,如有雷同,纯属雷同。 声明2:前两个 bug 是在腾讯期间发生的,所以引用了马总的照片,与掘金无关。

0x1. 挤兑的代价

若干年前,北京,12月的某天,23点,-10℃,骑摩托刚到家不久,正坐在暖气片上加热被冻的冰凉的屁股,接到同事电话:

合作团队 X 部门说我们最近几个月的 CDN 带宽陡增,每个月有近千万的成本

千万每月?我以为我听错了,他又重复了一遍,我蹭地一下站了起来,连呼三声卧槽,差点整个人都凉了。彼时,临近年底,老板正在分配年终奖,如果真要支付这么多成本,还发啥年终奖啊,部门都可以就地解散了。

稍后,同事又补充道,这是折扣前的成本,折扣后应该会少很多,具体需要拉上相关同事详细计算。罢了,事已至此,先睡觉吧。

次日,找到相关同事简单讨论了下,基本确定了原因。我们的产品是 SDK,先说下背景:

  1. 不久之前的某个版本增加了功能 A,功能 A 需要用到一些配置 C
  2. 为了能让用户体验更好,SDK 初始化时会主动从 CDN 下载配置 C

最近,我们完善了功能 A,配置 C 的体积也增大了数倍。同时,为了配合推广功能 A,我们做了一次运营活动,鼓励更多用户升级到最新版本。于是,在用户数量和配置 C 的体积双双陡增的情况下,带来了 CDN 流量的暴涨。

雪上加霜的是,一些宿主 APP 用黑科技对抗 ROM,力求做到「保活」,导致 APP 短时间内多次被系统干掉又自动重启,引发 SDK 初始化并下载配置 C。

另外,CDN 的计费是按照当天的峰值带宽来的,24小时内,哪怕波峰只持续了1秒,当天的成本也是按照最高点的带宽来核算的,如下图就是按照接近80的带宽来计算:

再考虑一下我们平时使用手机的习惯,有明显的3个高峰期:

  1. 06:30 ~ 08:30
  2. 11:30 ~ 13:00
  3. 18:00 ~ 21:00

这3个高峰期与我们观察到的 CDN 带宽曲线非常吻合,而且早晚高峰远高于午高峰。虽然配置文件 C 并不大,但是海量的用户一股脑地同时请求 CDN,直接将瞬时带宽推上天了,进而导致核算成本超高。就像今年初的硅谷银行,因为储户的大量挤兑,直接把它给干倒闭了。

原因找到了,解决就简单了,各个击破之:

  1. 找到流量占大头的宿主 APP,与开发者沟通,配置其不请求 CDN,带宽直接降低 90%
  2. 确定根本不需要功能 A 的宿主 APP,配置其不请求 CDN,带宽再次降低 50%
  3. 减小配置 C 的文件体积,精简、移除不必要的内容
  4. 削峰填谷,优化下载策略,平滑 CDN 带宽曲线

前两步在当天就完成了,第3步和第4步是逐步完善的,最终带宽稳定在优化前的5%左右。

我猜,肯定有读者质疑,为何要在 SDK 初始化时就请求 CDN 下载配置?应该先请求某个后台 CGI 接口,由后台决定是否需要下载或更新配置。

这就是另一个话题了,历史原因,前后端的合作比较拧巴,许多本该后端完成的工作,下放到客户端了,导致技术方案很山寨。后来通过两次请求 CDN 迂回实现了这个功能:

  1. 先以某个固定 URL 请求 CDN,得到配置文件 C 的 URL,URL 中有 C 的哈希
  2. 如果 URL 中的哈希与本地配置文件的哈希不同,再次请求 CDN,下载配置文件 C

问题虽然解决了,已经产生的带宽费用怎么办?部门间结算是按季度进行的,但是负责基建的 X 部门,未能及时告知我们带宽异常情况,造成了带宽的浪费。彼时,降本增效尚未开始,经过与 X 部门的协商,对方减免了我们近几个月的带宽费用。

这个问题持续了几个月,粗略的估算,即使打折,实际消耗也有数百万了。虽然 X 部门没要钱,看似是我们赚了,但最终肯定是小马哥给报销,亏的他只能坐公交了。

0x2. 最贵的字符

不久后,轰轰烈烈的降本增效运动开始席卷整个公司。如何降本?最简单粗暴的方法就是开猿节流:

幸运的是,我所在的部门一直有盈利,没有采取这种低级的手段。不开猿,就只能节流了。在解决上面的 bug 后,我们就开始尝试使用不同手段来优化各种机器成本,包括 CDN 带宽、磁盘存储、CPU 资源等等。尤其是 CDN 带宽,每天上班都会看一眼,防止又出事了。

几个月后,优化初见成色,着实为部门省下了一大笔钱。距离当初定的优化目标,每天都在更近一步,心中甚是喜悦。然鹅,快乐的日子总是短暂的,在准备将这份喜悦分享给老板的前夕,出岔子了。

某天,我突然被 Y 部门的人拉到一个群,询问其 CDN 上的某个文件 F 是否归属我的部门。在得到我肯定的答复后,他们说其 CDN 上 99% 的流量来自文件 F,让我们赔付近几个月消耗的数百万元,同时不排除追溯历史费用。

我屮艸芔茻!Yesterday Once More?稍作冷静,直觉告诉我不可能有这么多钱,因为 F 的使用方式如下:

  1. 应用中自带一份 F,程序启动时会加载 F
  2. 当且仅当本地的 F 与 CDN 上的不一致时,才会重新下载

我们两到三个月才会更新一次 F,理论上,只有在更新 F 时才会产生 CDN 流量,费用最多只有他说的 1/10。彼时,大家都在「降本」,我不敢懈怠,为了尽快把锅甩出去,赶紧找经验丰富的同学的帮忙排查。

很快啊,锅就回来了,因为某处多了一个字符,导致 CDN 带宽暴涨。先暂停1分钟,能猜到可能的原因吗?

---------- 我是没用的分隔符 ----------

问题出在上面的第 2 步,假设 CDN_FILE_HASH 是 CDN 上的文件 F 的哈希值,由后台的 CGI 接口返回给客户端。整个流程的伪代码如下:

scss 复制代码
 CDN_FILE_HASH = get_cdn_file_hash_from_cgi();
 if (CDN_FILE_HASH != localFile.hash()){
   downloadFile();
 }

简单的 debug 了下,cgi 返回的 CDN_FILE_HASH 比预期多了个换行符 \n,这就导致了 if 语句始终为真。于是,应用每次启动时,都会重新下载 F。谁说人不能两次踏入同一条河流的?这跟第1个 bug 不是一毛一样吗👀。

我们每次在更新 F 后,会将其哈希写入另一个配置文件 H。在收到客户端的请求时,后台读取 H 的内容,返回给客户端。后台相关代码自上线后就没动过,所以多出来的 \n 只能是来自文件 H。

之前,我们是先人肉更新 CDN 上的 F,再将其哈希写入 H,每次都需要在 Web 上填一堆东西,比较麻烦。为了增效,就写了个脚本,一键更新 F 并将其哈希写入 H,真爽!

不用说,肯定是写文件的地方有问题,伪代码如下。暂停1分钟,看出 bug 了吗?

python 复制代码
 def write_F_hash_to_H(F)
   with open('H', 'w') as H:
     print(F.hash(), file=H)

---------- 我是没用的分隔符 ----------

对 python 熟悉的小伙伴应该看出来了,print 会自动追加换行符(默认为\n),而 JAVA 只有 println 才会追加:

就这样,价值数百万的换行符诞生了,这是我见过的最贵的 bug 了,这亏钱速度,大 A 看了都要落泪😭。

修复也极其简单,将 print 函数的 end 参数赋值为空字符串即可。当务之急是减小损失,遂立刻人肉删除文件 H 中的换行符,CDN 流量瞬间就跌下来了:

之后的 CDN 带宽走势与上图箭头右侧非常相似,这是我去年中买的一支股票,买在箭头所示的地方,两个月前割了,仅亏损95%😎。

现在,同样的问题又来了,已产生的几百万的费用咋办?彼时,各部门都在「降本」,我们之前那套说辞不好使了,对方坚称要赔付。经过几轮「友好」的争吵与互相问候,几番讨价还价,赔付自 bug 发生日期之后的费用即可,分期付款。

上面提到,我每天都会看一眼 CDN 带宽,这条大鱼为啥还会漏网呢?这就说来话长了:

很久之前,我们也隶属于 Y 部门,CDN 自然也是同一个。

后来,组织架构调整,我们被"赶出" Y 部门,自立门户了。

因为文件 F 非常重要,为了保证存量客户端版本不受影响,分家的时候,F 没有迁移,仍然保留在原 CDN 上。后续 F 有更新,还是上传到原 CDN。正常情况下,F 的带宽非常小,可能 Y 部门没发现或者懒得计较,放任我们白嫖了。

我没有权限查看 Y 部门的 CDN 的监控面板,亦不了解那段历史,直到因为 bug 暴雷,我才知道是 Y 部门在替我们「负重前行」。

bug 持续了两个多月,粗略估算,我们支付的费用,足够正常情况下使用好几年了。真的是应了那句:

所有命运馈送的礼物,早已在暗中标好了价格

不用说,最终又是马总一个人承担了所有

0x3. 狸猫换太子

前面的 bug,根本原因都是技术方案不完善和细节考虑不周所致,简单的说,是自己人的锅。但,即使把代码写的完美无瑕,就一定能正确运行吗?

11年前,我是个刚出道不久的小菜鸟,接手了一个偶现 bug,涉案金额可以忽略不计。业务逻辑非常简单,如下:

  1. 客户端 POST 本地数据库中的数据至服务端,服务端返回响应 rsp
  2. 如果上传成功,客户端删除本地已上传数据;否则,再重试一次

零星有几个深圳的用户反馈我们的 APP 消耗了很多流量,最终的排查结果,嘿,您猜怎么着?高情商的说法是「涨见识了」,低情商的说法是「操」!

为了复现这个问题,我尝试了三个运营商的手机卡,在GPRS,EDGE,CDMA 1x,3G等多种网络条件下测试,流量完全正常。因为缺少必要的 log,只能从代码入手,初步怀疑可能有 bug 的地方:

  1. 上传成功,但本地数据删除失败,导致每次重复上传旧数据
  2. 重试逻辑不严谨,如果上传失败,可能多次重试,浪费流量

喊上导师一起仔细读了几遍代码,确定代码没问题。之后的细节记不清了,最终是在深圳同事的协助下,找到了复现的严苛条件:

深圳,中国电信手机卡,数据流量上网,选择 CTWAP 接入点

复现时,远程 debug 发现,客户端每次都走到了上传失败的分支,但手机的网络是正常的,也能 ping 通我们的域名。

猜测是客户端或服务端收到的数据有问题,亦或二者皆有,用 tcpdump 在客户端和服务端分别抓包,很快确定了:

  1. 服务端地收到了正确的数据,返回的 rsp 是 gzip 压缩的 JSON 串
  2. 但是,客户端收到的 rsp 与服务端发出的不一致,有些字节被篡改了

看到这,可能有读者会说,这不就是「HTTP 劫持」吗?某些小运营商,利用 iFrame 在网页上插个「XX荷官,在线发牌」的广告,祖传手艺了。

根本原因肯定是 HTTP 中间人攻击,篡改了数据,但我觉得更像是运营商 CTWAP 网关的 bug,因为以下两种修改都可以收到正确的 rsp,说明并非深圳电信刻意为之:

  1. 手机上将接入点改为 CTNET
  2. rsp 不启用 gzip 压缩

在选择 CTWAP 接入点时,手机的 HTTP 请求都会被转发到电信内网的代理服务器 10.0.0.200:80。怀疑是深圳部署的 proxy 有 bug,识别不了 gzip 格式,或者某些二进制字节被误判为「非法」字符,出于好心,就顺手帮我们改了。参考那个交换机的段子。

再说回 bug,现在原因很明显了:

上传成功 -> 服务端返回 rsp -> CTWAP 网关篡改 rsp -> 客户端认为上传失败 -> 重试 -> 再次失败 -> 下次满足条件时继续上传...

修复也很简单,因为 gzip 压缩前的 JSON 也就几十个字节,没必要压缩。正好客户端使用的 HTTPClient 可以自动识别 rsp 是否被 gzip 压缩并正确处理,所以只需要服务端关闭 gzip 压缩即可。

彼时 3G 已经普及了,大多数电信手机的默认接入点都是 CTNET,加上网速也不快,所以实际的影响非常有限。当然,针对受影响的用户,我们如实赔付了其损失的流量费。同时,也将这个问题报给了深圳电信,至于是否修复,就不得而知了。

这大概就是「人在家中坐,锅从天上来」吧,所以,bug 不可怕,可怕的是没 bug,那必定是有 bug🐶

0x4. 总结 & 反思

所谓「常在河边走,哪有不湿鞋」,即使经验丰富的大佬,也能写出匪夷所思的 bug,例如臭名昭著的goto fail漏洞:

这段代码存在于 iOS 7.0.6 之前,其正式编号为CVE-2014-1266,会导致非法的 SSL 证书也能被接受,有极大的安全隐患,详细分析见dwheeler.com/essays/appl...

所以,也没啥好总结的,就像每次版本发布之后,无论针对 bug 的「批斗大会」开的多么成功,码农的反思多么深刻,下次一定还会有 bug,正如黑格尔所说的:

人类从历史中学到的唯一教训,就是人类无法从历史中学到任何教训

虽然无法完全杜绝 bug,但一向好为人师的我,还是想 BB 三句,仅针对本文分享的几个 bug:

  1. 提高成本意识:客户端开发大多都没有机器成本的概念,包括我自己,我们需要尽可能优化网络请求次数和数据量,这些都是💰啊,除非你是帮老板解决钱花不完的烦恼🐶
  2. 增加白盒测试:程序正确运行,不代表没 bug,本文第2个 bug,如果有白盒测试,上线前一定能发现每次都下载 F 的问题。至于哪里用白盒,没有标准,与💰强相关的地方,优先考虑
  3. 一切皆有可能:当出现 bug 时,如果无论如何也找不到原因,也许真的就不是自己代码的 bug,先拖一拖吧,也许它自己就好了,尤其 Android,太多玄学的事情了。。。

最后,用专治八阿哥的雍正帝镇楼,保佑码农兄弟姐妹们碰到 bug 时都能迎刃而解:

相关推荐
拭心42 分钟前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王3 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡3 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道4 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库4 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道5 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe5 小时前
Android Hook - 动态加载so库
android
居居飒6 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He9 小时前
桌面列表小部件不能点击的问题分析
android
工程师老罗9 小时前
Android笔试面试题AI答之Android基础(1)
android