在 SaaS 数据分析平台中,让多个租户(例如组织、客户或业务单元)共享同一套数据库基础设施,同时对各自数据进行逻辑隔离,是一种常见模式。通过这种方式,不同用户可以在同一平台上安全地访问各自的数据。
根据具体需求,实现多租户架构有多种方式。下面的指南将介绍如何在 ClickHouse Cloud 中实现这些方式。
共享表
在这种方案中,所有租户的数据都存储在单个共享表中,并使用一个字段(或一组字段)来标识每个租户的数据。为最大化性能,该字段应包含在主键中。为确保只能访问各自租户的数据,我们使用基于角色的访问控制,并通过行策略来实现。
推荐使用这种方案,因为它是最易于管理的选择,尤其适用于所有租户共享相同数据 schema 且数据量适中(< TB 级)时
通过将所有租户数据整合到一张表中,可通过优化数据压缩和减少元数据开销来提升存储效率。此外,由于所有数据集中管理,schema 更新也更为简单。
在需要处理大量租户(可能达到数百万)时,这种方法尤为有效。
但是,如果不同租户之间的数据 schema 存在差异,或者预期会随着时间逐渐分化,其他方案可能更为合适。
在不同租户之间存在显著数据量差异的情况下,小租户可能会承受不必要的查询性能影响。需要注意的是,通过在主键中包含租户字段,这一问题在很大程度上可以被缓解。
这是一个共享表多租户模型实现的示例。
首先,让我们创建一个共享表,在主键中包含字段 tenant_id。
--- Create table events. Using tenant_id as part of the primary key
CREATE TABLE events
(
tenant_id UInt32, -- Tenant identifier
id UUID, -- Unique event ID
type LowCardinality(String), -- Type of event
timestamp DateTime, -- Timestamp of the event
user_id UInt32, -- ID of the user who triggered the event
data String, -- Event data
)
ORDER BY (tenant_id, timestamp)
我们来插入一些示例数据。
-- Insert some dummy rows
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_1 和 user_2。
-- Create users
CREATE USER user_1 IDENTIFIED BY '<password>'
CREATE USER user_2 IDENTIFIED BY '<password>'
我们创建行策略,以将 user_1 和 user_2 限制为只能访问各自租户的数据。
-- Create row policies
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
CREATE ROLE user_role
-- Grant read only to events table.
GRANT SELECT ON default.events TO user_role
GRANT user_role TO user_1
GRANT user_role TO user_2
现在你可以以 user_1 身份连接并执行一个简单的 SELECT 查询。只会返回来自第一个租户的行。
-- Logged as 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 的事件。
-- Create table for tenant 1
CREATE TABLE events_tenant_1
(
id UUID, -- Unique event ID
type LowCardinality(String), -- Type of event
timestamp DateTime, -- Timestamp of the event
user_id UInt32, -- ID of the user who triggered the event
data String, -- Event data
)
ORDER BY (timestamp, user_id) -- Primary key can focus on other attributes
-- Create table for tenant 2
CREATE TABLE events_tenant_2
(
id UUID, -- Unique event ID
type LowCardinality(String), -- Type of event
timestamp DateTime, -- Timestamp of the event
user_id UInt32, -- ID of the user who triggered the event
data String, -- Event data
)
ORDER BY (timestamp, user_id) -- Primary key can focus on other attributes
插入测试数据。
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_1 和 user_2。
-- Create users
CREATE USER user_1 IDENTIFIED BY '<password>'
CREATE USER user_2 IDENTIFIED BY '<password>'
然后在相应的表上 GRANT SELECT 权限。
-- Grant read only to events table.
GRANT SELECT ON default.events_tenant_1 TO user_1
GRANT SELECT ON default.events_tenant_2 TO user_2
现在您可以以 user_1 身份连接并对该用户对应的表运行简单的 SELECT 查询。仅返回第一个租户的行。
-- Logged as 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 服务中的独立数据库内。
如果每个租户需要大量表并可能需要 materialized views,且各自的数据 schema 不同,这种方式会非常有用。不过,当租户数量非常多时,管理起来可能会变得很有挑战。
实现方式与独立表方案类似,但不是在表级授予权限,而是在数据库级授予权限。
请注意,此方案无法扩展到成千上万个租户。请参阅使用限制。
这是一个使用独立数据库实现多租户模型的示例。
首先,我们创建两个数据库,分别用于 tenant_1 和 tenant_2。
-- Create database for tenant_1
CREATE DATABASE tenant_1;
-- Create database for tenant_2
CREATE DATABASE tenant_2;
-- Create table for tenant_1
CREATE TABLE tenant_1.events
(
id UUID, -- Unique event ID
type LowCardinality(String), -- Type of event
timestamp DateTime, -- Timestamp of the event
user_id UInt32, -- ID of the user who triggered the event
data String, -- Event data
)
ORDER BY (timestamp, user_id);
-- Create table for tenant_2
CREATE TABLE tenant_2.events
(
id UUID, -- Unique event ID
type LowCardinality(String), -- Type of event
timestamp DateTime, -- Timestamp of the event
user_id UInt32, -- ID of the user who triggered the event
data String, -- Event data
)
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_1 和 user_2。
-- Create users
CREATE USER user_1 IDENTIFIED BY '<password>'
CREATE USER user_2 IDENTIFIED BY '<password>'
然后为相应的表授予 SELECT 权限。
-- Grant read only to events table.
GRANT SELECT ON tenant_1.events TO user_1
GRANT SELECT ON tenant_2.events TO user_2
现在,你可以以 user_1 身份连接到数据库,并在相应数据库的 events 表上执行一个简单的 SELECT 查询。只会返回第一个租户的行。
-- Logged as 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 限制。
独立的云服务
最激进的方法是为每个租户单独使用一个 ClickHouse 服务。
这种较少使用的方法适用于需要将租户数据存储在不同区域的场景——通常出于法律、安全或就近访问等原因。
必须在每个服务上为用户创建账户,以便用户能够访问其各自租户的数据。
这种方法更难管理,并且每个服务都会带来额外开销,因为它们各自需要独立的基础设施来运行。服务可以通过 ClickHouse Cloud API 进行管理,也可以使用 官方 Terraform provider 进行编排。
这是一个独立服务多租户模型实现示例。请注意,该示例展示了在一个 ClickHouse 服务上创建数据表和用户,同样的操作需要在所有服务上执行。
首先,让我们创建表 events
-- Create table for tenant_1
CREATE TABLE events
(
id UUID, -- Unique event ID
type LowCardinality(String), -- Type of event
timestamp DateTime, -- Timestamp of the event
user_id UInt32, -- ID of the user who triggered the event
data String, -- Event data
)
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 users
CREATE USER user_1 IDENTIFIED BY '<password>'
然后为相应的表授予 SELECT 权限。
-- Grant read only to events table.
GRANT SELECT ON events TO user_1
现在你可以以 user_1 身份连接到租户 1 的服务,并执行一个简单的 SELECT 查询。只会返回租户 1 的行。
-- Logged as 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"} │
└──────────────────────────────────────┴─────────────┴─────────────────────┴─────────┴─────────────────────────────────────────┘