Julik Tarkhanov logo

Shardines 方案:Rails 中的 SQLite 多租户实现

· 2025年4月25日

我非常喜欢一种模式,即在具有多个隔离用户的 Web 应用程序中使用 “one database per tenant”(每个租户一个数据库)。最近,我需要修复一个长期使用的应用程序,其中这种 database-per-tenant 多租户模式完全崩溃了,因为我错误地处理了连接管理。 这就引出了一个问题:如何正确地实现它?

结果发现我并不孤单。 最流行的多租户 Gem - Apartment - 我甚至在我过去失败的创业公司中使用过 - 也存在这个问题。

“不能很好地处理多线程” 的罪魁祸首实际上更深层次。 使用 Rails 进行运行时定义的多数据库,直到最近才变得不那么随意,并且没有通过 Gems 或内置工具来简化这些流程。 它还积累了大量的复杂性,并且每次 Rails 的主要版本更新都会发生变化。

TL;DR 如果你现在需要使用 Rails 或 ActiveRecord 正确地实现 database-per-tenant 多租户,请从 这个 gist 中获取中间件并继续。

如果你对这个解决方案的起源感到好奇,请系好安全带 - 我们将经历一个相当大的问题,以及一个重要的 API - ActiveRecord 连接管理。 阅读并加入我的旅程! 非常感谢 Kir ShatrovStephen Margheim 在这方面的帮助。

“database per tenant” 的优势

如果你有一个租户应用程序(你的 “租户” 是你的数据模型的一个子图,它可以独立运行,并且主要引用来自其内部的其他实体),你有很多方法来实现这样的架构。 假设我们有一个系统,其中租户是一个 Site。 该系统是某种最终用户可维护的 CMS,用户拥有多个 Sites,并且可以管理 Site 中的页面、媒体和其他项目。 数据模型如下:

class Site < ActiveRecord::Base
end
class Page < ActiveRecord::Base
 belongs_to :site
 has_many :media_blocks
 has_many :pages, through: :media_blocks
end
class MediaBlock < ActiveRecord::Base
 belongs_to :media_item
 belongs_to :page
end
class Comment < ActiveRecord::Base
 belongs_to :page # and thus to a Site, "through"
end
class MediaItem < ActiveRecord::Base
 has_many :media_items
 has_many :pages, through: :media_items
 has_one_ :site, through: :pages # since it can be reused across multiple pages
end

Sites 很少被合并在一起,并且 99% 在 Site 中创建的数据都永久地保留在该 Site 中。 无论是因为你的系统已经超过了托管单个 Site 的能力,还是因为你想要拥有强大的隔离(你不希望 Jane 将内容发布到她的 Site 上,结果文章却错误地出现在 Blake 的 Site 上),或者是因为你的系统非常成功且有利可图,你可能想要应用以下策略:

还有其他一些技巧可以很好地进行分片/多租户 - 例如,使用生成的包含租户 ID 在内的主键 - 这样就可以合并分片等。

但我们在这里特别感兴趣的是最后一种方法 - 每个租户一个数据库。 对于我较小的网站,使用 SQLite 作为数据库已成为必不可少的一部分。 拥有一个可以轻松配置的数据库服务器非常好。 拥有一个根本不必配置的数据库服务器 - 非常出色。 备份也是如此:集中式备份非常棒且有用。 但没有什么比 rsync 更好的了,如果这正是你需要做的备份。 并且 schema 也会变小 - 我们可以直接将 Site 从数据库中移出,并且其余模型不再需要与 site 的关联:

class Page < ActiveRecord::Base
 has_many :media_blocks
 has_many :pages, through: :media_blocks
end
class MediaBlock < ActiveRecord::Base
 belongs_to :media_item
 belongs_to :page
end
class Comment < ActiveRecord::Base
 belongs_to :page # and thus to a Site, "through"
end
class MediaItem < ActiveRecord::Base
 has_many :media_blocks
end

在开发方面,这些设置也很容易 - 如果你需要调试特定 Site 中的内容,你需要做的就是下载该站点的数据。 通常只需一个 scp 命令。 房间里也有大象:

但请记住:使用这种方法有一个令人震惊的优势。 它坚定地将你推出 “大数据” 领域,甚至推出 “中等数据” 领域 - 它是 “微小数据”。

如果这有帮助,我对我对 “大数据” 的正式定义是 “当存储在合理速度的立即访问的随机读取存储介质上时,无法放在你的桌子下的数据”。

一些公司通过这样做取得了非凡的成功。 例如,Expensify 因在大多数人认为舒适的界限之外推动和拉动 SQLite 而闻名。 我知道 Autodesk 自己的 ShotGrid - 当它曾经是 Shotgun 并且是一个独立的软件产品时 - 广泛使用了 SQLite3。 伴随着偶尔出现的 SQLite3::BusyException 😉

为什么这在 Rails 中具有挑战性

当 “裸” 使用 SQLite3 时,处理数据库的 “打开” 和 “关闭” 绝对是微不足道的:

SQLite3::Database.open("site_1.sqlite3") do |db|
 site_title = db.get_first_value("SELECT title FROM sites LIMIT 1")
 pages = db.execute("SELECT * FROM pages ")
end

如果我们使用 Rack,我们只需将其包装在一个中间件中:

def call(env)
 SQLite3::Database.open("site_1.sqlite3") do |db|
  @app.call(env.merge("site_db" => db))
 end
end 

但是,为了使此工作生效,必须显式地将 db 变量(数据库的句柄)用于每个查询! 然而,ActiveRecord 管理连接不是通过你给它的变量,而是通过它自己 “回忆” 特定 ActiveRecord 超类连接到哪个数据库:

class Page < ActiveRecord::Base
 establish_connection database: "site_1.sqlite3"
end

不用说,这段代码只运行一次(并且你不知道确切的 “何时” - 答案是 在第一次查询时),并且根本不是为一直断开和重新连接而设计的。 现在,如果有一种方法可以做到这一点:

class Page < ActiveRecord::Base
 obtain_connection_from { tenancy_system.database_config }
end

它会更容易,但唉。 并且随着连接池、查询缓存、schema 缓存的添加 - 你正在查看一个相当大的东西放在其他东西上面的装置。 这就是使这项练习如此令人沮丧的原因:你知道使用 “裸” API 非常简单的事情,当通过 ActiveRecord 执行时却令人恼火地复杂。 此外 - 这是 SQLite3 的主要用例之一,而 ActiveRecord 似乎使其几乎不可能执行。 怎么会这样?

不可避免的变动

Rails 中多个数据库出现困难的原因归结为该功能的历史以及 超大规模企业(生态系统的 Github、Shopify 和 Zendesk)的需求。

由于我使用 Rails(和这个 ActiveRecord)已经很长时间了 - 这是一个简短的历史回顾:

所有这些有趣的部分是,虽然 ActiveRecord 示例代码 包含如下代码段:

class Person < ActiveRecord::Base
 establish_connection adapter: "sqlite3", database: "foobar.db"
 connection.create_table table_name, force: true do |t|
  t.string :name
 end
end

Rails 处理多个数据库的方式的变化导致这个示例代码仅在非常少的情况下才有用。 例如:对于这样的代码段,我们如何 close_connection? 或者,在执行一两个查询后,我们如何告诉 Person 连接到不同的数据库?

如果你开始研究这个问题,一个完整的世界将在你眼前打开。 并且这个世界有它自己的分类 - 并且它很大。 ConnectionHandlingDatabaseConfigDatabaseConfigurations(是的,复数)、ResolverPoolConfigPoolManager…所有这些东西都在精心管理的舞蹈中互动、生活和呼吸。 剧透:它们可以做我们需要的一切,但我们必须像一个小管弦乐队一样指挥它们 - 就像这样。

发散的 API 设计

ActiveRecord 的设计方式 - 拥有没有明确方式告诉它们 “通过哪个连接” 它们应该为此查询工作的模型类 - 意味着总会有一些隐藏状态。 它可以是全局变量,也可以是类变量(它是…一个美化的全局变量)- 或者是线程本地的,但某个地方 存在 一个连接,并且直到最近(确切地说是 Rails 6),还没有官方方法告诉 ActiveRecord 使用哪个连接。

理论上,可以实现如下 API: Page.with_connection(tenant_db_conn).first 但是,然后需要将此数据库连接参数提供给 ActiveRecord 的每个调用 - 并且 API 确实非常庞大。

另一个复杂之处在于,AR 的许多设计都假设与数据库的连接(并且它将是 数据库)将尽早打开,然后保持完整。 schema 缓存(例如,让 ActiveRecord 子类知道表中存在哪些列)加载一次。 查询缓存初始化一次。 迁移运行一次 - 并且,再次,它们在 数据库 上运行。

发散的配置生命周期

如果你想使用 SQLite3 构建一个小租户的多租户系统,每个租户分配一个数据库,那么你的需求与假设的 Shopify 的需求并不完全一致:他们想要拥有 cluster_acluster_bcluster_eucluster_us,每个集群都有数十 TB 的大小。 相反,你想要拥有 site_1site_2 等 - 其中一些只有几 KB 大小。

这意味着对于他们来说,这些大型集群的配置可以编程方式输出到 database.yml 中。 它可以是源代码控制的,并且遵循关于以下内容的严格和特定的语义:

上述三个 “大公司” 中没有一个具有真正的、每个租户单个数据库的设置 – 或者至少我从未听说他们这样做。

大多数现代 ActiveRecord 基础设施都建立在这些假设之上,而不是因为该功能的制作者想要反对 “我们” 想要的东西 - 他们只是做出了不同的权衡。

发散的数据库引擎权衡

另一个重要的项目是数据库性能。“大型” 数据库服务器的设计基于一些假设。例如,如果你有一个数据库,引擎会对为该数据库保持打开的文件描述符或来自该数据库的 mmap() 文件感兴趣。如果表空间很大 - 它们将被缓存在内存中,并且会被相当积极地缓存。如果有索引 - 引擎会尝试将它们也缓存在内存中,并保持对包含索引数据的文件访问的近距离。

最终结果是,至少当我在一个基于 Apartment 的系统中使用 MySQL 5.7 时,在创建一定数量的数据库后,你将开始达到文件描述符限制。这些限制在 MacOS 上设置得很低,但仍然很明显 - 并且很明显,这只是一个时间问题(和规模)- 虽然我们还没有达到 - 在此之前这将成为一个实际问题。

因此,总的来说,数据库服务器针对 少量大型数据库 进行了优化–而不是针对数千个小型数据库进行了优化。这是静态 database.yml 方法如此吸引人的另一个原因。

只需查看 这篇文章

通过实施这些优化,我看到了显着的性能改进:单个 MySQL 服务器每分钟处理 2M+ 个票务预订事务,而 SELECT … FOR UPDATE SKIP LOCKED 查询的平均延迟保持在 400μS 以下。

这是那些 “大型引擎” 优化的性能类型。 而不是 “快速处理此站点内的 120 页,这是 2 千个站点之一”。

使用 SQLite3,故事完全不同。 SQLite3 多个小型数据库中 蓬勃发展。 由于 SQLite3 数据库只是一个文件(嗯,有时是 3 个文件,但你明白了),因此在系统中为每个租户拥有一个数据库是完全有意义的。 由于系统是多租户的,因此保证对租户 A 的请求不需要来自租户 B 的数据。 此外 - 当我们处理对租户 A 的请求时,我们不需要来自 B 的任何资源 - 因此我们甚至不需要连接(文件句柄)。

拥有较小的 SQLite3 数据库具有更多的优势 - 例如,在每个租户的基础上进行备份更快 - 并且更精细。 调试变得更加容易 - 你无需执行复杂的 SELECT 序列来提取特定租户的 “切片” 数据,只需批量复制租户的数据库即可。 粒度还原也是如此。 删除也是如此 - 删除租户,即使是大型租户,也只需一个 unlink 即可。

因此,Rails 中现代多数据库功能不支持使用自动分配的数据库进行动态租户管理(大量)的原因之一是,至少从表面上看,目前只有 SQLite3 使这种模式可行。

它不仅可行–而且 光荣。 你是否知道 iCloud 的工作方式是数百万个隔离的 SQLite 数据库,存储在 更大的 Cassandra 数据库内部

回到最初的问题

所以,我有一个应用程序。 它已经运行了十多年了。 最初它是使用静态 HTML 构建的,其中一些模板在使用 rsync 上传服务器之前进行了预处理 - 它本质上是一个美化的静态站点生成器。 然后出现了 “管理” 功能,并且该应用程序获得了数据库。 从一开始,该应用程序(这是一种迷你 CMS)就为每个网站所有者提供了一个 UI 来编辑其网站的内容。 每个网站也有自己的数据库。 它们之间的请求永远不会交叉 - 并且在整个过程中没有涉及任何 site_id

最初它基于原始 ERB 和一些粘合代码。 然后它被重写为 Camping,并且数据库切换看起来大致类似于上面的 establish_connection 示例。 这是 Rails-3 之前的版本,所以没有 Rack 中间件,没有装饰,什么都没有。

随着 ActiveRecord 3 的升级,代码被拆分为更合适的东西,以及转换为 Sinatra。 连接管理(已移至 Rack 中间件)采用以下形式:

def call(env)
 begin
  ActiveRecord::Base.establish_connection(adapter: 'sqlite3', 
   database: env.fetch('site_db_path'),
   timeout: BUSY_TIMEOUT)
  s, h, b = app.call(env.merge('database' => self))
  connection_closing_body = ::Rack::BodyProxy.new(b) do
   ::ActiveRecord::Base.clear_active_connections!
  end
  [s, h, connection_closing_body]
 rescue Exception
  ::ActiveRecord::Base.clear_active_connections!
  raise
 end
end

这大部分都奏效了。 大约在同一时间,我安装了 Sentry,但我没有养成定期查看它的习惯 - 不记得确切的原因了。 但是,经过几年的无痛操作后,该应用程序开始抛出奇怪的错误。 引起我注意的错误是 ActiveRecord::ConnectionNotEstablished,并且站点上的负载越大,错误变得越频繁。 更多访问 - 更频繁的错误。 快进几年,错误变得非常频繁。

我的假设是,要弄清楚发生了什么,我可以更好地升级到至少 ActiveRecord 6。这个版本是最旧的 Rails 版本,由于 Ruby 版本的兼容性,它已经能够在 Apple Silicon 上运行 - 我现在是它的骄傲拥有者。

几个小时后,以及转换了大量 CoffeeScript 文件(甚至编辑了更多的 Ruby 文件),更新完成了。 我在本地进行了测试,验证了一切运行良好,并部署了该应用程序。

仅仅 30 分钟后 - ConnectionNotEstablished。 不仅如此,而且比以前频繁 10 倍。 更新没有解决问题 - 事实上,它使问题变得更糟。 我做了一些实验:

没有任何效果。 在 Kir 的帮助下,他负责其中一个 “大男孩” 的数据库分片管理 - 我得到了一个想法,应该可以使用新的 roles: 参数 - 和一个伪造的数据库配置 - 来实现此功能。

我的错误是我试图自己手动管理池和连接 - 而新的 Rails 功能实际上是为 Rails 管理池和连接 为你 而设计的。 因此,在这种情况下,也存在…对 API 的不同理解。

我仍然在使用 establish_connection(就像在过去的日子里一样)来管理该基础设施的范例中。 但 “受祝福” 的方法实际上是向 Rails 提供连接配置,并让它自动处理它们。

解决方案

ActiveRecord::Base 现在有一个名为 connected_to 的类方法。 它允许你准确地执行 database-per-tenant 设置所需的操作 - 进入一个块,其中某些内容是你的 “主” 数据库。 以前,它接受带有整个 DB 配置的 database:,但现在它只接受 role:(并且 - 使用 Rails 7 及更高版本 - shard:)。 这是你使用它的方式:

ActiveRecord::Base.connected_to(shard: "sites_1") do
 site = Site.find(site_id) # Which lives on this shard, "sites_1"
 posts = site.pages.order(created_at: :desc)
end

挑战在于 sites_1 在哪里配置。 通常它会在你的 database.yml 中,根据 官方文档:

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

但是,如果你想实时地在租户之间切换 - 并且租户是在运行时创建(和删除!)的 - 拥有带有交叉引用键的静态配置根本无法实现。 此外 - 即使你可以更改 “上帝配置” - 你如何强制 ActiveRecord 重新加载它? 你如何告诉 ActiveRecord 分片/租户不再存在? 你如何以线程安全的方式执行此操作? 它会导致重新初始化所有连接池,还是只是添加删除?

然后,解决方案集中在一个领域:接管 ActiveRecord 管理这些连接池,并自动命名 rolesshards。 最终,这是大部分工作所在的地方。 我们想将我们的租户数据库名称/文件名转换为一个字符串,以设计我们可以提供给 AR 的 role 名称。 对于我来说,我只更新到 Rails 6,所以我还没有进入分片。 如果我这样做了(并且我可能会最终这样做),则租户名称将是 shard,并且 readingwriting 角色可用于托管 readonly: true DB 连接和一个可写的连接。 但是,我最终使用的方法即使在 Rails 8 上也不支持分片,请继续阅读。

那么我们如何创建这些池?

方法是 查询 ActiveRecord,了解是否已经设置了特定的连接池。 如果没有 - 如果你尝试切换到不存在的角色/分片,则会出现 NoConnectionPool 异常。 但是通过 rescues 执行此操作不是很好 - 控制流变得有点复杂。 我们可以做的是检查是否为特定角色/分片设置了连接池,然后连接(如果没有)。 请注意,由于这管理池 - 因此需要受互斥锁保护:

MUX.synchronize do
 if ActiveRecord::Base.connection_handler.connection_pool_list(role_name).none?
  ActiveRecord::Base.connection_handler.establish_connection(database_config_hash, role: role_name)
 end
end

之后,我们可以使用 connected_to - 它将是线程安全的、快速的并且非常整洁:

MUX.synchronize do
 if ActiveRecord::Base.connection_handler.connection_pool_list(role_name).none?
  ActiveRecord::Base.connection_handler.establish_connection(database_config_hash, role: role_name)
 end
end
ActiveRecord::Base.connected_to(role: role_name) do
 pages = Page.order(created_at: :desc).limit(10) # Only selects from that site/tenant
end

不要忘记流式传输 Rack 主体

我们需要注意一个小小的额外元素:在 Rack 中正确地执行此操作。 为了拥有类似于 Apartment::Elevator 的东西,我们需要做一些事情 - 再次 - apartment 并没有正确地执行。 如果我们假设我们应用程序的所有渲染都被缓冲了,我们可以这样做:

def call(env)
 site_name = env["SERVER_NAME"]
 connection_config_hash = {adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}
 role_name = "site_#{site_name}"
 # Create a connection pool for that tenant if it doesn't exist
 MUX.synchronize do
  if ActiveRecord::Base.connection_handler.connection_pool_list(role_name).none?
   ActiveRecord::Base.connection_handler.establish_connection(connection_config_hash, role: role_name)
  end
 end
 ActiveRecord::Base.connected_to(role: role_name) do
  @app.call(env) # returns [status, header, body]
 end
end

但我们当然知道,Rack 主体是 可调用可迭代的 - 并且 Rack 响应主体可能也只是在执行 SQL 查询。 即使使用它的人少得多,驻留在 Rack 响应主体中并提供流数据的代码与驻留在应用程序 call() 方法中的代码一样有用。 我甚至在 Appsignal(我最喜欢的 APM)上处理了一个大型补丁,这使得它可以像报告应用程序的 call 一样报告 Rack 流主体中发生的事情。 对于那些主体的资源释放,通常的工作方式是:

f = File.open(path, "rb")
status, headers, body = @app.call(env)
body_with_close = Rack::BodyProxy.new(body) { f.close }
[status, headers, body_with_close]

这会将一个回调附加到我们返回的 Rack 主体的 #close 方法,根据 Rack [SPEChttps://github.com/rack/rack/blob/main/SPEC.rdoc] - 必须由 Web 服务器 调用中间件调用。

合理的是,connected_to 的 ActiveRecord API 仅适用于块。 这是鼓励正确(且安全)使用相当直率的工具的好主意。 然而,特别是在这种情况下,它妨碍了。 幸运的是,这个问题可以通过明智地应用 Fiber 来绕过:

connected_to_context_fiber = Fiber.new do
 ActiveRecord::Base.connected_to(role: role_name) do
  Fiber.yield
 end
end
connected_to_context_fiber.resume
status, headers, body = @app.call(env)
body_with_close = Rack::BodyProxy.new(body) { connected_to_context_fiber_.resume }
[status, headers, body_with_close]

因此,我们的 “租户切换中间件” 用于 ActiveRecord 连接管理,每个租户一个数据库变为:

class Shardine
 MUX = Mutex.new
 def initialize(connection_config)
  @config = connection_config
  @role_name = connection_config.fetch(:database).to_s
 end
 def with(&blk)
  # Create a connection pool for that tenant if it doesn't exist
  MUX.synchronize do
   if ActiveRecord::Base.connection_handler.connection_pool_list(@role_name).none?
    ActiveRecord::Base.connection_handler.establish_connection(@config, role: @role_name)
   end
  end
  ActiveRecord::Base.connected_to(role: @role_name, &blk)
 end
 def enter!
  @fiber = Fiber.new do
   with(conn) { Fiber.yield }
  end
  @fiber.resume
  true
 end
 def leave!
  # Probably there is something in ConnectionHandling
  # that can be used here, but I was too lazy to look
  to_resume, @fiber = @fiber, nil
  to_resume&.resume
 end
 class Middleware
  def initialize(app, &database_config_lookup)
   @app = app
   @lookup = database_config_lookup
  end
  def call(env)
   connection_config = @lookup.call(env)
   switcher = TenantDatabaseSwitcher.new(connection_config)
   did_enter = switcher.enter!
   status, headers, body = @app.call(env)
   body_with_close = Rack::BodyProxy.new(body) { switcher.leave! }
   [status, headers, body_with_close]
  rescue
   switcher.leave! if did_enter
   raise
  end
 end
end

然后我们在 config.ru 中配置它(或在 Rails 中类似地):

use Shardine::Middleware do |env|
 site_name = env["SERVER_NAME"]
 {adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}
end

瞧 - 一个安全且高性能的 database-per-tenant 切换器。

额外的障碍

由于我正在升级到 Rails 6 - 这似乎是我真正不得不去的最低 “现代” 版本 - 因此还有一个额外的障碍。

默认情况下,当你在没有 Rails 的情况下使用 ActiveRecord 时,它会以 “保守” 方式配置 - 或者更确切地说,根本没有配置。 Rails 6 具有 legacy_connection_handling 的概念。 无需过多详细介绍,为了使此解决方案有效,必须显式关闭该参数。 在 Rails 7 及更高版本中,此参数不再存在。

一些剩余的工作

由于我最初将我的应用程序迁移到 ActiveRecord 6,因此我还没有 shard 支持。 实际上,让你的 “读取副本” 成为 readonly: true SQLite3 数据库是完全有意义的,正如 Stephen 所写的那样。

另一方面,目前没有 API 可以在从系统中删除租户时删除连接池,这我只是不需要(我的租户不会经常更改)。

当你需要 “进入” 租户时,处理其他上下文可以使用类似的方法,可以使用 connected_to 或使用 fiber 方法。

当然,“database per tenant” 工作流程才刚刚开始,并且仅在最近几年,特别是使用 ONCE 系列产品,SQLite3 才再次开始发光 - 作为 “大数据,大量数据” 的引擎。

愿我们有生之年看到这种模式最终成为人们关注的焦点。 © Julik Tarkhanov 2025