菜鸟 CTO 线研发效能团队开发了一个大促协同平台,来提高大家在处理大促相关工作时的协同效率,内部应用名称为 iwork 。某一天 iwork 应用突然发生了一个线上问题:
线上的一张核心数据表,所有的数据突然全都不见了。
删库跑路,是程序员的终极技能,新闻上也数次见过报道。小伙伴们虽然第一次在现实中遇到这种紧急情况,但是大家镇定地处理了这个问题。
首先在 DBA 的帮助下,导出 binlog 日志, 一起临时恢复了数据,对问题进行了止血。紧接着是要定位造成问题的原因。
第一反应是生产环境的代码执行了数据删除逻辑,但是近一段时间小伙伴们都在埋头开发新功能,生产环境有一段时间没有发布过了,如果生产环境代码有问题的话,早该暴露出来了。查看了一下 master 分支上面的代码,只有一个 delete 方法会删除对应表的数据,但是这个方法并没有在任何地方被调用。
如果不是生产环境代码的问题,还有什么可能性会导致数据删除呢?我们想到预发环境也有可能,因为预发和正式共用同一个数据库。这个时候一位小伙伴提供了一条关键信息,在预发环境的数据库操作日志中,找到了 delete 操作的执行记录,看来是预发环境的代码删除了数据。
本以为事情到这里已经快水落石出了,但是当我们去查看预发环境代码的时候,发现代码中也没有任何地方会对数据表进行删除,更加诡异的是,甚至 master 分支上的那个 delete 方法,在预发代码的分支上,已经被注释掉了。为什么要注释掉这段代码呢?小伙伴在日常开发的时候,遇到了数据偶有丢失的问题,但是由于日常环境经常不稳定,并且问题也没有复现,代码中也找不到调用该 delete 方法的地方,为了保险起见,就把整个 delete 方法注释掉了。
问题排查到这里陷入了困境,所有的线索都指向这个推论:
被注释掉的delete方法,不甘心自己的生命被终结,它决定向程序员复仇。在某个时间,它复活并运行了自己,把对应的数据表中的所有数据,所有数据都给删掉,然后跑路。
时值盛夏,突然觉得公司的空调开得有点凉。

没有办法根据代码顺藤摸瓜,我们只能转头去寻找更多的线索作为输入。内部鹰眼( eagleeye )系统提供了强大的链路查询功能,让我们可以查出数据删除操作相关的整个链路调用情况。

基于预发环境的数据库操作日志,我们查询了这段时间附近的调用链路,最终找到了问题的根本原因:最近新开发的一个数据同步功能,其代码引入了一个 Bug ,代码如下:

代码没太多可说的, query 对象没有处理查询字段为空的情况,导致 list 方法返回了全表对象,在后续的循环中调用了 delete 方法,删除了整张表的数据。
至此根本原因已经找到了,但是案子只破了一半,我们还没有回答幽灵代码是如何产生的。以上这段代码,是在一个分支 B 当中,但是当前预发环境中运行的是分支 A ,而分支 A ,如前文所述,是不包含这段问题代码的,甚至 delete 方法都是注释掉的。
其实产生幽灵的元凶,是当前 iwork 应用采用的分支模式:

由于 iwork 日常环境不稳定/不可用,大家常常用预发来做测试。预发上面部署的分支,其功能通常还没有开发完成,不能直接集成,于是为了测试自己的分支,常常需要把其它人的分支踢掉(利用 Aone 提供的退出预发功能),部署上自己的分支。越是临近项目发布阶段紧张关头,这种踢来踢去的情况就越普遍。
幽灵正是这样产生的,问题代码所在的分支 B ,之前在预发环境部署运行过,现在被分支 A 踢掉了。分支 A 上运行着健康的代码,让我们误以为幽灵的存在。
需要指出的是,如果分支 A 和分支 B 能够尽早集成的话,是可以发现合并冲突的。并且,虽然当前菜鸟已经强制对所有应用进行代码审核卡点,分支 B 的代码并没有经过代码审核,就部署上了预发,因为大家都通常会等到功能完全开发验证完成,发布正式的时候才提交代码审核。
在团队内部事后的复盘中,提出了一系列方案,来防止删库跑路的问题:
删库跑路是客观确定的问题,通过一系列手段总是可以预防/恢复的。本文想讨论的,是更加棘手的幽灵代码的问题。
反思——如何避免幽灵代码
如果不从根本上解决问题,幽灵代码会一次次来敲你的门,可能是白天,也可能是午夜。
既然元凶是分支模式,那就让我们来聊聊各种分支模式。
AoneFlow 就是日常工作中大家所说的分支模式,是Aone应用开发的默认模式,菜鸟大部分应用都是采用的这种模式,上文中也贴出了页面截图,大家应该都比较熟悉。
关于 AoneFlow 的详细描述,可以参考:https://help.aliyun.com/document_detail/59315.html
用一张图来描述:
简单来说,AoneFlow分别给日常、预发和生产环境,建立了独立的发布分支(release branch)。实际开发时,自由地组合特性分支(feature branch),与各自环境的发布分支做集成。
核心特性
AoneFlow的核心特性在于灵活的特性分支:
特性分支的好处:这里的好处是特性分支自带的,它可以让开发者专注于自己的功能不受干扰,不受干扰地工作对程序员的诱惑是巨大的。
坏处
特性分支的坏处,这里的坏处也是特性分支与生俱来的,参考:
https://fire.ci/blog/why-you-should-not-use-feature-branches/
灵活的特性分支,让犯错变得容易,发布变得困难,质量也难以保障。
Git/Github Flow
Git/Github Flow 是开源世界普遍使用的分支模式, Aone 也提供了很好的支持。在 Aone ,开启持续交付功能之后,默认使用的就是 git-flow 模式。

Git Flow 模式常用在企业级项目和大型的开源项目管理,分支模型包含了 Master Branch、Develop Branch、Feature Branch、Release Branch、HotFix Branch ,比较复杂但很好地满足了大型项目的管理要求。

GitHub Flow 对 Git Flow 做了精简,只保留了 Master Branch 和 Feature Branch ,大部分开源项目都使用这种分支模式。

Git/GitHub Flow 避免了 Aone Flow 的灵活分支组合,但本质上仍是基于特性分支的模式,继承了特性分支的好处和坏处。
采用Git/GitHub Flow 的团队,虽然特性分支的坏处无法完全避免,但避免了AoneFlow灵活分支组合带来的问题,通过一些规范约束,结合 Aone 的持续交付功能,可以得到较好的应用质量。
主干模式(Trunk-based development, TBD)
主干模式是所有开发者在同一主干上进行协作的分支模式,禁止其它长生命周期的开发分支存在,以避免集成地狱( merge hell ),其形式如下如所示:
主干模式:https://trunkbaseddevelopment.com/

主干分支的核心特性在于清晰简单,并且主干永远保持可发布状态。为了达到这样的特性,需要相应的持续交付实践配合。
在上古时期, git 还未诞生的时代,人们使用的是诸如 SVN、Perforce、Clearcase 这样的中央式版本管理系统( Centralized Version Control System ),和 git 不同,它们不具备灵活的分支管理和强大的分支合并功能,所以那时除了主干模式别无其他选择,但是由于当时并没有 Jenkins、Docker 之类的 DevOps 基础设施,持续交付实践也不成熟,所以主干长期处于不可发布的状态,主干模式在大家的灵魂深处留下了痛苦的记忆。
没有配合持续交付实践的主干模式,很容易阻塞整体的开发流程,带来的破坏远远大于收益。配合持续交付之后,主干模式会进入另一重天地。
持续交付
特性开关:
https://martinfowler.com/bliki/FeatureToggle.html
特性开关实现起来比较简单。如果是 web 应用,可以在前端通过 URL 控制,不在页面上暴露链接入口。也可以使用动态配置,通过配置管理中间件来启用/禁用功能。麻烦一点的做法,可以在代码入口处增加 if/else 逻辑,等功能正式上线后再删掉。
快速编译:持续交付提倡迅速反馈,为了在代码提交之后获得高效的反馈,我们需要让应用能够快速完成编译。
从架构上来说,可以把大应用拆分成微服务,让每个服务独立快速编译。对于单个应用,可以通过减少未使用 jar 包的方式提高编译速度。同时, Aone 也提供了热部署功能,通过热部署而非重新编译的方式,大大降低打包和启动的时间。
代码门禁:代码门禁是应用质量的第一道关卡,只有通过了 CodeReview 、规约检查、冒烟测试/单元测试的代码,才允许集成到主干。
对于 CodeReview 来说,要求团队成员完成一个小功能的开发验证之后,立刻 push 代码并创建 CodeReview 请求。这样做不仅可以避免自己的代码和别人的代码混合在一起 review ,也可以有效控制每次需要 review 的代码量,保证 review 质量。
持续测试:基于测试金字塔,通过单元测试、集成测试、链路测试来保证应用质量。一开始可以把测试作为一个异步检查的过程,测试出错发送相应修复通知,但不阻塞整体流程,后续待测试足够可靠之后,可以把测试检查加入流程卡点。
团队需要树立良好的测试理念:测试用例不是用来发现问题的,而是用来证明软件确实如预期那般可靠工作。
紧急发布代码
Aone Flow 对紧急代码发布做了非常灵活的支持,可以跳过日常和预发直接部署正式,那么主干模式如何实现同样的功能呢?回答是做不到也不允许。
事实上做好了主干模式,通过不断提高应用质量防患于未然,紧急的线上问题是可以被扼杀在摇篮之中的。退一步说,真的遇到了紧急线上问题需要恢复,我们也该优先采用代码回滚、修改配置项(比如特性开关)这样的非代码方式。再退一步说,如果一定要通过提交代码的方式修复紧急问题,需要考虑的不是在 CodeReview 、日常及预发的部署验证环节节省时间,而是应该思考如何更加迅速地定位问题,因为大部分时间往往是花在问题定位上。
团队案例
除了严格的单主干,一种常见的变种策略是多主干模式,我们团队的 iwatch 应用采用的是双主干模式:

其中:
1、 shadow 分支。可以看作 develop 分支的一个镜像,我们引入 shadow 分支,是为了适配 Aone 的代码审核功能。每次 push 代码到 shadow 分支之后,提交代码审核请求,系统基于 shadow 和 develop 分支之间的 diff 创建代码审核页面。通过代码审核及其他代码门禁检查的代码,将自动合并到 develop 分支。
2、 develop 分支。Aone 持续交付功能之后,默认侦听的是 develop 分支,一旦 develop 分支上有了新的提交,就会触发持续交付流水线,依次在流水线的各个环境中进行打包编译、启动应用以及测试验证。从预发环境到正式环境,依然需要人工验证之后再手动进行发布,所以我们是做到了持续交付而没有做到持续部署。实践中我们在 git 源码库将 develop 分支设置为 protected ,禁止开发人员直接 push 代码到 develop 。

3、master 分支。代码发布到正式环境之后,会自动向 master 分支写入基线,开发人员对此无需感知。
日常的开发流程简单明了,我们约法三章:
1、所有的代码提交都在 shadow 分支,所有的集成和发布都在 develop 分支, master 分支作为基线。2、原子性提交,降低粒度。每次提交之后,立即创建合并请求,触发 code review 。3、提交的代码,遇到了任何 code review、merge、应用启动、单元测试、集成测试方面的问题请立即修复( Aone 会发工作通知),保证应用永远处于可发布状态。
为了打造平等开放的 CodeReview 文化, code reviewer 需要添加所有相关的开发同学,而不只是指定团队内更资深更高层级的同学作为 reviewer 。鼓励团队内所有同学主动担当 code review 的责任,对代码提出自己的建议。代码面前无层级, JVM 不会因为是高P的代码就不抛空指针,当代码穿越磁盘站在程序计数器面前的时候,它们是平等的。
iwatch 是一个普通的 java web 应用,3~4 人在同时开发,已经持续运行了四年多的时间。两年多已前刚加入到这个应用的时候,当时使用的也是 Aone Flow ,后来升级到 git-flow ,并且在持续交付方面不断进行积累,在一年多之前升级到了现在的主干模式,此后至今应用未发生过大的线上问题,并且在两个月之前刚刚完成了一次大重构(修改超过 90% 文件)。统计了一下部署数据,近两周平均每天集成 9 次,发布 0.7 次。
总体评价
流畅的开发体验和高质量的应用,会在团队内部沉淀出良好的技术氛围与技术文化,氛围文化又会反过来进一步提促进开发体验和应用质量的提升,形成正向循环。
一个常见的说法,主干模式只适合小规模团队,但是谷歌和Facebook实践证明,大规模开发团队一样可以把主干模式做得很好。
谷歌:
http://www.ruanyifeng.com/blog/2016/07/google-monolithic-source-repository.html?20160703175643#comment-last
Facebook:
https://paulhammant.com/2013/03/04/facebook-tbd/
如果 iwork 应用也采用了主干分支模式,那么开头的幽灵代码问题,至少有三次挽救的机会:通过代码合并之前的 CodeReview 发现代码中的问题;通过自动化测试发现边界值异常;在主干代码上快速定位到问题代码。
好的分支模式/开发流程,要让做正确的事情很容易,做错误的很难,而不是在功能灵活的旗帜下,让做正确的事情很容易,错误的事情更容易。
好的分支模式/开发流程,需要约束人性的灰暗面,放大人性的光明面。人们喜欢沉浸于当下的舒适,倾向于延迟未来的痛苦,所以需要把大家的工作约束在主干,迫使集成更早更频繁地发生。人们希望自己的工作在团队内部创造更好的价值,而非给别人带来麻烦,所以需要鼓励大家频繁细粒度地提交代码,避免给别人带来的不必要的干扰。
逃出分支模式的肖申克
人生而自由,却永陷枷锁,每个人心中都有一座肖申克,详情参考:http://mindhacks.cn/2009/01/18/escape-from-your-shawshank-part1/
常常听说“没有最好的分支模式,只有最合适团队的分支模式”。很多时候,当前的分支模式只是最适合团队现状的模式;不是分支模式最适合团队,只是团队适应了这种分支模式而不愿逃离。
希望每个团队的开发活动,都可以成为童话故事,而非鬼故事~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!