你刚做SaaS三个月,客户开始要报表。第一家客户Acme Corp想看到自己的订单、注册量和收入。你随手写了个WHERE tenant_id = 42就上线了。
六个月后,400个租户、30套报表、一个能自动生成SQL的AI助手,还有某个周二早晨忘记写WHERE子句的工程师。现在Acme Corp能看到Globex的数据。这种周一早晨的头条新闻,你绝对不想上。
这篇文章讲如何架构多租户SQL报表系统,让单个遗漏的子句永远不会变成数据泄露。涉及租户schema设计、Postgres行级安全(RLS)、JWT对接、性能索引,以及生产环境里坑过无数人的陷阱。
先选隔离模型,再写任何SQL。三种经典方案:
共享schema + tenant_id列——90%的SaaS该选这个。便宜、能扩展,Postgres自带工具保安全。下文默认这个模式。
第一步:每行打上tenant_id标签
存客户数据的表全都要有租户标识符,没有例外。events、audit_logs、ai_query_history这些表也一样。
注意两点:tenant_id设为NOT NULL(空值意味着孤儿行会绕过过滤),索引把tenant_id放最前面。所有报表查询都按租户过滤,这就是你的核心索引。
第二步:用行级安全当兜底
应用层过滤没问题,直到有人忘记写。Postgres RLS在数据库层强制执行过滤,就算裸跑SELECT * FROM orders,返回的也只是当前租户有权查看的行。
USING子管你能读哪些行,WITH CHECK子句防止你往其他租户插入或更新数据。两个方向都堵住。
现在每个连接必须先设app.tenant_id再查:
SET LOCAL把设置绑定到当前事务,连接池里的请求不会串号——这点很重要。
第三步:把租户上下文接到认证层
设置值必须来自可信源。标准做法是从验签过的JWT里提取:
应用启动事务时,从JWT解出tenant_id,执行SET LOCAL,后续所有查询自动带租户隔离。就算ORM或AI生成的SQL漏了WHERE子句,RLS也会拦截越权访问。
性能:索引策略
RLS有开销。Postgres会对每行检查策略,大表可能变慢。关键优化:
• 复合索引把tenant_id放首位,覆盖最常见的查询模式
• 避免在RLS策略里放复杂函数,尽量直接比较
• 用EXPLAIN ANALYZE验证查询计划,确认索引被用上
生产环境的坑
连接池+RLS的交互容易出事。SET LOCAL只在事务内有效,但某些池化配置会复用连接而不重置状态。务必测试边界情况:事务中途报错、连接超时、强制回滚后的状态。
另一个陷阱:备份和ETL流程。这些工具通常用超级用户连接,默认绕过RLS。要么给它们专门的用户角色并启用RLS,要么在导出前显式过滤数据。
还有AI生成SQL的场景。LLM可能"聪明"地试图优化查询,比如把tenant_id条件改写成子查询或JOIN。只要最终执行时app.tenant_id已设置,RLS仍然生效——但调试会变得很头疼,因为生成的SQL看起来和你预期的不一样。
最后,测试。不是测"正常情况能工作",而是测"WHERE子句被删掉时会不会泄露数据"。自动化测试里专门写几个故意漏掉tenant过滤的查询,验证RLS确实拦截。
多租户数据隔离没有银弹。共享schema+RLS是性价比最高的方案,但前提是每一步都踩实:schema设计、索引策略、JWT对接、连接池配置、测试覆盖。任何一个环节偷懒,那个忘写WHERE子句的周二早晨就会找上门。
热门跟贴