Chris van Eijk:Go、pgx 与 PostgreSQL 行级安全实测

Chris van Eijk:Go、pgx 与 PostgreSQL 行级安全实测

💡 原文英文,约2600词,阅读约需10分钟。
📝

内容提要

本文实测了Go语言pgx连接池为PostgreSQL行级安全设置租户的九种方式。用pool.Exec设租户再pool.QueryRow查询会因连接不同而泄漏,约半数请求读到他人数据;连接池钩子直连有效,但在PgBouncer事务模式下失效;最可靠且最快的是每请求事务或批处理,首条语句用set_config设租户,每次耗时0.06毫秒。

🔎

延伸解读

连接池泄漏的根源与风险

文章实测显示,用pool.Exec设置租户后,再用pool.QueryRow查询,由于两次操作可能获取不同连接,约半数请求会读到其他租户的数据。在30并发下,400个租户请求中有193和178个读到了他人发票。这种泄漏在单请求时不易察觉,但并发下错误率极高,且无租户的请求几乎全部看到数据。

连接池钩子的有效性与局限

PrepareConn和AfterRelease钩子直连PostgreSQL时能保证所有请求正确,但存在三个限制:钩子读取获取连接时的上下文而非语句上下文,跨请求持有连接会保留前一个租户;它只提供租户设置而非事务,插入会立即提交;会话级设置无法被事务池化器携带。

PgBouncer事务模式下的失效

在PgBouncer事务模式下,钩子设置的租户、查询和重置分属三个事务,可能落在不同后端。实测中,400个租户请求里仅96和100个正确,67和82个读到他人数据,237和218个无结果。无租户请求也有72和85个看到行。连接固定(pool.Acquire)同样无效。

可靠方案:事务或批处理

每请求事务以set_config(…, true)为首条语句,确保设置随事务结束,在直连和PgBouncer下均无错误。批处理将set_config和查询合并为一次往返,同样可靠,且耗时仅0.06毫秒,是最快变体。两者都需始终传递租户,即使为空字符串,否则可能读到连接上的残留设置。

❓

Q&A

在Go中用pgx设置PostgreSQL行级安全的租户,为什么用pool.Exec设置后再用pool.QueryRow查询会泄漏数据?

因为pool.Exec和pool.QueryRow各自从连接池获取连接,执行一条语句后归还。pool.Exec在某个连接上设置租户,而pool.QueryRow可能在另一个连接上执行查询,导致查询看不到正确的租户设置。在并发下,约一半的请求会读到其他租户的数据。

使用pgxpool的连接池钩子(PrepareConn和AfterRelease)设置租户,在直连PostgreSQL时效果如何?

直连PostgreSQL时,如果同时使用PrepareConn设置租户和AfterRelease重置租户,并且每个请求都获取自己的连接,那么所有请求都能得到正确结果。但仅使用PrepareConn而不重置,无租户的请求会读到前一个请求的租户数据。

为什么pgxpool的连接池钩子在PgBouncer事务模式下会失效?

因为PgBouncer事务模式下,每个事务可能被分配到不同的后端连接。钩子中的set_config、查询和reset是三个独立的事务,可能运行在不同的后端上,导致租户设置与查询不在同一连接,从而失效。

在Go中使用pgx和PostgreSQL行级安全时,哪种设置租户的方式最可靠且性能最好?

最可靠且最快的是每请求事务或批处理,将set_config作为第一条语句。事务方式使用pgx.BeginFunc,批处理使用pool.SendBatch,两者都能确保租户设置与查询在同一连接或事务中。批处理每次请求耗时约0.06毫秒,是最快的变体。

使用pgx事务设置租户时,为什么必须始终传递租户参数,即使为空?

因为如果事务中不设置租户,连接上可能残留之前会话级别的租户设置,导致查询读到错误的数据。即使租户为空,也应调用set_config('app.tenant', '', true)来显式清除,确保安全。

在pgx批处理中设置租户,有哪些注意事项?

批处理中必须将set_config作为第一条语句,并且不能跳过其执行结果(如br.Exec())。批处理适用于语句之间不依赖彼此结果的请求;如果请求需要读取后决定写入,则应使用事务。此外,批处理同样需要始终传递租户参数,即使为空。

🏷️

标签

➡️

继续阅读