后端系统工程 21:有限能力的数据库路由代理
系列导航:上一篇 20 发号服务的唯一性与失败模式 | 本篇 21 | 下一篇 22 非分片键查询与全局分页
账户表拆到两个 PostgreSQL 实例之后,每一次读写都要先回答“这一行在哪个库”。把这个问题从业务代码里拿走的常见做法,是在应用与数据库之间放一个路由层。路由层一旦被叫作“数据库代理”,读者往往默认它像一个数据库:任何 SQL 都能发,事务照常工作,会话参数照常生效。这种默认是错的。路由代理能承诺的范围,取决于它真正解析了什么、钉住了什么、拒绝了什么。
本篇实现一个能力有限的路由代理,并把限制写成接口的一部分:有限路由代理不等于兼容任意 SQL 或数据库协议。它只认一张分片表上的严格语法子集,只支持单分片事务,对子集之外的请求返回带编码的拒绝。代码在 examples/21-routing-proxy/,全部输出在 evidence/21/output.txt,证据边界见 evidence/21/LIMITS.md。
范围与不变量
业务对象是合成的测试账户:account(account_id, owner, balance, version),account_id 是分片键。客户端需要的操作只有五类:按账户号读、按账户号改、按账户号删、插入一个新账户、在同一个分片内做多语句事务(例如两个账户之间转账)。
代理对客户端承诺下面几条,也只承诺这几条:
- 每个账户号在任一路由版本下只映射到一个分片,插入和后续读写都去那个分片。
- 一个显式事务内的全部语句在同一个后端连接上执行;事务一旦需要第二个分片,代理拒绝并让事务进入失败状态,绝不把一半语句提交到别处。
- 子集外的语句不会到达后端。代理把解析结果重新生成为 SQL 再发出,客户端原文不直达数据库。
- 某个分片不可用时,只有路由到它的请求失败,其它分片照常服务。
- 路由表变化时,进行中的事务不更换目标。
规模取值全部是教学假设:两个分片,16 个桶,偶数桶在 s0、奇数桶在 s1;每个代理对每个分片最多 4 条后端连接;代理每 200ms 检查一次路由版本。实验没有测量吞吐和延迟,正文不给性能数字。
为什么不实现 PostgreSQL 线协议
让 psql 或 JDBC 直接连接代理,看起来最省事,代价是代理必须完整地扮演一个 PostgreSQL 服务器。按 PostgreSQL 16 前后端协议,至少要处理这些部分:
启动阶段的 StartupMessage 携带用户、数据库和任意会话参数,之后是认证往返,启动参数会成为会话默认值。Simple Query 的一个 Query 消息可以包含多条以分号分隔的语句,默认作为一个隐式事务执行,结束时回一个 ReadyForQuery。Extended Query 把一次执行拆成 Parse、Bind、Execute、Sync:命名的 prepared statement 存活到会话结束,命名的 portal 存活到当前事务结束,出错后后端丢弃消息直到 Sync。服务端在会话参数变化时主动发送 ParameterStatus。每个 ReadyForQuery 带一个事务状态字节,消息格式对它的定义是:“‘I’ if idle (not in a transaction block); ‘T’ if in a transaction block; or ‘E’ if in a failed transaction block (queries will be rejected until block is ended)”。
这些状态都长在后端会话上。代理一旦为了节省连接把多个客户端复用到同一批后端连接,就要逐项决定:A 准备过的语句在 B 借到的连接上不存在时怎么办,A 改过 DateStyle 后要不要在换连接时重放,一个 Query 消息里三条语句分属两个分片时怎么办。PgBouncer 的做法提供了一份现成的边界清单:它的 features 页说明 transaction pooling “breaks client expectations of the server by design”,并在特性表里把 SET/RESET、LISTEN、WITH HOLD CURSOR、PREPARE / DEALLOCATE、LOAD、会话级 advisory lock 标为 transaction 模式下 “Never”;协议级 prepared statement 要到 1.21.0 才通过 max_prepared_statements 跟踪支持(changelog)。PgBouncer 的定位是连接池,不负责分片路由,仅连接复用一项就需要这样一张表。
本章的目标是检验路由、连接绑定与拒绝规则,所以客户端协议自己定义,一行一个请求:
1 | |
响应里的 tx 借用 ReadyForQuery 的三个取值,rv 是这条语句实际使用的路由版本,pid 是执行它的后端进程号。后两个字段是为了让证据可以直接检查“用的是哪张路由表、哪条连接”。
状态归属
图 21-1:代理、元数据库与分片的状态归属
flowchart LR
C1[客户端会话 A] -->|行协议 47210| P
C2[客户端会话 B] -->|行协议| P
subgraph P[代理进程 内存状态,进程退出即丢失]
RT[路由表快照 rv]
SS[会话状态 I/T/E<br/>事务快照 + 钉住的连接]
POOL0[s0 连接池 ≤4]
POOL1[s1 连接池 ≤4]
end
P -->|每 200ms 读版本| META[(b2_21_meta @55432<br/>route_meta / shard / route_bucket<br/>持久)]
POOL0 -->|JDBC| S0[(b2_21_s0 @55432<br/>account 持久)]
POOL1 -->|JDBC| S1[(b2_21_s1 @55421 独立实例<br/>account 持久)]
持久状态只在 PostgreSQL 里:元数据库保存路由版本、分片地址和桶到分片的映射(setup-meta.sql),分片库保存账户行(setup-shard.sql)。代理不持有任何需要恢复的状态,崩溃后重启只需重新加载路由表;它在内存里持有的未提交事务,崩溃时由 PostgreSQL 回滚。s1 放在一个可以单独停止的实例上,用来做分片故障实验,不影响共享实例。
支持的子集与拒绝规则
语法刻意写得很窄,关键字大小写不敏感,所有值必须是 ? 参数,语句末尾不能带分号:
1 | |
拒绝按原因分码,便于客户端区分“改写请求即可”与“需要别的系统”:
| 代码 | 原因 | 代码 | 原因 |
|---|---|---|---|
| R01 | 子集外语法(ORDER BY、COPY、FOR UPDATE、多行 VALUES) |
R24 | DDL |
| R02 | 协议帧错误 | R25 | 会话或事务级状态语句(SET、RESET、SHOW、LISTEN、PREPARE、SAVEPOINT、LOCK 等) |
| R10 | 缺少可路由的分片键条件(含 OR、INSERT 缺分片键) |
R26 | 未知表或列 |
| R11 | 单条语句涉及多个分片(IN 跨分片) |
R27 | 参数个数或类型不符 |
| R12 | 事务内路由到另一分片 | R28 | SQL 中出现字面量 |
| R13 | 失败事务块内的数据语句与 COMMIT |
R29 | 更新分片键 |
| R20 | JOIN 或多表 FROM | R30 | 分片不可用,语句未发出 |
| R21 | 子查询、CTE、集合运算 | R31 | 后端返回错误,附 SQLSTATE |
| R22 | 分号或多语句 | R32 | 写语句或 COMMIT 发出后连接断开,结果未知 |
| R23 | 注释 | R40 | 事务内再次 BEGIN |
解析器(src/SqlParser.java)的检查顺序决定了错误码是否准确。先做一遍识别字符串边界的扫描,找注释和分号,因为它们在分词之前就能改变语句的含义;再看首个关键字,把 DDL 和会话状态语句挑出来,这样 SET statement_timeout = '1s' 得到 R25,而不是因为含有字面量得到 R28;然后分词并拒绝一切字面量、引号标识符、JOIN、多余的 SELECT;最后才按语法逐词匹配。WHERE 子句只有两种形状:
1 | |
WHERE balance = ? AND account_id = ? 在语义上同样钉住了分片键,仍被拒绝。允许任意顺序的合取条件,就要写一个能判断“这组条件是否把分片键限定为有限个值”的分析器,OR、NOT、函数调用都会进入这个问题。严格顺序把这类判断从代理里移走,代价是客户端必须按固定形状写 SQL。
参数绑定按列类型进行:account_id 传 abc 得到 R27,不会被拼进 SQL。代理发给后端的是自己生成的语句,例如 update account set balance = balance - ? where account_id = ?,所以拒绝规则同时是一道注入边界。
reject_rules 用 37 条子集外语句和两个畸形帧检验这张表:
1 | |
其中包括 DROP TABLE account、TRUNCATE account 和不带条件的 DELETE FROM account;两个分片的行数在前后都是 20,说明它们没有到达后端。
路由与路由表
路由函数是 bucket = floorMod(splitmix64(account_id), 16),再查 route_bucket[bucket] 得到分片(src/RouteTable.java)。桶数固定、桶到分片的映射可变,这样移动数据时只需改映射,不需改哈希函数。Vitess 用 Primary Vindex 表达同一件事:它把列值映射为 keyspace ID,插入时由这个映射决定目标分片,官方文档称其“conceptually … equivalent to a NoSQL Sharding Key”(Vitess v24.0 Vindexes)。本章的桶就是一个极简的 keyspace ID。
代理在一个可重复读的只读事务中读取三张元数据表,保证版本号和映射来自同一快照。新表必须满足两个条件才会替换旧表:版本号严格递增;每个桶恰好有一行且指向已知分片。否则记录被拒版本并保留旧表。invalid_route_table_rejected 先写入缺少桶 15 的版本 4,再写入版本号回退到 3 的表:
1 | |
缺桶的表如果被接受,落在桶 15 的请求会没有目标;版本回退如果被接受,一个已经迁走的桶会被路由回旧分片。两者都由加载器拒绝,代理继续用旧表服务。
40 个账户经代理插入后,直接查询两个分片核对位置:
1 | |
连接绑定与事务状态
自动提交语句按当前路由表选分片,从该分片的池里借一条连接,执行后归还。显式事务不同:BEGIN 只记录当前路由表快照,不占用后端连接;第一条数据语句按快照路由,借出连接、关闭自动提交并钉在会话上,直到 COMMIT 或 ROLLBACK 才归还。
图 21-2:会话事务状态机(前置条件与拒绝路径)
stateDiagram-v2
[*] --> I
I --> I: 自动提交语句(借连接→执行→归还)
I --> T0: BEGIN / 记录路由快照 rv
T0 --> T1: 首条数据语句 / 按快照路由,借连接并钉住
T0 --> I: COMMIT 或 ROLLBACK(无后端往返)
T1 --> T1: 同分片语句(同一 pid)
T1 --> E: 路由到其它分片 R12 / 后端错误 R31 / 子集外语句<br/>代理立即回滚并归还连接
T0 --> E: 子集外语句 / 分片不可用 R30
T1 --> I: COMMIT 成功 / ROLLBACK
E --> E: 数据语句与 COMMIT 返回 R13;其它请求返回各自拒绝码
E --> I: ROLLBACK
T1 --> T1: BEGIN → R40,状态不变
T0 与 T1 对客户端都显示为 T,区别只在是否已经钉住连接。进入 E 的那一刻,代理就在后端执行回滚并把连接还给池,后端事务已经结束,会话只是在等客户端确认。代理还加了一条自己的规则:E 状态下的 COMMIT 返回 R13。PostgreSQL 16 的 COMMIT 文档没有说明失败事务块中执行 COMMIT 会怎样。代理不模仿这一未写明的行为,要求客户端显式写出 ROLLBACK,误以为可以提交的客户端因此一定收到错误。
绑定检查在 src/Proxy.java 的 dml 方法里:
1 | |
同分片转账的四条语句落在同一个后端进程上,事务外的直接查询在提交前读到旧余额:
1 | |
后端错误也让事务进入失败状态:扣款违反 balance >= 0 约束时得到 R31 与 SQLSTATE 23514,随后的查询与 COMMIT 都是 R13,ROLLBACK 回到 I,余额不变。
跨分片事务:拒绝,以及不拒绝的后果
从 s0 账户转 100 到 s1 账户,在 strict 代理上的结果:
1 | |
s0IdleInTx before/after=1/0 来自 pg_stat_activity:贷方语句被拒之前,s0 上有一个处于 idle in transaction 的后端;拒绝之后为 0,扣款已在后端回滚。
负向对照用 --mode naive 启动另一个代理进程。它同样钉住首个分片的连接,但路由到其它分片的语句改在池化的自动提交连接上执行,不报错:
1 | |
客户端看到四个 OK,以为整笔转账都被回滚了。实际上扣款回滚、入账已经提交,系统总额多出 100。这种结果对客户端完全不可见,只有对账才能发现。Vitess 的 Multi 事务模式在文档中被写成“Can span multiple shards with best-effort commits … No atomicity”,并把它标为默认值(Vitess v24.0 分布式事务);naive 代理演示的正是“跨分片但无原子性”在中途回滚时的样子。
需要跨分片原子性时,有三条路径:
| 方案 | 代理需要承担的 | 失败时留下什么 | 本章取舍 |
|---|---|---|---|
| 拒绝跨分片事务(本章) | 绑定检查与 R12 | 无部分提交;业务需要把同一事务的数据放在同一分片,或改用下两种方案 | 采用 |
| 两阶段提交 | 事务管理器:持久记录全局事务、对各分片 PREPARE TRANSACTION、全部成功后 COMMIT PREPARED、崩溃后扫描并决断悬挂事务 |
管理器在准备与提交之间崩溃时,已准备的事务持续持锁 | 不做 |
| 业务补偿(Saga) | 无需代理参与;由业务层记录步骤并编排反向操作 | 中间状态对外可见,补偿本身也可能失败并需要重试 | 不在本章 |
两阶段提交在 PostgreSQL 上有现成的原语,但原语本身说明了它的成本。PREPARE TRANSACTION 执行后“the transaction is no longer associated with the current session”,它“is not intended for use in applications or interactive sessions”,目的是让外部事务管理器做跨库原子提交;准备好的事务“continues to hold whatever locks it held”,文档建议在没有事务管理器跟踪时把 max_prepared_transactions 保持为 0,而它的默认值正是 0(资源配置)。把这些放进代理,代理就要有自己的持久日志、恢复流程和超时决断,它从一个可以随时 kill -9 的无状态进程变成需要备份和高可用的协调者。Vitess 的 TwoPC 模式也要求在 tablet 上配置判定事务被遗弃的时长,并且文档说明它不提供跨分片隔离,跨分片查询可能看到正在提交中的部分结果。本章的验收目标是“不误切、显式拒绝”,两阶段提交超出这一目标。
会话参数:转发一次,泄漏给下一位
naive 代理还把子集外的会话语句原样转发到 s0 的池化连接,归还时不复位。对照实验把 s0 池限制为 1 条连接:
1 | |
客户端 1 设置 statement_timeout 后断开;客户端 2 是全新会话,却在同一个后端进程上读到 50ms。测试随后用一条直连事务锁住目标行,客户端 2 的普通 UPDATE 本应等待锁释放,结果在 82ms 时以 57014(语句取消)失败。SET 的作用域是会话,代理复用了会话,作用域就跨过了客户端。strict 代理对同一条 SET 返回 R25;同样的锁等待持续约 300ms,在锁释放后成功:
1 | |
另一种处理是允许 SET,在每次归还时执行 DISCARD ALL 把会话恢复初始状态,PgBouncer 的 server_reset_query 默认就是它;但 PgBouncer 文档同时说明,transaction pooling 下不使用这条复位语句,“because in that mode, clients must not use any session-based features”(config)。事务结束就换连接时,复位来不及保护事务之间的语句。本章的池只在归还时回滚残留事务,会话状态问题由拒绝规则在入口处解决。
客户端路由库还是独立代理
同样的路由逻辑可以做成应用内的库:
| 维度 | 客户端路由库 | 独立代理进程 |
|---|---|---|
| 网络跳数 | 应用直连分片 | 多一跳 |
| 连接数 | 每个应用实例 × 每个分片各一个池,随实例数增长 | 由代理实例数决定,可以集中限额 |
| 路由表一致性 | 每个实例各自加载,升级要覆盖所有语言与版本 | 代理实例少,版本集中 |
| 故障面 | 与应用同生共死 | 新增一个必须高可用的组件;代理崩溃时其上全部事务回滚 |
| 拒绝规则 | 可以在编译期或调用前检查 | 运行时按协议返回错误码 |
两者的绑定、拒绝和路由版本规则相同,差别在部署与运维。本章选独立进程,是为了能用 kill -9 检验“代理崩溃不留部分提交”,以及让 naive 与 strict 两种实现在同一组分片上并排运行。
故障与恢复
客户端在事务中断开:会话线程读到 EOF 后回滚钉住的连接并归还。pg_stat_activity 显示该后端从 idle in transaction 变为 idle,连接留在池中复用,行锁已释放:
1 | |
代理进程被 kill -9:测试先经新代理提交一笔 50 的转账,再开启一笔 100 的转账并停在两条 UPDATE 之后,然后杀掉代理。内核关闭了代理的套接字,PostgreSQL 读到连接结束,回滚并退出该后端:
1 | |
已提交的 50 保留,未提交的 100 没有出现。这里的回滚由 PostgreSQL 完成,与代理代码无关;代理能保证的是一个事务只在一个连接上,所以“连接断开即回滚”覆盖了整个事务。同一实验若换成 naive 代理,被拆到自动提交连接上的那一半在崩溃前已经提交。
分片不可用:先让一个事务钉在 s1,再停止 s1 所在的独立实例:
1 | |
钉住的事务收到 57P01(管理员命令终止连接)并进入 E。s1 的读写立即得到 R30,因为池在借出前校验空闲超过 500ms 的连接,校验失败后建连被拒,语句没有发出。区分 R30 与 R32 是为了重试:R30 表示语句没有到达后端,写操作可以安全重试;R32 表示写语句或 COMMIT 已经发出而连接断开,结果未知,只能先查询再决定。本次实验没有触发 R32。s0 的读写全部成功。实例重启后第一次请求就成功,池销毁了失效连接并新建一条。
路由版本在事务进行中变化:
图 21-3:路由版本变化时进行中的事务保持原目标
sequenceDiagram
participant C as 客户端
participant P as 代理
participant M as b2_21_meta
participant S0 as s0
participant S1 as s1
C->>P: BEGIN(快照 rv=1)
C->>P: UPDATE 账户 x
P->>S0: 借连接 pid=1457801,执行
M-->>P: 轮询读到 rv=2:x 所在桶 14 → s1
C->>P: UPDATE 账户 x
P->>S0: 仍按快照 rv=1,同一 pid
C->>P: COMMIT
P->>S0: COMMIT,归还连接
C->>P: SELECT 账户 x(自动提交)
P->>S1: 按 rv=2 路由,0 行(数据未迁移)
1 | |
事务内三条语句都在 s0、rv=1、同一 pid 上执行,提交后 s0 余额加 2;提交之后的查询和新事务都按 rv=2 去了 s1,在那里查不到这一行,因为实验只改了映射,没有搬数据。这条结果同时暴露了本章路由版本的边界:如果桶在某个事务 BEGIN 之后被迁走,这个事务仍会写旧分片。阻止这类迟到写入需要在分片端检查路由版本或设置写栅栏,这是第 23 章的内容。
用例与证据
| 用例 | 类型 | 观察值(evidence/21/output.txt) |
|---|---|---|
normal_autocommit_routing |
正常 | 40 行,s0/s1 各 20,错位 0;空事务不占用后端 |
reject_rules |
拒绝 | 37/37 码匹配,R02 两例,拒绝前后行数 20/20 |
boundary_in_list |
边界 | 同分片 IN 返回 2 行;跨分片 IN 得 R11 |
same_shard_transfer |
正常 | 4 条语句 1 个 pid;提交前外部读到 1000 |
backend_error_aborts_tx / boundary_nested_begin |
失败、边界 | 23514 → E → R13 → ROLLBACK;嵌套 BEGIN 得 R40 且事务可继续 |
cross_shard_tx_rejected |
拒绝 | R12,s0 未决事务 1 → 0,总额差 0 |
negative_control_naive_partial |
负向对照 | 借方差 0、贷方差 +100,总额差 +100 |
negative_control_naive_set_leak / strict_session_state_rejected |
负向对照 | 泄漏的 50ms,82ms 时 57014;strict 得 R25,等待 301ms 成功 |
client_disconnect_in_tx |
恢复 | idle in transaction → idle,16ms 内归还 |
route_version_change_in_tx |
恢复 | 事务内 rv=1、s0;提交后 rv=2、s1 |
invalid_route_table_rejected |
失败 | 缺桶 v4、回退 v3 均被拒,旧表继续服务 |
shard_unavailable_isolated |
故障 | s0 5/5 成功;s1 R30×5;重启后恢复 |
proxy_kill9_in_tx |
故障 | 退出码 137,后端 11ms 内消失,未提交转账不可见 |
运行入口为 examples/21-routing-proxy/run.sh,环境记录在 evidence/21/env.txt(PostgreSQL 16.15、OpenJDK 21.0.12.1、pgJDBC 42.7.4、4 核)。代理日志在 evidence/21/proxy-*.log,其中可以看到路由版本 v1 → v2 → v3、v4 被拒、v5、v3 被拒、v6 的完整序列,以及断开会话被回滚的记录。章节端口段落在本机临时端口范围内,首次运行时一个出站连接占用了 47212,代理因此加入了绑定重试,详见 LIMITS.md。
结论边界
这些实验支持的结论限于本章定义的子集:在两个真实 PostgreSQL 分片上,代理按版本化的桶映射路由单键语句;一个显式事务只使用一个后端连接,跨分片时显式拒绝而不是部分执行;子集外语句与会话状态语句在入口被拒,不会污染池化连接;客户端断开、代理被 kill -9、单个分片停止时都没有留下部分提交,故障范围限于对应会话或分片。
它们不支持以下说法:代理兼容 PostgreSQL 协议或任意 SQL(没有实现线协议,只认上文的语法);跨分片事务可以原子提交(本章拒绝它们);路由表切换时数据保持一致(实验只改映射,切换后新分片上查不到行);跨机器故障下后端会及时回滚(本地 kill -9 由内核立刻关闭套接字,掉电或网络分区要依赖 TCP keepalive 或 idle_in_transaction_session_timeout,未测试)。吞吐和延迟没有测量。
这个代理拒绝了所有不带分片键的查询。按 owner 查账户、跨分片分页列出账户,是下一篇 22 非分片键查询与全局分页 要解决的问题;路由表变化时怎样搬数据、怎样拦住迟到写入,留给第 23 章。





