数据库基础体系 · 第 79/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

多租户数据库设计:共享表、独立 Schema、独立库和数据隔离

多租户系统服务多个相互独立的客户或组织。每个客户通常称为一个租户(tenant),租户拥有自己的用户、订单、配置和业务数据。多租户数据库设计要解决的并不只是“数据放在哪张表”,还包括:

  • 如何定义租户边界;
  • 如何阻止租户 A 读取或修改租户 B 的数据;
  • 如何设计主键、唯一约束和外键;
  • 如何执行迁移、备份、恢复和审计;
  • 如何处理连接池、事务、缓存、异步任务和只读副本;
  • 当租户数量、数据量或合规要求变化时,如何迁移到另一种隔离模型。

本文比较四类常见方案:

  1. 共享数据库、共享 Schema、共享表;
  2. 共享数据库、独立 Schema;
  3. 独立数据库;
  4. 混合架构,以及数据库之外的隔离措施。

这里的“Schema”必须按数据库产品区分:

  • 在 PostgreSQL 中,Schema 是同一个数据库内的命名空间,多个 Schema 可以属于同一个 database。
  • 在 MySQL 中,SCHEMADATABASE 的同义词。MySQL 没有 PostgreSQL 意义上“一个 database 内再划分多个 schema”的独立层级。因此,MySQL 中说“独立 Schema”,通常实际指“每个租户一个 database”。

一、先定义“隔离”:它不是一个单一属性

“数据隔离”至少包含五个层面。

1. 逻辑隔离

同一张表中的不同租户通过 tenant_id 区分:

orders
+----+-----------+--------+
| id | tenant_id | amount |
+----+-----------+--------+
|  1 | acme      | 100.00 |
|  2 | beta      | 200.00 |
+----+-----------+--------+

逻辑隔离要求应用在访问数据时始终加入正确的租户条件。

2. 命名空间隔离

不同租户使用不同 Schema 或不同数据库对象名称:

tenant_acme.orders
tenant_beta.orders

此时表名相同,但对象所在的命名空间不同。

3. 权限隔离

数据库账号本身只能访问被授权的数据或对象。例如:

  • 某个租户账号只能访问自己的 Schema;
  • 应用角色只能执行受限函数;
  • 运维账号可以访问结构,但不能直接读取业务明文。

权限隔离属于数据库安全边界,强度通常高于仅依靠应用代码中的 WHERE tenant_id = ?

4. 物理隔离

数据位于不同数据库实例、不同存储卷、不同云账号或不同网络边界中。物理隔离可以降低误操作、资源争抢和合规范围扩散,但成本也更高。

5. 生命周期隔离

备份、恢复、删除、归档、加密密钥轮换和审计是否可以按租户独立操作。

例如,单个租户要求删除其全部数据时:

  • 共享表需要定位并删除所有相关表中的该租户行;
  • 独立 Schema 可以删除整个 Schema;
  • 独立数据库可以删除或恢复该租户数据库。

因此,隔离模型不仅决定查询方式,也决定运维方式。


二、共享表:同一张表中用 tenant_id 划分数据

共享表是最常见的多租户模型。所有租户共用表结构,租户标识作为每张业务表的一部分。

1. 基本模型

以下示例使用 PostgreSQL 或 MySQL 都支持的基础 SQL。为了明确事务边界,假设每次业务请求在一个数据库事务中完成。

CREATE TABLE tenants (
    tenant_id VARCHAR(64) PRIMARY KEY,
    name      VARCHAR(200) NOT NULL
);

CREATE TABLE orders (
    tenant_id  VARCHAR(64) NOT NULL,
    order_id   BIGINT NOT NULL,
    status     VARCHAR(20) NOT NULL,
    amount     DECIMAL(18, 2) NOT NULL,
    created_at TIMESTAMP NOT NULL,
    PRIMARY KEY (tenant_id, order_id),
    FOREIGN KEY (tenant_id) REFERENCES tenants (tenant_id)
);

这里的主键不是单独的 order_id,而是:

(tenant_id, order_id)

这意味着订单号只要求在租户内部唯一。租户 acme 可以有 order_id = 100,租户 beta 也可以有 order_id = 100

插入数据:

INSERT INTO tenants (tenant_id, name)
VALUES ('acme', 'Acme');

INSERT INTO tenants (tenant_id, name)
VALUES ('beta', 'Beta');

INSERT INTO orders
    (tenant_id, order_id, status, amount, created_at)
VALUES
    ('acme', 100, 'paid', 100.00, CURRENT_TIMESTAMP),
    ('beta', 100, 'paid', 200.00, CURRENT_TIMESTAMP);

查询 Acme 的订单:

SELECT order_id, status, amount
FROM orders
WHERE tenant_id = 'acme'
ORDER BY order_id;

预期结果只有:

order_id | status | amount
----------+--------+--------
100      | paid   | 100.00

2. 为什么 tenant_id 必须进入主键和约束

如果把表设计成:

CREATE TABLE bad_orders (
    order_id  BIGINT PRIMARY KEY,
    tenant_id VARCHAR(64) NOT NULL,
    amount    DECIMAL(18, 2) NOT NULL
);

那么全局主键会迫使所有租户共享一个编号空间。这不一定错误,但它会带来几个问题:

  1. 业务对象编号暴露全局规模;
  2. 数据迁移和分片时不容易保留租户内的局部编号;
  3. 忘记租户条件时,开发者容易误以为 order_id 足以定位租户;
  4. 外键和唯一约束容易漏掉租户维度。

对于租户内唯一的业务字段,也应将租户标识纳入唯一约束:

CREATE TABLE products (
    tenant_id VARCHAR(64) NOT NULL,
    product_id BIGINT NOT NULL,
    sku       VARCHAR(100) NOT NULL,
    name      VARCHAR(200) NOT NULL,
    PRIMARY KEY (tenant_id, product_id),
    UNIQUE (tenant_id, sku),
    FOREIGN KEY (tenant_id) REFERENCES tenants (tenant_id)
);

此时:

(acme, "SKU-001") 可以存在
(beta, "SKU-001") 也可以存在

但同一个租户内重复插入 SKU-001 会违反唯一约束。

3. 外键必须防止“跨租户引用”

仅有如下外键是不够的:

FOREIGN KEY (product_id) REFERENCES products(product_id)

因为 product_id 可能在不同租户之间重复。正确的关系应包含完整租户键:

CREATE TABLE order_items (
    tenant_id  VARCHAR(64) NOT NULL,
    order_id   BIGINT NOT NULL,
    line_no    INTEGER NOT NULL,
    product_id BIGINT NOT NULL,
    quantity   INTEGER NOT NULL,

    PRIMARY KEY (tenant_id, order_id, line_no),

    FOREIGN KEY (tenant_id, order_id)
        REFERENCES orders (tenant_id, order_id),

    FOREIGN KEY (tenant_id, product_id)
        REFERENCES products (tenant_id, product_id)
);

这条外键保证:

订单属于租户 T,商品也必须属于租户 T。

它阻止了以下错误关系:

订单 (acme, 100)
商品 (beta, 10)

即使 product_id = 10 存在,也不能被 Acme 的订单引用。

4. 共享表的安全条件

设:

  • TT 是所有租户集合;
  • RR 是一张业务表;
  • tenant(r)tenant(r) 表示行 rr 所属租户;
  • tt 是当前请求的租户;
  • QtQ_t 是租户 tt 发出的查询。

理想的读取条件是:

Qt(R){rRtenant(r)=t}Q_t(R) \subseteq \{r \in R \mid tenant(r)=t\}

也就是说,查询结果必须是该租户行集合的子集。

如果应用为每次请求自动追加:

WHERE tenant_id = :current_tenant

则读取隔离在逻辑上成立。但这个结论依赖一个很强的前提:

所有读取、更新、删除和关联查询都必须经过正确的租户约束。

只要存在一次未加条件的查询:

SELECT * FROM orders;

结果就是:

Q(R)=RQ(R)=R

而当 RR 包含多个租户时:

R{rtenant(r)=t}R \nsubseteq \{r \mid tenant(r)=t\}

隔离立即失效。

更新和删除的风险更大:

DELETE FROM orders
WHERE order_id = 100;

如果不同租户都存在 order_id = 100,这条语句可能删除多个租户的数据。正确写法至少应为:

DELETE FROM orders
WHERE tenant_id = 'acme'
  AND order_id = 100;

5. 不要只在查询时加租户条件

以下写法不完整:

SELECT *
FROM orders
WHERE tenant_id = ?;

但插入时信任请求体中的 tenant_id

INSERT INTO orders (tenant_id, order_id, amount)
VALUES (?, ?, ?);

攻击者可以提交别的租户标识。租户上下文应来自已认证身份、服务端会话或受信任的路由结果,而不是直接来自客户端字段。

更安全的应用层接口通常是:

createOrder(currentTenant, orderData)

而不是:

createOrder(requestBody.tenantId, requestBody.orderData)

数据库约束仍然必要,因为应用错误、批处理脚本和后台任务都可能绕过普通请求流程。


三、PostgreSQL 行级安全:把共享表的租户条件下沉到数据库

PostgreSQL 提供 Row-Level Security,简称 RLS,即行级安全。它允许数据库根据当前角色或会话上下文决定某一行是否可见、可插入、可更新或可删除。

RLS 不是普通的 VIEW。普通视图通常只控制查询表达式,而 RLS 可以参与表的行访问策略。

1. 一个可运行的 PostgreSQL 示例

以下示例适用于 PostgreSQL 当前稳定版本公开语义。使用超级用户或具备相应建表权限的账号创建对象。

CREATE TABLE tenant_orders (
    tenant_id  text NOT NULL,
    order_id   bigint NOT NULL,
    amount     numeric(18, 2) NOT NULL,
    PRIMARY KEY (tenant_id, order_id)
);

ALTER TABLE tenant_orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_orders_select
ON tenant_orders
FOR SELECT
USING (
    tenant_id = current_setting('app.tenant_id', true)
);

CREATE POLICY tenant_orders_insert
ON tenant_orders
FOR INSERT
WITH CHECK (
    tenant_id = current_setting('app.tenant_id', true)
);

CREATE POLICY tenant_orders_update
ON tenant_orders
FOR UPDATE
USING (
    tenant_id = current_setting('app.tenant_id', true)
)
WITH CHECK (
    tenant_id = current_setting('app.tenant_id', true)
);

CREATE POLICY tenant_orders_delete
ON tenant_orders
FOR DELETE
USING (
    tenant_id = current_setting('app.tenant_id', true)
);

这里有两个不同概念:

  • USING:决定已有行是否可见、是否可被更新或删除;
  • WITH CHECK:决定新插入或更新后的行是否满足条件。

例如,更新语句:

UPDATE tenant_orders
SET tenant_id = 'beta'
WHERE tenant_id = 'acme'
  AND order_id = 1;

即使原始行能够被 Acme 看到,更新后的行也必须满足 WITH CHECK。因此不能借助更新把数据改成另一个租户。

2. 设置事务内租户上下文

请求进入数据库事务后设置租户:

BEGIN;

SELECT set_config('app.tenant_id', 'acme', true);

SELECT order_id, amount
FROM tenant_orders
ORDER BY order_id;

COMMIT;

第三个参数为 true,表示只在当前事务内设置。查询结果只能包含:

tenant_id | order_id | amount
-----------+----------+--------
acme      | ...      | ...

如果没有设置:

BEGIN;

SELECT order_id, amount
FROM tenant_orders;

COMMIT;

current_setting('app.tenant_id', true) 在参数不存在时返回 NULL,而:

tenant_id = NULL

不是 TRUE,因此通常不会返回任何行,也不会允许插入。

3. RLS 的真实边界

RLS 只有在访问者确实受 RLS 约束时才成立。必须特别注意:

  • 表所有者在默认语义下可能绕过 RLS;
  • PostgreSQL 超级用户绕过 RLS;
  • 具有 BYPASSRLS 属性的角色绕过 RLS;
  • 可以绕过 RLS 的管理账号不应直接用于应用连接;
  • 可以修改 app.tenant_id 的任意客户端不能被自动视为可信租户身份。

因此应用连接应使用专门的非所有者角色,并明确检查权限:

CREATE ROLE app_runtime LOGIN PASSWORD 'change-this-password';

GRANT USAGE ON SCHEMA public TO app_runtime;
GRANT SELECT, INSERT, UPDATE, DELETE
ON tenant_orders
TO app_runtime;

生产环境不能把示例密码直接使用,也不应让应用角色拥有建表、改策略或切换到超级用户的能力。

还要注意连接池。若连接池复用物理连接,而租户上下文使用会话级设置:

SELECT set_config('app.tenant_id', 'acme', false);

那么事务结束后该设置仍可能保留。下一次被分配到同一连接的 Beta 请求就可能错误地使用 Acme 上下文。

因此常见做法是:

BEGIN;
SELECT set_config('app.tenant_id', 'acme', true);
-- 所有业务 SQL
COMMIT;

并确保:

  1. 租户设置和业务 SQL 在同一事务中;
  2. 事务结束时上下文自动清理;
  3. 连接池不会在事务之外执行需要租户身份的 SQL;
  4. 事务异常时执行回滚;
  5. 后台任务、异步消费者和管理脚本也设置租户上下文。

RLS 是数据库防线,不会自动修复缓存键、消息队列、对象存储路径或应用日志中的租户混淆。例如,数据库查询受 RLS 保护,但缓存仍使用:

order:100

那么 Acme 写入的缓存可能被 Beta 读取。缓存键至少应包含租户:

tenant:acme:order:100

四、MySQL 中的共享表:没有同等的原生行级安全语义

MySQL 8.4 的权限模型主要授予数据库对象、表、列等层级的访问权,并不提供 PostgreSQL RLS 那样的通用表级行策略。

因此 MySQL 的共享表通常依赖以下方式:

  1. 应用层强制添加 tenant_id
  2. 使用只暴露租户过滤条件的视图;
  3. 使用存储过程或受限服务账号;
  4. 通过数据库代理或访问层统一重写与校验;
  5. 将不同租户分配给不同数据库账号。

例如可以建立一个视图:

CREATE TABLE orders (
    tenant_id  VARCHAR(64) NOT NULL,
    order_id   BIGINT NOT NULL,
    amount     DECIMAL(18, 2) NOT NULL,
    PRIMARY KEY (tenant_id, order_id)
);

但视图需要一个可信的租户上下文。MySQL 没有与 PostgreSQL current_setting 完全等价、且可直接用于通用行级策略的内置机制。可以使用连接会话变量:

SET @app_tenant_id = 'acme';

SELECT order_id, amount
FROM orders
WHERE tenant_id = @app_tenant_id;

然而,拥有执行任意 SQL 权限的应用账号也可以执行:

SET @app_tenant_id = 'beta';

所以会话变量本身不是安全身份边界。它只有在数据库账号权限受限、连接建立和上下文设置由可信服务控制时才有价值。

一个视图示例:

CREATE VIEW current_tenant_orders AS
SELECT order_id, amount
FROM orders
WHERE tenant_id = @app_tenant_id;

然后应用查询:

SELECT *
FROM current_tenant_orders;

这个方案仍有明显限制:

  • 应用账号可能直接访问基表;
  • 会话变量可能被修改;
  • 视图对写入、复杂关联和管理操作的约束不等于完整 RLS;
  • 连接池复用连接时会产生上下文残留;
  • 开发者仍可能绕过视图执行基表查询。

因此,MySQL 共享表的核心安全保证通常来自“受限账号 + 统一数据访问层 + 约束和测试”,而不是来自一个可普遍套用的原生行级策略。


五、独立 Schema:对象隔离和数据隔离

1. PostgreSQL 中的独立 Schema

PostgreSQL 的一个 database 可以包含多个 Schema:

database: application
├── schema: tenant_acme
│   ├── orders
│   └── products
└── schema: tenant_beta
    ├── orders
    └── products

创建示例:

CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_beta;

CREATE TABLE tenant_acme.orders (
    order_id BIGINT PRIMARY KEY,
    amount   NUMERIC(18, 2) NOT NULL
);

CREATE TABLE tenant_beta.orders (
    order_id BIGINT PRIMARY KEY,
    amount   NUMERIC(18, 2) NOT NULL
);

查询时显式写 Schema:

SELECT *
FROM tenant_acme.orders;

也可以使用 search_path

BEGIN;

SET LOCAL search_path = tenant_acme, public;

SELECT *
FROM orders;

COMMIT;

此时 orders 解析为 tenant_acme.orders,但这种方式有两个风险。

风险一:上下文错误

如果连接上一次事务设置过:

SET search_path = tenant_acme, public;

而下一次请求没有正确修改,就可能访问错误租户的表。

使用 SET LOCAL 可以让设置只持续当前事务,但仍要求每个事务都显式设置。

风险二:对象劫持

未限定 Schema 的对象名会依赖名称解析顺序。若 search_path 包含可被低权限用户写入的 Schema,用户可能创建同名函数或对象,导致应用调用到非预期对象。

更稳妥的做法是:

  • 关键 SQL 显式写全限定名;
  • search_path 只包含受信任的 Schema;
  • 应用角色不拥有可被写入且位于搜索路径中的 Schema;
  • 对函数调用、迁移脚本和 SECURITY DEFINER 函数特别检查名称解析。

2. Schema 隔离并不等于独立数据库

多个 PostgreSQL Schema 仍共享:

  • 同一个 database;
  • 同一个数据库实例和资源池;
  • 同一个连接入口;
  • 同一个 WAL、故障域和通常的备份边界;
  • 数据库级扩展和部分数据库级配置。

权限也必须单独配置。创建 Schema 不会自动把访问者限制在该 Schema:

REVOKE ALL ON SCHEMA tenant_beta FROM app_acme;
REVOKE ALL ON ALL TABLES IN SCHEMA tenant_beta FROM app_acme;

GRANT USAGE ON SCHEMA tenant_acme TO app_acme;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA tenant_acme
TO app_acme;

还需要处理未来创建的表。已有表的授权不会自动覆盖所有未来对象,通常需要配置默认权限:

ALTER DEFAULT PRIVILEGES IN SCHEMA tenant_acme
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLES TO app_acme;

执行 ALTER DEFAULT PRIVILEGES 的角色和对象所有者关系很重要。若迁移由不同角色执行,默认权限可能没有作用于预期对象。

3. Schema 级方案的数据流

一次请求大致经历:

认证租户
  ↓
路由层得到 tenant_acme
  ↓
获取 PostgreSQL 连接
  ↓
BEGIN
  ↓
SET LOCAL search_path = tenant_acme, public
  ↓
执行 SELECT/INSERT/UPDATE
  ↓
COMMIT 或 ROLLBACK
  ↓
归还连接

故障路径包括:

  • 路由把 Beta 映射成 Acme:访问错误租户;
  • 忘记设置 search_path:可能访问 public.orders 或失败;
  • 使用会话级 SET:连接池复用导致上下文泄漏;
  • 角色同时拥有多个 Schema 权限:数据库权限边界失效;
  • 迁移只更新一个 Schema:租户之间结构不一致。

Schema 模型还会放大迁移数量。假设有 NN 个租户,每次结构迁移需要对每个 Schema 执行一次,则迁移目标数量为:

M=NM=N

迁移失败的中间状态也可能是:

tenant_acme  已升级
tenant_beta  已升级
tenant_gamma  未升级

应用必须能够处理版本差异,或者使用严格的迁移编排、暂停写入和失败恢复策略。


六、MySQL 的“独立 Schema”实际上是独立 Database

在 MySQL 中:

CREATE SCHEMA tenant_acme;

等价于:

CREATE DATABASE tenant_acme;

因此下面两个对象都属于不同数据库:

CREATE TABLE tenant_acme.orders (
    order_id BIGINT PRIMARY KEY
);

CREATE TABLE tenant_beta.orders (
    order_id BIGINT PRIMARY KEY
);

MySQL 可以在同一连接中使用限定名访问不同数据库:

SELECT *
FROM tenant_acme.orders;

也可以执行跨数据库查询:

SELECT a.order_id, b.order_id
FROM tenant_acme.orders AS a
JOIN tenant_beta.orders AS b
  ON a.order_id = b.order_id;

这与 PostgreSQL 的 database 边界不同。PostgreSQL 普通 SQL 连接只能访问当前 database 内的 Schema;不能使用类似:

SELECT * FROM other_database.public.orders;

来完成普通跨数据库查询。需要跨数据库访问时,通常要使用额外连接、外部数据包装器或应用层聚合,这些都引入新的事务和故障语义。

MySQL 的数据库级权限可以提供较清晰的对象边界:

CREATE USER 'acme_app'@'%' IDENTIFIED BY 'replace-with-secret';

GRANT SELECT, INSERT, UPDATE, DELETE
ON tenant_acme.* TO 'acme_app'@'%';

但应注意:

  • 账号密码不应写入代码仓库;
  • '%' 是示例主机范围,生产环境应按网络边界收紧;
  • 具有更高权限的运维账号仍可能访问其他数据库;
  • 同一个服务账号若同时被授予多个租户数据库权限,数据库层就不能再提供租户间权限隔离;
  • MySQL 中跨数据库查询能力既方便运维,也使“数据库名隔离”不自动等于“不可访问”。

七、独立数据库:更强的边界,但不是自动的安全保证

“独立库”可能有两种含义:

  1. 每个租户一个 database,仍运行在同一个数据库实例或集群中;
  2. 每个租户一个独立数据库实例、存储卷或云资源。

两者不能混为一谈。

1. 同一实例上的独立数据库

以 PostgreSQL 为例:

PostgreSQL cluster
├── database: tenant_acme
└── database: tenant_beta

数据库是连接目标。客户端连接时指定:

host=db.example
port=5432
dbname=tenant_acme
user=acme_app

PostgreSQL 在普通 SQL 中不提供跨 database 的直接连接,因此每个数据库通常需要独立连接。应用的事务也只能天然覆盖当前连接所在的 database。

这会影响跨租户或全局操作。例如全局租户目录位于 control_plane database,业务数据位于租户数据库:

事务 A:写 control_plane
事务 B:写 tenant_acme

它们不是一个普通本地事务。若 A 成功而 B 失败,就会出现状态不一致。解决方式可能包括:

  • 将必须原子提交的数据放在同一个数据库;
  • 使用事件和幂等消费者;
  • 使用补偿事务;
  • 使用分布式事务协议,但复杂度和故障成本更高。

2. 独立实例上的独立数据库

如果每个租户使用独立实例,则边界进一步扩大:

租户请求
  ↓
租户目录服务
  ↓
tenant_id → endpoint、database、credential、region
  ↓
连接池
  ↓
对应数据库实例

数据库连接路由必须是可信的。不能直接相信客户端传入的数据库名:

GET /orders?tenant=tenant_beta

正确流程应是:

  1. 从认证令牌确定调用者身份;
  2. 查询服务端维护的租户目录;
  3. 校验该身份是否属于目标租户;
  4. 根据目录获取数据库端点和凭据;
  5. 建立或复用对应连接;
  6. 在事务中执行 SQL。

租户目录本身通常是控制面数据,至少需要记录:

tenant_id
database_endpoint
database_name
credential_reference
region
schema_version
status

3. 独立数据库的资源边界

独立数据库可以分别管理:

  • 备份和恢复;
  • 保留周期;
  • 加密密钥;
  • 数据库账号;
  • 读副本;
  • 资源配额;
  • 网络访问控制;
  • 租户级迁移。

但是,如果这些数据库仍位于同一个实例,CPU、内存、连接数、磁盘和故障域仍可能共享。一个租户的大量查询可能影响其他租户,这称为“嘈杂邻居”问题。

真正的资源隔离通常需要实例级或集群级资源边界,而不是仅仅不同的 database 名称。


八、四种方案的核心差异

方案 典型对象边界 应用路由复杂度 迁移数量 租户级恢复 资源隔离 跨租户查询
共享表 1 套表 较困难 容易,但风险高
PostgreSQL 独立 Schema Schema 每租户一套 Schema 中等 弱到中 同一 database 内容易
MySQL 每租户一个 database database 每租户一套 database 中等 通常仍共享实例资源 可跨 database 查询
独立 database、同一 PostgreSQL 实例 database 每租户一套 database 较容易 连接和对象边界更强 普通 SQL 不直接支持
独立实例 实例或集群 很高 每租户一套资源 容易 需要应用层或专门数据同步

这张表只描述通常的工程特征,不是数据库产品对安全或性能的绝对保证。错误权限、错误路由、超级用户和备份账号都可能跨越这些边界。


九、如何选择:隔离强度与运维成本的函数

可以把设计选择抽象为几个变量:

  • NN:租户数量;
  • DiD_i:第 ii 个租户的数据量;
  • QiQ_i:第 ii 个租户的负载;
  • CmC_m:维护一套额外 Schema 或数据库的成本;
  • II:合规要求的隔离强度;
  • RR:租户级恢复、导出和删除要求;
  • SS:对资源争抢的敏感程度。

共享表的管理对象数量近似为常数:

O(1)O(1)

但每次业务访问都要带租户谓词,且租户级恢复成本随总数据量增长:

Rsharedf(iDi)R_{\text{shared}} \approx f\left(\sum_i D_i\right)

独立 Schema 或独立 database 的对象数量近似为:

O(N)O(N)

迁移、监控和连接管理成本会随租户数量增长,但单租户恢复范围更小:

Risolatedf(Di)R_{\text{isolated}} \approx f(D_i)

如果 NN 很大而租户规模差异不大,共享表通常更容易控制运维复杂度。如果少量大型租户具有强合规、独立备份或独立扩容要求,独立 database 或独立实例更合适。

现实中常用分层模型:

普通租户       → 共享表
中型租户       → 独立 Schema 或独立 database
高合规/大租户  → 独立实例

这不是让所有租户永久固定在一种模型中,而是允许租户在生命周期中迁移。


十、迁移模型时必须处理一致性

假设原来使用共享表:

shared.orders(tenant_id, order_id, ...)

现在要把 Acme 迁移到 PostgreSQL 的 tenant_acme.orders

1. 直接复制并删除的危险流程

以下流程存在丢失窗口:

1. SELECT Acme 数据
2. INSERT 到 tenant_acme.orders
3. DELETE shared.orders 中的 Acme 数据

如果第 1 步和第 2 步之间 Acme 产生新订单,那么新订单可能没有复制;如果第 3 步提前执行,则可能丢失数据。

2. 一种可验证的迁移流程

具体实现取决于停机要求,但基本状态可以是:

正常写共享表
  ↓
标记租户为 migrating
  ↓
阻止或排队该租户新写入
  ↓
复制存量数据
  ↓
校验行数、主键范围、金额汇总或校验和
  ↓
切换路由到新对象
  ↓
验证新路径读写
  ↓
保留旧数据一段观察期
  ↓
删除或归档旧数据

校验不能只比较总行数。至少要考虑:

  • 主键集合;
  • 每个分区或时间范围的行数;
  • 金额等业务汇总;
  • updated_at 最大值;
  • 关键表之间的外键关系;
  • 复制期间的增量变更。

切换路由本身也需要版本化。例如租户目录中增加:

tenant_id = acme
storage_mode = schema
target = tenant_acme
routing_version = 8

请求在一个事务开始时读取一次路由版本,避免同一业务事务中途从旧库切到新库。


十一、事务边界:租户隔离不等于业务原子性

多租户系统中经常存在两类数据:

控制面:
tenant、套餐、路由、账单状态

数据面:
订单、商品、业务流水

如果控制面和数据面位于不同数据库,就不能假设下面的操作具有单事务原子性:

1. 扣减套餐额度
2. 创建订单

可能出现:

控制面提交成功
数据面提交失败

或相反。

在共享表模型中,如果控制面和数据面都在同一个数据库,且使用同一事务,可以自然地使用本地事务:

BEGIN;

UPDATE tenant_quota
SET remaining = remaining - 1
WHERE tenant_id = 'acme'
  AND remaining > 0;

INSERT INTO orders (...);

COMMIT;

但在独立库模型中,需要重新设计:

  • 哪一侧是事实来源;
  • 哪些操作可重试;
  • 事件是否带幂等键;
  • 失败后如何补偿;
  • 用户看到的是“处理中”还是“成功”。

数据库隔离越强,跨租户或跨库事务的代价通常越高。


十二、索引、分区和热点:共享表的性能机制

共享表的索引必须把 tenant_id 纳入访问路径。常见索引:

CREATE INDEX orders_by_tenant_created
ON orders (tenant_id, created_at, order_id);

对查询:

SELECT order_id, amount
FROM orders
WHERE tenant_id = 'acme'
  AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
ORDER BY created_at, order_id;

该索引能够先定位租户,再按时间访问。

如果查询经常只按 order_id 查找,则需要根据真实访问模式增加索引:

CREATE INDEX orders_by_order_id
ON orders (order_id);

但这会增加写放大和存储开销。不能因为某个查询漏掉 tenant_id,就无条件建立全局索引来掩盖数据访问设计错误。

租户分区

在数据量很大时,可以按租户或租户哈希进行分区。分区并不会自动产生安全隔离:

分区定位解决“去哪找”
RLS 或 WHERE 条件解决“能不能看”

如果分区键是 tenant_id,数据库可能减少扫描范围,但仍需验证查询是否包含分区键,以及优化器是否能有效剪枝。

按租户建立一个分区也可能退化成“每租户一个数据库对象”的运维问题。租户数量很大时,哈希分区通常比创建数十万分区更可管理。


十三、常见失败表现与诊断方法

1. 返回了其他租户的数据

重点检查:

  • SQL 是否遗漏 tenant_id
  • ORM 的全局过滤器是否被原生 SQL 绕过;
  • JOIN 的关联条件是否包含租户;
  • 子查询、聚合和 UNION 是否分别带租户条件;
  • 缓存键是否带租户;
  • 异步消息是否携带并校验租户;
  • PostgreSQL RLS 是否被表所有者或高权限角色绕过;
  • 连接池中的会话上下文是否残留。

危险查询:

SELECT o.*
FROM orders o
JOIN order_items i
  ON i.order_id = o.order_id;

正确的共享表关联通常应是:

SELECT o.*
FROM orders o
JOIN order_items i
  ON i.tenant_id = o.tenant_id
 AND i.order_id  = o.order_id
WHERE o.tenant_id = 'acme';

2. 更新或删除了多个租户的数据

检查:

DELETE FROM orders WHERE order_id = ?;
UPDATE orders SET status = ? WHERE order_id = ?;

这类语句在订单号仅租户内唯一时是不安全的。应用接口、DAO、存储过程和后台脚本都需要执行同样的键约束。

3. 偶发访问到上一个租户

常见原因是:

  • 使用连接会话变量而没有清理;
  • search_path 使用会话级 SET
  • 连接池归还连接前没有回滚;
  • 事务异常后仍继续使用连接;
  • ORM 会话对象跨请求复用;
  • 缓存对象没有租户前缀。

诊断时应记录但避免泄露敏感数据:

request_id
authenticated_tenant_id
resolved_storage_target
database_session_id
transaction_id
routing_version

同时不要把完整 SQL 参数、令牌和业务明文直接写入普通日志。

4. 迁移后部分租户结构不同

应维护结构版本:

tenant_acme  schema_version = 12
tenant_beta  schema_version = 11

应用必须明确是否兼容两个版本。不能假设“迁移脚本执行成功”就意味着所有 Schema 都已升级;应查询数据库对象、列、索引、约束和版本记录进行验证。

5. RLS 测试通过,但生产仍可越权

常见原因是测试使用了普通角色,而生产应用连接使用了:

  • 表所有者;
  • 超级用户;
  • 拥有 BYPASSRLS 的角色;
  • 可修改策略或会话上下文的高权限账号。

应分别用生产同等权限的运行时角色测试:

SELECT current_user;
SELECT session_user;

并验证:

  • 读取其他租户是否返回零行;
  • 插入其他租户是否失败;
  • 更新后把行改到其他租户是否失败;
  • 删除其他租户是否失败;
  • 事务回滚后租户上下文是否清理。

十四、隔离设计与最小权限、加密、审计和注入防护

多租户数据隔离不能替代数据库安全治理。

最小权限

应用角色通常不应拥有:

  • 创建或删除数据库;
  • 修改 RLS 策略;
  • 修改租户路由目录;
  • 读取所有租户的原始业务表;
  • 使用超级用户或 BYPASSRLS 权限。

迁移角色、查询角色、后台任务角色和应用运行时角色可以分开。

加密

传输加密保护客户端到数据库的链路;静态加密保护磁盘或备份介质。它们都不自动阻止一个已经获得数据库查询权限的账号读取明文。

若要求租户级密钥隔离,需要把密钥管理范围纳入架构:

tenant_id → key reference → encryption/decryption service

密钥引用也必须进行租户授权检查,不能只依赖数据库表中的 tenant_id

审计

审计事件应包含:

tenant_id
actor_id
operation
object
result
request_id
source
timestamp

但审计日志本身也可能含有跨租户敏感信息,需要限制读取权限并防止日志注入。

SQL 注入防护

参数化查询能够防止用户输入改变 SQL 语法,但不能自动正确处理动态 Schema 名、数据库名或排序字段。

例如,值参数可以参数化:

SELECT *
FROM orders
WHERE tenant_id = ? AND order_id = ?;

但表名通常不能直接作为普通值参数传入。动态对象名必须来自服务端维护的白名单或可靠映射,而不是直接拼接客户端输入:

tenant_id → tenant_acme

即使已防止 SQL 注入,也要继续验证调用者是否有权访问该租户。


十五、备份、恢复、删除和审计的差异

共享表

恢复单个租户通常不能简单恢复整个数据库,否则会覆盖其他租户的当前数据。常见方式是:

  1. 将备份恢复到临时数据库;
  2. tenant_id 导出目标租户数据;
  3. 校验主键、外键和版本;
  4. 在生产库中按事务导入;
  5. 记录恢复批次和结果。

这要求所有相关表都能可靠定位租户。若某张表没有 tenant_id,只能依赖外键追溯,恢复复杂度会显著上升。

独立 Schema

可以按 Schema 导出,但仍需确认:

  • 全局表是否包含租户配置;
  • 跨 Schema 的函数、扩展和默认权限;
  • 序列、触发器和依赖对象;
  • 恢复时是否会覆盖同 database 中其他对象。

独立数据库

租户数据库通常可以独立备份、恢复和删除,生命周期边界更清晰。但同一实例上的 WAL、实例故障和管理权限仍可能是共享的。

“删除租户”也不等于只执行:

DROP DATABASE tenant_acme;

还必须处理:

  • 备份保留副本;
  • 只读副本;
  • 对象存储;
  • 搜索索引;
  • 缓存;
  • 消息队列;
  • 审计记录;
  • 数据仓库和导出文件。

十六、一个可落地的决策顺序

设计时可以按以下顺序推导,而不是先决定“每个租户一张表”或“每个租户一个库”。

第一步:确定租户边界

明确哪些数据属于租户,哪些是全局数据,哪些是共享引用数据。每张业务表都应能回答:

这一行属于哪个租户?

如果答案只能通过多级关联推导,应评估是否需要冗余保存 tenant_id,并用复合外键保证一致性。

第二步:确定最小安全边界

询问:

  • 应用错误是否必须由数据库阻止?
  • 是否需要租户级数据库账号?
  • 是否存在强合规或客户审计要求?
  • 运维人员是否可以接触多个租户的数据?
  • 是否需要单租户恢复和独立密钥?

如果必须由数据库直接阻止跨租户行访问,PostgreSQL 共享表可考虑 RLS;MySQL 则通常需要更强的对象级拆分或受限访问层。

第三步:确定资源和故障边界

询问:

  • 租户是否会产生明显不同的负载;
  • 是否必须独立扩容;
  • 一个租户的故障是否可以影响其他租户;
  • 是否需要把租户放到不同区域;
  • 只读副本和故障转移是否按租户执行。

这些问题通常决定是否需要独立实例,而不仅是独立 Schema 或 database。

第四步:设计路由和事务

无论哪种模型,都应把以下过程明确化:

身份认证
→ 租户授权
→ 存储目标解析
→ 获取连接
→ 设置租户上下文
→ 开启事务
→ 执行业务 SQL
→ 提交或回滚
→ 清理上下文

每一步都应有失败处理。特别是“身份中的租户”和“客户端请求参数中的租户”不一致时,必须拒绝,而不是以后者覆盖前者。

第五步:设计迁移和回收

提前定义:

  • 共享表到独立 Schema 的迁移;
  • Schema 到独立 database 的迁移;
  • 租户合并或拆分;
  • 单租户恢复;
  • 租户删除;
  • 备份和保留期;
  • 迁移失败回滚。

没有迁移路径的隔离模型,往往会在租户增长后变成不可逆的历史负担。


多租户设计的核心不是选择一个看起来更“隔离”的对象层级,而是把租户边界落实到键、约束、权限、连接、事务、缓存、消息、备份和运维流程中。

共享表依赖正确的租户谓词、复合约束和严格访问层;独立 Schema 强化了命名空间边界,但仍共享数据库资源;独立 database 提供更清晰的对象和生命周期边界,却会增加连接路由与跨库事务复杂度;独立实例才能进一步提供资源和故障域隔离。

因此,合理的系统可以同时使用多种模型:用共享表承载普通租户,用独立 Schema 或 database 承载大型租户,再将高合规租户迁移到独立实例。真正需要保证的是:无论数据位于哪种层级,系统都能明确证明“谁可以在什么条件下访问哪一行、哪个对象和哪个资源”。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。