内容提要
多租户SaaS的PostgreSQL索引应将tenant_id放在首位。实测200万行、1000租户数据:复合索引(tenant_id, issued_on)查询最快,小租户0.28毫秒、大租户0.09毫秒;其他顺序则一方快一方慢。行级安全不改变计划,但预编译语句会因租户非参数而冻结计划,导致性能骤降,需显式传参。分区不提升查询速度,但便于删除大租户数据。
延伸解读
索引顺序为何关键:数据分布不均的陷阱
多租户表中租户数据量差异巨大,本文实测中最大租户有26.7万行,中位租户仅534行。若索引不以tenant_id开头,如(issued_on),大租户查询快但小租户需扫描大量无关行;而(tenant_id)单独索引则小租户快、大租户慢。只有(tenant_id, issued_on)能同时兼顾,因为先等值定位租户,再按排序字段读取。这提醒我们,用单一租户测试索引可能掩盖问题,必须考虑数据分布。
行级安全与预编译语句的隐藏风险
RLS策略本身不改变执行计划,但当租户ID来自current_setting()而非查询参数时,预编译语句会因无参数而始终使用通用计划,导致计划被第一个租户“冻结”。实测中,为小租户生成的计划用于大租户时,查询从82毫秒恶化到138毫秒;反之则慢11倍。解决方案是显式将租户ID作为参数传入查询,同时保留RLS策略作为安全兜底。此外,需检查数据库驱动是否自动预编译,避免无意中触发此问题。
分区的主要价值:运维效率而非查询速度
在200万行规模下,按tenant_id分区并未提升查询速度,因为聚合大租户数据的总行数不变,且表小于内存。但分区显著优化了运维操作:删除大租户数据,DELETE需141毫秒,而DETACH PARTITION加DROP TABLE仅需4.4毫秒,且可单独备份。因此,为超大客户单独分区,主要目的是简化数据清理、恢复和vacuum,而非加速读取。分区收益需在表超过物理内存时才会体现在查询性能上。
自查与迁移:低成本修正索引顺序
文章提供了一条SQL查询,可列出所有tenant_id不是首列的索引。但并非所有命中都是错误:仅服务内部跨租户任务的索引可能合理。然而,面向客户界面的索引若tenant_id不在首位,通常是性能隐患;唯一约束如unique(number)而非unique(tenant_id, number)则可能是逻辑错误。修正成本较低,可用CREATE INDEX CONCURRENTLY在线创建新索引,再删除旧索引,不影响写入。
Q&A
多租户SaaS的PostgreSQL索引为什么要把tenant_id放在第一列?
因为多租户表的数据分布不均匀,大租户可能占很大比例。将tenant_id放在首位,查询可以先通过等值条件定位到特定租户,再按排序或范围条件读取数据,避免扫描其他租户的行。实测中,(tenant_id, issued_on)索引对小租户和大租户都最快,而其他顺序的索引往往一方快一方慢。
在PostgreSQL中,行级安全(RLS)会影响多租户查询的执行计划吗?
不会。使用正确的索引时,RLS策略中的条件会成为索引查找的一部分,而不是额外的过滤条件。执行计划与没有RLS时相同,都是对(tenant_id, issued_on)的索引扫描。规划器也能正确估计行数。
为什么在RLS下使用预编译语句会导致某些租户查询变慢?如何解决?
因为预编译语句只计划一次,而RLS中租户来自current_setting(),不是参数,所以会使用通用计划。第一个租户的计划会被复用于所有租户,导致计划不适合其他租户。解决方法是在查询中显式传递租户参数,例如where tenant_id = $1,这样规划器可以为每个租户生成定制计划。
对于多租户SaaS,按租户分区能提高查询速度吗?
在文章测试的规模下(200万行),分区并不能提高查询速度。分区裁剪虽然有效,但读取大量数据的总成本不变。分区的主要优势在于运维操作,例如删除大租户数据时,DETACH PARTITION加DROP TABLE比DELETE快得多(4.4ms vs 141ms),也便于单独备份和恢复。
如何检查现有表中是否有索引没有把tenant_id放在第一列?
可以使用一个SQL查询,连接pg_index和pg_attribute,找出所有包含tenant_id列但tenant_id不是第一列的索引(排除主键)。例如:select i.indrelid::regclass as table_name, i.indexrelid::regclass as index_name, pg_get_indexdef(i.indexrelid) as definition from pg_index i join pg_attribute t on t.attrelid = i.indrelid and t.attname = 'tenant_id' and not t.attisdropped where i.indkey[0] <> t.attnum and not i.indisprimary order by 1, 2;
tenant_id使用UUID还是整数对索引大小有影响吗?
有影响。在200万行数据上,同样的(tenant_id, issued_on)索引,使用UUID时大小为32MB,使用整数时为23MB,小了约四分之一。对于新产品,这是一个需要权衡的成本;对于已有产品,由于更改键类型涉及所有表的迁移,通常不建议仅为了索引大小而更改。