内容提要
多租户SaaS在PostgreSQL中需确定六个关键决策:隔离模型、RLS强制隔离、索引以tenant_id开头、金额使用numeric或整数分、单租户恢复与迁移。实测表明,恢复单租户仅需0.15秒;删除操作因外键缺少索引耗时21.8秒,添加索引后降至0.12秒。schema-per-tenant方案在约1300个schema时,pg_dump因共享内存不足失败,导致备份中断。
延伸解读
隔离模型:早期决策,后期难改
文章指出,多租户SaaS在PostgreSQL上的六个关键决策中,隔离模型、RLS强制、单租户恢复与迁移这四项一旦选定,后期变更需迁移生产数据,成本极高。只有索引和金额类型相对可调整。因此,这些决策应在项目第一周而非第二年确定,避免技术债累积。
外键缺失索引:删除性能的隐形杀手
实测显示,删除一个租户的2000张发票及明细耗时21.8秒,而恢复仅需0.15秒。原因是invoice_lines.invoice_id外键缺少索引,导致每次删除都全表扫描。添加索引后,删除降至0.12秒,提升182倍。文章建议定期检查未索引的外键列,避免在客户投诉时才发现问题。
schema-per-tenant的备份瓶颈
当schema数量达到约1300个(约13000张表)时,pg_dump因共享内存不足而失败,导致无法完成备份。默认配置下,max_locks_per_transaction为64,max_connections为100,可锁对象约6400个。修复需调整max_locks_per_transaction并重启数据库,这在托管平台可能需维护窗口。因此,schema-per-tenant的扩展性受限于备份恢复,而非查询性能。
RLS并非万能:五个静默失效点
行级安全(RLS)能将租户过滤下沉到数据库,但文章提醒,若未启用FORCE ROW LEVEL SECURITY,表所有者会绕过策略;RESET后租户上下文为空字符串而非NULL;外键检查也会忽略策略。这些漏洞可能导致数据泄露。文章建议使用提供的查询检查策略覆盖情况,并确保应用角色非表所有者。
Q&A
多租户SaaS在PostgreSQL中需要锁定哪六个关键决策?
六个决策包括:1. 隔离模型(每租户数据库、每租户schema或共享表加tenant_id);2. 如何强制隔离(共享表使用行级安全并加FORCE ROW LEVEL SECURITY);3. 索引(tenant_id放在复合索引最前面);4. 金额列类型(numeric指定精度或整数分);5. 如何单独删除或恢复一个租户(pg_dump无法过滤行);6. 租户数量增长时如何迁移。
在PostgreSQL中恢复单个租户的数据需要多长时间?
在包含200,000张发票和200,000条发票行的数据库中,导出单个租户的2,000张发票和2,000条行耗时0.10秒,加载回去耗时0.15秒。但需要自己编写导出脚本,因为pg_dump无法按行过滤。
为什么删除一个租户比恢复它慢182倍?如何解决?
删除一个租户(2,000张发票和2,000条行)耗时21.8秒,而恢复仅需0.15秒。原因是invoice_lines.invoice_id外键缺少索引,导致每次删除发票时都要全表扫描invoice_lines。添加索引后,删除时间降至0.12秒。
schema-per-tenant方案在多少个schema时会导致pg_dump失败?为什么?
在约1,300个schema(每个schema有10张表,共约13,000张表)时,pg_dump --schema-only会因共享内存不足而失败,报错“out of shared memory”。这是因为pg_dump在一个事务中锁定所有表,默认max_locks_per_transaction=64和max_connections=100下,可锁定的对象数有限。
如何检查数据库中哪些外键缺少索引?
可以使用以下查询:select c.conrelid::regclass as table_name, (select string_agg(a.attname, ', ') from unnest(c.conkey) k join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k) as columns from pg_constraint c where c.contype = 'f' and not exists (select 1 from pg_index i where i.indrelid = c.conrelid and (i.indkey::smallint[])[0:array_length(c.conkey,1)-1] = c.conkey) order by 1;
在PostgreSQL中存储金额应该使用什么数据类型?
推荐使用numeric并指定精度和标度(如numeric(14,2)),或者当金额主要用于求和且与支付提供商交互时使用bigint存储整数分。避免使用real或double precision(不精确),也不要使用money类型(无法存储分的分数,且依赖lc_monetary设置)。