ทำไม Database ถึงอยู่ใน Cluster ต่างกัน
Rails associations ทำงานโปร่งใสตลอดที่ข้อมูลอยู่ใน database เดียว แต่เมื่อข้อมูลกระจายข้าม multiple database clusters ความโปร่งใสจะพัง
JOIN ต้องการให้ทั้งสองตารางอยู่ใน database server เดียวกัน พยายาม JOIN ข้าม cluster จะได้ error:
ActiveRecord::StatementInvalid (Table 'people_cluster.humans' doesn't exist)
นี่ไม่ใช่ configuration mistake แต่เป็น physical constraint: database servers ไม่สามารถ JOIN ตารางที่ไม่ได้ host เอง ปัญหาเกิดกับ has_many :through และ has_one :through เพราะ generate intermediate JOIN queries ส่วน has_many หรือ belongs_to โดยตรง ไม่ต้อง join จึงทำงานข้าม cluster ได้
ตัวอย่าง: User ใน database accounts, Post ใน content -> User has_many :posts OK แต่เพิ่ม Subscription ใน billing และ User has_many :posts, through: :subscriptions -> Rails พยายาม join subscriptions และ posts ใน query เดียว -> cluster boundary problem
Three-tier Database Configuration
Rails ใช้โครงสร้าง 3 ระดับใน config/database.yml:
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
migrations_paths จำเป็นสำหรับ generators และ db:migrate route migrations ไป directory ที่ถูกต้อง
แต่ละ secondary database ต้องมี abstract record class ใช้ --database flag:
rails generate model Subscription plan:string --database accounts
สร้าง AccountsRecord class และ Subscription inherit จากมัน
Abstract Record Classes และ Connection Routing
Abstract record classes = mechanism ที่ Rails ใช้ route queries ไป cluster ที่ถูกต้อง ใช้ connects_to declare database mapping
# application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :primary, reading: :primary }
end
# accounts_record.rb
class AccountsRecord < ApplicationRecord
self.abstract_class = true
connects_to database: { writing: :accounts, reading: :accounts }
end
# content_record.rb
class ContentRecord < ApplicationRecord
self.abstract_class = true
connects_to database: { writing: :content, reading: :content }
end
User model อยู่ใน accounts cluster inherit AccountsRecord Models ใน content cluster inherit ContentRecord อื่นๆ inherit ApplicationRecord -> primary database
Active Record หา connection pool โดยเดินขึ้น class hierarchy จนเจอ class ที่เรียก connects_to
Common mistake: ใช้ establish_connection บน model ตัวๆ แทน abstract classes -> สร้าง connection pools แยกกันเยอะ(Abstract classes แชร์ pool เดียว)
Cross-Cluster Associations ทำงานอย่างไร
disable_joins: true = mechanism ตรงๆ สำหรับทำให้ through associations ข้าม cluster ได้ Rails ทิ้ง single JOIN query strategy กลายเป็น 2+ sequential SELECT statements pipe IDs จาก query แรกเข้า WHERE ... IN (...) ของ query ที่สอง
Model Setup ตัวอย่าง (3 clusters)
# user.rb - accounts database
class User < AccountsRecord
has_many :subscriptions
has_many :posts, through: :subscriptions, disable_joins: true
end
# subscription.rb - accounts database
class Subscription < AccountsRecord
belongs_to :user
has_many :posts
end
# post.rb - content database
class Post < ContentRecord
belongs_to :subscription
end
เรียก user.posts -> Rails สร้าง 2 queries แทน JOIN:
-- Query 1 (accounts cluster)
SELECT "subscriptions"."id" FROM "subscriptions" WHERE "subscriptions"."user_id" = 1
-- Query 2 (content cluster)
SELECT "posts".* FROM "posts" WHERE "posts"."subscription_id" IN (4, 7, 12)
Query แรกเก็บ primary keys จาก subscriptions ส่งเข้า IN clause ของ query ที่สอง ไม่มี cross-cluster join Rails ประกอบ result set ใน application memory
disable_joins ทำงานเหมือนกันกับ has_one :through
เมื่อไหร่ต้องตั้ง disable_joins ให้ชัดเจน
Rails ไม่ auto-detect cluster boundaries Association loading เป็น lazy SQL กำหนดตอน define association ไม่ใช่ตอน trigger ทุก through association ที่ข้าม cluster ต้องมี disable_joins: true
Audit: หา through association ที่ source และ target model inherit จาก abstract record classes ต่างกัน
Eager Loading ข้าม Clusters
disable_joins ส่งผลต่อ association loading แต่ไม่เปลี่ยน eager loading strategies
eager_load: ใช้ LEFT OUTER JOIN -> fail ข้าม clusterpreload: ใช้ separate queries ประกอบใน Ruby -> ถูกต้อง สำหรับ cross-clusterincludes: delegate ไป preload โดย default (ถ้าไม่มี where บน associated table) -> works แต่ถ้ามี where บน associated table -> switch ไป eager_load -> fail
ถ้าลังเล ใช้ preload โดยตรง
Scoped Associations และ Cross-Cluster Filtering
Scope บน has_many ข้าม cluster -> scope SQL รันบน target database ไม่ใช่ source
has_many :published_posts, -> { where(published: true) },
through: :subscriptions, source: :posts, class_name: "Post", disable_joins: true
where(published: true) ต่อท้าย query ที่ 2 (content database) - ถูกต้อง
ไม่ได้: reference columns จาก intermediate table ใน scope (intermediate query จบแล้ว)
แก้: filter intermediate records โดยเพิ่ม scope บน intermediate association แทน
Horizontal Sharding และ Cross-Shard Associations
Split logical database ตาม partition key (tenant_id) เพิ่มมิติที่ 2 ของ cross-cluster problem disable_joins ยังใช้ได้ แต่ connection routing ซับซ้อนขึ้น
Rails มี connected_to สำหรับ switch shards:
ActiveRecord::Base.connected_to(role: :writing, shard: :shard_one) do
User.find(1) # Hits shard_one
end
Rails 8 เพิ่ม introspection methods: shard_keys, sharded?, connected_to_all_shards
Testing Cross-Cluster Associations
Test environment ต้อง mirror production database topology database.yml ต้องมี test environment block สำหรับแต่ละ database
Fixtures/factories ต้อง target database ที่ถูกต้อง Rails wrap test ใน transaction per-connection multiple databases = multiple transactions cleanup อิสระต่อ database
Verify query count ด้วย sql.active_record notification - has_many :through + disable_joins บน single record ควรเป็น 2 queries พอดี
Caveats
disable_joins แก้ association loading แต่ไม่ขยายไป query chaining ไม่ chain .where, .order, .group ที่ reference columns ข้าม cluster บน single relation ได้
แก้: decompose manually - fetch IDs จาก cluster หนึ่ง ใช้เป็น input query cluster อีกตัว ทำ cluster boundaries มองเห็นในโค้ด