跳到主要内容
跳到主要内容

多租户

在 SaaS 数据分析平台中,让多个租户(例如组织、客户或业务单元)在共享同一套数据库基础设施的同时保持各自数据的逻辑隔离是很常见的做法。这样,不同用户就可以在同一平台上安全地访问各自的数据。

根据不同的需求,实现多租户有多种方式。下面是使用 ClickHouse Cloud 实现多租户的指南。

共享表

在这种方案下,所有租户的数据都存储在一张共享表中,并通过某个字段(或一组字段)来标识每个租户的数据。为了最大化性能,应将该字段包含在主键中。为确保用户只能访问其所属租户的数据,我们通过行策略实现基于角色的访问控制

我们推荐采用这种方案,因为它最易于管理,尤其适用于所有租户共享相同数据模式且数据量适中(小于 TB 级别)时

通过将所有租户数据整合到一张表中,可以借助优化的数据压缩和减少元数据开销来提升存储效率。此外,由于所有数据集中管理,模式更新也更加简单。

这种方法在处理大量租户(可达数百万)时尤其有效。

但是,如果不同租户具有不同的数据模式,或预期其模式会随着时间逐渐分化,则其他方法可能更为合适。

当不同租户之间的数据量差异较大时,数据量较小的租户可能会受到不必要的查询性能影响。需要注意的是,将租户字段包含在主键中在很大程度上可以缓解这一问题。

示例

这是一个共享表的多租户模型实现示例。

首先,让我们创建一个共享表,并在主键中包含字段 tenant_id

--- 创建表 events。使用 tenant_id 作为主键的一部分
CREATE TABLE events
(
    tenant_id UInt32,                 -- 租户标识符
    id UUID,                    -- 唯一事件 ID
    type LowCardinality(String), -- 事件类型
    timestamp DateTime,          -- 事件时间戳
    user_id UInt32,               -- 触发事件的用户 ID
    data String,                 -- 事件数据
)
ORDER BY (tenant_id, timestamp)

现在我们来插入一些示例数据。

-- 插入一些虚拟数据行
INSERT INTO events (tenant_id, id, type, timestamp, user_id, data)
VALUES
(1, '7b7e0439-99d0-4590-a4f7-1cfea1e192d1', 'user_login', '2025-03-19 08:00:00', 1001, '{"device": "desktop", "location": "LA"}'),
(1, '846aa71f-f631-47b4-8429-ee8af87b4182', 'purchase', '2025-03-19 08:05:00', 1002, '{"item": "phone", "amount": 799}'),
(1, '6b4d12e4-447d-4398-b3fa-1c1e94d71a2f', 'user_logout', '2025-03-19 08:10:00', 1001, '{"device": "desktop", "location": "LA"}'),
(2, '7162f8ea-8bfd-486a-a45e-edfc3398ca93', 'user_login', '2025-03-19 08:12:00', 2001, '{"device": "mobile", "location": "SF"}'),
(2, '6b5f3e55-5add-479e-b89d-762aa017f067', 'purchase', '2025-03-19 08:15:00', 2002, '{"item": "headphones", "amount": 199}'),
(2, '43ad35a1-926c-4543-a133-8672ddd504bf', 'user_logout', '2025-03-19 08:20:00', 2001, '{"device": "mobile", "location": "SF"}'),
(1, '83b5eb72-aba3-4038-bc52-6c08b6423615', 'purchase', '2025-03-19 08:45:00', 1003, '{"item": "monitor", "amount": 450}'),
(1, '975fb0c8-55bd-4df4-843b-34f5cfeed0a9', 'user_login', '2025-03-19 08:50:00', 1004, '{"device": "desktop", "location": "LA"}'),
(2, 'f50aa430-4898-43d0-9d82-41e7397ba9b8', 'purchase', '2025-03-19 08:55:00', 2003, '{"item": "laptop", "amount": 1200}'),
(2, '5c150ceb-b869-4ebb-843d-ab42d3cb5410', 'user_login', '2025-03-19 09:00:00', 2004, '{"device": "mobile", "location": "SF"}'),

接下来创建两个用户 user_1user_2

-- 创建用户
CREATE USER user_1 IDENTIFIED BY '<password>'
CREATE USER user_2 IDENTIFIED BY '<password>'

我们创建行策略,使 user_1user_2 只能访问各自租户的数据。

-- 创建行级策略
CREATE ROW POLICY user_filter_1 ON default.events USING tenant_id=1 TO user_1
CREATE ROW POLICY user_filter_2 ON default.events USING tenant_id=2 TO user_2

然后使用一个通用角色,为共享表授予 GRANT SELECT 权限。

-- 创建角色
CREATE ROLE user_role

-- 授予 events 表的只读权限
GRANT SELECT ON default.events TO user_role
GRANT user_role TO user_1
GRANT user_role TO user_2

现在,你可以以 user_1 身份连接并运行一个简单的 select 查询。只会返回来自第一个租户的行。

-- 以 user_1 登录
SELECT *
FROM events

   ┌─tenant_id─┬─id───────────────────────────────────┬─type────────┬───────────timestamp─┬─user_id─┬─data────────────────────────────────────┐
1. │         1 │ 7b7e0439-99d0-4590-a4f7-1cfea1e192d1 │ user_login  │ 2025-03-19 08:00:00 │    1001 │ {"device": "desktop", "location": "LA"} │
2. │         1 │ 846aa71f-f631-47b4-8429-ee8af87b4182 │ purchase    │ 2025-03-19 08:05:00 │    1002 │ {"item": "phone", "amount": 799}        │
3. │         1 │ 6b4d12e4-447d-4398-b3fa-1c1e94d71a2f │ user_logout │ 2025-03-19 08:10:00 │    1001 │ {"device": "desktop", "location": "LA"} │
4. │         1 │ 83b5eb72-aba3-4038-bc52-6c08b6423615 │ purchase    │ 2025-03-19 08:45:00 │    1003 │ {"item": "monitor", "amount": 450}      │
5. │         1 │ 975fb0c8-55bd-4df4-843b-34f5cfeed0a9 │ user_login  │ 2025-03-19 08:50:00 │    1004 │ {"device": "desktop", "location": "LA"} │
   └───────────┴──────────────────────────────────────┴─────────────┴─────────────────────┴─────────┴─────────────────────────────────────────┘

独立表

在这种方案中,每个租户的数据都存储在同一数据库内的独立表中,因此不再需要使用特定字段来标识租户。通过使用 GRANT 语句 来实施用户访问控制,确保每个用户只能访问包含其所属租户数据的表。

当不同租户的数据模式(schema)不同时,使用独立表是一个不错的选择。

对于租户数量较少、但每个租户都拥有非常大的数据集且查询性能至关重要的场景,此方案可能优于共享表模型。由于不需要过滤其他租户的数据,查询可以更加高效。此外,主键也可以进一步优化,因为不需要在主键中包含额外字段(例如租户 ID)。

请注意,这种方案不适用于扩展到成千上万的租户。参见 使用限制

示例

这是独立表多租户模型实现的示例。

首先,创建两个表,一个用于 tenant_1 的事件,另一个用于 tenant_2 的事件。

-- 为租户 1 创建表 
CREATE TABLE events_tenant_1
(
    id UUID,                    -- 唯一事件 ID
    type LowCardinality(String), -- 事件类型
    timestamp DateTime,          -- 事件时间戳
    user_id UInt32,               -- 触发事件的用户 ID
    data String,                 -- 事件数据
)
ORDER BY (timestamp, user_id) -- 主键可以侧重于其他属性

-- 为租户 2 创建表 
CREATE TABLE events_tenant_2
(
    id UUID,                    -- 唯一事件 ID
    type LowCardinality(String), -- 事件类型
    timestamp DateTime,          -- 事件时间戳
    user_id UInt32,               -- 触发事件的用户 ID
    data String,                 -- 事件数据
)
ORDER BY (timestamp, user_id) -- 主键可以侧重于其他属性

插入模拟数据。

INSERT INTO events_tenant_1 (id, type, timestamp, user_id, data)
VALUES
('7b7e0439-99d0-4590-a4f7-1cfea1e192d1', 'user_login', '2025-03-19 08:00:00', 1001, '{"device": "desktop", "location": "LA"}'),
('846aa71f-f631-47b4-8429-ee8af87b4182', 'purchase', '2025-03-19 08:05:00', 1002, '{"item": "phone", "amount": 799}'),
('6b4d12e4-447d-4398-b3fa-1c1e94d71a2f', 'user_logout', '2025-03-19 08:10:00', 1001, '{"device": "desktop", "location": "LA"}'),
('83b5eb72-aba3-4038-bc52-6c08b6423615', 'purchase', '2025-03-19 08:45:00', 1003, '{"item": "monitor", "amount": 450}'),
('975fb0c8-55bd-4df4-843b-34f5cfeed0a9', 'user_login', '2025-03-19 08:50:00', 1004, '{"device": "desktop", "location": "LA"}')

INSERT INTO events_tenant_2 (id, type, timestamp, user_id, data)
VALUES
('7162f8ea-8bfd-486a-a45e-edfc3398ca93', 'user_login', '2025-03-19 08:12:00', 2001, '{"device": "mobile", "location": "SF"}'),
('6b5f3e55-5add-479e-b89d-762aa017f067', 'purchase', '2025-03-19 08:15:00', 2002, '{"item": "headphones", "amount": 199}'),
('43ad35a1-926c-4543-a133-8672ddd504bf', 'user_logout', '2025-03-19 08:20:00', 2001, '{"device": "mobile", "location": "SF"}'),
('f50aa430-4898-43d0-9d82-41e7397ba9b8', 'purchase', '2025-03-19 08:55:00', 2003, '{"item": "laptop", "amount": 1200}'),
('5c150ceb-b869-4ebb-843d-ab42d3cb5410', 'user_login', '2025-03-19 09:00:00', 2004, '{"device": "mobile", "location": "SF"}')

接下来创建两个用户 user_1user_2

-- 创建用户 
CREATE USER user_1 IDENTIFIED BY '<password>'
CREATE USER user_2 IDENTIFIED BY '<password>'

然后在相应的表上 GRANT SELECT 权限。

-- 授予事件表的只读权限。
GRANT SELECT ON default.events_tenant_1 TO user_1
GRANT SELECT ON default.events_tenant_2 TO user_2

现在您可以以 user_1 身份连接并对该用户对应的表运行简单的 select 查询。仅会返回第一个租户的行数据。

-- 以 user_1 登录
SELECT *
FROM default.events_tenant_1

   ┌─id───────────────────────────────────┬─type────────┬───────────timestamp─┬─user_id─┬─data────────────────────────────────────┐
1. │ 7b7e0439-99d0-4590-a4f7-1cfea1e192d1 │ user_login  │ 2025-03-19 08:00:00 │    1001 │ {"device": "desktop", "location": "LA"} │
2. │ 846aa71f-f631-47b4-8429-ee8af87b4182 │ purchase    │ 2025-03-19 08:05:00 │    1002 │ {"item": "phone", "amount": 799}        │
3. │ 6b4d12e4-447d-4398-b3fa-1c1e94d71a2f │ user_logout │ 2025-03-19 08:10:00 │    1001 │ {"device": "desktop", "location": "LA"} │
4. │ 83b5eb72-aba3-4038-bc52-6c08b6423615 │ purchase    │ 2025-03-19 08:45:00 │    1003 │ {"item": "monitor", "amount": 450}      │
5. │ 975fb0c8-55bd-4df4-843b-34f5cfeed0a9 │ user_login  │ 2025-03-19 08:50:00 │    1004 │ {"device": "desktop", "location": "LA"} │
   └──────────────────────────────────────┴─────────────┴─────────────────────┴─────────┴─────────────────────────────────────────┘

独立数据库

每个租户的数据都存储在同一 ClickHouse 服务内的独立数据库中。

如果每个租户需要大量的表,可能还包括物化视图,并且拥有不同的数据模式,那么这种方式会很适用。不过,如果租户数量非常多,管理起来可能会变得具有挑战性。

这种实现方式与独立表的方案类似,但不是在表级别授予权限,而是在数据库级别授予权限。

请注意,这种方式无法很好地扩展到成千上万的租户。参见使用限制

示例

以下示例展示了基于独立数据库的多租户模型实现。

首先,让我们创建两个数据库,一个用于 tenant_1,另一个用于 tenant_2

-- 为 tenant_1 创建数据库
CREATE DATABASE tenant_1;

-- 为 tenant_2 创建数据库
CREATE DATABASE tenant_2;
-- 为 tenant_1 创建表
CREATE TABLE tenant_1.events
(
    id UUID,                    -- 唯一事件 ID
    type LowCardinality(String), -- 事件类型
    timestamp DateTime,          -- 事件时间戳
    user_id UInt32,               -- 触发事件的用户 ID
    data String,                 -- 事件数据
)
ORDER BY (timestamp, user_id);

-- 为 tenant_2 创建表
CREATE TABLE tenant_2.events
(
    id UUID,                    -- 唯一事件 ID
    type LowCardinality(String), -- 事件类型
    timestamp DateTime,          -- 事件时间戳
    user_id UInt32,               -- 触发事件的用户 ID
    data String,                 -- 事件数据
)
ORDER BY (timestamp, user_id);

现在插入一些模拟数据。

INSERT INTO tenant_1.events (id, type, timestamp, user_id, data)
VALUES
('7b7e0439-99d0-4590-a4f7-1cfea1e192d1', 'user_login', '2025-03-19 08:00:00', 1001, '{"device": "desktop", "location": "LA"}'),
('846aa71f-f631-47b4-8429-ee8af87b4182', 'purchase', '2025-03-19 08:05:00', 1002, '{"item": "phone", "amount": 799}'),
('6b4d12e4-447d-4398-b3fa-1c1e94d71a2f', 'user_logout', '2025-03-19 08:10:00', 1001, '{"device": "desktop", "location": "LA"}'),
('83b5eb72-aba3-4038-bc52-6c08b6423615', 'purchase', '2025-03-19 08:45:00', 1003, '{"item": "monitor", "amount": 450}'),
('975fb0c8-55bd-4df4-843b-34f5cfeed0a9', 'user_login', '2025-03-19 08:50:00', 1004, '{"device": "desktop", "location": "LA"}')

INSERT INTO tenant_2.events (id, type, timestamp, user_id, data)
VALUES
('7162f8ea-8bfd-486a-a45e-edfc3398ca93', 'user_login', '2025-03-19 08:12:00', 2001, '{"device": "mobile", "location": "SF"}'),
('6b5f3e55-5add-479e-b89d-762aa017f067', 'purchase', '2025-03-19 08:15:00', 2002, '{"item": "headphones", "amount": 199}'),
('43ad35a1-926c-4543-a133-8672ddd504bf', 'user_logout', '2025-03-19 08:20:00', 2001, '{"device": "mobile", "location": "SF"}'),
('f50aa430-4898-43d0-9d82-41e7397ba9b8', 'purchase', '2025-03-19 08:55:00', 2003, '{"item": "laptop", "amount": 1200}'),
('5c150ceb-b869-4ebb-843d-ab42d3cb5410', 'user_login', '2025-03-19 09:00:00', 2004, '{"device": "mobile", "location": "SF"}')

接下来,我们创建两个用户 user_1user_2

-- 创建用户 
CREATE USER user_1 IDENTIFIED BY '<password>'
CREATE USER user_2 IDENTIFIED BY '<password>'

然后在相应的表上授予 SELECT 权限。

-- 授予 events 表的只读权限。
GRANT SELECT ON tenant_1.events TO user_1
GRANT SELECT ON tenant_2.events TO user_2

现在,你可以以 user_1 身份连接,并在相应数据库中的 events 表上执行一个简单的 SELECT 查询。只会返回来自第一个租户的行。

-- 以 user_1 用户身份记录
SELECT *
FROM tenant_1.events

   ┌─id───────────────────────────────────┬─type────────┬───────────timestamp─┬─user_id─┬─data────────────────────────────────────┐
1. │ 7b7e0439-99d0-4590-a4f7-1cfea1e192d1 │ user_login  │ 2025-03-19 08:00:00 │    1001 │ {"device": "desktop", "location": "LA"} │
2. │ 846aa71f-f631-47b4-8429-ee8af87b4182 │ purchase    │ 2025-03-19 08:05:00 │    1002 │ {"item": "phone", "amount": 799}        │
3. │ 6b4d12e4-447d-4398-b3fa-1c1e94d71a2f │ user_logout │ 2025-03-19 08:10:00 │    1001 │ {"device": "desktop", "location": "LA"} │
4. │ 83b5eb72-aba3-4038-bc52-6c08b6423615 │ purchase    │ 2025-03-19 08:45:00 │    1003 │ {"item": "monitor", "amount": 450}      │
5. │ 975fb0c8-55bd-4df4-843b-34f5cfeed0a9 │ user_login  │ 2025-03-19 08:50:00 │    1004 │ {"device": "desktop", "location": "LA"} │
   └──────────────────────────────────────┴─────────────┴─────────────────────┴─────────┴─────────────────────────────────────────┘

计算-计算分离

上述三种方法也可以通过使用 Warehouses 进一步隔离。数据存放在共享的对象存储中,但通过计算-计算分离,每个租户都可以根据不同的 CPU/内存比拥有自己的独立计算服务。

用户管理与前面描述的方法类似,因为同一 Warehouse 中的所有服务都会共享访问控制

请注意,同一 Warehouse 中子服务的数量被限制在一个较小的范围内。参见 Warehouse limitations

独立云服务

最彻底的方法是为每个租户使用不同的 ClickHouse 服务。

如果出于法律、安全或访问就近等原因,要求将各租户数据存储在不同区域,可以考虑采用这种较少使用的方法作为解决方案。

必须在每个服务上为用户创建一个账户,以便用户可以访问其所属租户的数据。

这种方法更难管理,并且每个服务都会带来额外开销,因为每个服务都需要自己的基础设施来运行。服务可以通过 ClickHouse Cloud API 进行管理,也可以通过 官方 Terraform provider 实现编排。

示例

这是一个独立服务型多租户模型实现的示例。请注意,该示例展示的是在一个 ClickHouse 服务上创建表和用户,所有服务上都需要进行同样的配置。

首先,让我们创建表 events

-- 为 tenant_1 创建表
CREATE TABLE events
(
    id UUID,                    -- 唯一事件 ID
    type LowCardinality(String), -- 事件类型
    timestamp DateTime,          -- 事件的时间戳
    user_id UInt32,               -- 触发事件的用户 ID
    data String,                 -- 事件数据
)
ORDER BY (timestamp, user_id);

接下来插入一些模拟数据。

INSERT INTO events (id, type, timestamp, user_id, data)
VALUES
('7b7e0439-99d0-4590-a4f7-1cfea1e192d1', 'user_login', '2025-03-19 08:00:00', 1001, '{"device": "desktop", "location": "LA"}'),
('846aa71f-f631-47b4-8429-ee8af87b4182', 'purchase', '2025-03-19 08:05:00', 1002, '{"item": "phone", "amount": 799}'),
('6b4d12e4-447d-4398-b3fa-1c1e94d71a2f', 'user_logout', '2025-03-19 08:10:00', 1001, '{"device": "desktop", "location": "LA"}'),
('83b5eb72-aba3-4038-bc52-6c08b6423615', 'purchase', '2025-03-19 08:45:00', 1003, '{"item": "monitor", "amount": 450}'),
('975fb0c8-55bd-4df4-843b-34f5cfeed0a9', 'user_login', '2025-03-19 08:50:00', 1004, '{"device": "desktop", "location": "LA"}')

然后创建两个用户 user_1

-- 创建用户 
CREATE USER user_1 IDENTIFIED BY '<password>'

然后为相应的表授予 SELECT 权限。

-- 授予 user_1 对 events 表的只读权限。
GRANT SELECT ON events TO user_1

现在,你可以连接到租户 1 的服务上,以 user_1 身份运行一个简单的 SELECT 查询。结果中只会返回来自租户 1 的行。

-- 以 user_1 登录
SELECT *
FROM events

   ┌─id───────────────────────────────────┬─type────────┬───────────timestamp─┬─user_id─┬─data────────────────────────────────────┐
1. │ 7b7e0439-99d0-4590-a4f7-1cfea1e192d1 │ user_login  │ 2025-03-19 08:00:00 │    1001 │ {"device": "desktop", "location": "LA"} │
2. │ 846aa71f-f631-47b4-8429-ee8af87b4182 │ purchase    │ 2025-03-19 08:05:00 │    1002 │ {"item": "phone", "amount": 799}        │
3. │ 6b4d12e4-447d-4398-b3fa-1c1e94d71a2f │ user_logout │ 2025-03-19 08:10:00 │    1001 │ {"device": "desktop", "location": "LA"} │
4. │ 83b5eb72-aba3-4038-bc52-6c08b6423615 │ purchase    │ 2025-03-19 08:45:00 │    1003 │ {"item": "monitor", "amount": 450}      │
5. │ 975fb0c8-55bd-4df4-843b-34f5cfeed0a9 │ user_login  │ 2025-03-19 08:50:00 │    1004 │ {"device": "desktop", "location": "LA"} │
   └──────────────────────────────────────┴─────────────┴─────────────────────┴─────────┴─────────────────────────────────────────┘