v7.1.3.2
更多資訊請至 rubyonrails.org: 更多 Ruby on Rails

使用 Active Record 的多個資料庫

本指南涵蓋如何將多個資料庫與 Rails 應用程式搭配使用。

閱讀本指南後,您將知道

隨著應用程式越來越受歡迎和使用,您需要擴充應用程式以支援新使用者和他們的資料。您的應用程式可能需要擴充的一個層面是在資料庫層級。Rails 支援使用多個資料庫,因此您不必將資料全部儲存在同一個地方。

目前支援下列功能

  • 多個寫入資料庫和各個資料庫的複本
  • 自動切換與您所使用模型的連線
  • 根據 HTTP 動詞和最近的寫入內容,自動在寫入資料庫和複本之間切換
  • Rails 任務,用於建立、刪除、遷移和與多個資料庫互動

下列功能(目前)不支援

  • 負載平衡複本

1 設定您的應用程式

雖然 Rails 會試著為您完成大部分的工作,但您仍需要執行一些步驟,才能讓您的應用程式準備好使用多個資料庫。

假設我們有一個應用程式,其中只有一個寫入資料庫,而且我們需要為一些新加入的資料表新增一個新的資料庫。新資料庫的名稱會是「animals」。

database.yml 如下所示

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

讓我們新增一個名為 animals 的第二個資料庫,以及兩個資料庫的複本。為此,我們需要將我們的 database.yml 從 2 層式設定變更為 3 層式設定。

如果提供了主要設定,它將會用作「預設」設定。如果沒有名為 "primary" 的設定,Rails 會將第一個設定用作每個環境的預設設定。預設設定會使用預設的 Rails 檔名。例如,主要設定會使用 schema.rb 作為架構檔,而所有其他項目會使用 [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 也是如此。

其次,寫入者和副本的使用者名稱應不同,並且副本使用者的資料庫權限應設定為僅讀取,而不寫入。

使用副本資料庫時,您需要在 database.yml 中為副本新增 replica: true 項目。這是因為 Rails 否則無法知道哪一個是副本,哪一個是寫入者。Rails 將不會對副本執行某些任務,例如遷移。

最後,對於新的寫入者資料庫,您需要將 migrations_paths 設定為您將儲存該資料庫遷移的目錄。我們稍後將在這個指南中進一步了解 migrations_paths

現在我們有了新的資料庫,讓我們設定連線模型。為了使用新的資料庫,我們需要建立一個新的抽象類別並連接到 animals 資料庫。

class AnimalsRecord < ApplicationRecord
  self.abstract_class = true

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

然後,我們需要更新 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
end

連接到 primary/primary_replica 的類別可以繼承自您的主要抽象類別,就像標準 Rails 應用程式一樣

class Person < ApplicationRecord
end

預設情況下,Rails 預期資料庫角色分別為 primary 和 replica 的 writingreading。如果您有舊系統,您可能已經設定了不想變更的角色。在這種情況下,您可以在應用程式設定中設定新的角色名稱。

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

在單一模型中連接到資料庫,然後繼承該模型以取得資料表,而不是將多個個別模型連接到同一個資料庫,這一點很重要。資料庫用戶端對可開啟的連線數量有限制,如果您這樣做,它會增加連線數量,因為 Rails 使用模型類別名稱作為連線規範名稱。

現在我們有了 database.yml 和設定好的新模型,是時候建立資料庫了。Rails 6.0 內建所有您在 Rails 中使用多個資料庫所需的 rails 任務。

您可以執行 bin/rails -T 以查看所有您可以執行的指令。您應該會看到下列內容

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

然後透過 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 設定為所有分片的相同路徑。在產生遷移時,您可以傳遞 --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

然後在檔案中取消註解下列內容

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
  Dog.first # Will read from shard_one_replica. If no connection exists for shard_one_replica,
  # a ConnectionNotEstablished error will be raised
  Person.first # Will read from primary writer
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 個或更多查詢的 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]]

使用此選項時,有一些重要事項需要注意

  1. 由於現在會執行兩個或更多查詢(視關聯而定)而非聯結,因此可能會影響效能。如果 humans 的選取傳回大量的 ID,則 treats 的選取可能會傳送過多的 ID。
  2. 由於我們不再執行聯結,因此包含順序或限制的查詢現在會在記憶體中排序,因為無法將一個表格的順序套用至另一個表格。
  3. 必須將此設定新增至所有要停用聯結的關聯。Rails 無法為您猜測此設定,因為關聯載入是延遲的,Rails 在 @dog.treats 中載入 treats 時,已經需要知道應該產生哪些 SQL。

8.2 架構快取

如果您要為每個資料庫載入架構快取,則必須在每個資料庫設定中設定 schema_cache_path,並在應用程式設定中設定 config.active_record.lazily_load_schema_cache = true。請注意,這會在建立資料庫連線時延遲載入快取。

9 注意事項

9.1 負載平衡複本

Rails 也不支援複本的自動負載平衡。這非常仰賴您的基礎架構。我們可能會在未來實作基本的原始負載平衡,但對於大規模的應用程式,這應該是您的應用程式在 Rails 外部處理的事項。

回饋

我們鼓勵您協助提升本指南的品質。

如果你發現任何錯字或事實錯誤,請貢獻你的力量。首先,你可以閱讀我們的文件貢獻章節。

你可能也會發現不完整或未更新的內容。請為 main 新增任何遺失的文件。請務必先查看Edge Guides,以驗證問題是否已在 main 分支上修復。查看Ruby on Rails 指南方針,了解樣式和慣例。

如果你發現需要修復的地方,但無法自行修補,請開啟問題

最後但並非最不重要的是,歡迎在官方 Ruby on Rails 論壇上討論任何與 Ruby on Rails 文件相關的事項。