关于 activerecord-tenanted
这份技能指南介绍如何用 Basecamp 的 `activerecord-tenanted` gem 将 Rails 租户数据隔离到各自的 SQLite 数据库,同时把身份和租户记录留在全局数据库。内容涵盖分片选择、请求作用域、后台任务、迁移与测试。
主要功能
- 01区分全局记录与租户记录
- 02为每个租户配置 SQLite 数据库
- 03根据请求路径设置租户作用域
- 04在后台任务中重新进入租户作用域
- 05安全迁移和测试租户数据库
适用场景
- 01设计基于路径的 Rails 多租户模式
- 02分离身份记录与工作区数据
- 03让后台任务访问租户记录
- 04测试需要活动租户的代码
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill activerecord-tenanted安装后在 Claude Code 中输入/activerecord-tenanted确认已加载。
附带文件reference.md
详细介绍
activerecord-tenanted(每个租户使用一个 SQLite 数据库)
通过 Basecamp 的 activerecord-tenanted gem 为每个租户使用一个 SQLite 文件。身份、会话和租户记录存放在全局数据库中。产品数据存放在 storage/tenants/{env}/{tenant_key}/main.sqlite3。
这是 Herald / Cortex / Innkeeper 使用的模式。它不是:
- Nebula
acts_as_tenant——一个数据库,每行都有workspace_id - Fizzy 路径租户模式——一个数据库,使用
Current.account+SCRIPT_NAMEURL 前缀
如果应用已在共享架构中使用 acts_as_tenant 或 Fizzy 风格的账户 slug,请勿引入此 gem。
设计理念
- 两个基类,两个数据库。
GlobalRecord永远不会访问租户数据。ApplicationRecord永远不会访问身份数据。 - 没有租户就不能查询租户数据。 在
with_tenant/Current.tenant=之外调用User.find是一个 bug,即使是在任务和 rake 任务中也一样。 - URL(或 Current)决定分片。 gem 默认的子域名解析器不适用于基于路径的应用——请禁用它。
- 作业重新进入租户上下文。 Active Job 不会自动传递分片。传入一个全局 ID(account/tenant/project),并在
perform中使用with_tenant包裹。
数据放在哪里
全局(GlobalRecord) | 租户级(ApplicationRecord) |
|---|---|
| Identity、Session、MagicLink | 实际的产品记录 |
| Tenant / Account | User(Cortex/Innkeeper——租户内的个人资料) |
| Membership(Identity ↔ tenant) | 该工作区中的其他所有内容 |
| JoinCode、API tokens | Active Storage blobs(启用 tenanted_rails_records 时) |
Herald 将 Project 保留在全局数据库中(这样在选择分片之前就有 slug/API key),并将 releases/subscribers 存储在账户租户数据库中。Cortex/Innkeeper 则将几乎所有产品表放在租户数据库中。两种方式都可以——选择一种并保持一致。
步骤 1:Gem + database.yml
# Gemfile
gem "activerecord-tenanted"
# config/database.yml — every environment
primary:
adapter: sqlite3
database: storage/tenants/<%= Rails.env %>/%{tenant}/main.sqlite3
tenanted: true
max_connection_pools: 50
global:
adapter: sqlite3
database: storage/<%= Rails.env %>.sqlite3
migrations_paths: db/global_migrate
queue:
adapter: sqlite3
database: storage/<%= Rails.env %>_queue.sqlite3
migrations_paths: db/queue_migrate
# cache + cable the same way
%{tenant} 是 gem 的占位符。在 primary 上设置 database_tasks: false 是可选的(Herald 设置此项,以免 Rails 尝试转储单个主架构)。
迁移:
db/global_migrate/——Identity、Tenant/Account、Membership、Session。运行一次。db/migrate/——租户级表。gem 会为每个租户运行这些迁移。
bin/rails db:migrate # tenanted
bin/rails db:migrate:global # global — check the gem's task name in the app
步骤 2:两个抽象类
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
tenanted
end
# app/models/global_record.rb
class GlobalRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :global, reading: :global }
end
新模型:如果是个人、会话或租户记录本身 → GlobalRecord。如果是工作区内的数据 → ApplicationRecord。弄错这一点是头号 bug。
步骤 3:禁用子域名解析器 + 默认租户
基于路径的应用(我们所有的应用)都不能使用 gem 的子域名解析器:
# config/initializers/tenanted.rb
Rails.application.configure do
config.active_record_tenanted.tenant_resolver = ->(request) { nil }
config.active_record_tenanted.default_tenant =
Rails.env.local? ? "#{Rails.env}-tenant" : nil
config.active_record_tenanted.tenanted_rails_records = true
config.active_record_tenanted.connection_class = "ApplicationRecord"
end
生产环境中的 default_tenant 必须为 nil。 否则启动可能会因 ARTENANT not set 而失败;更糟的是,每个请求都可能共用一个遗留分片。在本地,指定一个默认租户可让控制台/测试正常启动。
tenanted_rails_records = true 会将 Active Storage / Action Text 存储在租户数据库中,并使用带前缀的键。请搭配 active-storage-multitenant skill 使用。
步骤 4:Current 切换分片
class Current < ActiveSupport::CurrentAttributes
attribute :identity, :session
attribute :tenant # or :account — pick one name and use it everywhere
attribute :user
def tenant=(tenant)
super
return if ActiveRecord::Base.shard_swapping_prohibited?
ApplicationRecord.current_tenant =
tenant ? tenant.external_id : ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
end
end
Herald 使用 Current.account= 和 account.tenant_key("account-#{id}")。Cortex/Innkeeper 使用 Current.tenant= 和 tenant.external_id。机制相同。
在请求结束时重置(Cortex):
resets do
next if ActiveRecord::Base.shard_swapping_prohibited?
ApplicationRecord.current_tenant = ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
end
步骤 5:从 URL 解析租户
在控制器 concern 中执行,而不是使用 gem 的解析器:
module TenantScoping
extend ActiveSupport::Concern
included do
before_action :set_current_tenant
before_action :set_current_user
end
def set_current_tenant
record = Tenant.find_by(external_id: params[:tenant_external_id])
Current.tenant = record if record
end
def set_current_user
return unless Current.identity && Current.tenant
Current.user = User.find_by(identity_id: Current.identity.id)
end
end
路由嵌套在租户 key 下(/:tenant_external_id/...,或会加载 Account 的项目 slug 下)。未认证页面(登录、来自供应商的 webhooks)会跳过此步骤,并且只对租户级操作进行封装。
创建 Tenant/Account 时创建租户数据库:
after_create :provision_tenant_database
def ensure_tenant_database!
ApplicationRecord.create_tenant(tenant_key) unless database_exists?
rescue ActiveRecord::Tenanted::TenantExistsError
# parallel tests / retries
end
步骤 6:作业、rake、控制台
def perform(tenant_external_id, record_id)
ApplicationRecord.with_tenant(tenant_external_id) do
Current.tenant = Tenant.find_by(external_id: tenant_external_id)
Record.find(record_id).do_work
end
end
永远不要将属于租户的 Active Record 序列化为 GID,并指望 perform 能加载它——作业运行时没有租户上下文。请传入全局租户键和记录 ID。
Rake / 单次任务:
Tenant.find_each do |tenant|
ApplicationRecord.with_tenant(tenant.external_id) do
puts Project.count
end
end
对于 URL 中不含租户信息的公开入站 Webhook:遍历租户(Innkeeper),或查找指向某个租户的全局令牌,然后调用 with_tenant。不要在作业顶层查询 User。
测试
每个涉及租户模型的测试都必须设置租户。使用以下辅助方法:
def with_tenant(tenant = tenants(:acme), &block)
ApplicationRecord.with_tenant(tenant.external_id, &block)
end
集成测试:访问包含租户前缀的 URL,以便运行 TenantScoping。不要 stub Current.tenant 后再断言 HTTP 结果——分片与请求不匹配。
反模式
- 在已经采用租户隔离的应用中使用
acts_as_tenant :workspace——这会引入两套租户系统。Nebula 是另一种模型;不要混用。 - 将 Identity 或 Session 放在 ApplicationRecord 上——切换分片时,这些表必须继续可用。
- 在作业中不调用
with_tenant就执行User.find——结果会静默为空,或出现TenantNotSet。 - 保留子域名解析器——基于路径的应用会解析出错误的租户,或无法解析租户。
- 在生产环境中使用
default_tenant——所有进程都会共享同一个数据库文件。 - 假设租户数据库使用整数主键——我们的租户表使用 UUID。请参阅
uuid-primary-keys。 - 未设置当前租户就创建 blob——“Cannot generate a Blob key without a tenant.”请使用
with_tenant和 Active Storage 技能。
相关内容
- Identity 与 User、成员关系、加入码:
identity-membership - 租户隔离的 Active Storage / Lexxy:
active-storage-multitenant - 在租户上下文中运行的出站 Webhook:
outgoing-webhooks - 无密码登录(全局 Identity):
magic-link-auth
有关 database.yml、Herald 与 Cortex 的命名,以及分片切换的注意事项,请参阅 reference.md。