Một trong những điểm hay của Rails framework là khả năng sử dụng các liên kết Ruby on Rails trong các mô hình của bạn. Các liên kết này cho phép bạn truy cập vào bộ sưu tập bản ghi trong mã của mình bằng cú pháp dễ chịu, loại bỏ nhu cầu viết các truy vấn SQL cơ bản. Sự trừu tượng đó tồn tại miễn là tất cả dữ liệu của bạn vẫn ở một nơi. Thời điểm các bảng của bạn được trải rộng trên các cụm cơ sở dữ liệu riêng biệt, một số loại liên kết nhất định sẽ ngừng hoạt động.
Bài viết này sẽ trình bày chính xác ranh giới đó ở đâu và Rails cung cấp những gì để hoạt động trong đó. Chúng tôi bắt đầu với lý do tại sao sự cố xảy ra và các liên kết nào trong Rails bị ảnh hưởng, sau đó chuyển sang cấu hình cơ sở dữ liệu và phân cấp mô hình hỗ trợ nhiều cụm và nhiều mối quan hệ. Từ đó, chúng tôi sẽ đề cập đến cách mỗi mẫu truy cập dữ liệu khác nhau tương tác với sự phân tách đó như thế nào.
Nếu bạn đang tìm kiếm một hướng dẫn liên kết Rails cụ thể bao gồm các thiết lập đa cơ sở dữ liệu thì đây chính là nó. Chúng ta cũng sẽ thảo luận về nhiều thứ khác nên hãy tiếp tục nhé.
Tại sao cơ sở dữ liệu lại nằm trong các cụm khác nhau
Khi một ứng dụng Rails lưu trữ tất cả dữ liệu của nó trong một cơ sở dữ liệu, các liên kết Active Record sẽ được xử lý một cách minh bạch và bạn không bao giờ phải nghĩ đến SQL cơ bản. Thời điểm dữ liệu của bạn tồn tại trên nhiều cụm cơ sở dữ liệu, tính minh bạch đó sẽ bị phá vỡ. Một JOIN yêu cầu cả hai bảng tồn tại trong cùng một máy chủ cơ sở dữ liệu. Việc thử một cái trên các cụm sẽ tạo ra ActiveRecord::StatementInvalid lỗi như thế này:
ActiveRecord::StatementInvalid (Table 'people_cluster.humans' doesn't exist)
Đây không phải là lỗi cấu hình. Đó là một hạn chế cứng về mặt vật lý:máy chủ cơ sở dữ liệu không thể JOIN chống lại các bảng họ không lưu trữ. Chúng tôi gặp vấn đề này ở has_many :through và has_one :through các liên kết, bởi vì đó là các loại liên kết tạo ra JOIN trung gian truy vấn. Trực tiếp has_many hoặc belongs_to các mối quan hệ không yêu cầu tham gia nên chúng hoạt động trên các cụm mà không có bất kỳ sửa đổi nào.
Hiểu khi nào bạn sẽ đạt được ranh giới này là bước đầu tiên. Nếu User sống ở accounts cơ sở dữ liệu và một Post sống ở content cơ sở dữ liệu, User has_many :posts hoạt động tốt Nhưng nếu bạn thêm Subscription trung gian mô hình trong billing cơ sở dữ liệu và xác định User has_many :posts, through: :subscriptions , Rails sẽ cố gắng tham gia subscriptions và posts trong một truy vấn duy nhất. Đó là lúc mà ranh giới cụm trở thành vấn đề.
Cấu hình cơ sở dữ liệu ba tầng
Trước khi viết bất kỳ mã mô hình nào, cấu hình cơ sở dữ liệu cần phản ánh bố cục nhiều cụm. Rails sử dụng cấu trúc ba tầng trong config/database.yml cho mục đích này. Mỗi khóa môi trường cấp cao nhất chứa các tên cơ sở dữ liệu lồng nhau và mỗi khóa chứa chi tiết kết nối cho cụm đó.
# config/database.yml
default: &default
adapter: postgresql
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
development:
primary:
<<: *default
database: myapp_primary_dev
accounts:
<<: *default
database: myapp_accounts_dev
migrations_paths: db/accounts_migrate
content:
<<: *default
database: myapp_content_dev
migrations_paths: db/content_migrate
production:
primary:
<<: *default
database: myapp_primary_prod
username: <%= ENV['DB_USER'] %>
password: <%= ENV['DB_PASSWORD'] %>
accounts:
<<: *default
database: myapp_accounts_prod
username: <%= ENV['DB_USER'] %>
password: <%= ENV['DB_PASSWORD'] %>
content:
<<: *default
database: myapp_content_prod
username: <%= ENV['DB_USER'] %>
password: <%= ENV['DB_PASSWORD'] %>
migrations_paths key là không bắt buộc nếu bạn muốn trình tạo Rails và db:migrate để định tuyến di chuyển đến đúng thư mục. Không có nó, tất cả các lần di chuyển mặc định là db/migrate và được áp dụng vào cơ sở dữ liệu chính. Mỗi cơ sở dữ liệu thứ cấp cũng phải có một lớp bản ghi trừu tượng tương ứng mà các mô hình Rails kế thừa từ đó. Trình tạo tự động xử lý việc này khi bạn chuyển --database cờ:
rails generate model Subscription plan:string --database accounts
Điều này tạo ra một AccountsRecord lớp nếu một lớp chưa tồn tại và Subscription được tạo mô hình kế thừa từ nó.
Các lớp bản ghi trừu tượng và định tuyến kết nối
Các lớp bản ghi trừu tượng là cơ chế mà Rails sử dụng để định tuyến các truy vấn đến đúng cụm. Mỗi người gọi connects_to để khai báo cơ sở dữ liệu nào nó ánh xạ tới cho các hoạt động ghi và đọc. Ứng dụng của bạn thường có ba lớp trong hệ thống phân cấp này.
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :primary, reading: :primary }
end
# app/models/accounts_record.rb
class AccountsRecord < ApplicationRecord
self.abstract_class = true
connects_to database: { writing: :accounts, reading: :accounts }
end
# app/models/content_record.rb
class ContentRecord < ApplicationRecord
self.abstract_class = true
connects_to database: { writing: :content, reading: :content }
end
Mô hình Người dùng là một mỏ neo tốt để hiểu hệ thống phân cấp này. Nó sống ở accounts cụm và kế thừa từ AccountsRecord . Các mẫu trong content cụm kế thừa từ ContentRecord . Mọi thứ khác kế thừa từ ApplicationRecord và truy cập cơ sở dữ liệu chính. Chuỗi kế thừa này là cách Active Record xác định nhóm kết nối nào sẽ sử dụng khi thực hiện truy vấn. Nó đi lên theo hệ thống phân cấp lớp cho đến khi tìm thấy lớp có tên là connects_to . 
Một lỗi phổ biến là gọi establish_connection trên các mô hình riêng lẻ thay vì sử dụng các lớp trừu tượng. Mỗi establish_connection cuộc gọi sẽ mở ra một nhóm kết nối riêng biệt. Nếu bạn có 50 mẫu trong accounts cơ sở dữ liệu, mỗi lần gọi establish_connection , bạn sẽ có 50 nhóm kết nối trỏ đến cùng một máy chủ. Các lớp trừu tượng giải quyết vấn đề này bằng cách chia sẻ một nhóm duy nhất trên tất cả các mô hình kế thừa từ chúng.
Các liên kết giữa các cụm trong Rails thực sự hoạt động như thế nào
disable_joins: true tùy chọn là cơ chế trực tiếp để tạo through các liên kết hoạt động khi các bảng liên quan nằm trong các cụm khác nhau. Đường ray has_many là loại liên kết được sử dụng phổ biến nhất và là loại liên kết bị ảnh hưởng trực tiếp nhất bởi ranh giới cụm. Khi Rails gặp tùy chọn này trên một liên kết, nó sẽ bỏ qua JOIN duy nhất chiến lược truy vấn và thay vào đó đưa ra hai (hoặc nhiều) SELECT tuần tự các câu lệnh, chuyển ID từ truy vấn đầu tiên vào WHERE ... IN (...) mệnh đề thứ hai.
Đây là thiết lập mô hình cụ thể bao gồm ba cụm. Thiết lập mô hình bên dưới là mối quan hệ nhiều-nhiều, Người dùng kết nối với Bài đăng thông qua Đăng ký và đó là mô hình bộc lộ trực tiếp nhất vấn đề liên cụm.
# app/models/user.rb - lives in the accounts database
class User < AccountsRecord
has_many :subscriptions
has_many :posts, through: :subscriptions, disable_joins: true
end
# app/models/subscription.rb - lives in the accounts database
class Subscription < AccountsRecord
belongs_to :user
has_many :posts
end
# app/models/post.rb - lives in the content database
class Post < ContentRecord
belongs_to :subscription
end
Khi bạn gọi user.posts , Rails tạo cặp truy vấn này thay vì một JOIN duy nhất :
-- Query 1: fetch subscription IDs from the accounts cluster
SELECT "subscriptions"."id"
FROM "subscriptions"
WHERE "subscriptions"."user_id" = 1
-- Query 2: fetch posts from the content cluster using those IDs
SELECT "posts".*
FROM "posts"
WHERE "posts"."subscription_id" IN (4, 7, 12)
Truy vấn đầu tiên chạy theo accounts cơ sở dữ liệu để thu thập khóa chính. Lệnh thứ hai chạy ngược lại content . Rails giải quyết mối quan hệ bằng cách tuân theo các khóa ngoại, user_id về đăng ký và subscription_id trên các bài đăng, trên hai cụm. Truy vấn đầu tiên thu thập các giá trị khóa chính từ các đăng ký, sau đó chuyển chúng vào IN mệnh đề của truy vấn thứ hai. Không có truy vấn nào thử tham gia nhiều cụm. Rails tập hợp kết quả cuối cùng vào bộ nhớ ứng dụng.
Tùy chọn tương tự hoạt động giống hệt trên has_one :through :
# app/models/user.rb
class User < AccountsRecord
has_one :profile
has_one :avatar, through: :profile, disable_joins: true
end
# app/models/profile.rb - accounts database
class Profile < AccountsRecord
belongs_to :user
has_one :avatar
end
# app/models/avatar.rb - content database
class Avatar < ContentRecord
belongs_to :profile
end
user.avatar sẽ thực hiện hai truy vấn:một để lấy profile_id , một cái khác để tìm nạp bản ghi hình đại diện từ cụm nội dung.
Khi disable_joins phải được đặt rõ ràng
Rails không tự động phát hiện ranh giới cụm và chèn disable_joins dành cho bạn. Việc tải liên kết trong Bản ghi hoạt động rất chậm. SQL cho một liên kết được xác định tại thời điểm liên kết được xác định trên mô hình, chứ không phải khi nó thực sự được kích hoạt. Đến lúc user.posts thực thi, Rails đã quyết định có nên sử dụng JOIN hay không hoặc các truy vấn riêng biệt dựa trên khai báo liên kết.
Điều này có nghĩa là mọi through liên kết vượt qua ranh giới cụm cần disable_joins: true trên tờ khai.
Một cách thiết thực để kiểm tra các mô hình của bạn là tìm kiếm bất kỳ through: nào liên kết trong đó mô hình nguồn và mô hình đích kế thừa từ các lớp bản ghi trừu tượng khác nhau. Nếu User < AccountsRecord và Post < ContentRecord , thì has_many :posts, through: :subscriptions cần disable_joins: true bất kể ở đâu Subscription cuộc sống.
Háo hức tải trên các cụm
disable_joins tùy chọn ảnh hưởng đến cách tải các liên kết, nhưng nó không thay đổi cách các chiến lược tải háo hức tương tác với dữ liệu trên nhiều cụm. Hiểu được sự khác biệt này sẽ giúp tránh được các truy vấn N+1 trong thiết lập nhiều cơ sở dữ liệu.
eager_load không còn phù hợp với các liên kết giữa các cụm. Nó tạo ra một LEFT OUTER JOIN , có giới hạn vật lý tương tự như JOIN thông thường , cả hai bảng phải ở trên cùng một máy chủ. Nếu bạn thử User.eager_load(:posts) nơi các bài đăng nằm trong một cụm khác, bạn sẽ nhận được cùng một StatementInvalid lỗi.
preload là chiến lược đúng đắn. Nó đưa ra các truy vấn riêng biệt cho từng liên kết và tập hợp mối quan hệ trong Ruby. Cấu trúc này giống hệt với disable_joins làm cho một bản ghi duy nhất. Sự khác biệt là tỷ lệ:preload sắp xếp truy vấn thứ hai trên tất cả các bản ghi gốc đã tải.
# This works across clusters.
# Query 1: SELECT "users".* FROM "users"
# Query 2: SELECT "posts".* FROM "posts" WHERE "posts"."subscription_id" IN (...)
users = User.preload(:posts).all
users.each do |user|
user.posts.each { |post| puts post.title } # No additional queries fired
end
includes sẽ hoạt động trong trường hợp nó ủy quyền cho preload nội bộ, điều này được thực hiện theo mặc định khi không có điều kiện nào tham chiếu đến bảng được liên kết. Nếu bạn thêm .where mệnh đề chạm vào các cột của bảng liên kết, includes chuyển sang eager_load hành vi và sẽ thất bại trên các cụm. Khi nghi ngờ về chiến lược nào includes sẽ chọn, rõ ràng và sử dụng preload trực tiếp.
# includes delegates to preload here, works across clusters
User.includes(:posts).all
# includes switches to eager_load because of the where clause, fails across clusters
User.includes(:posts).where("posts.published = ?", true)
# Use preload + a separate where for cross-cluster filtering
User.preload(:posts).all.select { |u| u.posts.any?(&:published?) }
# Or filter in application code after loading
Liên kết theo phạm vi và lọc cụm chéo
Một trong những tương tác tinh tế hơn trong thiết lập đa cơ sở dữ liệu là các liên kết có phạm vi. Khi bạn xác định phạm vi trên has_many vượt qua các cụm, SQL của phạm vi chạy dựa trên cơ sở dữ liệu đích chứ không phải nguồn.
class User < AccountsRecord
has_many :subscriptions
has_many :published_posts,
-> { where(published: true) },
through: :subscriptions,
source: :posts,
class_name: "Post",
disable_joins: true
end
where(published: true) mệnh đề được thêm vào truy vấn thứ hai, truy vấn chạy với content cơ sở dữ liệu. Đây là hành vi đúng và có nghĩa là phạm vi của bạn có thể tham chiếu các cột trên bảng mục tiêu mà không gặp vấn đề gì. Điều bạn không thể làm là tham chiếu các cột từ bảng trung gian trong phạm vi đó, vì truy vấn trung gian đã hoàn thành vào thời điểm truy vấn theo phạm vi thực thi.
# This will fail because subscriptions.active is not a column in the content database
has_many :active_posts,
-> { where("subscriptions.active = ?", true) },
through: :subscriptions,
source: :posts,
disable_joins: true
Lọc các bản ghi trung gian bằng cách thêm một phạm vi vào liên kết trung gian:
class User < AccountsRecord
has_many :active_subscriptions, -> { where(active: true) }, class_name: "Subscription"
has_many :active_posts, through: :active_subscriptions, source: :posts, disable_joins: true
end
Bây giờ lọc trên subscriptions.active xảy ra trong truy vấn đầu tiên, dựa trên accounts cơ sở dữ liệu và chỉ ID từ các đăng ký đang hoạt động mới được chuyển sang truy vấn thứ hai.
Liên kết phân đoạn ngang và phân đoạn chéo
Chia một cơ sở dữ liệu logic trên nhiều máy chủ dựa trên khóa phân vùng như tenant_id giới thiệu một chiều thứ hai cho vấn đề liên cụm. disable_joins cơ chế vẫn được áp dụng nhưng việc định tuyến kết nối trở nên phức tạp hơn.
Rails cung cấp connected_to để chuyển đổi giữa các phân đoạn trong một yêu cầu:
ActiveRecord::Base.connected_to(role: :writing, shard: :shard_one) do
User.find(1) # Hits shard_one
end
Khi các liên kết mở rộng cả cụm và phân đoạn, bạn cần đảm bảo cả bối cảnh phân đoạn và disable_joins tùy chọn đã có sẵn. Một User trên shard_one truy cập các bài đăng nằm trong một content riêng biệt cơ sở dữ liệu vẫn cần phân tách hai truy vấn tương tự.
Rails 8 đã thêm các phương pháp xem xét nội tâm giúp việc suy luận về cấu trúc liên kết phân đoạn dễ dàng hơn trong thời gian chạy:
class ShardedBase < ActiveRecord::Base
self.abstract_class = true
connects_to shards: {
shard_one: { writing: :shard_one },
shard_two: { writing: :shard_two }
}
end
class User < ShardedBase; end
User.shard_keys # => [:shard_one, :shard_two]
User.sharded? # => true
ShardedBase.connected_to_all_shards do
User.current_shard # Yields :shard_one, then :shard_two
end
connected_to_all_shards đặc biệt hữu ích cho các công việc nền cần xử lý bản ghi trên mọi phân đoạn. Nó lặp lại từng phân đoạn theo trình tự, chuyển đổi bối cảnh kết nối cho mỗi lần thực thi khối.
Đối với phân đoạn dựa trên đối tượng thuê, lock: true mặc định khi chuyển đổi phân đoạn sẽ ngăn người thuê vô tình nhảy vào giữa yêu cầu. Đây là một cơ chế an toàn:sau khi yêu cầu được chuyển đến phân đoạn của đối tượng thuê, mã ứng dụng không thể chuyển sang phân đoạn của đối tượng thuê khác mà không chuyển lock: false một cách rõ ràng . Các liên kết nhiều cụm trong phân đoạn của một đối tượng thuê vẫn sử dụng disable_joins dành cho các liên kết chạm vào một cụm cơ sở dữ liệu khác.
Thử nghiệm liên kết giữa các cụm
Việc kiểm tra các thiết lập nhiều cơ sở dữ liệu yêu cầu môi trường kiểm tra của bạn phản ánh cấu trúc liên kết cơ sở dữ liệu sản xuất. Khung kiểm tra của Rails hỗ trợ điều này, nhưng cấu hình phải rõ ràng.
Mỗi cơ sở dữ liệu trong database.yml cần một test khối môi trường. Đồ đạc và dữ liệu thử nghiệm tại nhà máy phải nhắm mục tiêu vào cơ sở dữ liệu chính xác. Nếu là User nhà máy tạo bản ghi trong accounts cơ sở dữ liệu và một Post nhà máy tạo một cái ở content , sự liên kết giữa chúng chỉ hoạt động nếu cả hai bản ghi đều tồn tại trong cơ sở dữ liệu tương ứng trong cùng một giao dịch thử nghiệm.
Theo mặc định, Rails bao bọc từng thử nghiệm trong một giao dịch, nhưng giao dịch đó là trên mỗi kết nối. Với nhiều cơ sở dữ liệu, mỗi kết nối sẽ có giao dịch riêng. Điều này có nghĩa là quá trình dọn dẹp kiểm tra (tự động khôi phục vào cuối mỗi lần kiểm tra) diễn ra độc lập trên mỗi cơ sở dữ liệu. Nếu bài kiểm tra của bạn ghi User tới accounts và một Post tới content , cả hai sẽ được khôi phục nhưng chỉ khi khung kiểm tra biết về cả hai kết nối.
fixtures khai báo xử lý việc này một cách tự động khi các mô hình kế thừa từ lớp trừu tượng chính xác. Đối với các thiết lập tại nhà máy (FactoryBot, Fabricator), hãy đảm bảo create của mỗi nhà máy chiến lược truy cập đúng cơ sở dữ liệu bằng cách cho phép connects_to của chính mô hình định tuyến thực hiện công việc.
# spec/factories/users.rb
FactoryBot.define do
factory :user do
# User inherits from AccountsRecord and writes to accounts DB automatically
name { Faker::Name.name }
end
end
# spec/factories/posts.rb
FactoryBot.define do
factory :post do
# Post inherits from ContentRecord and writes to content DB automatically
association :subscription
title { Faker::Lorem.sentence }
end
end
Để xác minh rằng các liên kết cụm chéo đang thực hiện số lượng truy vấn dự kiến, hãy đăng ký sql.active_record thông báo:
# spec/support/query_counter.rb
module QueryCounter
def assert_query_count(expected, &block)
count = 0
callback = ->(_name, _start, _finish, _id, payload) do
count += 1 unless payload[:name] == "SCHEMA" || payload[:sql].start_with?("EXPLAIN")
end
ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)
assert_equal expected, count, "Expected #{expected} queries, got #{count}"
end
end
Một has_many :through với disable_joins: true trên một bản ghi sẽ tạo ra chính xác 2 truy vấn. Nếu bạn thấy 1, thì việc kết nối vẫn đang được thử (và sẽ không thành công trong quá trình sản xuất đối với các máy chủ riêng biệt). Nếu bạn thấy N+1 nghĩa là quá trình tải háo hức không hoạt động như mong đợi.
Một số lưu ý
disable_joins giải quyết vấn đề tải liên kết, nhưng nó không mở rộng sang chuỗi truy vấn. Bạn không thể xâu chuỗi .where , .order , hoặc .group các mệnh đề tham chiếu các cột trên các cụm trên một mối quan hệ Bản ghi hoạt động duy nhất:
# This does not work, you cannot filter products by order columns across clusters
customer.purchased_products.where("orders.total > ?", 100)
Đối với các truy vấn cần lọc hoặc sắp xếp dựa trên dữ liệu trong nhiều cụm, hãy phân tách chúng theo cách thủ công. Tìm nạp ID hoặc giá trị bạn cần từ một cụm, sau đó sử dụng chúng làm đầu vào cho truy vấn đối với cụm khác:
high_value_order_ids = Order.where(customer_id: customer.id)
.where("total > ?", 100)
.pluck(:id)
line_item_product_ids = LineItem.where(order_id: high_value_order_ids).pluck(:product_id)
products = Product.where(id: line_item_product_ids)
Đây là cách phân tách tương tự như disable_joins thực hiện nội bộ nhưng được thực hiện rõ ràng để bạn có thể áp dụng tính năng lọc ở từng giai đoạn. Nó dài dòng hơn nhưng nó làm cho ranh giới cụm hiển thị trong mã thay vì ẩn chúng đằng sau các liên kết trong cú pháp Rails.
Ghi chú của biên tập viên:Bài đăng này ban đầu được xuất bản vào tháng 1 năm 2023 và đã được cập nhật để đảm bảo tính chính xác.