4. 共享Schema下的租户标识设计
共享 Schema 模式的核心思想是:所有租户的数据存放在同一套表结构中,通过一个额外的租户标识列把不同租户的行区分开。这个标识列通常命名为 tenant_id。它看起来只是一个普通字段,但它决定了数据隔离是否可靠、查询能否走索引、唯一约束是否真正按租户生效,以及跨租户误操作能否被数据库层面拦住。本节围绕 tenant_id 的类型选择、复合主键、唯一约束、索引策略和数据完整性展开。
一、tenant_id 字段设计:类型与语义
tenant_id 的第一决策是类型。常见选择有三类:自增整数、UUID、以及带业务含义的短字符串编码。自增整数占用空间小、索引效率高,但会暴露租户规模,且在多环境合并数据时容易冲突。UUID(建议使用 UUIDv7 或有序 UUID)全局唯一、便于分布式生成,但 16 字节宽度会让每个二级索引都变大。短字符串编码(如 t_8f3a2c)可读性好,适合需要人工排查的场景,但要注意定长与字符集。
无论选哪种类型,都应遵守三条约定:第一,tenant_id 一经分配不可变更,避免历史数据出现“孤儿行”;第二,它必须为 NOT NULL,绝不允许出现“无租户”的行;第三,它不参与业务语义,只作为隔离键。下面是一个典型的建表示例,采用 BIGINT 租户号:
CREATE TABLE orders (
tenant_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
status VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, order_id)
);
这里 order_id 只在单个租户内唯一,因此主键必须是 (tenant_id, order_id) 的复合主键。这正是共享 Schema 与独立 Schema 最大的结构差异:业务主键不再全局唯一,而是“租户内唯一”。
二、复合主键与唯一约束
复合主键把 tenant_id 放在最左列,有两个直接好处:一是天然保证同一租户内主键唯一,不同租户可以使用相同的 order_id;二是主键索引本身就以 tenant_id 为前缀,等值查询 WHERE tenant_id = ? AND order_id = ? 可以直接命中。若把顺序写成 (order_id, tenant_id),则按租户范围扫描会失效,这是常见错误。
业务唯一约束同样要带上 tenant_id。例如“同一租户下订单号唯一”,应写成:
ALTER TABLE orders
ADD CONSTRAINT uq_orders_tenant_no UNIQUE (tenant_id, order_no);
如果漏掉 tenant_id,就会变成全局唯一,导致 A 租户占用了某个订单号后 B 租户无法使用,直接破坏多租户可用性。反过来,如果唯一约束里只有 tenant_id,那约束就退化成“每租户只能有一行”,属于典型设计事故。判断标准很简单:凡是业务上“租户内唯一”的规则,约束列必须包含 tenant_id,且 tenant_id 通常放首位。
三、索引策略:让 tenant_id 成为前缀
共享 Schema 下几乎所有查询都会带 tenant_id 条件,因此索引设计的第一原则是:tenant_id 作为联合索引的最左前缀。常见模式有:
- 列表查询:
(tenant_id, status, created_at DESC),支持“某租户某状态的订单按时间倒序”。 - 关联查询:
(tenant_id, customer_id),支持按客户查订单。 - 唯一校验:
(tenant_id, order_no)唯一索引,兼顾约束与点查。
需要警惕两类反模式。其一是为每个租户单独建索引,例如 idx_orders_t1001,这会让索引数量随租户增长而爆炸,DDL 与统计信息维护成本极高。其二是只建 (status) 这类不含 tenant_id 的索引,查询时数据库仍需回表过滤租户,隔离性与性能都差。正确做法是统一使用带 tenant_id 前缀的联合索引,由优化器按租户裁剪。
tenant_id 做哈希分区,使每个分区只服务部分租户,既保留共享 Schema 的运维便利,又获得接近独立表的裁剪效果。四、数据完整性:把隔离写进约束
仅靠应用层拼 WHERE tenant_id = ? 并不可靠,一次漏拼就可能造成跨租户读写。数据库层应提供兜底:
- 所有业务表
tenant_id NOT NULL,并建立指向tenants(id)的外键,防止出现无效租户号。 - 子表通过复合外键绑定父表,例如
order_items(tenant_id, order_id)引用orders(tenant_id, order_id),从结构上杜绝“A 租户的明细挂到 B 租户订单”。 - 对关键表启用行级安全策略(RLS),以会话变量
app.tenant_id为过滤条件,即使 SQL 漏写租户条件也会被自动拦截。
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY p_orders_tenant ON orders
USING (tenant_id = current_setting('app.tenant_id')::BIGINT);
下图概括了 tenant_id 在表结构、约束与索引中的位置关系,以及它如何同时承担隔离键与索引前缀两种角色。
五、一个可执行的检查清单
在评审共享 Schema 表设计时,可用下面这份清单逐项核对,任何一项不通过都意味着隔离或性能存在隐患:
| 检查项 | 通过标准 | 常见错误 |
|---|---|---|
| tenant_id 类型 | 全局唯一、不可变、NOT NULL | 允许 NULL 或可更新 |
| 主键顺序 | tenant_id 在最左列 | 写成 (order_id, tenant_id) |
| 业务唯一约束 | 包含 tenant_id | 漏掉导致全局唯一 |
| 联合索引 | tenant_id 为前缀 | 只建 status 单列索引 |
| 子表外键 | 复合外键绑定父表租户 | 仅用 order_id 关联 |
| 兜底隔离 | 启用 RLS 或等价策略 | 完全依赖应用层拼接 |
把 tenant_id 当作“结构的一部分”而非“查询条件的一部分”,是共享 Schema 设计成熟度的分水岭。类型选对、主键与唯一约束带上它、索引以它为前缀、外键与 RLS 把它固化进约束,四件事同时做到,多租户数据才能在共享表结构下既高效又安全地共存。