编程 MaxScale 25.01 readwritesplit:causal_reads 七个模式,与 transaction_replay 重复写两次的边界

2026-10-22 00:03:41

readwritesplit 的两个隐性代价:causal_reads 读到旧数据与 transaction_replay 重复提交

一次 1 主 1 从的复现:causal_reads=global 下执行

insert into test.test(id) values (1) returning 1;
select count(*);

第二条 SQL 落到从库,读到 0。原因是 INSERT ... RETURNING 返回 resultset 时 readwritesplit 没有更新 GTID,后续 SELECT 的 MASTER_GTID_WAIT 前缀等的是再前一个事务的 GTID。这是 MXS-5527,24.02.4 及更早版本存在。

另一个切面:COMMIT 已经在途,连接断了。代理不知道这个事务是否已提交,重放就可能写两次。transaction_replay_safe_commit 默认 true 就是为了不重放这类事务。

读写分离代理的判定成本,大部分不在“写走主库、读走从库”这条规则上,而在这两个地方暴露出来。

代理层职责的边界

PgBouncer 是连接池,不解析 SQL,不做读写路由:它把多条客户端连接多路复用到更少的后端连接,但只连一个库。读写路由是另一层。MySQL/MariaDB 侧是 ProxySQL(正则规则驱动)和 MaxScale readwritesplit(解析 SQL 后分类)。

MaxScale 单进程多线程,一个实例可用满一台机器的核;PgBouncer 单线程,一个进程一个核,要横向起多进程 + SO_REUSEPORT/负载均衡。

参考:readwritesplit 文档、25.01 release notes、MaxScale 仓库。

readwritesplit 的默认行为

  • 路由器解析每条语句,写(DML/DDL/写事务内语句)发主库,普通 autocommit SELECT 发从库并做负载均衡。
  • 事务不可拆分:BEGIN 之后所有语句钉在同一台服务器上;含写的事务整段钉主库,事务内的只读 SELECT 也在主库跑。ORM 默认的“每请求一个事务”会让从库空转。
  • 写意图读必须走主库:SELECT ... FOR UPDATE / FOR SHARE、写 CTE(WITH ... AS (INSERT ... RETURNING ...))、可能写数据的函数/存储过程。规则代理里漏掉这类规则是静默错误。
  • max_slave_replication_lag:超过该延迟(秒)的从库会被路由器忽略,不接只读流量。

causal_reads:七种模式

causal_reads 类型 enum,可选 none / local / global / fast / fast_global / universal / fast_universal,动态可改,默认 none。

  • local:写后本连接可见,每次写同步一次,无额外延迟增量(同步在写路径上);只保证发起写的那个连接看到自己的写。
  • global:全局可见,同一 service 的其他连接也能看到;每次读要同步一次,延迟中等;多 domain GTID 从 2.5.15 起安全;GTID 坐标永不重置,复制重置后坐标“倒退”不会被识别为更新——MaxScale 6.4.11 加了 reset-gtid 模块命令(maxctrl call command readwritesplit reset-gtid),否则要重启。
  • fast:完全不做同步,只把读发给“已知已复制到该 GTID”的从库;没有从库追上就用主库。会提高读延迟表现但牺牲读扩展性。causal_reads_timeout 在此模式被忽略。复制状态由 mariadbmon 在每次监控时更新,所以 monitor_interval 决定了这个模式的准确度。
  • fast_global:同 fast,但用全局 GTID 而非会话本地 GTID。
  • universal:最严格。每次读之前到主库读一次 @@gtid_current_pos,保证读到“收到请求时已可见”的事务。代价是每个 SELECT 多一次到主库的往返,延迟约增加两倍网络 RTT,且主库多一条 SELECT 负载。
  • fast_universal:universal 的保证 + fast 的策略:没有追上来的从库就直接给主库。恒定一次额外往返,写负载中等时会把流量大量推回主库。

fast / fast_global / fast_universal 只适合几乎只读、偶尔写的负载;混合或写多的负载下,流量会几乎全部回主库。

实现与副作用

基于 MASTER_GTID_WAIT 函数 + 服务端状态变量追踪。文本协议是把同步语句前缀插进原 SQL:

SET @maxscale_secret_... = MASTER_GTID_WAIT('0-1-100', 10);
SELECT ...;

同步失败则该查询不在从库执行,回原事务的主库重试。二进制协议 prepared statement 是单独发一个 packet 做同步,与语句执行同时发出,以保持性能;同步超时会 KILL 掉连接再回主库重试,否则后续 prepared statement 会在从库上被执行。

24.02 起不再要求后端配置 session_track_system_variables(自动加 last_gtid);25.10 起会先检查服务端是否已追踪 last_gtid,已追踪就不再发 SET。

副作用:

  • 启用 causal_reads 会在协议层打开 multi-statement(等价 Connector/J 的 allowMultiQueries=true、CLIENT_MULTI_STATEMENTS),因为要把同步语句和业务语句一起发。25.01 有一条修复记录 MXS-5507:readwritesplit enables multi-statements regardless of the state of causal_reads。
  • 报文字节上限:原 SQL + 前缀超过 16777213 字节(max_allowed_packet 上限)时不做因果读,退化为普通读。只影响文本协议。
  • START TRANSACTION READ ONLY 里的因果读失败会直接返回错误(旧版本会尝试在主库重试并关连接 + warning)。
  • INSERT ... RETURNING:MXS-5527。修复版本 21.06.19 / 22.08.16 / 23.02.13 / 23.08.9 / 24.02.5 / 25.01.2。另外 INSERT ... RETURNING 只在 connector 支持 DEPRECATE_EOF 时才与 causal reads 兼容(Connector/J 3.5.2+、Connector/Node.js 3.4.0+、R2DBC 1.3.0+ 支持;Connector/C、C++、ODBC 不支持)。
  • Galera:24.02.5 起 fast* 三种模式可用于 Galera;依赖 MASTER_GTID_WAIT 的非 fast 模式仍不行,因为 Galera 没有“等某个 GTID”的机制。

transaction_replay:把中断的事务搬到另一台机器重放

默认 false。开启会连带打开 delayed_retry 与 master_reconnection,并把 master_failure_mode 覆盖为 fail_on_write。

  • transaction_replay_max_size:默认 1 MiB,按会话计。内存峰值 ≈ 1MiB × 客户端连接数。超限的事务不重放,日志 info 级别,计数在 maxctrl show service 的 trx_max_size_exceeded。
  • transaction_replay_attempts:默认 5。
  • transaction_replay_timeout:24.02 起默认 30s,23.08 及以前是 0(关闭)。开启后重放时长只由它控制;未开启时上限 = delayed_retry_timeout × attempts(默认 50s),下限可能只有 attempts 秒。delayed_retry_timeout 小于它时会被抬成同值。
  • transaction_replay_safe_commit:默认 true,23.08.0 引入。COMMIT 已在途时连接断了,无法知道是否已提交,重放可能重复写。默认不重放这种事务。
  • transaction_replay_retry_on_deadlock:默认 false。40001 死锁自动重试。
  • transaction_replay_retry_on_mismatch:默认 false。校验和不一致时重试。
  • transaction_replay_checksum:full(默认,所有响应进校验和,唯一能保证一致性)/ result_only(忽略 OK 包,仅当应用不使用自增 ID 等 OK 包信息时安全)/ no_insert_id(再忽略 LAST_INSERT_ID() 的结果)。
  • 校验和从 23.08 起是每条语句一个 128-bit xxHash,之前是整个事务一个 160-bit SHA1。结果不一致时 readwritesplit 直接关客户端连接。
  • 服务器特异结果的事务无法重放(NOW()、@@server_id、UUID()),但会先尝试,然后 mismatch 关连接。
  • 重复执行的三种典型:无自增主键的 INSERT、blind update(UPDATE t SET c=c+1 WHERE id=123)、blind delete(DELETE FROM t LIMIT 100)。避免方式:事务里改数据前先 SELECT ... FOR UPDATE(或 SERIALIZABLE),校验和会因原事务已提交而不一致,从而拦住重复写。
  • 隐式提交语句不重置事务:重放时会被提交两次。BEGIN / START TRANSACTION 例外,会被识别并正确重置。
  • 23.08 起(MXS-4549):事务内语句已部分返回给客户端后断连,也能重放;之前是直接关会话。

25.01 的升级改动

  • reuse_prepared_statements 从 readwritesplit 移除,功能搬到 PsReuse filter 模块。
  • optimistic_trx 从 readwritesplit 移除,功能搬到 OptimisticTrx filter 模块。
  • 25.01.0 的一次回归:transaction_replay_safe_commit=true 顺带把 delayed_retry 的单条 DML 也禁了,导致 autocommit 的写永不被重放;25.01.1 改回(delayed_retry 不再重发已经发给数据库的查询,事务外的写只在找不到目标时延迟、不重试)。
  • 25.01 还有一条相关变更:readwritesplit 无论 causal_reads 状态都启用 multi-statements。

含 reuse_prepared_statements 或 optimistic_trx 的旧配置在升级后会直接失效。

命令与配置

连接读写口:mysql -h maxscale -P 4006 -u app -p;只读口 4008 需自己配 listener。

maxctrl list servers / list services / show service Read-Write-Service
maxctrl call command readwritesplit reset-gtid Read-Write-Service(causal_reads=global 的 GTID 状态复位)

日志里的关键字:trx_max_size_exceeded、checksum mismatch、Causal read、MASTER_GTID_WAIT。

[Read-Write-Service]
type=service
router=readwritesplit
servers=db1,db2,db3
user=maxscale
password=***
causal_reads=global
causal_reads_timeout=10s
transaction_replay=true
transaction_replay_timeout=30s
transaction_replay_max_size=1MiB
max_slave_replication_lag=1s

推荐文章

程序员茄子在线接单