多租户系统常把租户 ID 写进 PostgreSQL 会话变量,再让行级安全策略(RLS)读取它。直连数据库时,这种设计看起来很自然;一旦接入 PgBouncer 的 transaction pooling,同一个客户端连接的前后两条语句可能落到不同后端,而不同客户端也可能先后复用同一个后端。此时,普通 SET app.tenant = ... 不但可能丢失,还可能被下一位调用者继承。
这不是单纯的连接池配置问题,而是安全边界放错了位置:请求属于事务,但租户身份却被写进了数据库会话。
问题不在 RLS,而在租户上下文的生命周期
典型的多租户策略如下:
CREATE TABLE docs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant text NOT NULL,
body text NOT NULL
);
INSERT INTO docs (tenant, body) VALUES
('a', 'alpha invoice'),
('a', 'alpha contract'),
('b', 'beta invoice');
ALTER TABLE docs ENABLE ROW LEVEL SECURITY;
ALTER TABLE docs FORCE ROW LEVEL SECURITY;
CREATE POLICY docs_by_tenant ON docs
USING (
tenant = NULLIF(current_setting('app.tenant', true), '')
);
应用随后在查询前设置租户:
SET app.tenant = 'a';
SELECT tenant, body FROM docs;
如果客户端直连 PostgreSQL,并且一条物理连接从始至终只服务一个可信会话,这种做法可能按预期工作。但 SET 修改的是后端会话状态,而不是当前事务状态。客户端断开或事务结束,并不会自动撤销它。
在 PgBouncer transaction 模式下,后端连接只在事务期间借给客户端。事务结束后,PgBouncer 可以立即把同一个 PostgreSQL 后端交给另一个客户端,却不会因为交接而自动执行完整的 DISCARD ALL。于是第二个客户端即使没有设置租户,也可能读到前一个客户端留下的值:
SELECT current_setting('app.tenant', true) AS inherited_tenant;
可能返回:
inherited_tenant
------------------
a
如果 RLS 正依赖这个值,第二个客户端看到的就不只是错误配置,而是租户 A 的真实数据。
需要注意,验证 RLS 时不要使用超级用户或带 BYPASSRLS 的角色。表所有者也有特殊行为,因此示例使用了 FORCE ROW LEVEL SECURITY;生产环境仍应让应用通过权限受限的独立角色访问数据。
哪些状态会跨越调用者
判断一项状态是否危险,关键不是它看起来像不像“连接配置”,而是 PostgreSQL 在什么时候清理它。
| 状态 | 生命周期 | transaction pooling 下的风险 |
|---|---|---|
SET app.tenant = ... |
会话 | 可能被下一位客户端继承 |
SET ROLE |
会话 | 下一位客户端可能以错误角色执行 |
SET search_path |
会话 | 相同 SQL 可能解析到另一个对象 |
SET statement_timeout |
会话 | 后续请求继承错误的超时 |
| 临时表 | 会话 | 表和其中的数据可能继续存在 |
SQL PREPARE |
会话 | 预备语句可能被其他客户端执行 |
CURSOR WITH HOLD |
会话 | 游标在提交后仍可存活 |
LISTEN |
后端会话 | 通知会跟随后端,而不是逻辑客户端 |
pg_advisory_lock |
会话 | 锁可能泄漏并重复叠加 |
SET LOCAL |
当前事务 | 提交或回滚时清除 |
set_config(..., true) |
当前事务 | 提交或回滚时清除 |
pg_advisory_xact_lock |
当前事务 | 事务结束时释放 |
PgBouncer 会为每个客户端追踪并恢复一小部分参数,例如 client_encoding、DateStyle、TimeZone、standard_conforming_strings 和 application_name,还可以通过 track_extra_parameters 扩展。但这不意味着任意自定义参数都会被隔离,更不能把该机制当成租户授权边界。
另一个容易误判的细节是,自定义参数一旦在某个后端上被设置过,重置后 current_setting('app.tenant', true) 可能得到空字符串而非 NULL。因此下面的策略并不稳妥:
-- 不推荐:空字符串不会通过 IS NULL
current_setting('app.tenant', true) IS NULL
更安全的写法是先将空字符串归一化:
NULLIF(current_setting('app.tenant', true), '')
授权策略应默认拒绝访问。租户上下文缺失、为空或无效时,应返回零行或直接报错,而不是回退到某个默认租户。
正确边界:一个请求对应一个显式事务
安全模式是:每个 HTTP 请求或 Agent 工具调用开启一个显式事务,在事务内部设置租户,并确保所有受保护查询都在提交前完成。
BEGIN;
SELECT set_config('app.tenant', 'a', true);
SELECT tenant, body
FROM docs
ORDER BY id;
COMMIT;
set_config 的第三个参数为 true,表示设置只在当前事务内有效,等价于:
BEGIN;
SET LOCAL app.tenant = 'a';
SELECT tenant, body FROM docs;
COMMIT;
不要把它错误地拆成三个独立事务:
-- 错误示例:在 transaction pooling 下无法保证落在同一后端
SET app.tenant = 'a';
SELECT tenant, body FROM docs;
RESET app.tenant;
即使最后写了 RESET,中途异常、超时或客户端取消也可能跳过清理。更重要的是,普通 SET 和查询之间可能发生后端切换:设置写在后端 1,查询却被后端 2 执行。
Python 服务中的可复制实现
下面假设应用使用 Psycopg 3,并通过 PgBouncer 的 transaction pooling 端点连接。运行前将 DATABASE_URL 改为实际地址:
python -m pip install 'psycopg[binary,pool]'
export DATABASE_URL='postgresql://app_user:app_password@127.0.0.1:6432/appdb'
保存为 app.py:
import os
from psycopg_pool import ConnectionPool
pool = ConnectionPool(
conninfo=os.environ['DATABASE_URL'],
min_size=1,
max_size=10,
open=True,
)
def list_docs(tenant_id: str) -> list[tuple[str, str]]:
if not tenant_id or len(tenant_id) > 128:
raise ValueError('invalid tenant id')
with pool.connection() as conn:
# conn.transaction() 保证租户设置和业务查询处于同一事务。
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"SELECT set_config('app.tenant', %s, true)",
(tenant_id,),
)
# 可选的防御性校验:发现上下文未生效时立即失败。
cur.execute(
"SELECT NULLIF(current_setting('app.tenant', true), '')"
)
active_tenant = cur.fetchone()[0]
if active_tenant != tenant_id:
raise RuntimeError('tenant context was not installed')
cur.execute(
'SELECT tenant, body FROM docs ORDER BY id'
)
return cur.fetchall()
if __name__ == '__main__':
print(list_docs('a'))
pool.close()
这里有四个重要约束:
- 租户 ID 必须来自已经验证的认证信息,不能直接相信模型生成的参数、HTTP 请求体或 MCP 工具参数。
set_config(..., true)与数据查询必须在同一个显式事务中。- 事务提交后,不再执行依赖该租户上下文的查询。
- 异常必须触发回滚;不要捕获异常后继续复用一个处于未知状态的事务。
对于 Agent 或 MCP 工具调用,这一点尤其重要。协议层请求彼此独立时,数据库不能假设“上一次工具调用已经设置好租户”。每次调用都应重新完成认证、授权、租户解析和事务级上下文绑定。
不要在事务池连接上使用这些能力
如果业务真正需要会话级功能,就不应假装 transaction pooling 能提供稳定会话。以下功能通常需要重新设计,或改走 session pooling/数据库直连:
LISTEN/NOTIFY的长期订阅;- SQL 级
PREPARE; - 跨事务使用的临时表;
WITH HOLD游标;- 会话级 advisory lock;
- 依赖固定
search_path、角色或自定义 GUC 的长生命周期流程。
强制 PgBouncer 在每次交接时执行重置,可以作为额外防线,但会带来性能和兼容性代价,也不能修复“同一逻辑请求中的两条语句落到不同后端”这一根本问题。正确方案仍然是让安全上下文与事务拥有相同生命周期。
池大小也不能随意压到 1 来追求可复现性或串行化。一个客户端只要在事务中空闲,就可能占住唯一后端,后续请求最终触发 query_wait_timeout。应同时监控长事务、idle in transaction、等待队列和连接池饱和度。
上线前检查清单
迁移现有多租户服务时,可以逐项确认:
- 搜索代码中的
SET、SET ROLE、set_config(..., false)和裸pg_advisory_lock。 - 确认每个请求都使用显式事务,而不是依赖驱动默认行为。
- 将租户设置改为
SET LOCAL或set_config(..., true)。 - 让 RLS 在租户缺失、空字符串或非法值时默认拒绝访问。
- 使用普通应用角色测试,禁止
SUPERUSER和BYPASSRLS。 - 在测试中交替发送租户 A、租户 B 和“不带租户”的请求,并提高并发以迫使后端复用。
- 同时记录逻辑请求 ID、租户 ID、
pg_backend_pid()和事务 ID,便于识别串线。 - 明确禁止在 transaction pooling 端点上使用会话级数据库功能。
- 为长事务、池等待和
idle in transaction设置告警。
最可靠的原则很简单:不要把调用者身份留在一张会被转交给别人的椅子上。租户上下文应在事务开始后建立,在提交或回滚时自动销毁;连接池只负责复用连接,不应承担授权隔离。