如我上篇文章所说,应用层的开发越来越像玩玩具。 种种底层的协议以及抽象让人获得了接近理想化的开发环境,仿佛一切都在鼓掌之中。 然而最近在维护一个已上线的Node项目时遇上了个bug让我对于应用层开发有了点新思考。

Bug

简单的说,那个Node的项目是个答题应用。 主要的use cases之一就是四个字“用户答题”。 这是个奇异的bug,简单的概括是,在上线以来的一年里,有1%的答案数据产生了重复。 一般来说,一个用户的一道题对应一条答案记录,然而有那么一些题目却有两条答案记录。 这些重复的答案记录刚好占总答案量的1%也就是数千条。

最终修复这个bug实际上并不困难,然而在发现bug来龙去脉的过程中却花了不少时间,可谓盲区bug。 有几个原因导致:

  • 无法重现:只有1%的发生率。我用了很多方式去模拟用户行为,例如多种浏览器访问,多一些不正常的操作,最终我都无法得到同样的结果。
  • bug比较温和:虽然存在已久,但这个bug对主体业务逻辑不影响,我便没太注意。
  • 缺乏针对个体的统计:对用户使用的技术,除了统计上的数据外,并没有针对个人进行统计。 因此我不能第一时间知道遭遇bug的用户使用的浏览器,地域,时间。
  • 对底层环境过于信任:这是最重要的一点。见下文。

起源与启发

最终的最终在经过诸多调查之后,我发现了代码是由于这样一段后台逻辑产生的。 所有的answer数据的插入操作都在下面的逻辑里。 数据库是mongodb,下面算是伪代码和文字的交杂,见谅。

后端:
接受到了一条answer数据。
if (answer._id) {
    插入answer进数据库。
} else {
    更新已有答案。
}
最终将newAnswer(相比于之前可能多了_id)返回给前端。

前端:
在填写新answer的情况下。
用户点击save,answer数据上传服务器。
若成功则将返回newAnswer覆盖本地answer。
若失败则提示用户失败,界面保持原样。

这样一段逻辑看起来非常的人畜无害。 因为新答案没有_id,而旧答案一定有,用此来判断新旧,相当正常。 用户填了新答案,提交后便得到了新_id,这样新答案就变成旧答案, 将来上传至服务器后之后只会触发更新操作。 哪来的重复数据呢?

问题就在于此,我把底层的条件想的太理想了。 事实是,在极端的条件下(用户的网络条件差,用户在海外,国内censorship也比较紧)用户随时可能中断和网站的连接。 那么就可能出现这种情况:

用户填了条新答案随后点击save。
数据的确被上传进服务器,数据库操作(插入)也的确发生了,然而在返回数据时网络中断。
这时在用户端看见了一个失败提示,用户下意识决定再点击一次save。
这时上传的数据依然没有_id,于是数据库又进行了一次插入操作。

就这样,重复数据就产生了。 并不复杂,不过很值得思考,虽然HTTP建立于可靠的TCP,然而网络终究是个物理属性,随时可能被别的物理条件影响。 而我在测试时,总是使用理想的网络环境,所以不论如何也测试不出这样的问题。 同时缺乏有效的个体统计导致我不能意识到这些用户遭遇bug时到底身在何方,网络条件如何。 总总条件包括我的蠢使这个简单的bug进入了我的盲区,难以发现。

最后这个bug对于我的启发意义便是,总是要记得没有理想的软件环境。 毕竟电一断,软件工程就立即香消玉殒。 正如黑客军团中有一句话,终极的hacking都要通过social engineering来完成。

至于解决 (optional)

解决自然容易,将利用_id的判断改成用答案本身自带的属性判断即可。 也就是服务器检查“用户u 第x区 第y组 第z题的答案”是否已经存在,如果不存在便插入,存在便更新。 _id是由数据库自动生成的,不可控,但是题目本身的属性是绝对可控的。 即使用户想尝试两次插入操作,也只会操作同一个对象。