编程 规则写了,SELECT 仍落到 default_hostgroup:ProxySQL 规则链语义与 fast_routing 的指针残留

2026-11-01 00:04:43

规则写了,SELECT 仍落到 default_hostgroup:ProxySQL 规则链语义与 fast_routing 的指针残留

项目地址:sysown/proxysql

现象

mysql_query_rules_fast_routing 里按 username + schemaname 配了目标组,runtime_mysql_query_rules 里也能看到规则已加载,但某类 SELECT 仍然落到 default_hostgroup。default_hostgroup 可达时没有任何报错,只有不可达时才抛出:

ERROR 9001 Max connect timeout reached while reaching hostgroup N

同样是“规则不生效”,背后至少有两种独立原因:规则链(flagIN / flagOUT / apply)的语义没配对,以及 3.0.x 里一个已修复的指针残留 bug。分开看。

mysql_query_rules 的执行语义

字段定义见官方文档 mysql tables。

  • rule_id 是 PRIMARY KEY AUTOINCREMENT,规则按 rule_id 升序处理。
  • active=1 的规则才会被 query processor 考虑,也只有 active=1 的规则会被加载进 runtime 数据结构。
  • flagIN / flagOUT / apply 三者构成规则链。查询进来时初始 flag 值为 0,所以一开始只测试所有 flagIN=0 的规则。
  • 某条规则命中且 flagOUT 有值,查询会以该 flagOUT 作为新的 flagIN 重新评估。
  • 命中且 apply=1,查询立即执行,不再往下看。
  • 一条规则都没命中,才回落到 destination_hostgroup 或 default_hostgroup。

destination_hostgroup 的例外:当前已开启事务且该用户 transaction_persistent=1 时,不会被规则改写路由。

其余字段容易看漏的几点:

  • match_digest 用正则匹配 digest,match_pattern 用正则匹配 query 文本;re_modifiers 默认 'CASELESS',另有 GLOBAL。
  • negate_match_pattern=1 表示只匹配“不命中该文本”的查询。
  • replace_pattern 走 RE2::Replace,设置时必须同时有 match_pattern。
  • cache_ttl 单位是毫秒(1.1.x 是秒)。
  • timeout 毫秒,超时自动 kill 查询;retries 未设时用全局 mysql-query_retries_on_failure。
  • delay 毫秒,做限流 / QoS。
  • multiplex:0 禁用复用,1 在无其他阻碍时可重新启用,2 该会话生命周期内都不复用。
  • gtid_from_hostgroup 指定哪个 hostgroup 作为 GTID 一致性 leader。
  • log:1 记入 events log,0 不记,NULL 跟随全局。

文档里有一句原话提醒:不要在第一条捕获全部流量的规则上设置 destination_hostgroup 或 apply,否则其他规则不会被评估。

三处常见误读

误读一:“规则按顺序执行,第一条匹配就停”

这是 issue #1673 里的情形:用户认为规则顺序执行且第一条命中即终止,于是缓存规则一直不生效。维护者 renecannao 的回复很直接:如果没有设置 apply=1,这个断言完全不正确。用户随后确认自己确实在某条规则上设了 apply=1,改成“SELECT 的路由规则放前面且 apply=0,缓存规则放后面”后恢复。

顺序处理是真的,命中即停是假的。停的条件只有 apply=1。

误读二:缓存规则和路由规则的先后

承接上一条:一条 apply=1 的路由规则如果在缓存规则之前命中,后面的缓存规则根本不会被评估。反过来,路由规则 apply=0 时,查询会继续往后走。规则链里没有“回退”,任何位于当前处理位置之前的规则不会被再次访问,不管 flagIN 是多少。

误读三:非 0 的 flagIN 一开始进不去

初始 flag 值是 0,所以 flagIN=99999 这类规则在查询刚进来时永远不会被考虑。它只能由前面某条规则命中后写 flagOUT=99999 来激活。同理,flagOUT 为 NULL 表示停留在当前链 id,不会给查询打标。

flagOUT 与 flagIN 相同则从该 flagIN 的第一条规则重新评估;不同则退出当前链、进入以新 flagIN 为输入的另一条链。这条规则来自官方 FAQ 的 flagIN/flagOUT/apply 说明。

另外两个容易踩的点:

  • 只有命中某条规则,才会被打上该规则的 flagOUT 标记。没命中就没有标记,也不会有后续链。
  • negate_match_pattern 与 replace_pattern 组合不符合直觉:replace 只在 match 时发生,开了 negate 就不匹配,也就不会替换。
  • catch-all 规则(匹配所有流量那条)上不要设 destination_hostgroup 或 apply,否则后续规则全部被吃掉。

关于禁用规则仍有 id 占位、runtime 集合看起来不连续的问题,Pythian 的生产笔记 里有记录:rule_id 是自增主键,禁用规则不会释放 id。

规则太多时:先分链,再考虑 fast_routing

规则多的时候,所有查询要跟所有规则比对。Percona 的测算 给了个量级:100 条规则对应 100 张表,平均每次要检查 50 条;改成链式(先按表名的某一位分流)后规则总数更多,但平均只检查约 15 条。

链式分流的做法就是用 flagOUT 打标,把大列表拆成若干条小链。这也是 mysql_query_rules_fast_routing 想解决的问题。

fast_routing 的作用与前提

mysql_query_rules_fast_routing 在 ProxySQL 1.4.7 引入,介绍见 new schemaname routing algorithm。

CREATE TABLE mysql_query_rules_fast_routing (
username VARCHAR NOT NULL,
schemaname VARCHAR NOT NULL,
flagIN INT NOT NULL DEFAULT 0,
destination_hostgroup INT CHECK (destination_hostgroup >= 0) NOT NULL,
comment VARCHAR NOT NULL,
PRIMARY KEY (username, schemaname, flagIN)
)

它的定位是 mysql_query_rules 的扩展,不是替代:

  • 先处理完 mysql_query_rules,再评估 fast_routing;除非 mysql_query_rules 里最后命中的规则设置了 apply=1。
  • 因为有 (username, schemaname, flagIN) 主键,规则装进哈希表,只会评估且必评估一条。三者任一不同就查不到对应条目。
  • flagIN 让它可以和规则链组合:先用 mysql_query_rules 打 flagOUT=1(写)或 2(读),这个值就成为 fast_routing 里的 flagIN,读写分离和按库路由能叠在一起。
  • 开销:最小约 1.7us(关闭 mysql-stats_time_query_processor 约 1.4us)。旧算法没有这个固定开销,所以规则数超过 100 条才值得启用。
  • ProxySQL Cluster 早期不支持该表,自 v2.0.0 起支持。

3.0.x 的 bug:未命中时 qr 指针残留

对应 issue #5620,修复见 PR #5763,报告人 @burnison,复现版本 3.0.3 和 3.0.7。

现象与开头一致:没有任何 mysql_query_rules 规则命中查询,但规则链里最后被遍历的那条规则 apply=1 时,Query_Processor::process_query(lib/Query_Processor.cpp)会静默跳过 mysql_query_rules_fast_routing。流量落到用户的 default_hostgroup;default_hostgroup 可路由时完全静默,不可路由时报 ERROR 9001 Max connect timeout reached while reaching hostgroup N。

根因在循环里的指针管理:

// 循环每轮开头
qr = *it;
// 规则不匹配时
continue;   // 没有把 qr 复位

循环结束仍无命中时,qr 指向最后被遍历的那条未匹配规则。出口检查是:

if (qr == NULL || qr->apply == false)

这里读的是残留指针的 apply 字段,恰好为 1 就整段跳过 fast-routing 分支。出口检查的假设是 qr 代表“最后被应用的规则”,循环实际把它当成“最后被遍历的规则”。

修复是一行,未匹配时把 qr 置 NULL:

if (rule_matches_query(qr, ...) == false) {
qr = NULL;
continue;
}

该代码位于模板实现,MySQL 和 PgSQL 两个协议都覆盖。

回归测试是 test/tap/tests/reg_test_5620_fast_routing_qr_leak-t.cpp,注册在 legacy-g6 组。测试用 ProxySQL 内置的 SQLite3 server(端口 6030)当 backend,让 default_hostgroup 和 fast_routing_hostgroup 都可达,从而通过 stats_mysql_query_digest 判断查询到底落到哪个 hostgroup,而不是靠连接失败反推。

  • Phase 1 基线:只有 user + fast-routing 规则,断言落在 fast_routing 组。
  • Phase 2 触发:插入一条 flagIN=99999, apply=1 的规则,清 digest 统计后再发查询。修复前落在 default_hostgroup(断言输出 expected 100 got 0),修复后落在 fast_routing 组。

除 3.0.3、3.0.7 外的其他 3.0.x 小版本未实测。

怎么确认规则真的生效

不要只看配置表。自查手段按可信度排:

1. stats_mysql_query_digest 看实际落点

它有 hostgroup、schemaname、username、digest、digest_text、count_star 等列。按 digest 分组看 hostgroup 分布,能直接看出某类语句实际被路由到哪,不用靠连接失败反推。

SELECT hostgroup, schemaname, username, digest, digest_text, count_star
FROM stats_mysql_query_digest
ORDER BY count_star DESC;

2. runtime_mysql_query_rules 才是正在跑的规则集

mysql_query_rules 只是配置表。改完必须:

LOAD MYSQL QUERY RULES TO RUNTIME;

持久化要另外做:

SAVE MYSQL QUERY RULES FROM RUNTIME;
SAVE MYSQL QUERY RULES TO DISK;

只改了 mysql_query_rules 而没 LOAD,runtime 里还是旧规则,看起来“规则写了但不生效”。

3. 规则上的 log=1

log=1 会把命中记录写进 events log,是 stats_mysql_query_digest 之外的另一条线索。注意 NULL 跟随全局,显式设 0 会关掉。

排错顺序清单

  1. 查 runtime_mysql_query_rules,不是 mysql_query_rules。确认 active=1、rule_id 顺序符合预期,禁用规则留下的 id 空洞不影响顺序判断。
  2. 用 stats_mysql_query_digest 按 digest 看 hostgroup 列,确认落点偏差出现在哪类语句上。
  3. 找出规则链里的 catch-all 规则,检查它是否设了 destination_hostgroup 或 apply=1。
  4. 检查所有非 0 flagIN 的规则:前面是否有规则能在命中后写出对应的 flagOUT。没有打标,这些规则永远进不去。
  5. 检查 apply=1 的位置。apply 只在命中时生效,但一旦命中的是路由规则,后面的缓存规则就不会被评估。
  6. 如果用了 fast_routing,核对 (username, schemaname, flagIN) 三个值与连接实际值是否一致,以及 mysql_query_rules 里是否有 apply=1 的规则先命中。
  7. 如果版本在 3.0.x 且规则链里有 apply=1 的规则,先确认是否包含 #5763 的修复。未命中查询被残留指针带偏时,表现和配置错误完全一样。
  8. 仍不确定时打开 log=1,对照 events log 里实际命中的规则 id。

推荐文章

程序员茄子在线接单