随着应用程序的普及和使用量的增加,您需要扩展应用程序以支持新用户及其数据。您的应用程序可能需要扩展的一个方面是在数据库级别。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
使用多个数据库时,有一些重要的设置。
首先,primary
和 primary_replica
的数据库名称应该相同,因为它们包含相同的数据。animals
和 animals_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 预计数据库角色分别为 writing
和 reading
,用于主数据库和副本。如果您有一个旧系统,您可能已经设置了不想更改的角色。在这种情况下,您可以在应用程序配置中设置新的角色名称。
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_paths
和 schema_dump
保持不变。在生成迁移时,您可以传递 --database
选项并使用其中一个分片名称。由于它们都设置了相同的路径,因此您选择哪个无关紧要。
$ bin/rails g scaffold Dog name:string --database primary_shard_one
然后,模型可以通过 connected_to
API 手动切换分片。如果使用分片,则必须传递 role
和 shard
。
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 through
或 has 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]]
使用此选项时,有一些需要注意的重要事项。
- 可能会影响性能,因为现在将执行两个或多个查询(取决于关联),而不是联接。如果
humans
的选择返回了大量 ID,则treats
的选择可能会发送太多 ID。 - 由于我们不再执行联接,因此带有排序或限制的查询现在在内存中排序,因为来自一个表的排序无法应用于另一个表。
- 必须将此设置添加到您希望禁用联接的所有关联。Rails 无法为您猜测这一点,因为关联加载是延迟的,要加载
@dog.treats
中的treats
,Rails 已经需要知道应该生成什么 SQL。
8.2 架构缓存
如果您想为每个数据库加载架构缓存,则必须在每个数据库配置中设置 schema_cache_path
,并在您的应用程序配置中设置 config.active_record.lazily_load_schema_cache = true
。请注意,这将在建立数据库连接时延迟加载缓存。
9 注意事项
9.1 负载均衡副本
Rails 不支持自动负载均衡副本。这高度依赖于您的基础设施。我们将来可能会实现基本的、原始的负载均衡,但对于大规模应用程序来说,这应该是您的应用程序在 Rails 之外处理的事情。