系列导航:上一篇 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
2
3
4
Q	<sql>	<param1>	<param2>...        # 字段 TAB 分隔,各自 URL 编码
STATUS
OK tag=UPDATE shard=s0 rv=1 tx=T pid=1457801 count=1
ERR code=R12 tx=E rv=1 msg=...

响应里的 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
2
3
4
5
6
BEGIN | COMMIT | ROLLBACK
SELECT (* | col[, col]*) FROM account WHERE <key>
UPDATE account SET item[, item]* WHERE <key> item := col = ? | col = col (+|-) ?
DELETE FROM account WHERE <key>
INSERT INTO account (col[, col]*) VALUES (?[, ?]*) 必须包含 account_id,单行
<key> := account_id = ? [AND version = ?] | account_id IN (?[, ?]*)

拒绝按原因分码,便于客户端区分“改写请求即可”与“需要别的系统”:

代码 原因 代码 原因
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
2
3
4
if (!isWord("account_id")) throw new Reject("R10", "WHERE must start with account_id = ? or account_id IN (...)");
...
if (isWord("or") || isWord("and")) throw new Reject("R10", "additional condition '" + here() + "' not supported");
done();

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
CASE reject_rules                             PASS rules=37 matched=37 byCode={R01=5, R10=7, R20=2, R21=3, R22=2, R23=2, R24=3, R25=5, R26=3, R27=2, R28=2, R29=1} protocolR02=true rowsBefore=20/20 rowsAfter=20/20

其中包括 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
2
incomplete table v4 -> status rv=3 rejected_rv=4; query -> OK tag=SELECT shard=s1 rv=3 tx=I pid=1457793 count=1
backwards version v3 -> status rv=5 rejected_rv=3

缺桶的表如果被接受,落在桶 15 的请求会没有目标;版本回退如果被接受,一个已经迁走的桶会被路由回旧分片。两者都由加载器拒绝,代理继续用旧表服务。

40 个账户经代理插入后,直接查询两个分片核对位置:

1
CASE normal_autocommit_routing                PASS inserted=40 s0Rows=20 s1Rows=20 misplaced=0 wrongShardTag=0 caseInsensitiveSelect=[[1000]] emptyTx=T,I emptyTxPid=none

连接绑定与事务状态

自动提交语句按当前路由表选分片,从该分片的池里借一条连接,执行后归还。显式事务不同: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
2
3
4
5
6
7
8
9
10
RouteTable rt = tx == 'T' ? snap : table;          // 事务内始终使用 BEGIN 时的快照
...
if (bound == null) {
Connection c = pool(rt, shard).acquire(acquireMs);
c.setAutoCommit(false);
bound = c; boundShard = shard;
} else if (!shard.equals(boundShard)) {
if (naive) return autocommit(rt, shard, st, vals); // 对照缺陷
return fail("R12", "transaction is bound to " + boundShard + ", statement routes to " + shard + ...);
}

同分片转账的四条语句落在同一个后端进程上,事务外的直接查询在提交前读到旧余额:

1
2
3
4
5
  OK tag=UPDATE shard=s0 rv=1 tx=T pid=1457801 count=1
OK tag=UPDATE shard=s0 rv=1 tx=T pid=1457801 count=1
OK tag=SELECT shard=s0 rv=1 tx=T pid=1457801 count=1
OK tag=COMMIT shard=s0 rv=1 tx=I pid=1457801
CASE same_shard_transfer PASS txStates=T,T,T,T,I distinctBackendPids=1 pid=[1457801] inTxRead=[[900]] outsideReadBeforeCommit=1000 a=1000->900 b=1000->1100 totalUnchanged=true

后端错误也让事务进入失败状态:扣款违反 balance >= 0 约束时得到 R31 与 SQLSTATE 23514,随后的查询与 COMMIT 都是 R13,ROLLBACK 回到 I,余额不变。

跨分片事务:拒绝,以及不拒绝的后果

从 s0 账户转 100 到 s1 账户,在 strict 代理上的结果:

1
2
3
4
5
6
  OK tag=UPDATE shard=s0 rv=1 tx=T pid=1457801 count=1
R12 rv=1 tx=E msg=transaction is bound to s0, statement routes to s1; cross-shard transactions are not supported
R13 rv=1 tx=E msg=current transaction is aborted; only ROLLBACK is accepted
R13 rv=1 tx=E msg=current transaction is aborted; only ROLLBACK is accepted
OK tag=ROLLBACK rv=1 tx=I
CASE cross_shard_tx_rejected PASS debit=OK@s0 credit=R12/tx=E afterAbort=R13 commit=R13 rollback=OK s0IdleInTx before/after=1/0 a=1000->1000 c=1000->1000 totalDelta=0

s0IdleInTx before/after=1/0 来自 pg_stat_activity:贷方语句被拒之前,s0 上有一个处于 idle in transaction 的后端;拒绝之后为 0,扣款已在后端回滚。

负向对照用 --mode naive 启动另一个代理进程。它同样钉住首个分片的连接,但路由到其它分片的语句改在池化的自动提交连接上执行,不报错:

1
2
3
4
  OK tag=UPDATE shard=s0 rv=1 tx=T pid=1458105 count=1
OK tag=UPDATE shard=s1 rv=1 tx=T pid=1458113 count=1
OK tag=ROLLBACK rv=1 tx=I
CASE negative_control_naive_partial PASS expectPartialCommit allOk=true debitDelta=0 creditDelta=100 totalDelta=100

客户端看到四个 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
2
3
4
  naive client1 SET -> OK tag=PASSTHROUGH shard=s0 rv=1 tx=I pid=1458105 count=0
naive client2 SHOW -> OK tag=PASSTHROUGH shard=s0 rv=1 tx=I pid=1458105 count=1 rows=[[50ms]]
naive client2 UPDATE on locked row -> R31 shard=s0 rv=1 tx=I sqlstate=57014 elapsed=82ms
CASE negative_control_naive_set_leak PASS expectLeak client2SeesStatementTimeout=[[50ms]] sameBackendPid=true client2Update=R31/57014 elapsed=82ms

客户端 1 设置 statement_timeout 后断开;客户端 2 是全新会话,却在同一个后端进程上读到 50ms。测试随后用一条直连事务锁住目标行,客户端 2 的普通 UPDATE 本应等待锁释放,结果在 82ms 时以 57014(语句取消)失败。SET 的作用域是会话,代理复用了会话,作用域就跨过了客户端。strict 代理对同一条 SET 返回 R25;同样的锁等待持续约 300ms,在锁释放后成功:

1
CASE strict_session_state_rejected            PASS set=R25 updateAfterLockRelease=OK waited=301ms

另一种处理是允许 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
CASE client_disconnect_in_tx                  PASS backendPid=1457801 before=idle in transaction(xid) afterDisconnect=idle withinMs=16 pool_s0=idle:1,borrowed:0,created:1,destroyed:0 balanceUnchanged=true rowLockFree=true

代理进程被 kill -9:测试先经新代理提交一笔 50 的转账,再开启一笔 100 的转账并停在两条 UPDATE 之后,然后杀掉代理。内核关闭了代理的套接字,PostgreSQL 读到连接结束,回滚并退出该后端:

1
CASE proxy_kill9_in_tx                        PASS proxyExit=137 backendPid=1458486 before=idle in transaction(xid) backendGoneMs=11 client=EOFException a=1000->950 b=1000->1050 committedKept=true uncommittedVisible=false rowLocksFree=true

已提交的 50 保留,未提交的 100 没有出现。这里的回滚由 PostgreSQL 完成,与代理代码无关;代理能保证的是一个事务只在一个连接上,所以“连接断开即回滚”覆盖了整个事务。同一实验若换成 naive 代理,被拆到自动提交连接上的那一半在崩溃前已经提交。

分片不可用:先让一个事务钉在 s1,再停止 s1 所在的独立实例:

1
2
3
  bound tx next statement -> R31 rv=6 tx=E sqlstate=57P01 | OK tag=ROLLBACK rv=6 tx=I
s1 write while down -> R30 shard=s1 rv=6 tx=I; s0 write -> OK tag=UPDATE shard=s0 rv=6 tx=I pid=1457801 count=1
CASE shard_unavailable_isolated PASS boundTx=R31/57P01/tx=E s0Reads=5/5 s1Reads=R30x5 s1FailMaxMs=2 s1Write=R30 s0Write=OK recoveredAfterMs=152 boundTxUpdateRolledBack=true

钉住的事务收到 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
CASE route_version_change_in_tx               PASS bucket=14 reloadSeenMs=62 inTx(shard/rv)=s0/1,s0/1,s0/1 samePid=true commit=OK s0Balance=1000->1002 afterCommit=s1/rv2/rows0 newTx=s1/rv2 restoredV3=s0/rows1

事务内三条语句都在 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 章。