数据库基础体系 · 第 133/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

数据库代理与网关:连接复用、读写路由、分片、审计和故障边界

数据库代理(database proxy)是位于应用与数据库之间、能够建立数据库协议连接并转发请求的中间层。数据库网关(database gateway)通常包含更宽的职责:除了连接转发,还可能负责认证、租户隔离、读写路由、分片路由、审计、限流、故障转移和协议适配。

两者在工程中经常由同一个组件承担,但概念边界不同:

  • 代理关注“请求如何到达某个数据库连接”;
  • 网关还关注“请求是否允许、应该去哪里、以什么身份执行、失败后能否重试”。

代理不会自动改变数据库的事务语义、锁语义和复制一致性。它只是增加了一个必须被纳入正确性分析的状态层和故障边界。


一、先建立端到端模型

一个请求通常经过以下路径:

应用线程
  │
  │ 逻辑连接、SQL、参数、事务操作
  ▼
数据库代理/网关
  │
  │ 认证、连接池、路由、审计、限流
  ▼
后端数据库连接
  │
  ├── 主库
  ├── 只读副本
  └── 某个分片

这里至少存在三种不同的“连接”概念:

  1. 应用逻辑连接:应用从客户端驱动获得的连接对象。
  2. 代理前端连接:代理与应用之间的 TCP 或数据库协议连接。
  3. 数据库后端连接:代理与某个数据库实例之间的实际协议连接。

如果代理只做一对一转发,那么一个前端连接通常对应一个后端连接,代理的价值主要是统一入口和路由。

如果代理支持连接复用,多个前端请求可以先后使用同一个后端连接,甚至在某些协议和事务边界下并发使用不同后端连接。此时,前端连接中的状态不能任意保留在后端连接上,否则后一个请求会观察到前一个请求留下的状态。

因此,最重要的抽象不是“代理有多少连接”,而是:

前端会话状态
后端会话状态
请求/事务是否允许在连接之间迁移

二、连接复用:减少连接数量,不等于增加数据库并发能力

2.1 为什么需要复用

数据库连接通常不是廉价对象。建立连接可能包含:

  • TCP 建连;
  • TLS 握手;
  • 用户认证;
  • 数据库进程或线程分配;
  • 会话参数初始化;
  • 权限和默认对象解析。

如果应用实例很多,且每个应用实例都维护较大的连接池,就可能出现:

应用实例数 × 每实例连接池上限

远大于数据库可承受后端连接数的情况。

例如:

应用实例:100 个
每实例连接池上限:20
理论前端连接:2000
数据库最大后端连接:100

代理可以把 2000 个前端连接映射到不超过 100 个后端连接。但这不意味着 2000 个请求可以同时在数据库中执行;同一时刻真正占用数据库执行资源的请求仍受后端连接数、CPU、锁和磁盘吞吐限制。

连接复用解决的是:

  • 后端连接建立成本;
  • 后端连接总数;
  • 多个应用池造成的连接峰值;
  • 空闲前端连接对数据库连接资源的占用。

它不能直接解决:

  • 慢 SQL;
  • 锁等待;
  • 数据库 CPU 饱和;
  • 事务持锁时间过长;
  • 连接池配置过大导致的排队和雪崩。

2.2 三种常见复用边界

会话级复用

一个前端连接长期绑定一个后端连接:

前端 A ───── 后端 1
前端 B ───── 后端 2

优点是语义最接近直接连接数据库,以下状态通常可以保留:

  • 当前事务;
  • 临时表;
  • 预处理语句;
  • 会话变量;
  • 游标;
  • PostgreSQL 的 session-level advisory lock;
  • MySQL 的临时表和会话变量。

代价是复用能力弱:前端连接空闲时,后端连接也可能被占用。

事务级复用

代理在事务结束后归还后端连接:

前端 A: BEGIN ─ SQL ─ COMMIT
                         │
                         ▼
                    后端 1 归还池中

前端 B: BEGIN ─ SQL ─ COMMIT
                         │
                         ▼
                    可能复用后端 1

一个事务内的语句必须固定在同一个后端连接上,否则事务上下文无法成立;事务结束后才允许迁移。

这种模式能显著提高复用率,但要求事务外的会话状态不可依赖,或者代理能够正确重建状态。

典型风险包括:

-- PostgreSQL
SET search_path TO tenant_a;
CREATE TEMP TABLE t(x int);
PREPARE q AS SELECT * FROM t;

-- MySQL
SET @tenant_id = 10;
CREATE TEMPORARY TABLE t(x INT);

如果这些操作发生在事务外,后续请求可能被分配到另一个后端连接,从而观察不到预期状态。即使操作在事务内,事务结束后临时表等会话对象仍然可能继续存在于后端连接上;代理若再次把该连接分给别的租户,就会产生状态污染或信息泄漏。

语句级复用

每条语句执行前临时取得后端连接,语句结束后归还。

这种模式只适用于能够证明每条语句独立、没有事务上下文依赖的场景。事务、多语句过程、游标和会话状态都使它变得危险。

不能把“一个 SQL 请求”简单理解成“一条 SQL 字符串”。数据库协议可能包含:

  • 解析;
  • 绑定参数;
  • 执行;
  • 读取结果;
  • 关闭门户或语句。

代理如果错误地在这些阶段之间切换后端连接,协议和语义都可能被破坏。

2.3 连接复用的必要条件

设一个请求包含后端可观察状态集合 SS。如果请求结束后状态仍可能影响后续请求,则不能安全复用,除非代理能够把状态恢复到标准值。

可安全复用的充分条件可以写成:

Safter request=SbaselineS_{\text{after request}} = S_{\text{baseline}}

其中:

  • Safter requestS_{\text{after request}} 是请求完成后的后端会话状态;
  • SbaselineS_{\text{baseline}} 是代理约定的初始状态。

如果不满足这个条件,则需要:

  1. 保持前端与后端绑定;
  2. 在释放连接前执行清理;
  3. 在分配连接时重新设置全部状态;
  4. 禁止依赖该状态的功能。

仅执行一个通用的 ROLLBACK 并不能清除所有会话状态。它通常只能结束当前事务,不能自动删除临时表、撤销所有会话变量、释放所有会话级锁或恢复所有会话参数。

2.4 连接池容量与排队

设:

  • CC:可用后端连接数;
  • RR:平均每个后端连接的完成速率;
  • λ\lambda:请求到达速率。

理论上,稳定运行至少要求:

λ<C×R\lambda < C \times R

如果事务平均持有后端连接时间为 TT,则单个后端连接的平均服务率近似为:

R=1TR = \frac{1}{T}

因此:

CλTC \gtrsim \lambda T

例如,事务平均持有后端连接 20 ms,即 T=0.02T=0.02 秒;到达率为每秒 3000 个事务:

C3000×0.02=60C \gtrsim 3000 \times 0.02 = 60

这只是容量估算,不是性能保证。锁等待、事务分布不均、长尾延迟和数据库内部资源都会使实际需要更高的余量。

CC 个后端连接全部忙碌时,新请求只能:

  • 在代理队列等待;
  • 被代理拒绝;
  • 让应用连接池继续等待;
  • 触发上游超时。

如果应用、代理和数据库各自都有排队,就会形成多层排队。总延迟大致是:

L=L应用池+L代理队列+L网络+L数据库执行L = L_{\text{应用池}} + L_{\text{代理队列}} + L_{\text{网络}} + L_{\text{数据库执行}}

排队超时后再无脑重试,会把原本一个请求变成多个数据库请求,形成重试风暴。


三、读写路由:语法分类只是开始

3.1 路由的基本目标

读写路由通常希望实现:

写操作、强一致读 ──> 主库
允许延迟的读     ──> 只读副本

但“读”不等于“可以去副本”。

一个查询是否能发往副本,至少取决于:

  1. SQL 是否修改数据;
  2. 当前事务是否已经包含写操作;
  3. 查询是否要求读到刚刚提交的数据;
  4. 副本是否已经追上主库;
  5. 副本是否处于可服务状态;
  6. 查询是否依赖会话状态或临时对象。

3.2 SQL 分类的限制

代理可以通过协议消息、SQL 解析器或关键字进行分类。例如:

SELECT * FROM orders WHERE id = 10;
INSERT INTO orders(id, amount) VALUES (10, 99);
UPDATE orders SET amount = 100 WHERE id = 10;

显然,INSERTUPDATE 不能发往只读副本。但以下语句不能只靠第一个关键字判断:

WITH changed AS (
    UPDATE orders
    SET amount = amount + 1
    WHERE id = 10
    RETURNING *
)
SELECT * FROM changed;

在 PostgreSQL 中,数据修改语句可以出现在 WITH 中。存储过程、函数、触发器也可能修改数据:

SELECT process_order(10);

仅看外层是 SELECT,不能证明它是只读操作。

此外,某些读操作有副作用或会依赖事务锁定:

SELECT * FROM orders WHERE id = 10 FOR UPDATE;

它不是普通只读查询,必须在允许加锁的主库上执行。

因此,路由器通常采用以下策略之一:

  • 只把明确安全的查询发往副本;
  • 遇到无法确定的 SQL 默认发主库;
  • 要求应用显式声明读一致性等级;
  • 使用事务标记或连接属性控制路由。

默认发主库牺牲部分读扩展性,但更容易保证正确性。

3.3 事务是路由的基本边界

考虑以下 PostgreSQL 事务:

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

SELECT balance
FROM accounts
WHERE id = 1;

COMMIT;

第二条 SELECT 必须看到同一事务中自己的更新。即使路由器知道它是 SELECT,也不能把它切到副本。

事务的路由状态可以表示为:

空闲
  │ BEGIN
  ▼
事务已开始、尚未写入
  │ 发现写操作
  ▼
事务已写入,固定主库
  │ COMMIT / ROLLBACK
  ▼
空闲

一旦事务中执行了写操作,后续语句通常应固定在同一个主库连接上,直到 COMMITROLLBACK

即便事务只读,也不能自动认为副本可用。PostgreSQL 和 MySQL 的事务隔离、快照建立时机以及副本回放状态都必须纳入判断。

3.4 复制延迟造成的读后写不一致

设:

t0: 客户端向主库写入订单 order_id=10
t1: 主库返回 COMMIT 成功
t2: 复制日志尚未回放到副本
t3: 客户端向副本查询 order_id=10

如果 t2>t3t2 > t3,查询可能返回“不存在”。

这不是数据库违反了主库事务提交语义,而是客户端先后访问了两个不同的复制状态:

副本状态(t3)<主库已提交状态(t1)\text{副本状态}(t_3) < \text{主库已提交状态}(t_1)

常见解决办法有三类。

会话粘滞

写成功后,在一段时间内把该客户端的读路由到主库。

它简单,但“时间窗口”只是经验参数:如果副本延迟超过窗口,问题仍会出现。

复制位置等待

主库提交后返回一个可表示提交位置的标记;读副本前,等待副本回放到至少该位置。

抽象条件为:

replay_positionreplicacommit_positionprimary\text{replay\_position}_{\text{replica}} \geq \text{commit\_position}_{\text{primary}}

这是比固定时间等待更准确的方法,但要求数据库、复制协议和代理能够暴露并处理对应位置。

应用声明一致性要求

例如:

强一致读:主库或已确认追平的副本
最终一致读:任意健康副本
写后读:携带上一次写入的位置要求

这比试图从 SQL 猜测业务意图更可靠。

3.5 一个常见反例

请求 A:
  INSERT user(id=1)
  COMMIT

请求 B:
  SELECT user WHERE id=1

如果请求 A 和 B 使用不同的应用线程、不同的前端连接,且代理只做“写主库、读副本”的语句分类,那么 B 很可能被路由到尚未追平的副本。

“同一个用户”或“同一个 HTTP 会话”对数据库并不可见,除非网关显式维护粘滞状态或应用传递一致性标记。


四、分片:路由键决定可扩展性,也决定查询边界

4.1 什么是分片

分片(sharding)是把一个逻辑数据集拆到多个独立数据库或数据库实例上。每个实例保存完整数据的一个子集。

设分片函数为:

s=h(k)modNs = h(k) \bmod N

其中:

  • kk 是分片键;
  • hh 是哈希函数;
  • NN 是分片数量;
  • ss 是目标分片编号。

例如以 user_id 分片,四个分片的路由为:

user_id=10: 10 mod 4 = 2  -> shard_2
user_id=11: 11 mod 4 = 3  -> shard_3

代理必须从 SQL 参数、协议绑定参数或应用元数据中取得 kk。如果查询没有分片键:

SELECT * FROM orders WHERE status = 'PAID';

代理无法确定唯一目标,只能:

  1. 广播到所有分片;
  2. 由路由表推断;
  3. 拒绝查询;
  4. 发往包含全局索引的专用节点。

广播查询的结果需要在代理层合并,可能涉及排序、分页、聚合和去重。

4.2 分片键不是普通索引列

良好的分片键需要同时满足:

  • 查询经常带上它;
  • 数据分布较均匀;
  • 相关事务尽量落在同一分片;
  • 生命周期和迁移策略可接受;
  • 不会形成单个热点分片。

例如,以 tenant_id 分片通常有利于租户隔离,但若一个租户远大于其他租户,会造成热点。以递增时间分片可能使最新分片成为写热点。

4.3 跨分片查询的执行过程

假设按 user_id 分四片,执行:

SELECT user_id, SUM(amount) AS total
FROM orders
WHERE user_id IN (10, 11, 12)
GROUP BY user_id;

代理可以先计算:

10 -> shard_2
11 -> shard_3
12 -> shard_0

然后改写为三个子查询:

-- shard_2
SELECT user_id, SUM(amount) AS total
FROM orders
WHERE user_id IN (10)
GROUP BY user_id;

-- shard_3
SELECT user_id, SUM(amount) AS total
FROM orders
WHERE user_id IN (11)
GROUP BY user_id;

-- shard_0
SELECT user_id, SUM(amount) AS total
FROM orders
WHERE user_id IN (12)
GROUP BY user_id;

最后代理合并结果。

对于 SUM,可以先分片局部聚合,再做全局求和;但并非所有 SQL 都能这样安全改写。

COUNT(*)

可以把各片结果相加:

countglobal=icounti\text{count}_{global} = \sum_i \text{count}_i

MINMAX

可以取各片局部结果的最小值或最大值。

AVG

不能直接平均各片的平均值,除非每片行数相同。正确做法是同时汇总:

avgglobal=isumiicounti\text{avg}_{global} = \frac{\sum_i \text{sum}_i}{\sum_i \text{count}_i}

ORDER BY ... LIMIT

各分片只取全局 LIMIT 行可能错误。正确策略通常是各片取足够的候选集,再在代理端合并排序;但候选数量、内存和网络开销会增加。

4.4 分片事务的边界

单分片事务可以保持普通数据库事务语义:

BEGIN;
UPDATE orders SET amount = 100 WHERE user_id = 10 AND order_id = 1;
INSERT INTO audit_log(...) VALUES (...);
COMMIT;

前提是两张表的相关行都位于同一分片。

跨分片事务则不同:

shard_0: 扣减库存
shard_1: 创建订单

如果 shard_0 成功、shard_1 失败,普通的本地 COMMIT 无法自动回滚另一片。

分布式事务通常需要协调者和两阶段提交:

协调者 -> 所有参与者:PREPARE
参与者 -> 协调者:prepared
协调者 -> 所有参与者:COMMIT

故障时可能出现参与者已经 prepared、但尚未收到最终 COMMIT 的状态。协调者日志、超时恢复和参与者查询都变得必要。

如果业务允许,常见替代方案是:

  • 把强一致操作设计在同一分片;
  • 使用消息和补偿事务;
  • 使用幂等状态机;
  • 接受暂时不一致,再异步收敛。

这里不能把“代理同时向多个分片发 SQL”称为事务。并发发送只解决执行效率,不自动提供原子性。

4.5 扩容与分片函数变化

直接把 N=4N=4 改成 N=8N=8 会导致大量键迁移:

旧:h(k) mod 4
新:h(k) mod 8

绝大多数键的目标都可能改变。扩容通常需要:

  1. 建立新分片;
  2. 确定迁移范围;
  3. 复制或导出数据;
  4. 处理迁移期间的增量写入;
  5. 校验行数、校验和或业务结果;
  6. 切换路由;
  7. 保留回滚或旧路由能力。

一致性哈希、虚拟槽位和路由表可以降低一次迁移的范围,但不能消除迁移期间的双写、读旧数据、重复写入和切换失败问题。


五、审计:记录“谁做了什么”,而不是只保存 SQL 字符串

审计的目标通常包括:

  • 追踪身份;
  • 记录访问对象;
  • 记录操作时间和结果;
  • 支持合规调查;
  • 发现异常访问;
  • 分析高风险 SQL。

一条可用的审计事件至少应区分:

真实用户身份
应用服务身份
代理身份
数据库登录身份
租户身份
请求 ID / Trace ID
客户端地址
目标实例和分片
SQL 模板
绑定参数或参数摘要
事务标识
执行结果
耗时、返回行数、错误码

5.1 代理审计与数据库审计不是同一件事

代理能看到应用发来的协议请求,但看不到所有数据库内部行为。例如:

SELECT process_order(10);

代理可能只记录这条调用;函数内部执行了哪些 SQL,只有数据库侧审计或数据库日志才可能完整记录。

同样,数据库内部还可能产生:

  • 触发器执行;
  • 级联删除;
  • 后台任务;
  • 管理员直接连接;
  • 复制线程;
  • 数据库自身的 DDL 或维护操作。

因此完整审计通常需要分层:

应用审计:业务动作和真实用户
代理审计:入口、路由、协议请求
数据库审计:实际数据库操作和内部行为
基础设施审计:实例、网络和运维操作

5.2 参数与隐私

只记录 SQL 模板:

SELECT * FROM users WHERE email = ?;

有利于聚合分析,但无法直接还原具体访问对象。完整记录参数又可能把密码、令牌、身份证号等敏感信息写入日志。

可采用:

  • 对敏感参数脱敏;
  • 对参数做不可逆摘要;
  • 对高风险字段按权限加密;
  • 单独记录参数是否存在而非具体值;
  • 让应用提供业务对象 ID 和请求 ID。

审计日志本身是敏感数据,必须有访问控制、保留周期、完整性保护和容量规划。开启审计后,日志写入、序列化和网络传输也会增加延迟;不能在高峰期无评估地记录完整结果集。


六、认证、权限和租户隔离

代理前端认证成功,不等于后端数据库已经完成正确的权限判断。

6.1 两种身份模型

统一数据库身份

所有应用请求使用一个数据库用户:

用户 A ─┐
用户 B ─┼─> proxy ─> db_user_app
用户 C ─┘

优点是连接管理简单;缺点是数据库只能看到统一身份,租户权限必须由代理或应用保证。

身份透传

不同用户或服务使用不同数据库身份,或者代理把身份映射到数据库可识别的会话属性。

这样数据库权限更细,但连接复用要求更高:后端连接归还前必须清理身份相关状态,且代理不能把前一个身份的会话状态带给后一个身份。

6.2 租户隔离的必要条件

以下做法不能单独构成可靠隔离:

SELECT * FROM orders WHERE tenant_id = 10;

如果 SQL 是应用拼接的,存在注入或漏加条件的风险。更强的方案包括:

  • 数据库原生行级安全机制;
  • 每租户独立数据库身份;
  • 每租户独立分片;
  • 代理强制解析并注入条件;
  • 应用层与数据库层双重校验。

代理改写 SQL 必须考虑嵌套查询、CTE、别名、视图、函数和参数绑定。简单的字符串替换很容易漏掉路径或改变语义。


七、故障边界:代理不是数据库高可用的替代品

增加代理后,系统至少多出以下故障边界:

应用 -> 代理
代理内部队列和路由状态
代理 -> 数据库
主库 -> 副本复制链路
分片之间的协调关系

每条边界都可能独立失败。

7.1 连接建立失败与请求执行失败

必须区分:

请求尚未到达数据库

例如代理在排队时断开、连接池获取失败。此时通常没有数据库副作用,重试相对安全。

请求已发送但结果丢失

例如数据库已经提交事务,代理在返回响应前崩溃:

代理 -> 数据库:COMMIT
数据库:提交成功
数据库 -> 代理:响应
代理:在收到响应前断开
客户端:只看到连接错误

客户端无法仅根据连接错误判断事务是否提交。此时无脑重试可能造成重复扣款、重复创建订单等副作用。

安全做法是使用业务幂等键:

INSERT INTO payments(request_id, user_id, amount)
VALUES ('req-001', 10, 100);

并对 request_id 建立唯一约束。重试时:

  • 若第一次未提交,第二次成功插入;
  • 若第一次已提交,第二次触发唯一约束,应用查询原记录并返回相同业务结果。

幂等键必须由数据库约束或等价的原子机制保护,不能只依赖应用先查询再插入,因为并发下会产生竞态。

7.2 断线重连不能自动恢复事务

以下事务执行到一半时连接断开:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 连接断开
COMMIT;

客户端无法假设事务已回滚,也无法假设更新已提交。不同数据库和断线时机下,服务器可能尚未收到语句、已执行但未提交,或已经提交而响应丢失。

代理可以自动重连并重放某些只读请求,但对写请求和事务不能仅凭网络错误重放。

7.3 主库故障转移

读写路由中的故障转移通常包含:

  1. 健康检查发现主库不可用;
  2. 判断副本是否足够新;
  3. 选举或指定新主库;
  4. 更新代理路由状态;
  5. 让旧主库不能继续接受写入;
  6. 让客户端重新建立连接。

第 5 步是防止脑裂的关键。若旧主库网络隔离但仍认为自己是主库,代理把一部分写请求发给旧主库、另一部分发给新主库,就会产生分叉数据。

健康检查也不能只检查 TCP 端口。一个实例可能端口可连接,但:

  • 数据库无法执行新事务;
  • 磁盘已满;
  • 复制严重落后;
  • 只读状态不符合预期;
  • 锁或连接资源耗尽。

路由健康应至少包含角色、可写性、复制进度和实际查询能力。

7.4 代理自身的高可用

单个代理成为所有应用的唯一入口后,它就是新的单点。常见部署是多个无状态代理实例:

应用
 │
 ├── proxy-1
 ├── proxy-2
 └── proxy-3
       │
       └── 数据库集群

但“无状态”不是绝对的。代理可能保存:

  • 后端连接池;
  • 路由缓存;
  • 主从角色状态;
  • 分片元数据;
  • 会话粘滞信息;
  • 审计缓冲区;
  • 分布式事务协调状态。

代理进程重启通常会使前端连接和后端连接同时断开。应用必须能够重新获取连接;未完成事务不应被假定为可恢复。


八、诊断:先定位在哪一层排队或失效

遇到“数据库慢”时,不能只看数据库 CPU。应沿请求路径分层观察。

8.1 连接层指标

关注:

前端连接数
后端连接数
后端连接池使用率
等待可用后端连接的请求数
连接建立速率
连接失败数
空闲连接数量
事务中连接数量

如果前端连接很多、后端连接很少,但代理队列很长,问题可能是后端容量不足,而不是连接复用失效。

如果后端连接很多、数据库 CPU 和锁等待很高,继续增大连接池通常会使问题更严重。

8.2 路由层指标

需要记录或统计:

主库请求数
副本请求数
广播查询数
无法解析的 SQL 数
因事务固定主库的请求数
副本延迟
路由切换次数
路由错误和回退次数

尤其要识别“读请求被发送到副本,但业务要求读后写一致”的情况。

8.3 数据库层验证

在 PostgreSQL 中,可以查看当前连接和活动查询:

SELECT pid,
       usename,
       application_name,
       client_addr,
       state,
       wait_event_type,
       wait_event,
       query
FROM pg_stat_activity;

在 MySQL 8.4 中,可以查看线程和当前语句:

SHOW FULL PROCESSLIST;

这些命令的结果只能说明数据库当前看到的连接和查询。若代理采用事务级复用,数据库看到的用户、客户端连接和应用逻辑请求可能不是一一对应关系,因此应通过连接初始化信息、请求 ID 或代理日志建立关联。

8.4 审计与慢查询的交叉验证

一个请求耗时很长,至少要区分:

等待应用连接池
等待代理后端连接
等待数据库锁
数据库实际执行
等待结果传输
代理审计写入

若数据库日志显示执行时间很短、应用却超时,问题可能在代理队列、网络或结果传输。若代理记录 SQL 已发送但没有返回,应结合数据库活动查询判断它是在执行、等待锁,还是已经结束但响应链路失败。


九、几个容易混淆的结论

“代理池越大越好”

错误。后端连接过多会增加:

  • 内存和进程/线程开销;
  • 上下文切换;
  • 锁竞争;
  • CPU 调度压力;
  • 故障时同时重连的冲击。

容量应由数据库可承受的并发、事务持有时间和排队目标共同决定。

“SELECT 都可以去副本”

错误。SELECT 可能:

  • 调用写函数;
  • 使用 FOR UPDATE
  • 依赖刚提交的数据;
  • 依赖当前事务中的写入;
  • 依赖尚未同步的会话状态。

“连接池已经保证事务安全”

错误。连接池只能管理连接资源。事务安全还要求:

  • 事务边界明确;
  • 一个事务不跨后端连接;
  • 连接释放前没有未提交事务;
  • 会话状态不会污染后续请求;
  • 驱动和代理对预处理协议的支持一致。

“分片后每个 SQL 都还能像单库一样执行”

错误。跨分片的连接、事务、排序、聚合、唯一约束和外键都需要重新定义。代理可以隐藏部分路由细节,但不能凭空创造全局索引和全局原子性。

“代理审计已经覆盖所有数据库行为”

错误。代理看到的是入口请求;函数、触发器、管理员直连和数据库内部任务需要数据库侧审计或日志补充。

“连接断开后重试即可”

错误。对于只读、幂等或明确未发送的请求,重试相对安全;对于提交结果未知的写事务,必须使用幂等设计、业务查询或人工恢复流程。


十、如何划分代理的职责

一个可验证的边界通常如下:

应用:
  事务边界、业务幂等、读一致性要求、分片键提供

代理/网关:
  连接复用、连接数控制、明确规则下的路由、入口审计、限流

数据库:
  约束、事务、锁、权限、数据落盘、复制和恢复

复制/集群系统:
  角色管理、故障转移、复制进度、脑裂防护

运维系统:
  配置发布、监控、审计保留、备份和演练

代理适合执行“机械且可验证”的策略,例如:

  • 根据显式分片键路由;
  • 根据事务状态固定主库;
  • 拒绝没有分片键的高风险查询;
  • 限制单租户连接数;
  • 记录请求和路由结果;
  • 在明确的错误条件下摘除故障节点。

代理不适合承担无法证明的推断,例如:

  • SELECT 关键字推断绝对只读;
  • 从网络断开推断事务未提交;
  • 从某个副本 TCP 可连接推断它已经追平;
  • 用字符串替换实现复杂 SQL 的安全改写;
  • 用一个本地事务模拟跨分片原子事务。

数据库代理与网关的核心价值,是把连接、路由、治理和故障处理集中到一个可观测入口;它的核心风险,也正来自这里:原本由应用直接面对数据库的状态、时序和错误,现在被代理重新组合。只有明确前端会话、后端会话、事务、复制位置、分片参与者和提交结果的边界,连接复用、读写路由、分片与审计才能在不改变数据正确性的前提下发挥作用。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。