更多信息请访问 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 作为架构文件,而所有其他条目将使用 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

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

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

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

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

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

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

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

可以这样在 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 连接到数据库,但不管理架构和迁移

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

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

3 生成器和迁移

用于多个数据库的迁移应该存放在它们自己的文件夹中,这些文件夹以配置中数据库键的名称为前缀。

您还需要在数据库配置中设置 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 保证“读取自己的写入”,如果在 delay 窗口内,将发送您的 GET 或 HEAD 请求到写入器。默认情况下,延迟设置为 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 调用中的“角色”查找在该连接处理程序(或角色)上连接的连接。reading 连接处理程序将保存所有通过 connects_to 连接到 reading 角色名称的连接。

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

如果您希望 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 水平分片

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

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

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

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

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

ActiveRecord::Base.connected_to(role: :writing, shard: :default) do
  @id = Person.create! # Creates a record in shard named ":default"
end

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

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

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

7 激活自动分片切换

应用程序能够使用提供的中间件根据请求自动切换分片。

ShardSelector 中间件为自动交换分片提供了一个框架。Rails 提供了一个基本框架来确定要切换到的分片,并允许应用程序在需要时编写自定义交换策略。

ShardSelector 采用一组选项(目前仅支持 lock),中间件可以使用这些选项来更改行为。lock 默认情况下为真,并且在块内部会阻止请求切换分片。如果 lock 为假,则允许交换分片。对于基于租户的分片,lock 应始终为真,以防止应用程序代码错误地在租户之间进行切换。

与数据库选择器相同的生成器可用于为自动分片交换生成文件。

$ 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
}

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 throughhas one through 关联,请传递 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 将生成多个选择查询以避免尝试跨集群联接。对于上面的关联,@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 的选择返回了大量 ID,则 treats 的选择可能会发送太多 ID。
  2. 由于我们不再执行联接,因此带有排序或限制的查询现在在内存中排序,因为来自一个表的排序无法应用于另一个表。
  3. 必须将此设置添加到您希望禁用联接的所有关联。Rails 无法为您猜测这一点,因为关联加载是延迟的,要加载 @dog.treats 中的 treats,Rails 已经需要知道应该生成什么 SQL。

8.2 架构缓存

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

9 注意事项

9.1 负载均衡副本

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



返回顶部