Chris van Eijk:Rails、ActiveRecord 与 PostgreSQL RLS 实测

Chris van Eijk:Rails、ActiveRecord 与 PostgreSQL RLS 实测

💡 原文英文,约1800词,阅读约需7分钟。
📝

内容提要

文章实测了 Rails 8.1 中为 PostgreSQL 行级安全设置租户的四种方式。关键发现:ActiveRecord 将连接归还连接池时不会重置会话设置,导致无租户请求读到上一客户的数据(200/200 次);使用 with_connection 会泄漏,而 lease_connection 将连接租给请求线程,600 次并发下零泄漏;请求内新建线程会读到其他连接的残留租户;每请求事务在渲染 500 时仍会提交插入,需在 around_action 中检查响应状态并回滚;事务开销约 0.14 毫秒。

🔎

延伸解读

连接池不重置会话设置,是租户泄漏的根源

文章实测发现,ActiveRecord 将连接归还连接池时不会执行 DISCARD ALL 或重置会话变量,因此 set_config 设置的租户会残留在连接上。无租户请求复用该连接时,200 次全部读到上一客户的发票。这意味着仅靠 before_action 设置租户而不清理,无法保证隔离;必须依赖连接租约机制或显式重置,否则任何跳过设置租户的路径都会暴露数据。

lease_connection 的粘性租约是 Rails 的隔离优势

ActiveRecord::Base.connection 会将连接租给当前请求线程,后续模型查询复用同一连接,因此 set_config 与查询共享连接,600 次并发下零泄漏。而 with_connection 在块结束后立即归还连接,若动作中有 2 毫秒其他工作,600 次请求中分别有 155 和 160 次读到其他租户数据。文章指出,无暂停时 with_connection 也偶然为零,但那是连接池倾向归还同一连接,并非保证。

请求内新建线程会拿到其他连接的残留租户

由于连接按线程租用,请求内 Thread.new 启动的线程会从池中获取另一条连接,读到该连接上残留的租户设置。实测中,租户 1 的请求其内部线程读到了租户 2 的发票。每请求事务方式下连接归还时无租户,线程读到空结果,虽不泄漏他人数据但结果错误。文章仅测量了普通线程,load_async 在默认配置下使用请求自身连接,配置执行器后行为不同。

每请求事务需检查响应状态才能回滚失败写入

around_action 中的 ActiveRecord::Base.transaction 仅在异常抛出时回滚。若动作自行 render 500 而不抛异常,事务正常返回并提交插入,实测中该插入被提交。文章建议在 yield 后检查 response.status,若大于等于 500 则 raise ActiveRecord::Rollback。加上该行后,渲染 500 的插入被回滚,201 仍提交,并发读取不受影响。事务开销约 0.14 毫秒。

❓

Q&A

Rails 8.1 中为 PostgreSQL RLS 设置租户,用 before_action 和 with_connection 有什么区别?

before_action 中调用 ActiveRecord::Base.connection 会将连接租给请求线程(lease_connection),后续查询复用同一连接,设置得以保留;而 with_connection 在块结束后立即归还连接,若动作中有其他耗时操作,连接可能被其他请求取走并覆盖租户设置,导致读到其他租户数据(实测 600 次中 155/160 次错误)。

为什么没有设置租户的请求会读到上一个客户的数据?

ActiveRecord 在连接归还连接池时不会重置会话级设置(如 set_config('app.tenant', …)),连接被再次取出时仍保留之前的租户值。实测中,无租户请求 200 次全部读到了上一个客户的发票数据。

在请求内新建线程查询数据库,租户设置会怎样?

连接是按线程租用的,新线程会从池中获取另一条连接,该连接上可能残留之前请求设置的租户。实测中,租户 1 的请求内线程读到了租户 1 的数据(来自更早请求),甚至租户 2 的请求内线程也读到了租户 1 的数据。

使用每请求事务设置租户时,为什么返回 500 的请求仍然提交了插入?

ActiveRecord::Base.transaction 只在块内抛出异常时回滚,若动作直接 render status: 500 而没有异常,块正常结束就会提交。解决方法是在 around_action 的 yield 后检查 response.status,若 >=500 则 raise ActiveRecord::Rollback。

每请求事务设置租户的性能开销有多大?

实测平均每请求约 0.94 毫秒,相比无事务的 0.80 毫秒,事务开销约 0.14 毫秒(增加 BEGIN 和 COMMIT)。

Rails 中设置租户的推荐做法是什么?

推荐在 before_action 中使用 ActiveRecord::Base.connection(或 lease_connection)设置租户,利用连接租用保证同一请求内查询使用同一连接;若无租户也应显式设置为空字符串,避免残留。若用事务,需在 around_action 中根据响应状态决定回滚。

🏷️

标签

➡️

继续阅读