更多内容请访问 rubyonrails.org:

使用 Active Record 的多数据库支持

本指南涵盖了如何在 Rails 应用程序中使用多个数据库。

阅读本指南后,您将了解

  • 如何为多数据库设置应用程序。
  • 自动连接切换的工作原理。
  • 如何对多个数据库使用水平分片。
  • 支持哪些功能以及哪些仍在开发中。

随着应用程序受欢迎程度和使用量的增长,您需要扩展应用程序以支持新用户及其数据。应用程序可能需要在数据库级别进行扩展。Rails 支持使用多个数据库,因此您不必将所有数据存储在一个地方。

目前支持以下功能

  • 多个写入数据库和每个数据库的副本
  • 您正在使用的模型的自动连接切换
  • 根据 HTTP 动词和最近的写入在写入器和副本之间自动切换
  • 用于创建、删除、迁移和与多个数据库交互的 Rails 任务

以下功能尚不支持

  • 负载均衡副本

1. 设置应用程序

尽管 Rails 尝试为您完成大部分工作,但您仍需执行一些步骤才能使应用程序为多个数据库做好准备。

假设我们有一个具有单个写入数据库的应用程序,我们需要为我们正在添加的一些新表添加一个新数据库。新数据库的名称将是“animals”。

config/database.yml 看起来像这样

production:
  database: my_primary_database
  adapter: mysql2
  username: root
  password: <%= ENV['ROOT_PASSWORD'] %>

让我们添加第二个数据库,名为“animals”,并为两个数据库添加副本。为此,我们需要将 config/database.yml 从 2 层配置更改为 3 层配置。

如果提供了 primary 配置键,它将用作“默认”配置。如果没有名为 primary 的配置,Rails 将使用第一个配置作为每个环境的默认值。默认配置将使用默认的 Rails 文件名。例如,主配置将使用 db/schema.rb 作为 schema 文件,而所有其他条目将使用 db/[CONFIGURATION_NAMESPACE]_schema.rb 作为文件名。

production:
  primary:
    database: my_primary_database
    username: root
    password: <%= ENV['ROOT_PASSWORD'] %>
    adapter: mysql2
  primary_replica:
    database: my_primary_database
    username: root_readonly
    password: <%= ENV['ROOT_READONLY_PASSWORD'] %>
    adapter: mysql2
    replica: true
  animals:
    database: my_animals_database
    username: animals_root
    password: <%= ENV['ANIMALS_ROOT_PASSWORD'] %>
    adapter: mysql2
    migrations_paths: db/animals_migrate
  animals_replica:
    database: my_animals_database
    username: animals_readonly
    password: <%= ENV['ANIMALS_READONLY_PASSWORD'] %>
    adapter: mysql2
    replica: true

数据库的连接 URL 也可以使用环境变量进行配置。变量名由连接名与 _DATABASE_URL 拼接而成。例如,设置 ANIMALS_DATABASE_URL="mysql2://username:password@host/database" 将合并到 production 环境中 database.ymlanimals 配置中。有关合并工作原理的详细信息,请参阅配置数据库

使用多个数据库时,有一些重要的设置。

首先,primaryprimary_replica 的数据库名称应该相同,因为它们包含相同的数据。animalsanimals_replica 也是如此。

其次,写入器和副本的用户名应该不同,并且副本用户的数据库权限应设置为只读而不是写入。

使用副本数据库时,您需要在 config/database.yml 的副本中添加 replica: true 条目。这是因为 Rails 否则无法知道哪个是副本,哪个是写入器。Rails 不会对副本运行某些任务,例如迁移。

最后,对于新的写入数据库,您需要将 migrations_paths 键设置为您将存储该数据库迁移的目录。我们将在本指南后面更详细地介绍 migrations_paths

您还可以通过将 schema_dump 设置为自定义 schema 文件名来配置 schema 导出文件,或者通过将 schema_dump: false 来完全跳过 schema 导出。

现在我们有了一个新数据库,让我们设置连接模型。

主数据库副本可以通过这种方式在 ApplicationRecord 中配置

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to database: { writing: :primary, reading: :primary_replica }
end

如果您为应用程序记录使用不同的类名,则需要设置 primary_abstract_class,以便 Rails 知道 ActiveRecord::Base 应该与哪个类共享连接。

class PrimaryApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  connects_to database: { writing: :primary, reading: :primary_replica }
end

在这种情况下,连接到 primary/primary_replica 的类可以像标准 Rails 应用程序使用 ApplicationRecord 一样继承自您的主抽象类。

class Person < PrimaryApplicationRecord
end

另一方面,我们需要设置存储在“animals”数据库中的模型

class AnimalsRecord < ApplicationRecord
  self.abstract_class = true

  connects_to database: { writing: :animals, reading: :animals_replica }
end

这些模型应该继承自该公共抽象类

class Dog < AnimalsRecord
  # Talks automatically to the animals database.
end

默认情况下,Rails 期望主数据库和副本的数据库角色分别为 writingreading。如果您有旧系统,您可能已经设置了不想更改的角色。在这种情况下,您可以在应用程序配置中设置新的角色名称。

config.active_record.writing_role = :default
config.active_record.reading_role = :readonly

重要的是在一个模型中连接到您的数据库,然后将该模型用于表,而不是将多个单独的模型连接到同一个数据库。数据库客户端对可以打开的连接数有限制,如果您这样做,它将使您的连接数倍增,因为 Rails 使用模型类名作为连接规范名。

现在我们已经设置了 config/database.yml 和新模型,是时候创建数据库了。Rails 附带了使用多个数据库所需的所有命令。

您可以运行 bin/rails --help 查看您可以运行的所有命令。您应该看到以下内容

$ bin/rails --help
...
db:create                          # Create the database from DATABASE_URL or config/database.yml for the ...
db:create:animals                  # Create animals database for current environment
db:create:primary                  # Create primary database for current environment
db:drop                            # Drop the database from DATABASE_URL or config/database.yml for the cu...
db:drop:animals                    # Drop animals database for current environment
db:drop:primary                    # Drop primary database for current environment
db:migrate                         # Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)
db:migrate:animals                 # Migrate animals database for current environment
db:migrate:primary                 # Migrate primary database for current environment
db:migrate:status                  # Display status of migrations
db:migrate:status:animals          # Display status of migrations for animals database
db:migrate:status:primary          # Display status of migrations for primary database
db:reset                           # Drop and recreates all databases from their schema for the current environment and loads the seeds
db:reset:animals                   # Drop and recreates the animals database from its schema for the current environment and loads the seeds
db:reset:primary                   # Drop and recreates the primary database from its schema for the current environment and loads the seeds
db:rollback                        # Roll the schema back to the previous version (specify steps w/ STEP=n)
db:rollback:animals                # Rollback animals database for current environment (specify steps w/ STEP=n)
db:rollback:primary                # Rollback primary database for current environment (specify steps w/ STEP=n)
db:schema:dump                     # Create a database schema file (either db/schema.rb or db/structure.sql  ...
db:schema:dump:animals             # Create a database schema file (either db/schema.rb or db/structure.sql  ...
db:schema:dump:primary             # Create a db/schema.rb file that is portable against any DB supported  ...
db:schema:load                     # Load a database schema file (either db/schema.rb or db/structure.sql  ...
db:schema:load:animals             # Load a database schema file (either db/schema.rb or db/structure.sql  ...
db:schema:load:primary             # Load a database schema file (either db/schema.rb or db/structure.sql  ...
db:setup                           # Create all databases, loads all schemas, and initializes with the seed data (use db:reset to also drop all databases first)
db:setup:animals                   # Create the animals database, loads the schema, and initializes with the seed data (use db:reset:animals to also drop the database first)
db:setup:primary                   # Create the primary database, loads the schema, and initializes with the seed data (use db:reset:primary to also drop the database first)
...

运行 bin/rails db:create 之类的命令将创建主数据库和 animals 数据库。请注意,没有创建数据库用户的命令,您需要手动执行此操作以支持副本的只读用户。如果您只想创建 animals 数据库,您可以运行 bin/rails db:create:animals

2. 连接到数据库,但不管理 Schema 和 Migrations

如果您想连接到外部数据库而不需要任何数据库管理任务,例如 schema 管理、迁移、种子等,您可以将每个数据库的配置选项 database_tasks: false。默认情况下它设置为 true。

production:
  primary:
    database: my_database
    adapter: mysql2
  animals:
    database: my_animals_database
    adapter: mysql2
    database_tasks: false

3. 生成器和 Migrations

多个数据库的迁移应位于以配置中数据库键名称为前缀的单独文件夹中。

您还需要在数据库配置中设置 migrations_paths,以告诉 Rails 在何处查找迁移。

例如,animals 数据库将在 db/animals_migrate 目录中查找迁移,而 primary 将在 db/migrate 中查找。Rails 生成器现在接受 --database 选项,以便在正确的目录中生成文件。命令可以这样运行

$ bin/rails generate migration CreateDogs name:string --database animals

如果您使用 Rails 生成器,脚手架和模型生成器将为您创建抽象类。只需将数据库键传递给命令行即可。

$ bin/rails generate scaffold Dog name:string --database animals

将创建一个类,其名称为驼峰式数据库名和 Record。在此示例中,数据库是“animals”,因此我们得到 AnimalsRecord

class AnimalsRecord < ApplicationRecord
  self.abstract_class = true

  connects_to database: { writing: :animals }
end

生成的模型将自动继承自 AnimalsRecord

class Dog < AnimalsRecord
end

由于 Rails 不知道哪个数据库是您的写入器的副本,因此您需要在完成后将其添加到抽象类中。

Rails 只会生成 AnimalsRecord 一次。它不会被新的脚手架覆盖,也不会在脚手架删除时删除。

如果您已经有一个抽象类,并且其名称与 AnimalsRecord 不同,则可以传递 --parent 选项以指示您要使用不同的抽象类

$ bin/rails generate scaffold Dog name:string --database animals --parent Animals::Record

这将跳过生成 AnimalsRecord,因为您已向 Rails 指示要使用不同的父类。

4. 激活自动角色切换

最后,为了在您的应用程序中使用只读副本,您需要激活用于自动切换的中间件。

自动切换允许应用程序根据 HTTP 动词以及请求用户是否有最近写入来从写入器切换到副本或从副本切换到写入器。

如果应用程序收到 POST、PUT、DELETE 或 PATCH 请求,应用程序将自动写入写入数据库。如果请求不是这些方法之一,但应用程序最近进行了写入,也将使用写入数据库。所有其他请求将使用副本数据库。

要激活自动连接切换中间件,您可以运行自动交换生成器

$ bin/rails g active_record:multi_db

然后取消注释以下行

Rails.application.configure do
  config.active_record.database_selector = { delay: 2.seconds }
  config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
  config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
end

Rails 保证“读写一致”,如果您的 GET 或 HEAD 请求在 delay 窗口内,它将发送到写入器。默认情况下,延迟设置为 2 秒。您应该根据您的数据库基础设施更改此设置。Rails 不保证在延迟窗口内其他用户“读取最近写入”,并且会向副本发送 GET 和 HEAD 请求,除非他们最近进行了写入。

Rails 中的自动连接切换相对原始,并且故意做得不多。目标是构建一个系统,演示如何进行自动连接切换,该系统足够灵活,可供应用程序开发人员自定义。

Rails 中的设置允许您轻松更改切换方式以及基于哪些参数。假设您想使用 cookie 而不是会话来决定何时交换连接。您可以编写自己的类

class MyCookieResolver < ActiveRecord::Middleware::DatabaseSelector::Resolver
  def self.call(request)
    new(request.cookies)
  end

  def initialize(cookies)
    @cookies = cookies
  end

  attr_reader :cookies

  def last_write_timestamp
    self.class.convert_timestamp_to_time(cookies[:last_write])
  end

  def update_last_write_timestamp
    cookies[:last_write] = self.class.convert_time_to_timestamp(Time.now)
  end

  def save(response)
  end
end

然后将其传递给中间件

config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
config.active_record.database_resolver_context = MyCookieResolver

5. 使用手动连接切换

在某些情况下,您可能希望应用程序连接到写入器或副本,而自动连接切换不足。例如,您可能知道对于特定请求,即使在 POST 请求路径中,您也始终希望将请求发送到副本。

为此,Rails 提供了一个 connected_to 方法,该方法将切换到您需要的连接。

ActiveRecord::Base.connected_to(role: :reading) do
  # All code in this block will be connected to the reading role.
end

connected_to 调用中的“role”查找在该连接处理器(或角色)上连接的连接。reading 连接处理器将保存所有通过 connects_toreading 角色名连接的连接。

请注意,带有角色的 connected_to 将查找现有连接并使用连接规范名称进行切换。这意味着如果您传递一个未知的角色,例如 connected_to(role: :nonexistent),您将收到一个错误,显示 ActiveRecord::ConnectionNotEstablished (No connection pool for 'ActiveRecord::Base' found for the 'nonexistent' role.)

如果您希望 Rails 确保所有执行的查询都是只读的,请传递 prevent_writes: true。这只会阻止看起来像写入的查询发送到数据库。您还应该将副本数据库配置为以只读模式运行。

ActiveRecord::Base.connected_to(role: :reading, prevent_writes: true) do
  # Rails will check each query to ensure it's a read query.
end

6. 水平分片

水平分片是指您将数据库拆分以减少每个数据库服务器上的行数,但保持“分片”之间的相同 schema。这通常称为“多租户”分片。

Rails 中支持水平分片的 API 类似于自 Rails 6.0 以来存在的多数据库/垂直分片 API。

分片在三层配置中声明,如下所示

production:
  primary:
    database: my_primary_database
    adapter: mysql2
  primary_replica:
    database: my_primary_database
    adapter: mysql2
    replica: true
  primary_shard_one:
    database: my_primary_shard_one
    adapter: mysql2
    migrations_paths: db/migrate_shards
  primary_shard_one_replica:
    database: my_primary_shard_one
    adapter: mysql2
    replica: true
  primary_shard_two:
    database: my_primary_shard_two
    adapter: mysql2
    migrations_paths: db/migrate_shards
  primary_shard_two_replica:
    database: my_primary_shard_two
    adapter: mysql2
    replica: true

然后通过 shards 键使用 connects_to API 连接模型

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  connects_to database: { writing: :primary, reading: :primary_replica }
end

class ShardRecord < ApplicationRecord
  self.abstract_class = true

  connects_to shards: {
    shard_one: { writing: :primary_shard_one, reading: :primary_shard_one_replica },
    shard_two: { writing: :primary_shard_two, reading: :primary_shard_two_replica }
  }
end

class Person < ShardRecord
end

如果使用分片,请确保所有分片的 migrations_pathsschema_dump 保持不变。生成迁移时,您可以传递 --database 选项并使用其中一个分片名称。由于它们都设置了相同的路径,因此选择哪个分片名称无关紧要。

$ bin/rails g scaffold Dog name:string --database primary_shard_one

然后模型可以通过 connected_to API 手动交换分片。如果使用分片,则必须同时传递 roleshard

ShardRecord.connected_to(role: :writing, shard: :shard_one) do
  @person = Person.create! # Creates a record in shard shard_one
end

ShardRecord.connected_to(role: :writing, shard: :shard_two) do
  Person.find(@person.id) # Can't find record, doesn't exist because it was created
                   # in the shard named ":shard_one".
end

水平分片 API 还支持读取副本。您可以使用 connected_to API 交换角色和分片。

ShardRecord.connected_to(role: :reading, shard: :shard_one) do
  Person.first # Lookup record from read replica of shard one.
end

7. 激活自动分片切换

应用程序能够使用 ShardSelector 中间件按请求自动切换分片,该中间件允许应用程序提供自定义逻辑来确定每个请求的适当分片。

用于上述数据库选择器的相同生成器可用于为自动分片交换生成初始化文件

$ bin/rails g active_record:multi_db

然后在生成的 config/initializers/multi_db.rb 中取消注释并修改以下代码

Rails.application.configure do
  config.active_record.shard_selector = { lock: true }
  config.active_record.shard_resolver = ->(request) { Tenant.find_by!(host: request.host).shard }
end

应用程序必须提供一个解析器来提供特定于应用程序的逻辑。一个使用子域来确定分片的示例解析器可能如下所示

config.active_record.shard_resolver = ->(request) {
  subdomain = request.subdomain
  tenant = Tenant.find_by_subdomain!(subdomain)
  tenant.shard
}

ShardSelector 的行为可以通过一些配置选项进行更改。

lock 默认为 true,它将禁止请求在请求期间切换分片。如果 lock 为 false,则允许分片交换。对于基于租户的分片,lock 应始终为 true,以防止应用程序代码错误地在租户之间切换。

class_name 是要切换的抽象连接类的名称。默认情况下,ShardSelector 将使用 ActiveRecord::Base,但如果应用程序有多个数据库,则应将此选项设置为分片数据库的抽象连接类的名称。

可以在应用程序配置中设置选项。例如,此配置告诉 ShardSelector 使用 AnimalsRecord.connected_to 切换分片

config.active_record.shard_selector = { lock: true, class_name: "AnimalsRecord" }

8. 细粒度数据库连接切换

从 Rails 6.1 开始,可以为一个数据库而不是全局所有数据库切换连接。

通过细粒度数据库连接切换,任何抽象连接类都将能够切换连接,而不会影响其他连接。这对于将 AnimalsRecord 查询切换为从副本读取,同时确保 ApplicationRecord 查询转到主数据库很有用。

AnimalsRecord.connected_to(role: :reading) do
  Dog.first # Reads from animals_replica.
  Person.first  # Reads from primary.
end

也可以对分片进行细粒度连接交换。

AnimalsRecord.connected_to(role: :reading, shard: :shard_one) do
  # Will read from shard_one_replica. If no connection exists for shard_one_replica,
  # a ConnectionNotEstablished error will be raised.
  Dog.first

  # Will read from primary writer.
  Person.first
end

要仅切换主数据库集群,请使用 ApplicationRecord

ApplicationRecord.connected_to(role: :reading, shard: :shard_one) do
  Person.first # Reads from primary_shard_one_replica.
  Dog.first # Reads from animals_primary.
end

ActiveRecord::Base.connected_to 保持全局切换连接的能力。

8.1. 处理跨数据库连接的关联

从 Rails 7.0+ 开始,Active Record 提供了一个选项来处理可能跨多个数据库执行连接的关联。如果您有一个 has many through 或 has one through 关联,并且您想禁用连接并执行 2 个或更多查询,请传递 disable_joins: true 选项。

例如

class Dog < AnimalsRecord
  has_many :treats, through: :humans, disable_joins: true
  has_many :humans

  has_one :home
  has_one :yard, through: :home, disable_joins: true
end

class Home
  belongs_to :dog
  has_one :yard
end

class Yard
  belongs_to :home
end

以前,在没有 disable_joins 的情况下调用 @dog.treats 或在没有 disable_joins 的情况下调用 @dog.yard 会引发错误,因为数据库无法处理跨集群的连接。使用 disable_joins 选项,Rails 将生成多个 select 查询,以避免尝试跨集群连接。对于上述关联,@dog.treats 将生成以下 SQL

SELECT "humans"."id" FROM "humans" WHERE "humans"."dog_id" = ?  [["dog_id", 1]]
SELECT "treats".* FROM "treats" WHERE "treats"."human_id" IN (?, ?, ?)  [["human_id", 1], ["human_id", 2], ["human_id", 3]]

@dog.yard 将生成以下 SQL

SELECT "home"."id" FROM "homes" WHERE "homes"."dog_id" = ? [["dog_id", 1]]
SELECT "yards".* FROM "yards" WHERE "yards"."home_id" = ? [["home_id", 1]]

此选项有一些重要事项需要注意

  1. 可能会有性能影响,因为现在将执行两个或更多查询(取决于关联)而不是连接。如果 humans 的 select 返回了大量 ID,则 treats 的 select 可能会发送过多 ID。
  2. 由于我们不再执行连接,因此带 order 或 limit 的查询现在在内存中排序,因为一个表的 order 不能应用于另一个表。
  3. 此设置必须添加到所有要禁用连接的关联中。Rails 无法为您猜测这一点,因为关联加载是惰性的,要在 @dog.treats 中加载 treats,Rails 已经需要知道应该生成什么 SQL。

8.2. Schema 缓存

如果您想为每个数据库加载 schema 缓存,您必须在每个数据库配置中设置 schema_cache_path,并在您的应用程序配置中设置 config.active_record.lazily_load_schema_cache = true。请注意,这将在建立数据库连接时延迟加载缓存。

9. 注意事项

9.1. 负载均衡副本

Rails 不支持副本的自动负载均衡。这非常依赖于您的基础设施。我们将来可能会实现基本的、原始的负载均衡,但对于大规模应用程序来说,这应该是您的应用程序在 Rails 之外处理的事情。



回到顶部