Vadim Yatsenko:拆分只读事务——在开放事务内进行副本读取

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

内容提要

Polar Proxy 提出一种在 PostgreSQL 事务内安全分流只读语句的方案:事务仍保留在主库,只读语句借用副本连接执行。该方案仅适用于读已提交隔离级别、WAL 已刷盘且不含锁子句的顶层 SELECT。通过导入事务 XID 并等待副本回放至目标 LSN,确保能读到事务自身的写入及此前已提交的数据。副本连接在事务期间保持复用,事务结束后才归还;副本故障时按策略重试或回退主库。

🔎

延伸解读

隔离级别决定分流边界

文章明确指出,只有读已提交隔离级别下才能安全分流只读语句。因为该级别每条语句取新快照,副本只需回放到目标 LSN 即可满足“读到语句前已提交数据”的语义。而可重复读和串行化事务在首条语句固定快照,副本难以精确复现,因此代理从不拆分这两类事务。读者需注意:分流能力并非普遍适用,隔离级别是首要前提。

首次写入不是硬边界

许多方案将事务中首次写入视为分流终点,但 Polar Proxy 允许写入前后都分流读取。写入前只需等待副本回放至目标 LSN;写入后还需导入事务 XID,让副本看到本事务未提交的写入。前提是事务自身 WAL 已刷盘,否则 RFQ 标记为待刷盘状态。这意味着一笔已写入但 WAL 未落盘的事务,其后续读取仍会留在主库,直到刷盘完成。

副本连接按事务持有

与单条自动提交语句即借即还不同,事务内首个合格读取选中的副本连接会一直保留到事务提交或回滚。这样做避免每条语句重新选连接,也让后续读取在副本已追平时跳过 LSN 等待。但若路由目标变化或副本失效,会话会释放并另选。该生命周期策略是 PolarDB 团队自定,上游 ProxySQL 无先例,读者应理解其权衡:以连接占用换取延迟稳定。

故障回退与结果一致性

分流读取可能因副本断连、等待超时或可重用 SQL 错误而失败。代理按策略处理:连接丢失时先尝试另一兼容副本,超时和可重用错误则回退主库执行并转发权威结果。关键限制是,一旦有结果行发送给客户端,代理立即停止重试,保留原始失败路径,避免客户端看到半截后重试的语句。这保障了故障场景下客户端不会收到不一致的结果序列。

❓

Q&A

Polar Proxy 如何在不破坏事务一致性的前提下,把事务内的只读查询分流到副本?

Polar Proxy 保留事务在主库上,只读语句借用副本连接执行。它通过导入事务的 XID 列表并等待副本回放至目标 LSN,来复现事务自身的可见性以及语句前已提交的数据,从而安全分流。

为什么只有读已提交隔离级别下才能拆分事务内的读?

读已提交隔离级别下,每条语句获取新快照,读只需看到语句前已提交的数据,这可以通过 LSN 等待实现。而可重复读和串行化隔离级别下,事务使用同一快照,副本难以复现,因此不能拆分。

哪些 SELECT 语句可以被安全地分流到副本?

必须满足:事务处于读已提交隔离级别;事务可拆分(无未刷盘 WAL);语句是顶层 SELECT 且不含锁定子句(如 FOR UPDATE);是单条语句;对于首次写之前的读,会话未在事务内执行 SET。任何条件不满足则留在主库。

Polar Proxy 如何知道事务是否可拆分?

通过扩展 libpq,在启动时请求额外事务元数据。后端在 ReadyForQuery 消息中附加标记字节:'x' 表示可拆分(无未刷盘 WAL),'w' 表示有未刷盘 WAL。可拆分时还会附带 XID 列表,代理据此判断是否需要导入 XID。

拆分读时,副本连接在事务期间如何管理?

事务中第一个符合条件的读会选择一个副本连接,并在整个事务期间保持复用,后续读直接使用同一连接,直到事务结束(提交或回滚)才归还连接池。若路由目标变化或副本失效,则释放并重新选择。

如果副本在拆分读过程中故障,Polar Proxy 如何处理?

代理会分类故障并应用可配置策略:连接丢失时尝试另一个副本重试一次,超时或可重用 SQL 错误则回退到主库执行并返回权威结果。一旦已向客户端发送结果行,则停止重试,保留原始错误路径。

🏷️

标签

➡️

继续阅读