你的SaaS跑了三个月,客户开始要仪表盘。第一个需求很简单——Acme Corp想看到自己的订单、注册量和收入。你随手写了个WHERE tenant_id = 42,上线收工。

六个月后,400个租户、30套报表、一个能实时写SQL的AI助手,再加上某个周二早上忘了加WHERE子句的工程师。现在Acme Corp能看到Globex的数据了。这种周一早晨的头条新闻,你不想上。

打开网易新闻 查看精彩图片

这篇文章聊的是多租户SQL报表的架构设计:怎么让一次疏忽永远成不了数据泄露。我们会覆盖租户Schema设计、Postgres行级安全(RLS)、JWT对接、性能索引,以及生产环境里那些咬人的坑。

打开网易新闻 查看精彩图片

三种多租户模式(为什么有一种在报表场景胜出)

动笔写SQL之前,先选隔离模型。经典选项有三个:

模式实现方式适用场景独立数据库每个租户一个Postgres实例企业级隔离,成本极高独立Schema共享实例,租户各有一个Schema中等规模,Schema管理复杂共享Schema + tenant_id单表加租户标识列90%的SaaS首选

对90%的SaaS应用来说,共享Schema加tenant_id列是正确的选择。便宜、能扩展,Postgres还给了足够的安全工具。下面的内容都基于这个模式。

第一步:每行数据打上tenant_id标签

所有存客户数据的表都需要租户标识。没有例外。包括eventsaudit_logsai_query_history这些边缘表。

CREATE TABLE orders (id BIGSERIAL PRIMARY KEY,tenant_id BIGINT NOT NULL REFERENCES tenants(id),customer_id BIGINT NOT NULL,amount_cents INT NOT NULL,status TEXT NOT NULL,created_at TIMESTAMPTZ DEFAULT now()CREATE INDEX orders_tenant_created_idxON orders (tenant_id, created_at DESC);

两个细节:tenant_idNOT NULL(空值意味着孤儿行,会绕过你的过滤器);索引把tenant_id放最前面。每个报表查询都会按租户过滤,这就是你的核心索引。

第二步:用行级安全(RLS)做最后一道防线

应用层过滤够好,直到有人忘记。Postgres的行级安全在数据库层强制执行过滤,就算裸跑SELECT * FROM orders,返回的也只是当前租户有权查看的行。

-- 1. 开启RLSALTER TABLE orders ENABLE ROW LEVEL SECURITY;-- 2. 定义策略CREATE POLICY tenant_isolation ON ordersFOR ALLUSING (tenant_id = current_setting('app.tenant_id')::BIGINT)WITH CHECK (tenant_id = current_setting('app.tenant_id')::BIGINT);

USING子句控制你能读哪些行;WITH CHECK子句防止你往其他租户插入或更新数据。两头都堵死。

现在每个连接必须在查询前设置app.tenant_id

SET LOCAL app.tenant_id = '42';SELECT count(*) FROM orders; -- 自动限定在租户42

SET LOCAL把设置绑定到当前事务,不会泄漏到连接池里的其他请求——这一点很重要。

第三步:把租户上下文对接认证层

这个设置必须来自可信来源。标准做法是从验签过的JWT里提取:

# 伪代码——每个请求在事务开始时执行def set_tenant_context(conn, jwt_token):claims = verify_jwt(jwt_token)  # 验签tenant_id = claims['tenant_id']  # 从payload提取conn.execute("SET LOCAL app.tenant_id = %s", tenant_id)return conn

关键链条:JWT签名验证 → 提取租户ID → 设置会话变量 → 所有后续查询自动隔离。中间任何一环断了,查询就会因为缺少app.tenant_id而失败,而不是错误地返回数据。

打开网易新闻 查看精彩图片

第四步:索引策略别偷懒

RLS有性能成本。Postgres会对每行检查策略条件,糟糕的索引会让报表查询变成全表扫描。

核心原则:

  • 所有RLS过滤列都要有索引(这里是tenant_id
  • 复合索引把租户列放最左(tenant_id, created_at而不是反过来)
  • 覆盖索引减少回表(INCLUDE常用查询字段)

一个典型报表查询:SELECT * FROM orders WHERE tenant_id = 42 AND created_at > '2024-01-01'。如果索引是(created_at, tenant_id),Postgres可能先扫大量历史数据再过滤租户;顺序反过来,直接定位到租户的时间切片。

生产环境的三个咬人坑

坑一:连接池的变量泄漏

用PgBouncer或类似池子时,SET不加LOCAL会让租户ID残留在连接里。下一个请求复用这个连接,就串了数据。必须用SET LOCAL,且确保事务结束时清理。

坑二:超级用户绕过RLS

Postgres的BYPASSRLS属性让超级用户无视策略。你的应用连接应该用普通角色,管理操作走单独的审计通道。别让postgres用户跑应用代码。

坑三:AI生成SQL的不可控

那个"实时写SQL的AI助手"是最大的变量。LLM可能生成子查询、CTE、或者跨表关联,RLS策略必须覆盖所有路径。建议:给AI设只读角色,启用RLS,并且在应用层再加一道查询审计——拦截包含tenant_id IS NULL或明显跨租户模式的SQL。

最后的检查清单

  • 所有客户数据表都有NOT NULLtenant_id
  • RLS开启,策略覆盖USINGWITH CHECK
  • 租户ID从JWT提取,验签后才进数据库
  • 索引最左列是tenant_id
  • 连接池配置正确,SET LOCAL绑定事务
  • AI生成的SQL有额外的审计层

数据隔离不是功能,是底线。一个WHERE子句的遗忘成本,可能是整个公司的信任破产。