多租户系统最难改的往往不是业务代码,而是数据边界。一旦产品开始增长,租户隔离模型会影响查询写法,RLS 会影响连接池,索引会影响写入成本,金额类型会进入财务链路,而单租户恢复和大规模迁移则直接决定事故处理能力。
真正重要的不是找到一个“万能架构”,而是在上线前明确六个决定,并用接近生产的数据量测量它们。
1. 先决定租户放在哪里
PostgreSQL 中常见的隔离模型有三种:
| 模型 | 优点 | 代价 | 更适合 |
|---|---|---|---|
| 所有租户共享表 | 资源利用率高,迁移和连接管理简单 | 每条访问路径都必须正确过滤租户 | 租户多、单租户数据量相对可控的 SaaS |
| 每个租户一个 schema | 命名空间更清晰,可按 schema 操作 | schema 数量增长后,迁移和连接状态变复杂 | 租户数量有限、需要一定逻辑隔离的系统 |
| 每个租户一个数据库 | 故障与资源边界更明确,便于独立迁移 | 连接池、版本升级、监控和成本显著增加 | 大客户、监管场景或高价值租户 |
很多团队最终采用混合模型:普通租户进入共享表,大型或受监管租户进入独立数据库。这样做时,需要一个独立的租户目录记录路由,而不是让业务代码猜测数据位置。
例如,可以维护如下控制表:
CREATE TABLE tenant_catalog (
tenant_id uuid PRIMARY KEY,
placement text NOT NULL CHECK (placement IN ('shared', 'dedicated')),
database_dsn text,
status text NOT NULL DEFAULT 'active'
);
不要只比较“每次查询快几毫秒”。还应测量连接数、迁移总时长、备份大小、故障域,以及新增一千个租户之后的运维成本。
2. RLS 是第二道门,不是唯一一道门
共享表模型通常把 tenant_id 放在每张租户数据表中,并在应用查询和数据库策略两层执行隔离。PostgreSQL Row-Level Security 可以降低漏写 WHERE tenant_id = ... 的风险,但它不会自动解决身份传递、连接复用和管理员权限问题。
下面是一套可以直接运行的最小实验。运行前需要安装 Docker,并确保本机的 5432 端口未被占用:
docker run --name tenant-pg \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d postgres:16
until docker exec tenant-pg pg_isready -U postgres >/dev/null 2>&1; do
sleep 1
done
docker exec -i tenant-pg psql -U postgres <<'SQL'
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE ROLE app_user LOGIN PASSWORD 'app-password';
CREATE TABLE invoices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
invoice_no text NOT NULL,
amount_minor bigint NOT NULL CHECK (amount_minor >= 0),
currency char(3) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, invoice_no)
);
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY invoices_by_tenant ON invoices
FOR ALL
TO app_user
USING (
tenant_id = current_setting('app.tenant_id', true)::uuid
)
WITH CHECK (
tenant_id = current_setting('app.tenant_id', true)::uuid
);
GRANT SELECT, INSERT, UPDATE, DELETE ON invoices TO app_user;
INSERT INTO invoices (tenant_id, invoice_no, amount_minor, currency)
VALUES
('11111111-1111-1111-1111-111111111111', 'INV-001', 2599, 'USD'),
('22222222-2222-2222-2222-222222222222', 'INV-001', 4700, 'USD');
SET ROLE app_user;
BEGIN;
SET LOCAL app.tenant_id = '11111111-1111-1111-1111-111111111111';
SELECT tenant_id, invoice_no, amount_minor, currency FROM invoices;
COMMIT;
RESET ROLE;
SQL
查询只应返回第一个租户的数据。实际接入应用时,要特别检查以下边界:
- 使用
SET LOCAL并把它放在事务内,避免租户上下文泄漏到连接池中的下一个请求。 - 表所有者和高权限角色可能绕过 RLS;应用不应使用迁移账号或超级用户连接数据库。
- 后台任务、报表、数据导出和管理员接口也必须定义明确的租户上下文。
- 如果连接池采用 statement pooling,依赖事务本地设置的方案可能无法工作,应调整池化模式或身份传递方式。
- RLS 应作为纵深防御。应用查询仍建议显式携带
tenant_id,便于代码审查和索引使用。
3. 索引和金额类型会进入每一条热路径
共享表中,唯一性通常也是租户范围内的。下面这个约束允许不同租户使用相同发票号,但禁止同一租户重复:
ALTER TABLE invoices
ADD CONSTRAINT invoices_tenant_invoice_no_key
UNIQUE (tenant_id, invoice_no);
如果典型请求是读取某租户最近的发票,可以这样建立索引:
CREATE INDEX CONCURRENTLY invoices_tenant_created_idx
ON invoices (tenant_id, created_at DESC)
INCLUDE (invoice_no, amount_minor, currency);
tenant_id 是否应该放在第一列,取决于真实查询条件,而不是固定规则。租户内查询通常从它开始;跨租户的运营分析则可能需要另一套索引,甚至应复制到分析系统,避免在事务库上维护大量相互重叠的索引。
验证索引时,不要只看估算成本:
EXPLAIN (ANALYZE, BUFFERS)
SELECT invoice_no, amount_minor, currency
FROM invoices
WHERE tenant_id = '11111111-1111-1111-1111-111111111111'
ORDER BY created_at DESC
LIMIT 50;
金额同样需要尽早定型。常见选择有:
- 使用
bigint保存最小货币单位,例如 2599 表示 25.99 美元。它适合小数位固定、以支付和账单为主的系统。 - 使用
numeric(p, s)保存需要明确十进制精度的金额、税率或计算结果。 - 不要用
double precision表示必须精确对账的金额。 - PostgreSQL 的
money类型带有格式和区域语义,通常不如显式的数值字段加货币代码清晰。
bigint 方案还必须保存货币代码,并处理不同货币的最小单位差异。汇率、按比例分摊和高精度税务计算通常仍需要 numeric。类型选择应由业务不变量决定,而不是为了节省几个字节。
4. 单租户恢复和大规模迁移必须提前演练
从全库备份中恢复一个租户
物理备份和时间点恢复通常以整个 PostgreSQL 实例或集群为边界,并不会天然提供“恢复一个租户”按钮。共享表模式下,更稳妥的恢复流程通常是:
- 将备份或时间点恢复到隔离环境。
- 从恢复出的数据库中导出目标租户数据。
- 校验行数、外键、对象存储引用和审计记录。
- 暂停该租户写入,或建立明确的冲突合并规则。
- 在事务中回灌生产环境,并保留操作审计。
在恢复环境中,可以按租户导出单张表:
export RECOVERY_DB='postgresql://postgres:secret@recovery-host/app'
export TENANT_ID='11111111-1111-1111-1111-111111111111'
psql "$RECOVERY_DB" -v ON_ERROR_STOP=1 \
-c "\copy (
SELECT id, tenant_id, invoice_no, amount_minor, currency, created_at
FROM invoices
WHERE tenant_id = '$TENANT_ID'
) TO 'tenant_invoices.csv' WITH (FORMAT csv, HEADER true)"
这只是单表示例。真实系统需要在一致性快照中处理所有相关表,并按照外键依赖顺序导出和导入。还要考虑共享字典表、租户外对象、全局唯一键,以及恢复期间产生的新写入。因此,单租户恢复应当作为定期演练的运行手册,而不是事故发生后临时拼命令。
在大表上迁移
多租户表变大后,一次阻塞式 DDL 就可能影响所有客户。可以采用扩展—回填—切换—收缩的方式:
-- 第一步:新增可空列,旧版本应用仍可继续工作
ALTER TABLE invoices ADD COLUMN payment_status text;
-- 第二步:由作业按主键或时间窗口分批回填,而不是一次更新整张表
UPDATE invoices
SET payment_status = 'pending'
WHERE id IN (
SELECT id
FROM invoices
WHERE payment_status IS NULL
LIMIT 1000
);
-- 第三步:先添加未验证约束,缩短持锁阶段
ALTER TABLE invoices
ADD CONSTRAINT invoices_payment_status_check
CHECK (payment_status IN ('pending', 'paid', 'void')) NOT VALID;
-- 第四步:数据回填完成后再扫描验证
ALTER TABLE invoices
VALIDATE CONSTRAINT invoices_payment_status_check;
-- 如需新索引,在事务外并发创建
CREATE INDEX CONCURRENTLY invoices_tenant_status_idx
ON invoices (tenant_id, payment_status);
批量回填需要循环执行,并设置超时、限速和可重试游标。CREATE INDEX CONCURRENTLY 不能放在普通事务块中,而且会消耗额外 I/O;上线前应在接近生产规模的数据集上测量。
用指标为六个决定验收
在正式锁定方案前,可以建立一张简单的验收清单:
- 隔离模型:一个热点租户是否会拖慢其他租户?路由和搬迁流程是否可操作?
- RLS:普通请求、后台任务、管理员操作和连接复用是否都经过测试?
- 索引:关键查询的
EXPLAIN (ANALYZE, BUFFERS)、P95 延迟和写放大是否可接受? - 金额:精度、舍入、货币代码、退款和对账规则是否有自动化测试?
- 恢复:是否真的从备份恢复过单个租户,并记录 RTO、RPO 和人工步骤?
- 迁移:最大表上的锁等待、WAL 增量、复制延迟和回填时间是否测量过?
这些决定之间并不独立。共享表让连接和迁移更集中,却提高了 RLS、复合索引和单租户恢复的重要性;独立数据库强化隔离,却把压力转移到连接、部署和批量升级上。最可靠的方案不是纸面上最纯粹的方案,而是团队能够持续测量、演练并安全运维的方案。