连按两次发布,现在只会发布一次
同一个应用的三个发布请求相隔 84 毫秒到达我们的 API,每个都启动了该应用的一次完整部署。它们相互竞争,抢占同一个托管环境,其中好几次发布因为别的发布先一步完成而失败,唯一可见的迹象只是一封「发布失败」的邮件,而应用本身最终是正常的。现在,当同一应用的另一次发布正在进行时到达的新发布,不会再启动第二次构建。数据库决定哪个请求胜出,其余请求则会被告知真实情况:已有一次发布正在进行。
发生了什么
当你在 Mythex 上发布应用时,API 会为其记录一次部署,并请求我们的编排器(负责运行构建的服务)来构建并发布它。发布可以从 Publish 按钮、另一个浏览器标签页,或者在你要求时由代理发起。双击、两个标签页,或者一个被送达两次的代理请求,都可能在几乎同一时刻为同一个应用产生不止一次发布。
这次有三个请求在 84 毫秒内到达。每个都运行了一次完整的部署。在托管平台上,同一应用的发布在同一秒内被创建,其中三次因为其他发布已经在进行中而失败。之后一个发布卡住了,但应用随后恢复了正常。
正因为这样,问题很容易被忽略。用户看到的是一个正常工作的应用,而失败只以发布失败邮件的形式出现。
为什么没有任何东西阻止它
发布代码确实有一个步骤会检查该项目中正在进行的部署。但它所做的,是在开始之前把每个正在进行的部署都标记为失败,理由是「已被新的发布取代」。它原本是用来清理崩溃进程遗留下来的构建的。
实际情况是,在构建进行中到达的第二次发布会宣布第一次发布已经死亡,并启动自己的发布。两者都会针对同一个应用一直运行到完成。这里没有锁,反而有一个主动为竞争清除道路的步骤。
应用的首次发布还有第二个更奇怪的症状。在还没有可复用的部署记录时,两个并发的首次发布都会尝试插入同一条记录。数据库在应用的链接名(即 slug,例如 your-app.mythex.ai 中的 your-app)上有一个唯一索引,因此第二次插入失败了。API 将其翻译成「该链接名已被占用」,建议用户重命名一个在那一刻正成功地使用该名称发布的应用。
修复:声明即为锁
现在,声明部署记录是一次单一的条件数据库更新:只有在没有其他人正在发布该记录时,才取得这一行并将其标记为构建中。由于它是一条语句,数据库会在同一毫秒内到达的请求之间做出裁决。只有一个请求会改变该行,其他请求会看到没有行被改变,于是停止,永远不会到达编排器。
锁还需要一个在其持有者死亡时的释放途径。如果 API 在发布过程中重启,该行就会永远停留在「构建中」,应用也就无法再发布。因此,一个其所有者已经停止发送心跳的行可以被重新声明:正在运行的发布每 30 秒会更新该行,而五分钟的沉默被视为已死亡。这个阈值来自心跳,而不是对构建时长的猜测。构建用户自己的 Dockerfile 可能合理地运行很长时间,而一个仍在发送心跳的慢速构建会保留它的声明。
在发布之前运行的、把所有进行中的部署都标记为失败的步骤已不再运行。只有已死进程遗留下来的行才会被清理。
把真相告诉失败的一方
失败的请求现在会得到一个明确的答复:publish_in_progress,并附带正在运行的那次部署的 ID。每个可以启动发布的地方都会处理它:
- 工作区会跟随已经在运行的构建,而不是在其上显示一个错误。
- 代理会被告知该应用的发布已经在进行中,并要它等待,而不是被告知某个操作失败了。之前的这种说法可能会让它去「修复」Dockerfile 并再次尝试。
- 首次发布时的链接名冲突会被检查:如果占用该名称的那一行是本项目自己正在进行的发布,答复就是「正在发布」,而不是「该名称已被占用」。与另一个项目的真正冲突仍会得到重命名的提示。
我们如何验证
我们编写了一个复现脚本,它在一个真实的数据库和一个桩编排器上,同时运行同一应用的三次发布,并统计编排器被请求了多少次部署。测试覆盖了首次发布和已上线应用的重新发布。修复前,它统计到两次部署;修复后,只有一次。
脚本还覆盖了逃生口:它把应用的行标记为构建中,且没有心跳达十分钟,然后再次发布,并检查新的发布能否声明该行并运行。
单元测试确保锁只做它必须做的两件事:三个同时发起的重新发布中,只有一个能通过,另外两个会被告知有发布正在进行;而构建的所有者死亡后,该构建可以被声明。另一项测试检查新的尝试不会沿用上一次尝试的错误信息。
我们从中得到的经验
- 清理代码可能是伪装的竞争。 「先把所有进行中的任务标记为失败,再开始」读起来像是卫生操作。但如果有两个调用方,那就是每一方都在取消对方。
- 让数据库来裁决。 先读后写会给另一个请求留下空隙。单个条件更新则不会。
- 每个锁都需要一个与存活性相关的过期机制。 没有它,一次崩溃就会变成一个卡住的应用。
- 错误信息就是指令。 「该名称已被占用」让人去做错误的事情。正确的信息更简单,也是真实的。
关于发布的更多信息,请参阅文档:如何发布。还有一个相关的修复:你的已发布应用在重新发布时保持在线。