Chris van Eijk:PgBouncer 与 RLS:租户上下文、SET LOCAL、共享计划

Chris van Eijk:PgBouncer 与 RLS:租户上下文、SET LOCAL、共享计划

💡 原文英文,约2000词,阅读约需8分钟。
📝

内容提要

PgBouncer事务模式下,SET设置的租户会泄漏给后续客户端,SET LOCAL在事务外无效,连接被污染时可能读到他人数据。不同租户共享预编译语句和查询计划,性能受首个租户影响。对策:每个工作单元使用显式事务加SET LOCAL,回读校验租户,策略用nullif处理缺失,租户作为参数并设置plan_cache_mode=force_custom_plan。

🔎

延伸解读

SET 与 SET LOCAL 在连接池中的实际风险

文章实测表明,在 PgBouncer 事务模式下使用 SET 设置租户变量会泄漏给后续客户端,导致读到他人数据;而 SET LOCAL 在显式事务外无效,若连接已被污染,同样会读到错误租户。两者单独出现时可能仅返回零行或警告,但组合后可能让中等租户请求读到最大租户的 267,023 行发票。因此必须避免使用 SET,并确保每个工作单元都在显式事务内设置租户。

策略表达式选择影响连接复用稳定性

文章对比了三种 RLS 策略写法:current_setting('app.tenant_id')::uuid 在缺失时直接报错;current_setting('app.tenant_id', true)::uuid 在全新连接返回零行,但在被使用过的连接上因空字符串转为 UUID 而报错;nullif(current_setting('app.tenant_id', true), '')::uuid 则始终返回零行。在连接池中,同一请求可能因抽到不同连接而成功或失败,因此应明确选择第一种或第三种,避免中间形式。

共享预编译语句导致跨租户计划复用

PgBouncer 1.24 起默认在事务模式下共享协议级预编译语句,同一 SQL 在不同客户端间共享一个内部名称和查询计划。由于 RLS 查询从 current_setting() 获取租户且无参数,总是使用通用计划,该计划基于首个执行租户的统计信息。实测中,小租户执行大租户留下的计划耗时 137 毫秒,而直接连接仅 77 毫秒。即使将租户作为参数,若未强制自定义计划,大租户仍可能继承小租户的通用计划。

缓解措施:参数化与强制自定义计划

文章建议将租户作为参数传递,并在应用角色上设置 plan_cache_mode = force_custom_plan,使每次执行都根据当前租户重新规划。实测显示,启用后参数化查询的耗时与未预编译查询相当。但该设置仅对带参数的语句有效,且需让 PgBouncer 重连以应用新设置。代价是每次执行增加少量规划时间,但可避免大租户因继承小租户计划而性能下降。

❓

Q&A

PgBouncer 事务模式下,使用 SET 设置租户上下文会导致泄漏吗?

会。一个客户端执行 SET app.tenant_id = '<租户ID>' 后断开,下一个未设置任何上下文的客户端会看到该租户的所有数据。例如,设置最大租户后,下一个客户端计数到 267,023 行。使用 set_config(..., false) 同样会泄漏。

SET LOCAL 在事务外执行会有什么效果?

在事务外执行 SET LOCAL 不会生效,只会产生一个警告。在自动提交模式下,每条语句都是独立事务,因此单独执行 SET LOCAL 相当于在事务外,设置不会保留。set_config(..., true) 更糟,它不会警告,但设置只在当前语句内有效,下一条语句就看不到。

如何正确设置租户上下文以避免泄漏?

每个工作单元使用显式事务(BEGIN),在事务内用 SET LOCAL 或 set_config(..., true) 设置租户,然后执行查询,最后 COMMIT。设置后应在同一事务内用单独语句回读 current_setting('app.tenant_id', true) 并与设置的租户比较,不一致则停止。

RLS 策略中如何处理租户缺失的情况?

策略应确保租户缺失时结果一致。推荐使用 nullif(current_setting('app.tenant_id', true), '')::uuid,这样在全新连接和已使用过的连接上都返回 0 行。避免使用 current_setting('app.tenant_id', true)::uuid,因为它在已使用过的连接上会因空字符串而报错。

PgBouncer 会在不同租户间共享查询计划吗?

会。PgBouncer 在事务模式下会共享协议级预编译语句,相同 SQL 字符串的预编译语句共享同一个内部名称,因此也共享同一个查询计划。一个租户首次执行生成的计划会被后续其他租户使用,导致性能受首个租户影响。

如何避免租户间共享查询计划导致的性能问题?

将租户作为参数传递(如 tenant_id = $1),并在应用角色上设置 plan_cache_mode = force_custom_plan。这样每次执行都会为当前租户生成自定义计划。注意,该设置只对带参数的语句有效,且需要 PgBouncer 重新连接后生效。

🏷️

标签

➡️

继续阅读