数据库基础体系 · 第 79/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
多租户数据库设计:共享表、独立 Schema、独立库和数据隔离
多租户系统服务多个相互独立的客户或组织。每个客户通常称为一个租户(tenant),租户拥有自己的用户、订单、配置和业务数据。多租户数据库设计要解决的并不只是“数据放在哪张表”,还包括:
- 如何定义租户边界;
- 如何阻止租户 A 读取或修改租户 B 的数据;
- 如何设计主键、唯一约束和外键;
- 如何执行迁移、备份、恢复和审计;
- 如何处理连接池、事务、缓存、异步任务和只读副本;
- 当租户数量、数据量或合规要求变化时,如何迁移到另一种隔离模型。
本文比较四类常见方案:
- 共享数据库、共享 Schema、共享表;
- 共享数据库、独立 Schema;
- 独立数据库;
- 混合架构,以及数据库之外的隔离措施。
这里的“Schema”必须按数据库产品区分:
- 在 PostgreSQL 中,Schema 是同一个数据库内的命名空间,多个 Schema 可以属于同一个 database。
- 在 MySQL 中,
SCHEMA是DATABASE的同义词。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
);
那么全局主键会迫使所有租户共享一个编号空间。这不一定错误,但它会带来几个问题:
- 业务对象编号暴露全局规模;
- 数据迁移和分片时不容易保留租户内的局部编号;
- 忘记租户条件时,开发者容易误以为
order_id足以定位租户; - 外键和唯一约束容易漏掉租户维度。
对于租户内唯一的业务字段,也应将租户标识纳入唯一约束:
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. 共享表的安全条件
设:
- 是所有租户集合;
- 是一张业务表;
- 表示行 所属租户;
- 是当前请求的租户;
- 是租户 发出的查询。
理想的读取条件是:
也就是说,查询结果必须是该租户行集合的子集。
如果应用为每次请求自动追加:
WHERE tenant_id = :current_tenant
则读取隔离在逻辑上成立。但这个结论依赖一个很强的前提:
所有读取、更新、删除和关联查询都必须经过正确的租户约束。
只要存在一次未加条件的查询:
SELECT * FROM orders;
结果就是:
而当 包含多个租户时:
隔离立即失效。
更新和删除的风险更大:
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;
并确保:
- 租户设置和业务 SQL 在同一事务中;
- 事务结束时上下文自动清理;
- 连接池不会在事务之外执行需要租户身份的 SQL;
- 事务异常时执行回滚;
- 后台任务、异步消费者和管理脚本也设置租户上下文。
RLS 是数据库防线,不会自动修复缓存键、消息队列、对象存储路径或应用日志中的租户混淆。例如,数据库查询受 RLS 保护,但缓存仍使用:
order:100
那么 Acme 写入的缓存可能被 Beta 读取。缓存键至少应包含租户:
tenant:acme:order:100
四、MySQL 中的共享表:没有同等的原生行级安全语义
MySQL 8.4 的权限模型主要授予数据库对象、表、列等层级的访问权,并不提供 PostgreSQL RLS 那样的通用表级行策略。
因此 MySQL 的共享表通常依赖以下方式:
- 应用层强制添加
tenant_id; - 使用只暴露租户过滤条件的视图;
- 使用存储过程或受限服务账号;
- 通过数据库代理或访问层统一重写与校验;
- 将不同租户分配给不同数据库账号。
例如可以建立一个视图:
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 模型还会放大迁移数量。假设有 个租户,每次结构迁移需要对每个 Schema 执行一次,则迁移目标数量为:
迁移失败的中间状态也可能是:
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 中跨数据库查询能力既方便运维,也使“数据库名隔离”不自动等于“不可访问”。
七、独立数据库:更强的边界,但不是自动的安全保证
“独立库”可能有两种含义:
- 每个租户一个 database,仍运行在同一个数据库实例或集群中;
- 每个租户一个独立数据库实例、存储卷或云资源。
两者不能混为一谈。
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
正确流程应是:
- 从认证令牌确定调用者身份;
- 查询服务端维护的租户目录;
- 校验该身份是否属于目标租户;
- 根据目录获取数据库端点和凭据;
- 建立或复用对应连接;
- 在事务中执行 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 不直接支持 |
| 独立实例 | 实例或集群 | 很高 | 每租户一套资源 | 容易 | 强 | 需要应用层或专门数据同步 |
这张表只描述通常的工程特征,不是数据库产品对安全或性能的绝对保证。错误权限、错误路由、超级用户和备份账号都可能跨越这些边界。
九、如何选择:隔离强度与运维成本的函数
可以把设计选择抽象为几个变量:
- :租户数量;
- :第 个租户的数据量;
- :第 个租户的负载;
- :维护一套额外 Schema 或数据库的成本;
- :合规要求的隔离强度;
- :租户级恢复、导出和删除要求;
- :对资源争抢的敏感程度。
共享表的管理对象数量近似为常数:
但每次业务访问都要带租户谓词,且租户级恢复成本随总数据量增长:
独立 Schema 或独立 database 的对象数量近似为:
迁移、监控和连接管理成本会随租户数量增长,但单租户恢复范围更小:
如果 很大而租户规模差异不大,共享表通常更容易控制运维复杂度。如果少量大型租户具有强合规、独立备份或独立扩容要求,独立 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 注入,也要继续验证调用者是否有权访问该租户。
十五、备份、恢复、删除和审计的差异
共享表
恢复单个租户通常不能简单恢复整个数据库,否则会覆盖其他租户的当前数据。常见方式是:
- 将备份恢复到临时数据库;
- 按
tenant_id导出目标租户数据; - 校验主键、外键和版本;
- 在生产库中按事务导入;
- 记录恢复批次和结果。
这要求所有相关表都能可靠定位租户。若某张表没有 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 承载大型租户,再将高合规租户迁移到独立实例。真正需要保证的是:无论数据位于哪种层级,系统都能明确证明“谁可以在什么条件下访问哪一行、哪个对象和哪个资源”。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:数据库容量规划:工作集、缓存命中、IOPS、连接、增长和压测
- 下一篇:时态数据与审计历史:有效时间、系统时间、版本表和可追溯性
- 延伸:数据库安全治理:最小权限、加密、审计、脱敏与注入防护
- 延伸:数据库复制、分片与高可用:一致性、路由、故障转移和扩容
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论