简介
海外某些公司在构建解决方案时,会遵循一项重要原则:避免做出“单向门”式决策。所谓“单向门”,是指那些一旦做出就难以撤销、难以扩展或难以修正的选择。无论是设计产品、功能、API 和后端系统,还是实施软件部署,这一原则都会贯穿软件开发的全过程。本文将重点说明,如何将这一原则应用于软件部署,并确保部署期间能够安全回滚。
部署的本质,是将软件环境从一种状态,也就是一个版本,转换到另一种状态,也就是另一个版本。软件在这两个稳定状态下都可能运行良好;但是,在向前迁移,也就是升级或前滚的过程中,或者在向后迁移,也就是降级或回滚的过程中,软件仍然可能出现异常。即使迁移完成后,系统也可能因为版本之间的兼容性问题而表现不佳。一旦软件运行异常,就可能引发服务中断,影响客户体验和服务可靠性。
本文假设软件的两个版本本身都能按预期运行,重点讨论如何确保软件部署过程中的前滚和回滚不会引发错误。
在发布新版本软件之前,我们通常会在 beta 或 gamma 等测试环境中,从功能、并发性、性能、规模以及下游故障处理等多个维度进行验证。这类测试可以帮助团队发现并修复新版本中的问题。不过,仅靠这些测试有时仍不足以保证部署一定成功。在生产环境中,系统可能遇到意料之外的情况,软件也可能表现不佳。
在成熟的软件工程实践中,团队通常希望避免陷入这样一种局面:当新版本出现问题时,回滚部署反而会在客户侧引发更多错误。为了避免这种情况,团队需要在每次部署之前充分做好回滚准备。能够在回滚时不出错,并且不会破坏先前版本中可用功能的软件版本,通常被称为具备向后兼容性的版本。为此,团队需要在规划和验证软件变更时,确保每一个修订版本都具备向后兼容能力。
在详细讨论软件部署和回滚策略之前,我们先来看独立软件部署与分布式软件部署之间的区别。

独立软件部署与分布式软件部署
对于在单台设备上以单个进程运行的独立软件而言,部署通常具有原子性。也就是说,系统中不会同时运行两个不同版本的软件。如果这类软件会维护持久化状态,那么新版本必须能够读取,也就是反序列化旧版本写入的数据;反过来,旧版本也应当能够读取新版本写入的数据。只有满足这一条件,部署才能安全地前滚和回滚。
在分布式系统中,部署要复杂得多。为了避免影响可用性,部署通常采用滚动更新的方式进行。新版本会先部署到一部分主机上,其余主机则继续处理请求。通常,这些主机会通过远程过程调用(RPC)或共享的持久化状态进行通信,例如共享元数据、检查点等。
通信和共享状态都会带来额外挑战:写入方和读取方可能运行着不同版本的软件,因此它们对同一份数据的解释可能不同。更严重的是,读取方甚至可能完全无法读取这些数据,从而导致服务中断。
协议变更:部署无法安全回滚的常见原因
在实践中,导致部署无法安全回滚的最常见原因之一,是协议变更。
举例来说,假设某次代码变更开始在将数据持久化到磁盘时对数据进行压缩。一旦新版本写入了压缩后的数据,系统就无法安全回滚到旧版本了。原因很简单:旧版本并不知道从磁盘读取数据后还需要先解压缩。如果数据存储在 Blob 或文档存储中,那么即使部署仍在进行,其他服务器也可能无法读取这些数据。如果这些数据在两个进程或两台服务器之间传递,接收方同样可能无法正确读取。
有时,协议变更可能非常细微。设想有两台服务器通过连接进行异步通信。为了相互确认对方仍在正常运行,它们约定每隔五秒发送一次心跳。如果其中一台服务器在规定时间内没有收到心跳,就会认为另一台服务器已经下线,并关闭连接。
现在,假设团队要部署一项更改,将心跳周期从 5 秒增加到 10 秒。从代码提交来看,这似乎只是改了一个数字,变化很小。然而,这项更改会让前滚和回滚都变得不安全。在部署期间,运行新版本的服务器每 10 秒发送一次心跳;而运行旧版本的服务器如果超过 5 秒没有收到心跳,就会断开与新版本服务器之间的连接。在大规模服务器集群中,这种情况可能同时发生在大量连接上,从而导致可用性下降。
这类细微变化很难仅靠阅读代码或设计文档识别出来。因此,团队需要显式验证每一次软件部署在前滚和回滚时是否安全。
两阶段部署:实现安全回滚的关键技术
确保安全回滚的一种常见方法,是采用“两阶段部署”技术。
下面以一个假设场景为例:某项服务负责管理某海外云服务商对象存储服务上的数据,并执行数据写入和读取操作。为了实现扩展性和可用性,该服务运行在跨多个可用区的服务器集群中。
当前,该服务使用 XML 格式持久化数据。也就是说,所有服务器都写入并读取 XML 数据。出于业务原因,团队希望改用 JSON 格式持久化数据。如果在一次部署中直接完成这一变更,那么接收到新版本的服务器会开始以 JSON 格式写入数据;但尚未升级的服务器并不知道如何读取 JSON 数据,于是就会出错。因此,这类变更应当拆分为两个部分,并通过两阶段部署完成。

第一阶段称为“准备”阶段。在这一阶段,团队会让所有服务器都具备读取 JSON 数据的能力,同时仍然能够读取 XML 数据;但是,这些服务器仍会继续写入 XML 数据。从系统行为上看,这一阶段并不会改变实际读写结果:所有服务器仍然可以读取 XML 数据,所有数据也仍然以 XML 格式写入。如果此时决定回滚,服务器会回到无法读取 JSON 数据的状态。这并不会造成问题,因为此时还没有任何数据以 JSON 格式写入。
第二阶段称为“激活”阶段。在这一阶段,团队会激活服务器,使其开始使用 JSON 格式写入数据。每台服务器接收到这一变更后,就会开始以 JSON 格式写入数据。尚未接收到该变更的服务器仍然能够读取 JSON 数据,因为它们已经在第一阶段完成了准备工作。如果此时决定回滚,那么曾短暂进入“激活”阶段的服务器所写入的数据会是 JSON 格式;尚未进入“激活”阶段的服务器写入的数据仍然是 XML 格式。这是一种安全状态,因为回滚后的服务器仍然能够读取 XML 和 JSON 两种格式的数据。
虽然上面的例子展示的是从 XML 到 JSON 的序列化格式变更,但这一技术同样适用于其他类型的协议变更。以前面的心跳周期变更为例:如果需要将服务器之间的心跳周期从 5 秒增加到 10 秒,那么在“准备”阶段,可以先将所有服务器对心跳间隔的容忍时间放宽到 10 秒,但仍然让所有服务器每 5 秒发送一次心跳;到了“激活”阶段,再将心跳发送频率改为每 10 秒一次。
两阶段部署的注意事项
采用两阶段部署时,需要特别注意一些关键细节。虽然下面仍以前文示例为参照,但这些注意事项适用于大多数两阶段部署场景。
许多部署工具会根据最低健康主机数量来判断部署是否成功。也就是说,只要有一定数量的主机接收到变更并报告运行状况正常,工具就可能将部署标记为成功。有些海外部署工具中就提供了类似 minimumHealthyHosts 的部署配置。
然而,在前面的两阶段部署示例中,一个关键假设是:第一阶段结束时,所有服务器都已升级,并且都能够同时读取 XML 和 JSON 数据。如果第一阶段中有一台或多台服务器升级失败,那么它们在第二阶段期间以及之后就可能无法读取 JSON 数据。因此,团队必须显式验证所有服务器是否都已在“准备”阶段成功接收变更。
在某海外数据库服务的一次实践中,团队曾决定更改大量服务器之间的通信协议,而这些服务器分布在多个微服务中。相关负责人需要协调所有微服务之间的部署,确保所有服务器先进入“准备”阶段,再进入“激活”阶段。为了防止意外发生,团队在每个阶段结束时都会显式验证每台服务器上的部署是否成功。对于这类涉及需求、开发、测试、发布和知识沉淀的复杂研发流程,团队也可以借助 PingCode 这类智能化研发管理工具,将部署计划、评审记录、测试验证、发布上线和经验复盘串联起来,让研发管理过程更加自动化、数据化和可追踪。
虽然两个阶段中的每个阶段都可以回滚,但不能同时回滚这两项变更。以前面的例子来说,在“激活”阶段结束后,服务器已经开始以 JSON 格式写入数据。而在“准备”和“激活”变更之前使用的软件版本并不知道如何读取 JSON 数据。因此,作为一项预防措施,团队通常会在“准备”阶段和“激活”阶段之间留出足够长的间隔。这段时间通常被称为“烘烤期”或“观察期”,持续时间可能为数天。通过等待足够长的时间,团队可以确认自己不需要回滚到更早的版本。
在“激活”阶段之后,也不能立即移除软件读取 XML 的能力。这样做并不安全,因为在“准备”阶段之前写入的数据仍然都是 XML 格式。只有在确认每个对象都已被重写为 JSON 格式之后,团队才能移除读取 XML 数据的能力。这个过程通常称为“回填”。回填可能需要额外工具,并且这些工具需要能够在服务持续读写数据的同时并发运行。
序列化最佳实践:降低部署回滚风险
大多数软件都会涉及数据序列化,无论是为了实现持久化存储,还是为了通过网络传输数据。随着软件不断演进,序列化逻辑通常也会发生变化。变更可能小到添加一个新字段,也可能大到完全更换数据格式。多年实践表明,以下序列化最佳实践有助于降低部署和回滚风险。
通常避免开发自定义序列化格式
自定义序列化的初始逻辑看起来可能很简单,甚至可能带来更好的性能。但是,随着格式不断演进,后续迭代会带来不少挑战,而这些挑战已经由许多成熟框架解决,例如 JSON、Protocol Buffers、Cap’n Proto 和 FlatBuffers。只要使用得当,这些框架能够提供转义、向后兼容、字段存在性跟踪等安全能力。所谓字段存在性跟踪,是指能够区分某个字段是被显式设置,还是被隐式赋予了默认值。
每次变更后,都为序列化程序明确分配不同版本
这一版本控制应当独立于源代码版本或构建版本。团队还应将序列化程序版本与序列化后的数据一起存储,或者以元数据形式存储。较旧版本的序列化程序应当能够在新软件中继续正常运行。
在实践中,团队通常还可以针对所写入或读取的数据版本发布指标。一旦出现错误,这些指标就能为运维人员提供可见性和故障排查信息。上述做法同样适用于 RPC 和 API 的版本管理。
避免序列化无法控制的数据结构
例如,团队可以通过反射来序列化 Java 的集合对象。但是,当 JDK 升级时,这类底层实现可能发生变化,从而导致反序列化失败。类似风险也存在于团队之间共享的库类中。如果某个数据结构不受当前团队控制,就不应轻易将其作为长期序列化格式的一部分。
通常将序列化程序设计为允许未知属性存在
在可行的情况下,序列化程序应当在回写数据时保留未知属性。这样一来,即使运行新版本软件的服务器在序列化数据时加入了新属性,运行旧版本软件的服务器在更新同一份数据时也不会清除这些属性。在某些情况下,这种设计可以避免将部署拆分为两个阶段。
当然,任何最佳实践都需要结合具体场景判断。上述准则并不适用于所有应用程序,也不适用于所有系统架构。
升级—降级测试:验证变更是否能够安全回滚
通常,团队会通过“升级—降级测试”显式验证软件变更是否能够安全地前滚和回滚。在这个过程中,需要搭建一个能够代表生产环境的测试环境。长期实践表明,在搭建测试环境时,应当避免以下几类常见问题。
第一类问题是测试环境过于简化。例如,有些测试环境中,每项服务只有一台服务器。因此,所有部署实际上都是原子性的,完全排除了不同版本软件并发运行的可能性。在这种环境中,即使变更通过了所有测试,部署到生产环境后仍然可能出错。更稳妥的做法是,即使测试环境的流量不如生产环境大,也应当尽量像生产环境一样,让每项服务都运行在不同可用区的多台服务器之后。成熟团队通常都重视成本效率,但为了保证质量,不应让成本节约成为牺牲测试有效性的理由。
第二类问题是测试环境虽然有多台服务器,但为了加快测试速度,部署会一次性应用到所有服务器。这种做法同样会阻止新旧版本软件同时运行,从而无法发现前滚过程中的问题。更好的做法是,在测试环境和生产环境中使用相同或足够接近的部署配置。
对于涉及微服务间协调的变更,团队应当在测试环境和生产环境中采用相同的跨微服务部署顺序。不过,前滚顺序和回滚顺序可能并不相同。例如,在序列化相关场景中,通常需要遵循特定顺序:前滚时读取方应先于写入方升级,回滚时写入方应先于读取方回滚。在测试环境和生产环境中,都应遵循相应顺序。若参与方较多,还可以通过 Worktile 这类通用项目协作系统统一管理任务、排期、文档、日历和审批,让跨团队部署协作更清晰,减少因沟通遗漏带来的回滚风险。
当测试环境的设置尽可能接近生产环境后,团队还需要尽量严密地模拟生产流量。例如,可以快速创建并读取多条记录或消息,让所有 API 持续运行。随后,将测试过程分为三个阶段,每个阶段都持续一段合理时间,以便识别潜在错误。这段时间必须足够长,确保所有 API、后端工作流和批处理作业至少运行一次。
第一阶段,将变更部署到服务器集群中大约一半的设备上,以确保新旧软件版本共存。
第二阶段,完成部署,使所有服务器都运行新版本软件。
第三阶段,启动回滚部署,并遵循类似步骤,直到所有服务器都重新运行旧版本软件。
如果在这三个阶段中没有出现错误或意外行为,就可以认为这项变更通过了升级—降级测试。
结论:通过安全回滚提升软件部署可靠性
确保部署能够在不造成客户中断的情况下回滚,是保障服务可靠性的关键。显式测试回滚安全性,可以减少对人工分析的依赖,因为人工分析往往容易遗漏细微但高风险的兼容性问题。
当团队发现某项变更不适合直接回滚时,通常可以将其拆分为两项或多项更小的变更,并确保每项变更都能够安全地前滚和回滚。通过向后兼容设计、两阶段部署、充分的观察期、必要的回填机制以及接近生产环境的升级—降级测试,团队可以显著降低部署风险,提高系统在持续交付过程中的可靠性。
文章包含AI辅助创作:部署安全回滚实践:如何确保软件部署期间能够安全回滚,发布者:shang,转载请注明出处:https://worktile.com/kb/p/3974473
微信扫一扫
支付宝扫一扫