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 前缀的联合索引,由优化器按租户裁剪。

在 PostgreSQL 中,若某张大表租户数量多但单租户数据量小,可考虑按 tenant_id 做哈希分区,使每个分区只服务部分租户,既保留共享 Schema 的运维便利,又获得接近独立表的裁剪效果。

四、数据完整性:把隔离写进约束

仅靠应用层拼 WHERE tenant_id = ? 并不可靠,一次漏拼就可能造成跨租户读写。数据库层应提供兜底:

  1. 所有业务表 tenant_id NOT NULL,并建立指向 tenants(id) 的外键,防止出现无效租户号。
  2. 子表通过复合外键绑定父表,例如 order_items(tenant_id, order_id) 引用 orders(tenant_id, order_id),从结构上杜绝“A 租户的明细挂到 B 租户订单”。
  3. 对关键表启用行级安全策略(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 的结构角色 tenants id (PK) name status orders tenant_id (PK①, FK) order_id (PK②) order_no customer_id status created_at order_items tenant_id (PK①,FK) order_id (PK②,FK) item_id (PK③) sku qty FK 复合FK 索引与约束(tenant_id 始终为最左前缀) PK (tenant_id, order_id) 主键点查 UNIQUE (tenant_id, order_no) 租户内唯一 IDX (tenant_id, status, created_at) 列表查询 RLS 策略:tenant_id = current_setting('app.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 把它固化进约束,四件事同时做到,多租户数据才能在共享表结构下既高效又安全地共存。