想到这个题目是因为最近我们组出现了一个严重的线上问题,某小哥在进行线上操作的时候传错了一个参数。这种问题在程序员这行很常见,所谓常在河边走,哪有不湿鞋。
工作这么多年我自己犯过错,也看到过身边的删库跑路案例,更有甚者因为泄露敏感信息而锒铛入狱。这里分享一些自己所见的真实案例,以及如何在编程上、工作习惯上避免犯错。
案例分享
信息泄露
大疆前员工泄露公司源代码,被罚 20 万、获刑半年,这个安全事故完全是因为员工的安全意识不足造成的。
这类问题非常多,不信你可以在 Github 上用 password、private key 之类的关键词搜索,很多人会无意识把这些敏感信息推送到 Github 上,而公司又没法完全禁止 Github,只能不断加强安全培训和监控。
从这个案例来看公司的损失非常非常大,单人力成本这块就难以估计,我也因为这个事故参与到了安全建设中,后面在这里领域工作了两三年左右。真是一人挖坑,无数人救火。
退款接口
我在做支付、物流相关的系统时,曾经因为一个诡异的接口造成了直接上的金钱损失。
我们的支付是通过第三方支付系统做的对接,比如用户通过支付系统向我们预支付了 100 美金,等他收货后第三方把钱给我们。第三方支付有个退款接口,假设他不想要货了于是发起退单,我们的系统就会调用第三方的支付接口去退款。
因为接口有时候调用失败或者返回不及时,我在写代码的时候默认既然对方预支付了 100 美金,多次发起退款接口自然也没问题,所以我有一些重试的机制来确保退款成功。过了一段时间后发现账目上有点差别,后来经过排查是因为重复调用了退款接口,这个接口如果两次调用就会退给客户退 200 美金!
最后我们只能发邮件给一些客户,说多退了钱,麻烦能退回来么,有的客户很好心就直接返回了钱。我记得有个客户回复说:我认为这是上帝给我的恩赐,对不起我已经花完了。
额,我就是那个可怜的上帝好么。
并发问题
我前公司所在的部门曾经有个发货系统,当多进程跑起来的时候,有个并发问题没处理好,最终导致用户收到多份相同的货物。
当程序员经验不足的时候这种错误就很容易出现。代码中哪些部分是可以重入的,哪些部分需要加锁,都需要仔细考虑。但是在业务快速发展,快速堆代码的时候,我们可能不一定有足够的时间把所有细节都考虑清楚。
配置错误
我之前出现过的一个最大的错误是因为配置错误。这件事我一直都记得,因为印象实在是深刻,现在对正则表达式都有所恐惧。
那天我正准备下班回家,我配置了一些安全上的防护规则。然后我的 Leader 说拦截的页面不够好看,我们要不统一个拦截页面。我想了一下觉得很简单,就准备在我们自己定制的网关 (Kong) 上配置一条全局规则,我想通过正则表达式把所有拦截页面 redirect 到订制的错误页面。
我通过后台 Admin 页面,在一个全局插件上写下了一条正则表达式,提交生效。然后立马就收到了报警,大量系统同时报警!因为有公司很多域名的请求都通过这个网关,而我配置的正则表达式嵌入到 Lua 代码中后有语法错误,导致所有系统的路由处理时都报错。
最要命的是我们的 Admin 页面也会经过这个网关,所以 Admin 页面也没法访问了,意味着我无法通过页面去回滚配置!我当时已经手心发汗,如热锅上的蚂蚁了。强迫自己镇静下来,马上修改插件的代码,赶紧让运维一起迅速地服务器上网关更新。
整个过程大概花费了 20 分钟,这期间整个公司估计有一半的系统都是不能访问的,包括那些官网、商城等。
经验总结
犯错并不可怕,只要是个人就可能会犯错。出现错误往往也不止是个人的问题,也意味着团队有问题,比如对代码质量要求不够,系统设计不够容错,权限划分不够好,安全机制不健全,没有 代码 Review 等等。错误是个人和团队最好的学习、提高的机会,而且我们已经交了学费。
但是随着我们成长,最好避免个人犯一些低级的错误,特别是安全类的问题。写程序、做系统设计的时候就做好防御,把犯错的概率降低到最小。
防御编程
面包落地的时候,永远是抹黄油的一面着地。
上面配置的问题,我在做网关的时候其实意识到了潜在风险, Admin 路由也经过自己控制那出问题不就嗝屁了吗?当时我自我安慰只要不对这个路由开有问题的全局插件就可以了,所以没有及时处理这个风险,最终导致自己掉入坑里。
当系统中存在潜在问题时,时间一拉长出现的概率就大了。因此我们编程的时候总要有意识想最坏的情况是什么,哪些是危险操作,比如写数据如果没写入成功会怎样,如果并发运行了会怎样,如果文件错误会怎样,这就是防御式编程。
做系统设计时,要考虑敏感的业务逻辑如何测试,如何在系统层面规避错误。对于敏感的资源一定要再从统计的角度进行复查。像我那个退款的问题就是对潜在的风险意识不够,想当然地对接口进行了错误的假设,而对方这个接口不是幂等的。后来我们在系统中加了很多检查,确保及时代码有问题也能尽早发现问题。
如果系统对正确性要求高,必须加大量单元测试和集成测试,并且每修复一个 Bug 都引入对应的测试,因为随着代码的不断演进,没人能保证新加的代码不会破坏掉原来的代码。测试能最大程度自动化地帮我们发现一些潜在问题。
我工作的第一家公司是做 EDA 相关软件的,因为 EDA 软件不像互联网这样的系统,crash 了就是发生在客户的机器上,很多时候都没法 debug,因此公司对代码质量要求极高,他们在自动化测试这块就做得非常棒,测试覆盖率几乎 100%,还有很多 fuzzy testing。
代码上的问题没法完全避免,那如何减少风险?微软有个实践就是大量运用 killswitch,本质上就是开关,每个新加的功能和代码,建议都是加上类似这样一个嵌套:
if(!killswitch-active(uuid))
{
// your new code ...
}
else {
// old code ...
}
这样的好处在于如果新的代码出现了问题,可以迅速把对应的 killswitch 打开,这样老的代码就继续跑了,也就是不用发新版本就能快速回滚。不得不说这个办法虽然有点土和笨,但是非常有用,因为这也救过我,而且让人发代码压力不至于那么大。坏处也很明显,当 killswitch 多了之后代码就很难读,所以要去定期清理那些老的开关。
工作经验丰富一些了之后 (掉入坑里足够多次),自然会对容易出现问题的部分有风险意识,这需要不断积累和总结。
工作习惯
安全是第一位的,我们在工作中对敏感信息、公司资产要有一定的安全意识。完全按照公司的安全准则来工作,否则提桶跑路可能是小事,被追究法律责任就麻烦了。
任何线上操作都是危险的,如非必要不要进行手动的线上操作。操作的时候尽量慢,然后想清楚如果错了如何恢复。比如删东西尽量软删除,把要删的东西移动目录或者设置状态。
如果一个动作是有危险的,应该思考如何把这动作自动化,如果是必须有人给输入,那需要一定的流程来进行 Review 和批准。
微软还有个好实践就是所有的线上命令,如果是写入型的命令默认不能运行,需要手动地运行命令提升权限。
运维方面,如果有条件和时间尽量往 Infrastructure as Code 方向上靠,减少人工进行操作。
_
写到最后,觉得写得不够系统和全面,这个题目范围太大,开发、运维、规范、安全等很多方面都涉及到,而且有很多细节问题。
一句话建议是:保持对工作的敬畏之心,特别是你的代码和工作会影响到很多用户时,即使一个小的错误也会造成大量损失。
先这样吧。