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