當應用程式的受歡迎程度和使用率提高時,您需要擴展應用程式以支援新的使用者及其資料。應用程式可能需要擴展的一種方式是在資料庫層級。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
從兩層配置變更為三層配置。
如果提供 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 產生器,則 scaffold 和 model 產生器會為您建立抽象類別。只需將資料庫索引鍵傳遞至命令列即可。
$ 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
。它不會被新的 scaffold 覆寫,也不會在 scaffold 被刪除時刪除。
如果您已經有一個抽象類別,且其名稱與 AnimalsRecord
不同,您可以傳遞 --parent
選項來表示您想要使用不同的抽象類別。
$ bin/rails generate scaffold Dog name:string --database animals --parent Animals::Record
由於您已向 Rails 指示您想要使用不同的父類別,因此這會跳過產生 AnimalsRecord
。
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 而不是 session 來決定何時切換連線。您可以編寫自己的類別
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 (找不到 'nonexistent' 角色的 'ActiveRecord::Base' 連線集區。)
如果您希望 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
預設為 true,並會禁止區塊內部的請求切換分片。如果 lock
為 false,則允許分片交換。對於基於租戶的分片,lock
應始終為 true,以防止應用程式碼錯誤地在租戶之間切換。
與資料庫選擇器相同的產生器可用於產生自動分片交換的檔案
$ 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 有一個選項可以處理會執行跨多個資料庫聯結的關聯。如果您有一個多對多或一對一的關聯,您想要停用聯結並執行 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]]
此選項有一些重要的注意事項
- 可能會產生效能影響,因為現在將會執行兩個或多個查詢(取決於關聯),而不是聯結。如果
humans
的 select 回傳大量的 ID,則treats
的 select 可能會傳送過多的 ID。 - 由於我們不再執行聯結,因此具有 order 或 limit 的查詢現在會在記憶體中排序,因為一個資料表中的順序不能應用於另一個資料表。
- 此設定必須新增到您想要停用聯結的所有關聯中。Rails 無法為您猜測這一點,因為關聯載入是延遲的,要在
@dog.treats
中載入treats
,Rails 已經需要知道應該產生什麼 SQL。
8.2 結構描述快取
如果您想要為每個資料庫載入結構描述快取,您必須在每個資料庫設定中設定 schema_cache_path
,並在您的應用程式設定中設定 config.active_record.lazily_load_schema_cache = true
。請注意,這會在建立資料庫連線時延遲載入快取。
9 注意事項
9.1 複本的負載平衡
Rails 不支援複本的自動負載平衡。這非常依賴您的基礎架構。我們可能會在未來實作基本、原始的負載平衡,但對於大規模的應用程式,這應該是您的應用程式在 Rails 外部處理的事情。