Ruby on Rails

本节研究如何将多租户 Rails 应用程序迁移到 Citus 存储后端。 我们将使用 activerecord-multi-tenant Ruby gem 来更轻松地进行横向扩展。

这个 Ruby gem 是从我们与客户扩展多租户应用程序的经验发展而来的。 它修补了 ActiveRecord 和 Rails 当前在自动查询构建方面的一些限制。 它基于出色的 acts_as_tenant 库, 并针对 Citus 等分布式多租户数据库的特定用例进行了扩展。

准备横向扩展多租户应用程序

最初,您通常会从将所有租户放置在单个数据库节点上开始, 并在您提供返回租户数据的 Web 请求时使用 Ruby on Rails 和 ActiveRecord 等框架为给定租户加载数据。

ActiveRecord 对限制横向扩展选项的数据存储做了一些假设。 特别是,ActiveRecord 引入了一种模式,您可以在其中规范化数据并将其拆分为许多不同的模型, 每个模型由单个 id 列标识,具有多个 belongs_to 关系,将对象(objects)与租户(tenant)或客户(customer)联系起来:

# typical pattern with multiple belongs_to relationships

class Customer < ActiveRecord::Base
  has_many :sites
end
class Site < ActiveRecord::Base
  belongs_to :customer
  has_many :page_views
end
class PageView < ActiveRecord::Base
  belongs_to :site
end

这种模式的棘手之处在于,为了找到客户的所有页面浏览量,您必须首先查询客户的所有网站。 一旦您开始对数据进行分片,这就会成为一个问题, 特别是当您对嵌套模型(如本示例中的页面视图)运行 UPDATE 或 DELETE 查询时。

你今天可以采取一些步骤,以便将来更轻松地进行扩展:

1. 在属于租户的每条记录上为 tenant_id 引入一列

为了扩展多租户模型,您必须快速找到属于一个租户的所有记录。 实现这一点的最简单方法是在属于租户的每个对象上简单地添加一个 tenant_id 列(或 “customer_id” 列等), 并回填现有数据以正确设置此列。

当您将来迁移到像 Citus 这样的分布式多租户数据库时, 这将是一个必需的步骤 - 但如果您以前这样做过, 您可以简单地 COPY 您的数据,而无需进行任何额外的数据修改。

2. 使用包含 tenant_id 的唯一约束

除了 tenant_id 之外的值的唯一(Unique)和外键(foreign-key)约束在任何分布式系统中都会出现问题, 因为很难确保没有两个节点接受相同的唯一值。 执行约束将需要对所有节点的数据进行昂贵的扫描。

为了解决这个问题,对于逻辑上与商店(我们的应用程序的租户)相关的模型, 您应该将 store_id 添加到约束中,从而有效地在给定商店内唯一地确定对象的范围。 这有助于将租户概念添加到您的模型中,从而使多租户系统更加健壮。

例如,Rails 默认创建一个主键,它只包含记录的 id

Indexes:
    "page_views_pkey" PRIMARY KEY, btree (id)

您应该修改该主键以包含 tenant_id:

ALTER TABLE page_views DROP CONSTRAINT page_views_pkey;
ALTER TABLE page_views ADD PRIMARY KEY(id, customer_id);

此规则的一个例外可能是用户(users)表上的电子邮件(email) 或用户名(username)列(除非您为每个租户提供自己的登录页面), 这就是为什么一旦您向外扩展,我们通常建议将这些从您的分布式表中拆分出来, 并且作为本地表放置在 Citus 协调器节点上。

3. 在所有查询中包含 tenant_id,即使您可以使用自己的 object_id 定位对象

在分布式系统中不受限制地运行典型 SQL 查询的最简单方法是始终访问位于单个节点上的数据,由您访问的租户确定。

出于这个原因,一旦您使用像 Citus 这样的分布式系统, 我们建议您始终为查询指定 tenant_id 和对象自己的 ID, 以便协调器可以快速定位您的数据, 并将查询路由到单个分片 - 而不是分别访问系统中的每个分片并询问分片是否知道给定的 object_id。

更新 Rails 应用程序

您可以通过将 gem 'activerecord-multi-tenant' 包含到您的 Gemfile 中, 运行 bundle install,然后像这样注释您的 ActiveRecord 模型来开始:

class PageView < ActiveRecord::Base
  multi_tenant :customer
  # ...
end

在这种情况下, customer 是租户模型, 并且您的 page_views 表需要有一个 customer_id 列来引用页面视图所属的客户。

activerecord-multi-tenant Ruby gem 旨在更容易地在典型的 Rails 应用程序中实现上述数据更改。

Note

该库依赖于 tenant id 列对于所有行都存在且非空。 但是,让库为 记录设置 tenant id, 同时将现有记录中缺少的 tenant id 值作为后台任务回填,这通常很有用。 这使得开始使用 activerecord-multi-tenant 变得更加容易。

为了支持这一点,该库具有只写模式,其中 tenant id 列不会在查询中过滤, 但会为新记录正确设置。在 Rails 初始化程序中包含以下内容以启用它:

MultiTenant.enable_write_only_mode

一旦您准备好强制执行租赁,请在您的 tenant_id 列中添加一个 NOT NULL 约束并简单地删除初始化程序行。

如开头所述,通过向模型添加 multi_tenant :customer 注释, 库会自动处理所有查询中包含的 tenant_id 。

为了让它工作,你总是需要指定你正在访问的租户,或者通过在每个请求的基础上指定它:

class ApplicationController < ActionController::Base
  # Opt-into the "set_current_tenant" controller helpers by specifying this:
  set_current_tenant_through_filter

  before_filter :set_customer_as_tenant

  def set_customer_as_tenant
    customer = Customer.find(session[:current_customer_id])
    set_current_tenant(customer) # Set the tenant
  end
end

或者通过将您的代码包装在一个块中,例如:对于后台和维护任务:

customer = Customer.find(session[:current_customer_id])
# ...
MultiTenant.with(customer) do
  site = Site.find(params[:site_id])

  # Modifications automatically include tenant_id
  site.update! last_accessed_at: Time.now

  # Queries also include tenant_id automatically
  site.page_views.count
end

一旦你准备好使用像 Citus 这样的分布式多租户数据库, 你只需要对迁移进行一些调整,就可以开始了:

class InitialTables < ActiveRecord::Migration
  def up
    create_table :page_views, partition_key: :customer_id do |t|
      t.references :customer, null: false
      t.references :site, null: false

      t.text :url, null: false
      ...
      t.timestamps null: false
    end
    create_distributed_table :page_views, :account_id
  end

  def down
    drop_table :page_views
  end
end

注意 partition_key: :customer_id, 它是由我们的库添加到 Rails 的 create_table 的, 它确保主键包含 tenant_id 列,以及 create_distributed_table, 它使 Citus 能够将数据扩展到多个节点。

更新测试套件

如果 Rails 应用程序的测试套件使用 database_cleaner gem 在运行之间重置测试数据库, 请确保使用“截断(truncation)”策略而不是“事务(transaction)”。 我们在测试中看到事务回滚期间偶尔会失败。 database_cleaner documentation 包含更改清理策略的说明。

持续集成

在持续集成中运行 Citus 集群的最简单方法是使用 Citus Docker 官方容器。 以下是如何在 Circle CI 上执行此操作。

  1. https://github.com/citusdata/docker/blob/master/docker-compose.yml 复制到 Rails 项目中,并将其命名为 citus-docker-compose.yml。

  2. 更新在 .circleci/config.yml 中的 steps:。这将启动一个协调器(coordinator)和工作器(worker)节点:

    steps:
      - setup_remote_docker:
          docker_layer_caching: true
      - run:
          name: Install Docker Compose
          command: |
            curl -L https://github.com/docker/compose/releases/download/1.19.0/docker-compose-`uname -s`-`uname -m` > ~/docker-compose
            chmod +x ~/docker-compose
            mv ~/docker-compose /usr/local/bin/docker-compose
    
      - checkout
    
      - run:
          name: Starting Citus Cluster
          command: docker-compose -f citus-docker-compose.yml up -d
    
  3. 让您的测试套件连接到 Docker 中的数据库,该数据库将位于 localhost:5432 上。

示例应用程序

如果您对更完整的示例感兴趣,请查看我们的 reference app, 该应用程序展示了用于广告分析的简化示例 SaaS 应用程序。

dashboard in example application showing a campaign

正如您在屏幕截图中看到的,大多数数据都与当前登录的客户相关联——即使这是复杂的分析数据, 所有数据都是在单个客户或租户的上下文中访问的。