你刚做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标签

存客户数据的表全都要有租户标识符,没有例外。eventsaudit_logsai_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子句的周二早晨就会找上门。