在共享表模式的多租户 SaaS 中,一张业务表可能同时服务 1,000 个租户。此时,索引列的顺序不是代码风格问题,而是决定 PostgreSQL 要扫描“某个租户的一小段数据”,还是“所有租户的一大片数据”。围绕 PostgreSQL 17、五种索引和同一条查询进行比较,最重要的结论是:当绝大多数请求都限定租户时,复合 B-tree 索引通常应以 tenant_id 开头。
索引顺序决定数据库从哪里开始扫描
假设发票表上的典型查询如下:
SELECT id, created_at, status, amount_cents
FROM invoices
WHERE tenant_id = 42
AND status = 'open'
AND created_at >= now() - interval '30 days'
ORDER BY created_at DESC
LIMIT 50;
这个查询同时包含三类条件:
tenant_id = 42:等值条件,选择一个租户;created_at >= ...:范围条件;ORDER BY created_at DESC LIMIT 50:希望直接从索引中读取最新记录。
因此,一个自然的索引是:
CREATE INDEX idx_invoices_tenant_created
ON invoices (tenant_id, created_at DESC);
PostgreSQL 可以先进入租户 42 对应的索引区间,再沿时间倒序扫描,找到 50 条结果后立即停止。索引顺序若改为 (created_at, tenant_id),数据库首先面对的是所有租户最近 30 天的数据;由于第一列已经使用范围条件,第二列通常无法像前导等值列那样有效缩小连续扫描区间。
可以把它理解为两种目录结构:
(tenant_id, created_at):先进入租户文件夹,再按时间找文件;(created_at, tenant_id):先进入日期文件夹,再从所有租户中筛选目标租户。
这并不意味着 tenant_id 永远必须放在第一位。如果查询本身跨租户,例如后台统计所有租户最近一小时的事件,那么以时间开头的索引可能更合适。索引必须服务真实查询,而不是服从一条脱离上下文的规则。
用 PostgreSQL 17 复现五种索引方案
下面是一套可以直接改造的本地实验。它创建 100 万条发票记录,并均匀分布到 1,000 个租户。数据模型和查询是用于复现实验思路的假设,并不代表来源中的原始表结构。
先启动 PostgreSQL 17:
docker run --name pg17-index-lab \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d postgres:17
export DATABASE_URL='postgresql://postgres:postgres@localhost:5432/postgres'
创建测试数据:
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
DROP TABLE IF EXISTS invoices CASCADE;
CREATE TABLE invoices (
id bigserial PRIMARY KEY,
tenant_id integer NOT NULL,
created_at timestamptz NOT NULL,
status text NOT NULL,
amount_cents integer NOT NULL
);
INSERT INTO invoices (tenant_id, created_at, status, amount_cents)
SELECT
((g - 1) % 1000) + 1,
now() - (((g * 7919) % 525600) * interval '1 minute'),
CASE WHEN g % 3 = 0 THEN 'open' ELSE 'paid' END,
100 + (g % 500000)
FROM generate_series(1, 1000000) AS g;
ANALYZE invoices;
SQL
然后逐个测试五种候选索引。脚本每次只保留一个候选索引,避免优化器直接选择其他更优索引:
labels=(
created_only
tenant_only
created_then_tenant
tenant_then_created
tenant_created_covering_partial
)
ddls=(
'(created_at DESC)'
'(tenant_id)'
'(created_at DESC, tenant_id)'
'(tenant_id, created_at DESC)'
'(tenant_id, created_at DESC) INCLUDE (id, amount_cents) WHERE status = '\''open'\'''
)
for i in "${!labels[@]}"; do
echo "===== ${labels[$i]} ====="
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<SQL
DROP INDEX IF EXISTS idx_candidate;
CREATE INDEX idx_candidate ON invoices ${ddls[$i]};
ANALYZE invoices;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT id, created_at, status, amount_cents
FROM invoices
WHERE tenant_id = 42
AND status = 'open'
AND created_at >= now() - interval '30 days'
ORDER BY created_at DESC
LIMIT 50;
SQL
done
比较输出时,不要只看最末尾的执行时间。还应关注:
Index Cond是否同时包含租户和时间条件;Rows Removed by Filter是否很高;- 实际读取了多少行才得到 50 行结果;
shared hit和shared read的数据块数量;- 是否出现额外的
Sort; - 覆盖索引是否触发
Index Only Scan,以及是否仍有Heap Fetches。
第五种索引是面向特定查询的进一步优化:它只保存 status = 'open' 的记录,并通过 INCLUDE 携带返回列。它可能减少索引体积和堆表访问,但代价是适用范围更窄。Index Only Scan 也不是必然发生,它还依赖可见性映射以及 vacuum 状态。
不要在生产环境中同时保留所有五个索引。重叠索引会增加磁盘占用,并放大 INSERT、UPDATE、vacuum 和缓存压力。实验的目标是选择索引,而不是收集索引。
RLS 会加入租户条件,但不会替你设计索引
行级安全策略(Row-Level Security,RLS)可以把租户隔离下沉到数据库。应用即使忘记在 SQL 中写 tenant_id,策略仍会限制可见数据。
可以这样配置:
CREATE ROLE app_user NOLOGIN;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT ON invoices TO app_user;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY invoices_tenant_policy
ON invoices
FOR SELECT
TO app_user
USING (
tenant_id = current_setting('app.tenant_id')::integer
);
测试时必须使用受 RLS 约束的角色。超级用户通常会绕过 RLS,因此用超级用户执行 EXPLAIN 很容易得到误导性的计划:
SET ROLE app_user;
BEGIN;
SET LOCAL app.tenant_id = '42';
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, status, amount_cents
FROM invoices
WHERE status = 'open'
AND created_at >= now() - interval '30 days'
ORDER BY created_at DESC
LIMIT 50;
COMMIT;
RESET ROLE;
在计划中检查 RLS 注入的 tenant_id 条件是否进入了 Index Cond,而不是只出现在 Filter。简单的等值策略通常容易与 (tenant_id, created_at) 索引配合,但复杂函数、类型转换、准备语句和通用计划都可能改变最终计划,因此应以真实应用角色、真实参数和真实连接方式执行 EXPLAIN。
连接池尤其需要谨慎。推荐在事务内使用 SET LOCAL,让租户上下文在提交或回滚时自动清除:
BEGIN;
SET LOCAL app.tenant_id = '42';
-- 在同一事务中执行该租户的查询
COMMIT;
RLS 是安全边界,不是性能魔法。它能注入限制条件,却不会自动创建正确索引,也不会消除低效查询。应用层仍可以显式携带 tenant_id 以提高可读性和计划稳定性,但绝不能把应用过滤当成唯一的隔离机制。
什么时候分区才值得
“有 1,000 个租户”本身不足以证明应该建立 1,000 个分区。按租户逐一分区会增加系统目录、规划、迁移、索引维护和运维成本。对于共享表,有限数量的哈希分区往往比每租户一个分区更容易管理。
一个用于新表实验的 32 分区方案可以这样写:
CREATE TABLE invoices_partitioned (
tenant_id integer NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
created_at timestamptz NOT NULL,
status text NOT NULL,
amount_cents integer NOT NULL,
PRIMARY KEY (tenant_id, id)
) PARTITION BY HASH (tenant_id);
DO $$
BEGIN
FOR i IN 0..31 LOOP
EXECUTE format(
'CREATE TABLE invoices_p%s PARTITION OF invoices_partitioned
FOR VALUES WITH (MODULUS 32, REMAINDER %s)',
i, i
);
END LOOP;
END $$;
CREATE INDEX ON invoices_partitioned (tenant_id, created_at DESC);
注意主键包含了分区键 tenant_id。查询时应通过 EXPLAIN 确认分区裁剪是否发生;如果租户值藏在复杂表达式、函数或某种准备计划中,裁剪效果需要实测。
分区更可能在以下场景中产生收益:
- 单表已经大到 vacuum、重建索引或备份维护困难;
- 查询能稳定裁剪到少量分区;
- 需要按分区并行维护、归档或迁移数据;
- 少数超大租户造成明显的热点和噪声邻居问题;
- 数据保留策略允许直接删除或分离整个分区。
如果问题只是单租户查询扫描过多,先修正索引顺序通常比引入分区更便宜。分区不会取代分区内部的索引;它只是让 PostgreSQL 先排除一部分物理数据。
上线前的决策清单
面对典型的多租户表,可以按这个顺序行动:
- 从
pg_stat_statements找出真实的高频、慢查询,而不是凭字段猜索引。 - 对以租户等值过滤、再按时间范围查询的路径,优先测试
(tenant_id, created_at)。 - 用
EXPLAIN (ANALYZE, BUFFERS)比较扫描行数、过滤行数和数据块访问量。 - 用非表所有者、非超级用户角色验证 RLS 计划。
- 在连接池事务中使用
SET LOCAL设置租户上下文。 - 只为稳定且高价值的查询增加覆盖索引或部分索引。
- 删除被新复合索引完全覆盖、且没有其他用途的冗余索引。
- 只有当维护成本、数据生命周期或分区裁剪收益足够明确时,再引入分区。
tenant_id 放在前面并不是因为它在语义上更重要,而是因为它通常是共享表上最稳定的第一层访问边界:先把搜索空间缩小到一个租户,再处理时间、状态和排序,PostgreSQL 才能用更少的索引页和数据页完成请求。