TL;DR按照下面的渐进式方法。将整个问题分成小的子问题,并逐一部署:
- 定义新的数据库结构
- 同时将其与旧的DB结构一起填入
- 将旧的数据迁移到新的结构中
- 定义新的关联,让代码使用它们
- 删除旧的DB结构和不再相关的代码。
在这篇文章中,你将:
- 学习如何在生产中以零停机时间维护Rails应用程序
- 了解如何对当前的数据库结构进行必要的修改并提供新的功能
- 弄清楚如何在没有中断、错误和停机的情况下将新功能运送到生产中去
- 通过一个发生在生产中的Rails应用程序的案例,感受什么是持续交付。
问题
想象一个已经部署到生产中的Rails应用程序。它昼夜不停地服务于来自真实用户的请求。也就是说,一个实时的Rails应用程序现在和将来都是24/7的。维护性停机是不可取的,必须尽可能地避免。这是一个好的企业所询问的最重要的要求,不是吗?
随着时间的推移,企业决定做以下改变。有一个下拉式允许在用户界面上挑选一个选择。但从现在开始,它应该有多个选择。用ActiveRecord的术语来说,系统中有一个属于/拥有****许多关联,必须改为拥有和属于许多。
以图解的方式解释这个问题:
如何处理这个问题呢?好吧,有人可能认为这对MVP、PoC或没有真正用户的新创公司来说不是什么大问题。这也是事实。在所有这些情况下,一切都可以改变并重新部署,同时删除旧的数据。
但是对于一个成熟的、有零停机时间要求的实时应用程序来说,任何数据损失或错误都是不可接受的,这种做法可能是一个很大的挑战。
这篇文章一步一步地指导如何在没有任何停工、中断、数据丢失或错误的情况下实现上述的过渡。而事实证明,建议的想法是遵循**持续交付**的方法,所以它带来了对这一棘手术语含义的澄清。
起始点
下面的例子假设能带来更多的清晰性。它深入到问题中并允许感受到描述的解决方案。这样,类似的情况就可以从知识中受益。
考虑一个有房产的房地产系统。和负责它们的经理,即把房产添加到系统中,保持它们的更新,使它们成为非活动/活动/可搜索/等。从Rails的角度来看,这意味着有Property 和Manager 两个模型。它们之间的关系是:Property 属于 Manager ,Manager 有许多 Property:
class Property < ApplicationRecord
belongs_to :manager
end
class Manager < ApplicationRecord
has_many :properties
end
在DB中,它看起来就是这样:

如今,有很多技术可以实现用户界面。这就是为什么几乎不可能就如何处理系统的这一部分给出一个共同的建议。为了简单起见,假设UI是由传统的Rails方法在服务器端的 "视图 "中呈现的。下面的假设和建议都是基于这个约定。
从代码的角度来看,这意味着在某个地方有调用:
- 在模型、控制器或视图层的
- 读数据
@property.manager,@property.manager_id,@manager.properties。@manager.property_ids - 写数据
@property.manager_id=,@property.manager=,@manager.property_ids=。@manager.properties= - 允许参数
params.require(:property).permit(:manager_id)
- 读数据
- 在视图层,即
- 渲染HTML
= f.input :manager_id, ...,select_tag :manager_id, ...,f.input :propery_ids, ..., 等等。
- 渲染HTML
注意,上面的方法可以明确地被调用。可以通过 "搜索 "代码轻松找到它们。或者它们可以被隐式调用。例如,通过对模型进行参数的大量分配。但尽管如此,每当需要处理属性-管理人关系时,上述方法最终会被调用。而这又意味着,它们形成了一个接口。如果你愿意的话,就是一个API。
目标
我们的目标是下面的代码:
class Property < ApplicationRecord
has_and_belongs_to_many :managers
end
class Manager < ApplicationRecord
has_and_belongs_to_many :properties
end
以及其相应的DB状态:

如果必要的变化立即发生,这意味着调用@property.manager,@properly.manager_id,@properly.manager_id=,@property.manager= 不再可能。它们会被各自的调用@property.managers,@properly.manager_ids,@properly.manager_ids=,@property.managers= 所取代。换句话说,它们破坏了当前的接口--前面提到的API。而这些变化一下子就很危险。它们可能会影响系统。可能会有大量的变化,必须在同一时间部署。最终,这可能会导致意想不到的错误,甚至是停工。没有一个成熟的企业会接受这种情况。
该怎么做
由于这个原因,最好是逐步交付变化。这将允许向生产连续运送许多小件,而不会阻碍整个开发过程或整个企业使用该软件。这将提供关于已部署的小件是否工作的即时反馈。一个不工作的小部件可以被修复或回滚,这比一大堆的变化要容易和快速得多。这就减少了风险。它使交付过程对双方都很舒适和安全:工作业务和软件开发。这是一个保证没有神经的过程。
但首先,这些自给自足的小块应该被确定。这可能看起来很难,也不明显。事实上,这种怀疑是真的。但是,一旦人们感觉到如何在一个特定的例子中做到这一点,像这个例子一样,类似的情况应该更容易解决。
在第一步,想想最初和最后阶段。它们之间有什么区别?哪些地方应该被改变,以便从起始点到最终点?前面的章节已经弄清楚了。这些是需要改变的地方:
-
DB结构。应该创建一个新的表
managers_properties,用于具有和属于许多关联,并删除旧的列properties.manager_id。 -
数据。记住,在系统的新状态下,旧列必须由另一个
managers_properties.manager_id。因此,应该有一个数据迁移,在新表中填入旧的数据。这样,当新的数据结构成为现实时,就不会丢失数据。 -
活动记录API。注意旧的关联
@property.manager,连同一堆自动生成的读者/写者方法被重命名为@property.managers。所有引用这些方法的地方都应该被适当地修改。 -
UI。只有一个选择的下拉菜单应该被一组允许许多管理者选择的复选框所取代(见本文的第一张图片)。
第一步:DB结构的改变
我的建议是先从DB结构开始,如果这个步骤出现在需要修改的列表中。在我们的案例中,它就在那里。
它的实现并不难:只要做一个通常的Rails迁移,就可以创建新的表。它完全可以不费吹灰之力就被运走。
请注意,因为所有进一步的步骤,这是一个简单的变化,几乎不会破坏生产服务器上的东西。
唯一需要注意的是不要忘记必要的索引、外键、具有正确类型的列和约束。否则,应该添加一个新的迁移并再次部署。这需要时间。所以,最好花些时间分析需求。在这个阶段,试着预测新结构的可能用途。花费时间定义一个正确的数据库结构是一个很好的投资,因为它将节省以后的时间。
让我们观察一下,以下的迁移应该满足所有的需求:
class CreateHbtmTable < ActiveRecord::Migration[6.0]
def change
create_table :managers_properties, id: false do |t|
t.belongs_to :manager, foreign_key: true, index: false, null: false
t.belongs_to :property, foreign_key: true, index: false, null: false
end
add_index :managers_properties, [:manager_id, :property_id], unique: true
end
end
注意表的名称,它应该包含由下划线组成的两个连接的表的名称,复数,按词法顺序。如果有问题,请参考原始文档。
为了保持数据的完整性:
- 列
manager_id和property_id不可以为空 - 它们有外键约束,因为它们指的是其他表
manager_id + property_id有唯一索引。
如果任何一个过滤字段是索引的一部分,PostgreSQL的选择查询会使用多列索引。换句话说,复合索引manager_id + property_id 将对ActiveRecord 语句.where(manager_id: ...) 和.where(property_id: ...) 起作用。这就是为什么没有为property_id 和manager_id 分别建立索引。这不仅降低了DB结构的复杂性,而且还节省了数据存储的空间。
请记住,这种带有复杂索引的技巧可能对一些旧的PostgreSQL版本或其他数据库(如MySQL)不起作用。这就是为什么这个问题应该在每个特定的情况下进行验证。
第2步:开始写入新的DB结构中
上一步迁移可以顺利部署,没有任何问题。但是创建的表在没有任何东西写入的情况下是没有用的。当它是空的时候,就没有必要从它那里读取数据。那我们就开始写吧!
我们需要将当前已知的数据从旧的属于关联中填入新的表中,尽管它被认为容纳了新的和属于许多关联的数据。这个技巧保证了切换到新的关联时不会丢失任何数据。换句话说,代表经理-属性关联的数据应该同时写入旧字段properties.manager_id 和新表managers_properties 。
在Rails应用程序中,至少有两种方法可以做到这一点:使用ActiveRecord回调或DB触发器。选择哪一种,取决于代码作者。这两种解决方案都有优点和缺点。但如果实施得当,发挥得很好。
无论选择哪种方式,都应该实现以下逻辑:
- 如果
property#manager_id被改变。- 含有
property#id和property#manager_id_was的managers_properties行应该被删除。 - 如果
property#id和property#manager_id的新值不存在,就应该插入新的一行property#manager_id。nil
- 含有
- 如果一个属性被删除,
managers_properties中的相应行应该被删除。 - 如果一个经理被删除,相应的关联
property#manager应该被取消,managers_properties中的相应行也应该被删除。
如果使用ActiveRecord的回调,可能需要做很多修改,而且要付出更多的努力。在某些情况下会跳过回调。例如,#update_column,#update_columns,.update_all 对ActiveRecord模型的调用不执行回调。考虑到这些方法可以用元编程隐式调用。而现在的实现看起来一点也不简单。所有的代码都应该仔细阅读。而且所有调用这些方法以及可能的其他方法的地方都应该注意同时写入。此外,如果之后添加了一些新的代码而错过了这一点,可能会出现数据不一致的情况。
这就是为什么使用DB触发器比较好。一旦它们被写出来,它们就会按照设计工作,没有任何注意事项。不需要阅读整个代码库,搜索上面的方法并改变这些行。不需要实现一些棘手的代码,等等。看看对PostgreSQL的实现吧:
class AddTriggers < ActiveRecord::Migration[6.0]
def up
execute <<~SQL
create extension if not exists plpgsql;
create function update_managers_properties() returns trigger
language plpgsql
as $$
begin
if coalesce(new.manager_id, 0) != coalesce(old.manager_id, 0) then
delete from managers_properties where manager_id = old.manager_id;
if coalesce(new.manager_id, 0) != 0 then
insert into managers_properties (manager_id, property_id) values (new.manager_id, new.id);
end if;
end if;
return null;
end
$$;
create trigger align_managers_properties after insert or update on properties
for each row execute procedure update_managers_properties();
SQL
end
def down
execute("drop function update_managers_properties() cascade")
end
end
只要运行这个迁移,从现在开始,对属于关联的改变会自动在多对多的表中做适当的改变。
应该是模型的代码看起来是这样的:
class Property < ApplicationRecord
belongs_to :manager, optional: true
end
class Manager < ApplicationRecord
has_many :properties, dependent: :nullify
end
感谢上面Rails的dependent: :nullify 选项,每当一个经理被移除时,它就会把所有相关的属性的manager_id 设置为null 。对null 的改变会执行DB触发器。这反过来意味着,上述所有的数据完整性要求都得到了满足。
现在是时候把这个迁移部署到生产中了。
第三步:迁移旧数据
但说到数据,这还不是全部。在properties.manager_id ,仍然有旧的数据没有相关的managers_properties 行。这可以通过所谓的 "数据迁移 "来解决。简而言之,它只是一个代码片段,使工作完成。有很多方法来实现它。所有这些都可以在另一篇文章中找到像老板一样改变迁移中的数据。
针对生产数据库运行下面的SQL代码段,就可以完成这一步的工作:
insert into managers_properties (property_id, manager_id)
(select id, manager_id from properties where manager_id is not null)
on conflict do nothing;
这个片段是空闲的,所以它可以被多次运行而不会对数据造成任何损害。此外,它不会与任何其他进程因任何原因同时将数据写入managers_properties 而发生冲突。
第四步:验证数据
为了证明数据是一致的,最好是让系统在上述部署的步骤下工作一段时间。比如说,一个星期。之后,针对生产数据库运行这个脚本:
select id, manager_id from properties where manager_id is not null
except
select property_id, manager_id from managers_properties;
select property_id, manager_id from managers_properties
except
select id, manager_id from properties where manager_id is not null;
第一个选择语句检查所有填入的属性-管理员关系是否在managers_properties 表中有相应的行。它应该不会返回任何结果。第二条语句检查在新表中是否没有 "外星人"。其结果也应该是空的。这两个语句共同保证了旧的属于/拥有许多DB数据结构与新的数据结构--拥有和属于许多--是同步的。
第5步:切换到具有和属于多的关联
当新旧结构之间的数据完整性被证明后,就只剩下一个主要步骤了。这一步与用户界面的变化有关。它需要对Rails应用程序的其他地方进行修复:模型、视图、控制器。可能,最容易的地方是模型,因为它只需要定义新的关系:
class Property < ApplicationRecord
has_and_belongs_to_many :managers
end
class Manager < ApplicationRecord
has_and_belongs_to_many :properties
end
对控制器的修改可能因项目而异。但通常的想法是允许manager_ids ,而不是以前的manager_id ,通过params 。例如,如果使用强参数,代码params.require(:property).permit(:manager_id) 应该改为params.require(:property).permit(manager_ids: []) 。
在视图层,属性的表单更新管理器可能看起来像这样。
= f.collection_check_boxes :manager_ids, Manager.all, :id, :id
所有这些变化可以一次性部署,也可以渐进式部署。例如,渲染关联的只读信息的视图可以首先被交付。在这之后,控制器和模型可以与params 结构一起为新的关联做准备,但还不能放弃旧的功能。接下来,可以改变UI更新属性管理器。
第六步:清理
在最后一步,过时的代码应该被直接丢弃。它可能包括以下变化:
- 删除旧的
properties.manager_id列。请记住,这应该分几步完成:首先,在代码层面忽略该列,然后从DB中删除该列。关于这一点的更多信息见这里 - 删除旧的定义关联和旧的
params键 - 删除触发器和它的功能
- 引用旧关联的脚本/片段/rake任务应该被更新或删除。
这就是实现房地产企业所要求的过渡的所有步骤。
总结
我们已经看到了如何在Rails应用程序的生产设置中进行数据库变更。这是一个多步骤的、不快速的过程。它需要时间来实现没有错误和停机的过渡。乍一看,这个任务似乎太难了。但所有需要的步骤都是小而简单的。
所描述的技术是一个非常强大的工具,在实际应用中得到证实。这篇文章展示了一个具体的例子,即如何将用户界面上的单一选择改为多个选择。但这个想法也适用于任何需要改变Rails应用程序的数据库的过渡,这些应用程序的生产设置需要全天候 零停机时间。
不幸的是,我们很难甚至不可能描述整个过程中的所有细节。这篇文章还有很多方面没有展示:
- 如何理解代码中需要修改的确切位置
- 如果UI是一个独立的单页程序(SPA),如何处理迁移问题
- 如果使用ActiveRecord回调,如何实现对新旧数据库结构的并行写入。这是否值得呢?在这篇文章中,部分地阐述了这个问题。但这些简短的解释可能看起来不够有说服力。
如果你有任何这些或其他问题,不要犹豫,请问我。谢谢你的关注,祝你在生产中的无错误编码愉快!