PgBouncer 事务池中的租户串线:为什么 SET 会把上一位客户留在连接里

2026-09-18 22 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

多租户系统常把租户 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_encodingDateStyleTimeZonestandard_conforming_stringsapplication_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()

这里有四个重要约束:

  1. 租户 ID 必须来自已经验证的认证信息,不能直接相信模型生成的参数、HTTP 请求体或 MCP 工具参数。
  2. set_config(..., true) 与数据查询必须在同一个显式事务中。
  3. 事务提交后,不再执行依赖该租户上下文的查询。
  4. 异常必须触发回滚;不要捕获异常后继续复用一个处于未知状态的事务。

对于 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、等待队列和连接池饱和度。

上线前检查清单

迁移现有多租户服务时,可以逐项确认:

  • 搜索代码中的 SETSET ROLEset_config(..., false) 和裸 pg_advisory_lock
  • 确认每个请求都使用显式事务,而不是依赖驱动默认行为。
  • 将租户设置改为 SET LOCALset_config(..., true)
  • 让 RLS 在租户缺失、空字符串或非法值时默认拒绝访问。
  • 使用普通应用角色测试,禁止 SUPERUSERBYPASSRLS
  • 在测试中交替发送租户 A、租户 B 和“不带租户”的请求,并提高并发以迫使后端复用。
  • 同时记录逻辑请求 ID、租户 ID、pg_backend_pid() 和事务 ID,便于识别串线。
  • 明确禁止在 transaction pooling 端点上使用会话级数据库功能。
  • 为长事务、池等待和 idle in transaction 设置告警。

最可靠的原则很简单:不要把调用者身份留在一张会被转交给别人的椅子上。租户上下文应在事务开始后建立,在提交或回滚时自动销毁;连接池只负责复用连接,不应承担授权隔离。


相关推荐