更多資訊請見 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 從兩層配置變更為三層配置。

如果提供 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 產生器,則 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_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 預設為 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]]

此選項有一些重要的注意事項

  1. 可能會產生效能影響,因為現在將會執行兩個或多個查詢(取決於關聯),而不是聯結。如果 humans 的 select 回傳大量的 ID,則 treats 的 select 可能會傳送過多的 ID。
  2. 由於我們不再執行聯結,因此具有 order 或 limit 的查詢現在會在記憶體中排序,因為一個資料表中的順序不能應用於另一個資料表。
  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 外部處理的事情。



回到頂端