当PostgreSQL的读流量涨到单机扛不住的时候,绝大多数团队的第一反应是加只读副本。但副本加完之后问题才真正开始:哪些查询该走主库、哪些该走从库、一个事务里既写又读怎么办?这些决策如果散落在业务代码里,维护成本会迅速失控,于是代理层(Proxy)就成了标配。查询路由的设计质量,直接决定了这套读写分离架构是稳定运行还是频繁出现数据不一致。本文从代理层的职责边界讲起,逐步拆解路由决策的完整链路。

一、代理层的职责边界与两种转发模式
首先要明确一点:代理层不是简单的转发器。它处在应用和数据库之间,至少承担四个职责:协议解析、路由决策、连接池化、健康检查。其中路由决策是核心,其他三个都是为它服务的。协议解析让代理看懂客户端发来的SQL和当前会话状态,连接池化降低后端连接开销,健康检查则保证路由目标始终可用。
从实现形态上看,代理层有两种典型模式。第一种是基于语句转发,代理逐条解析SQL,把SELECT语句发往从库,把INSERT、UPDATE、DELETE发往主库。这种方式路由粒度细、负载均衡效果好,但实现复杂度高,需要处理事务上下文切换、会话变量同步等一堆麻烦事。第二种是基于会话转发,代理在连接建立时就决定这个会话走哪个后端,之后整个会话都绑定在该后端上。这种模式实现简单、语义完全正确,但读写分离粒度粗,如果应用大部分连接都是混合读写,几乎起不到分流作用。
实践中常见的折中方案是会话级绑定加语句级逃生:默认按会话转发,一旦检测到会话进入显式事务(收到BEGIN),立刻把整个事务粘到主库,事务结束后恢复正常的负载均衡调度。pgpool-II的几种负载均衡模式基本就是这个思路的变体。
二、路由决策的核心:SQL分类与状态感知
路由决策的第一步是判断一条SQL是读还是写。看似简单,实际上坑很多。最基础的做法是取SQL第一个关键字做匹配,比如以SELECT开头的走从库。但PostgreSQL里有不少边界情况:SELECT ... FOR UPDATE虽然是SELECT,却要加行锁,必须路由到主库;WITH ... INSERT开头的CTE语句第一个词是WITH,实际是写操作;还有SELECT func()这种调用写函数的语句,从语法上完全看不出来副作用。
一个稍微健壮的实现示例如下:
import re
# 需要强制路由到主库的语句特征
WRITE_HINTS = re.compile(
r'^(insert|update|delete|merge|create|drop|alter|truncate|grant|revoke|'
r'copy|vacuum|reindex|comment|lock)\b',
re.IGNORECASE
)
# SELECT但包含写语义的模式
SELECT_LOCK = re.compile(r'\bfor\s+(update|share|no\s+key\s+update)\b', re.IGNORECASE)
WRITE_CTE = re.compile(r'^with\b.*?\b(insert|update|delete)\b', re.IGNORECASE | re.DOTALL)
def classify(sql: str) -> str:
sql = sql.strip().rstrip(';').strip()
if sql.startswith('('):
# 子查询包裹的语句,去掉外层括号再判断
return classify(sql[1:-1])
if WRITE_HINTS.match(sql):
return 'write'
if WRITE_CTE.match(sql):
return 'write'
if SELECT_LOCK.search(sql):
return 'write'
return 'read'更彻底的方案是不靠正则,而是用PostgreSQL自身的解析器。libpq_query或者把pg_parse_tree相关能力封装成服务,可以拿到完整的解析树,准确判断语句类型和是否包含写操作。代价是引入了C扩展依赖,部署和版本兼容要额外操心。对于大多数场景,规则匹配加上注释Hint兜底(比如支持应用在SQL里写/* master */强制走主库)已经够用。
除了语句本身的分类,代理必须维护会话状态机。PostgreSQL的扩展协议下,客户端发的是Parse、Bind、Describe、Execute消息,一条逻辑SQL被拆成多个协议消息,代理必须把它们正确关联起来,并且记住每个prepared statement的名字,这样才能在路由到不同后端时保证语句定义已同步。此外,SET设置的作用域、LISTEN/NOTIFY的订阅关系、临时表的生命周期,都是会话粘性的触发条件。一旦会话里出现过这些操作,最安全的策略是把该会话标记为粘性,固定路由到某个后端直到会话结束。
三、复制延迟处理与负载均衡策略
读写分离最大的隐患是复制延迟带来的脏读:应用刚写完一条数据,紧接着读却查不到,因为读请求被路由到了还没回放的从库。处理这个问题有几条路线。第一种是客户端等待,写操作返回后,代理记录主库当前的LSN位置,后续读请求先检查候选从库是否已回放到该LSN,没回放完就等待或者换一个从库。这依赖pg_last_wal_replay_lsn()和pg_current_wal_lsn()的配合,思路清晰但对延迟敏感的请求会造成明显的响应时间抖动。
第二种是因果会话粘性,也叫写后读一致性:同一个会话内,一旦发生过写操作,就把这个会话在一段时间内(或者直到下一次写操作)固定到主库。实现简单,语义对应用友好,代价是混合读写的会话完全享受不到分流收益。第三种是给应用暴露控制权,比如提供SET pipp.route_mode = 'master'这样的会话级开关,或者通过不同端口区分强读端口和弱读端口,让业务自己选择一致性级别。生产环境里通常是三种方式组合使用。
负载均衡方面,读请求的分发策略不宜只用简单轮询。每个从库的硬件配置、当前连接数、复制延迟都可能不同,加权最小连接数算法是更合理的选择。同时要设置延迟阈值,超过阈值的从库自动摘出读池,回落后再自动加回。下面是一个简化的后端选择逻辑:
def pick_replica(clients, session):
candidates = []
for node in read_pool:
# 健康检查失败或延迟超阈值的节点直接跳过
if not node.healthy or node.replay_lag_ms > 1000:
continue
# 会话粘性检查
if session.sticky_backend and session.sticky_backend is node:
return node
candidates.append(node)
if not candidates:
return primary # 全部从库不可用时降级到主库
# 加权最小连接数
return min(candidates, key=lambda n: n.active_conns / n.weight)故障转移是另一个必须提前设计的点。主库宕机时,代理要配合 Patroni 之类的集群管理工具完成VIP切换或自动改写后端地址。这里有个容易踩的坑:切换瞬间,挂在新主库上的旧连接必须全部断掉,否则可能出现两个客户端连接着不同时间线的情况。代理层应该在收到后端拓扑变更通知后,主动关闭受影响的连接池,并短暂进入只读模式等待新主库完全就绪。
四、自研还是复用:主流方案对比与选型建议
如果没有特殊需求,优先复用成熟组件。PgBouncer定位是轻量连接池,本身不提供查询级路由,但它的session和transaction pooling模式配合应用侧的路由SDK(比如带读写分离功能的JDBC驱动)是非常常见的组合。pgpool-II功能最全,内置解析器、查询级读写分离、负载均衡、复制延迟检查和自动故障转移,缺点是配置项繁多、性能在极高并发下会成瓶颈,而且它的SQL解析对某些新语法支持滞后,升级PostgreSQL大版本时要格外留意兼容性。
Haproxy加外部高可用方案走的是四层透明转发路线,不懂SQL、只按端口或节点分流,胜在稳定和性能,适合会话级路由或者把路由逻辑放在客户端SDK里的架构。近年来也出现了HAProxy与pgbouncer组合、客户端驱动直连多节点的去中心化方案,路由决策完全在客户端完成,少了一跳网络转发,延迟最低,代价是每种语言都要维护一套SDK逻辑。
自研代理层只有在以下情况才值得考虑:需要深度定制路由规则(比如按租户分片与读写分离叠加)、需要和公司内部的服务治理体系打通、或者对协议层有特殊改造需求。自研时建议直接复用PostgreSQL的协议文档和开源实现(比如研究pgpool-II或者PgDog的源码),从扩展协议的完整支持入手,先把Parse/Bind/Execute/同步语义做对,再谈路由策略的优化。路由决策模块务必设计成插件化的,让规则可以热更新,因为线上迟早会出现你需要紧急调整某类SQL路由方向的场景。把语句分类、状态感知、延迟控制、负载均衡这四块拆成独立的决策阶段,每一阶段都可以单独测试和灰度,这样的代理层才能在长期演进中保持可控。
PostgreSQL查询路由代理层读写分离修改时间:2026-09-16 17:56:57