Citus多租户用例

Citus多租户用例

如果您正在构建软件即服务(SaaS)应用程序,那么您可能已经在数据模型中内置了租赁概念。通常,大多数信息涉及租户/客户/帐户,数据库表捕获这种自然关系。

对于SaaS应用程序,每个租户的数据可以一起存储在单个数据库实例中,并与其他租户隔离和不可见。这在三个方面是有易的。首先,应用程序适用于所有客户。其次,在租户之间共享数据库有效地利用硬件资源。最后,管理所有租户的单个数据库比为每个租户管理不同的数据库服务器要简单得多。

但是,单个关系数据库实例传统上难以扩展到大型多租户应用程序所需的数据量。当数据超出单个数据库节点的容量时,开发人员被迫放弃这种关系模型的好处。

Citus允许用户编写多租户应用程序,就像它们连接到单个PostgreSQL数据库一样,而实际上数据库是一个可水平扩展的机器集群。客户端代码只需要很少的修改,并可以继续使用完整的SQL功能。

本指南介绍了多租户应用程序示例,并介绍了如何使用Citus对其进行可扩展性建模。在此过程中,我们将研究多租户应用程序的典型挑战,例如将租户与嘈杂的邻居隔离,扩展硬件以容纳更多数据,以及存储不同租户的数据。PostgreSQL和Citus提供了应对这些挑战所需的所有工具。

让我们制作应用程序 – 广告分析

我们将为追踪在线广告效果的应用构建后端,并在顶部提供分析仪表板。它非常适合多租户应用程序,因为用户对数据的请求一次涉及一个(他们自己的)公司。

让我们首先考虑这个应用程序的简化模式。该应用程序必须跟踪多个公司,每个公司都运行广告活动。广告系列包含许多广告,每个广告都有相关的点击次数和展示次数记录。

这是示例模式。稍后我们将进行一些小的更改,这使我们能够在分布式环境中有效地分发和隔离数据。

CREATE TABLE companies (
  id bigserial PRIMARY KEY,
  name text NOT NULL,
  image_url text,
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL
);

CREATE TABLE campaigns (
  id bigserial PRIMARY KEY,
  company_id bigint REFERENCES companies (id),
  name text NOT NULL,
  cost_model text NOT NULL,
  state text NOT NULL,
  monthly_budget bigint,
  blacklisted_site_urls text[],
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL
);

CREATE TABLE ads (
  id bigserial PRIMARY KEY,
  campaign_id bigint REFERENCES campaigns (id),
  name text NOT NULL,
  image_url text,
  target_url text,
  impressions_count bigint DEFAULT 0,
  clicks_count bigint DEFAULT 0,
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL
);

CREATE TABLE clicks (
  id bigserial PRIMARY KEY,
  ad_id bigint REFERENCES ads (id),
  clicked_at timestamp without time zone NOT NULL,
  site_url text NOT NULL,
  cost_per_click_usd numeric(20,10),
  user_ip inet NOT NULL,
  user_data jsonb NOT NULL
);

CREATE TABLE impressions (
  id bigserial PRIMARY KEY,
  ad_id bigint REFERENCES ads (id),
  seen_at timestamp without time zone NOT NULL,
  site_url text NOT NULL,
  cost_per_impression_usd numeric(20,10),
  user_ip inet NOT NULL,
  user_data jsonb NOT NULL
);

扩展关系数据模型

关系数据模型非常适合应用程序。它可以保护数据完整性,允许灵活的查询,并适应不断变化的数据。传统上唯一的问题是关系数据库不被认为能够扩展到大型SaaS应用程序所需的工作负载。开发人员必须忍受NoSQL数据库才能达到这个规模。

使用Citus,您可以保留数据模型并使其扩展。Citus将应用程序看作是单个PostgreSQL数据库,但它在内部将查询路由到可调数量的物理服务器(节点),这些服务器可以并行处理请求。

多租户应用程序有一个很好的属性,我们可以利用:查询通常总是一次请求一个租户的信息,而不是多个租户混合信息。例如,当销售人员在CRM中搜索潜在客户信息时,搜索结果特定于其雇主; 其他企业的潜在客户和笔记不包括在内。

由于应用程序查询仅限于单个租户(例如商店或公司),因此快速进行多租户应用程序查询的一种方法是将给定租户的所有数据存储在同一节点上。这可以最大限度地减少节点之间的网络开销,并允许Citus有效地支持所有应用程序的join,关键约束和事务。有了这个,您可以跨多个节点进行扩展,而无需完全重写或重新构建您的应用程序

我们通过确保模式中的每个表都有一列来清楚地标记哪个租户拥有哪些行,从而在Citus中执行此操作。在广告分析应用程序中,租户是公司,因此我们必须确保所有表都有一company_id列。

当行标记为同一公司时,我们可以告诉Citus使用此列来读取和写入行到同一节点。在Citus的术语company_id中将是分发列。

准备表和提取数据

在上一节中,我们为多租户应用程序确定了正确的分发列:公司ID。即使在单机数据库中,通过添加公司ID来对表进行非规范化也是有用的,无论是用于行级安全还是用于其他索引。正如我们所看到的,额外的好处是包括额外的列也有助于Citus多机器扩展。

到目前为止,我们创建的模式使用单独的id列作为每个表的主键。Citus要求主键和外键约束包括分发列。此要求使得在分布式环境中更有效地实施这些约束,因为只需要检查单个节点以保证它们。

在SQL中,此要求转换为通过包含使主键和外键复合company_id。这与多租户案例兼容,因为我们真正需要的是确保每个租户的唯一性。

总而言之,以下是准备分发表的更改company_id。

CREATE TABLE companies (
  id bigserial PRIMARY KEY,
  name text NOT NULL,
  image_url text,
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL
);

CREATE TABLE campaigns (
  id bigserial,       -- was: PRIMARY KEY
  company_id bigint REFERENCES companies (id),
  name text NOT NULL,
  cost_model text NOT NULL,
  state text NOT NULL,
  monthly_budget bigint,
  blacklisted_site_urls text[],
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL,
  PRIMARY KEY (company_id, id) -- added
);

CREATE TABLE ads (
  id bigserial,       -- was: PRIMARY KEY
  company_id bigint,  -- added
  campaign_id bigint, -- was: REFERENCES campaigns (id)
  name text NOT NULL,
  image_url text,
  target_url text,
  impressions_count bigint DEFAULT 0,
  clicks_count bigint DEFAULT 0,
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL,
  PRIMARY KEY (company_id, id),         -- added
  FOREIGN KEY (company_id, campaign_id) -- added
    REFERENCES campaigns (company_id, id)
);

CREATE TABLE clicks (
  id bigserial,        -- was: PRIMARY KEY
  company_id bigint,   -- added
  ad_id bigint,        -- was: REFERENCES ads (id),
  clicked_at timestamp without time zone NOT NULL,
  site_url text NOT NULL,
  cost_per_click_usd numeric(20,10),
  user_ip inet NOT NULL,
  user_data jsonb NOT NULL,
  PRIMARY KEY (company_id, id),      -- added
  FOREIGN KEY (company_id, ad_id)    -- added
    REFERENCES ads (company_id, id)
);

CREATE TABLE impressions (
  id bigserial,         -- was: PRIMARY KEY
  company_id bigint,    -- added
  ad_id bigint,         -- was: REFERENCES ads (id),
  seen_at timestamp without time zone NOT NULL,
  site_url text NOT NULL,
  cost_per_impression_usd numeric(20,10),
  user_ip inet NOT NULL,
  user_data jsonb NOT NULL,
  PRIMARY KEY (company_id, id),       -- added
  FOREIGN KEY (company_id, ad_id)     -- added
    REFERENCES ads (company_id, id)
)

SELECT  create_distributed_table ('companies' ,   'id' ); 
SELECT  create_distributed_table ('campaigns' ,   'company_id' ); 
SELECT  create_distributed_table ('ads' ,         'company_id' ); 
SELECT  create_distributed_table ('clicks' ,      'company_id' ); 
SELECT  create_distributed_table ('impressions' , 'company_id' );

集成应用程序并使用

包含过滤器的任何应用程序查询或更新语句company_id将继续完全按原样工作。如前所述,这种过滤器在多租户应用程序中很常见。使用对象关系映射器(ORM)时,您可以通过诸如where或等方法识别这些查询filter。

ActiveRecord:
Impression.where(company_id: 5).count
Django:
Impression.objects.filter(company_id=5).count()

基本上,当在数据库中执行的结果SQL 在每个表上包含一个子句(包括JOIN查询中的表)时,Citus将认识到该查询应该路由到单个节点并按原样执行。这可确保所有SQL功能都可用。毕竟该节点是普通的PostgreSQL服务器。WHERE company_id = :value

此外,为了使其更简单,您可以使用我们的Rails 主动记录多租户库,或Django的django-multitenant,它会自动将这些过滤器添加到您的所有查询,甚至是复杂的查询。查看我们的Ruby on Rails和Django迁移指南。

这是一个简单的查询和更新操作单个租户。

-- campaigns with highest budget

SELECT name, cost_model, state, monthly_budget
  FROM campaigns
 WHERE company_id = 5
 ORDER BY monthly_budget DESC
 LIMIT 10;

-- double the budgets!

UPDATE campaigns
   SET monthly_budget = monthly_budget*2
 WHERE company_id = 5

用户使用NoSQL数据库扩展应用程序的一个常见痛点是缺少事务和join。

-- transactionally reallocate campaign budget money

BEGIN;

UPDATE campaigns
   SET monthly_budget = monthly_budget + 1000
 WHERE company_id = 5
   AND id = 40;

UPDATE campaigns
   SET monthly_budget = monthly_budget - 1000
 WHERE company_id = 5
   AND id = 41;

COMMIT;

作为SQL支持的最终演示,我们有一个包含聚合和窗口函数的查询,它在Citus中的工作方式与在PostgreSQL中的工作方式相同。该查询会根据展示次数对每个广告系列中的广告进行排名。

SELECT a.campaign_id,
       RANK() OVER (
         PARTITION BY a.campaign_id
         ORDER BY a.campaign_id, count(*) desc
       ), count(*) as n_impressions, a.id
  FROM ads as a
  JOIN impressions as i
    ON i.company_id = a.company_id
   AND i.ad_id      = a.id
 WHERE a.company_id = 5
GROUP BY a.campaign_id, a.id
ORDER BY a.campaign_id, n_impressions desc;

简而言之,当查询范围限定为租户时,插入,更新,删除,复杂SQL和事务都按预期工作。

在租户之间共享数据

到目前为止,所有表都已分发company_id,但有时会有所有租户共享的数据,并且不属于任何租户。我们可以使用参考表。

CREATE TABLE geo_ips (
  addrs cidr NOT NULL PRIMARY KEY,
  latlon point NOT NULL
    CHECK (-90  <= latlon[0] AND latlon[0] <= 90 AND
           -180 <= latlon[1] AND latlon[1] <= 180)
);
CREATE INDEX ON geo_ips USING gist (addrs inet_ops);

SELECT create_reference_table('geo_ips');

SELECT c.id, clicked_at, latlon
  FROM geo_ips, clicks c
 WHERE addrs >> c.user_ip
   AND c.company_id = 5
   AND c.ad_id = 290;

数据模型的更改

多租户系统的另一个挑战是保持所有租户的数据模型同步。任何更改都需要始终反映在所有租户中。在Citus中,您可以简单地使用标准PostgreSQL DDL命令来更改表的结构,Citus将使用两阶段提交协议将它们从协调器节点传播到worker。

例如,此应用程序中的广告可以使用文本标题。我们可以通过在协调器上发出标准SQL来向表中添加一列:

ALTER TABLE ads
  ADD COLUMN caption text;

当租户需要不同数据时

鉴于所有租户共享一个共同的架构和硬件基础设施,我们如何容纳存储其他人不需要的信息?例如,使用我们的广告数据库的租户应用程序之一可能希望通过点击存储跟踪cookie信息,而另一个租户可能关心浏览器代理。传统上,使用共享模式方法进行多租户的数据库已经采用了创建固定数量的预分配“自定义”列,或者具有外部“扩展表”。但PostgreSQL提供了一种更简单的非结构化列类型JSONB。

请注意,我们的模式已经clicks调用了一个JSONB字段user_data。每个租户都可以使用它进行灵活存储。

假设公司5包括现场信息以跟踪用户是否在移动设备上。该公司可以查询以查找更多,移动或传统访问者的点击次数:

SELECT
  user_data->>'is_mobile' AS is_mobile,
  count(*) AS count
FROM clicks
WHERE company_id = 5
GROUP BY user_data->>'is_mobile'
ORDER BY count DESC;

数据库管理员甚至可以创建部分索引来提高单个租户的查询模式的速度。这是一个改进公司5的移动设备用户点击过滤器:

CREATE INDEX click_user_data_is_mobile
ON clicks ((user_data->>'is_mobile'))
WHERE company_id = 5;

另外,PostgreSQL支持JSONB上的GIN索引。在JSONB列上创建GIN索引将在该JSON文档中的每个键和值上创建索引。

CREATE INDEX click_user_data
ON clicks USING gin (user_data);

-- this speeds up queries like, "which clicks have
-- the is_mobile key present in user_data?"

SELECT id
  FROM clicks
 WHERE user_data ? 'is_mobile'
   AND company_id = 5;
文章浏览总量 1,005 次

要发表评论,您必须先登录