关闭AHI以后,查询就得读磁盘吗?分清InnoDB里的两种hash
在 InnoDB 内存结构笔记里,有两句话放得很近:
- 按表空间 ID 和页号做 hash,能找到内存中的页。
- 自适应哈希索引,也能帮助查询更快找到数据。
看起来都在“用 hash 找东西”。那它们是不是同一个结构?如果关掉自适应哈希索引,原来缓存在内存里的数据,还能不能继续用?
先不急着给这几个名词下定义。我们找一条能反复执行的查询,把开关前后的变化看出来。
先让同一条记录被查很多次
实验表hot有 10000 行,主键从 1 到 10000。v是主键的两倍,另放一列填充数据。我们反复查这一行:
SELECT id, v FROM hot WHERE id=777;每次都应该返回777、1554。这里故意只盯一条热点记录,让相关页在缓冲池里反复被访问,不是模拟真实业务的流量分布。
本次使用独立的 MySQL 8.0.46 实例,Buffer Pool 为 64 MiB。先打开 AHI,查询 4000 次预热,再单独记录接下来 2000 次查询的计数变化。随后关闭 AHI,不重启、不清缓存,仍执行同样的 2000 次查询。
得到下面的结果。数字都是这一小段测试的增量,不是服务器启动以来的累计值。
每轮2000次查询 ON预热后 AHI搜索: 2000 B-tree搜索: 0 逻辑读请求: 4000 新增读页: 0 OFF AHI搜索: 0 B-tree搜索: 2000 逻辑读请求: 4000 新增读页: 0 再ON预热后 AHI搜索: 2000 B-tree搜索: 0 逻辑读请求: 4000 新增读页: 0关闭以后,返回值一条没变。比较明显的变化是:AHI 搜索不再增长,B-tree 搜索开始增长。但Innodb_buffer_pool_reads仍然没有增加。
这就有点意思了:走 B-tree 查找,并不意味着每查一次就得从文件读一次页。
B-tree是在描述怎么找,不是在规定从哪读
先把 AHI 放一边。我们已知条件是id=777,不是“表空间 5 的第 23 页”。数据库仍要沿索引确定记录的位置。
假设有一棵很小的树,内部节点告诉我们该沿哪个范围继续向下找:
想找 id=777 ↓ 根页:读范围,选下一层 ↓ 叶子页:在页内定位777 ↓ 取出 v=1554这是为了说明查找动作画的小树,不是本次hot的实际页号或精确树高。
关键在于,根页和叶子页本身都可以已经在 Buffer Pool 中。用它们继续做比较、定位,并不需要先把它们扔出去,再从文件重新读回来。
所以这两个问题要拆开问:
- 沿索引怎样找到目标记录?
- 此刻要访问的那个页,内存里有没有?
把“没有走哈希快捷查找”直接推成“必然访问磁盘”,就是把它们合并成了一个问题。
原笔记里的按页号hash,回答第二个问题
沿树走的时候,下一步需要哪个页是可以确定的。比如现在要访问某个表空间的第 23 页,缓存管理器得找出它对应的内存页控制块。
这里才轮到 Buffer Pool 的page_hash。示意一下:
已知需要哪个页 表空间5,页23 ↓ page_hash ↓ 对应的页控制块 ↓ 内存中的数据页表空间和页号只是示意;不是说本次 777 号记录就在这个位置。页控制块还涉及状态和同步,不能把找到一个表项等同于任何时刻都能立刻读页。
在 MySQL 8.0.46 的 buf0buf.h 中,buf_pool_t的page_hash注释说明它用(space_id, offset)索引文件页的控制结构;这里offset对应页号,不是 SQL 里跳过多少行的OFFSET。
你给page_hash的问题是“这个页在哪”,不是“请根据 id=777 给我一行”。它不替代前面根据索引键确定搜索位置的过程。
AHI才是对反复索引查找加一条近路
现在把 AHI 放回来。反复查询某些索引键时,InnoDB 会观察搜索模式,在适合的热点索引页上建立自适应哈希信息。
这条近路利用的是索引键前缀等信息,把查找引向内存中的索引记录位置,而不是让你先交出页号。命中且通过相关检查时,可以省去一部分常规 B-tree 定位过程;不适用或尝试失败时,仍然有常规搜索路径。MySQL AHI 说明
用刚才那条查询对照:
常规定位 id=777 → 沿B-tree找 → 记录 AHI快捷定位成功时 索引键的hash → 记录位置 两种方式最后都需要访问数据 页是否在内存,是另一个问题不要把 AHI 画成“整条 SQL 文本 → 已算好的查询结果”。同一条 SQL 仍然要执行,也仍要处理记录访问等工作;AHI 不是结果缓存。
若想接着看源码,可以在同版本 btr0sea.cc 中找btr_search_guess_on_hash。它有适用性检查,按搜索前缀等信息计算哈希,再尝试取得记录。关停过程btr_search_disable清理的是 AHI 的相关状态,不能看见函数名里的 hash 就把它误读为清空整个 Buffer Pool。
现在回头看那组数字就顺了:关闭 AHI 后,热点记录还有常规 B-tree 路径可找,所需页也还在缓存里,于是搜索计数变了,新增读页却仍然为零。
为什么EXPLAIN看起来没变?
实验里两种状态都执行了:
EXPLAIN SELECT id, v FROM hot WHERE id=777;两边都是主键PRIMARY,访问类型const。如果只看这两列,好像什么都没有发生。
但执行计划告诉我们选择了什么访问方式和索引,不能直接当作这次引擎内部 AHI 命中的证据。我们实际还看到了 AHI 和 B-tree 计数的变化,两者并不矛盾。
读指标时也得区分名字:
adaptive_hash_searches:用来观察 AHI 成功搜索的计数。adaptive_hash_searches_btree:观察常规 B-tree 搜索计数。Innodb_buffer_pool_read_requests:逻辑读请求。Innodb_buffer_pool_reads:无法由 Buffer Pool 满足而需要读入的读计数。
本次关闭 AHI 后,逻辑读请求还在增长,新增读入为零。这个“零”只针对这段热查询窗口;不能扩写成服务器没有任何文件写入、日志活动,或机器完全没有 I/O。它也不是用来证明冷页都能凭空从内存找到。
这些指标是实例级的。正式业务里有其他连接和后台活动,不能把全局增量全算给自己的一条 SQL;我们用独立实例,就是为了减少这种混淆。AHI 指标的启用和状态读取方式见 INNODB_METRICS 文档,Buffer Pool 读计数见 服务器状态变量。
自己复现:准备表,再跑开关对照
只在可丢弃的独立 MySQL 实例上做。innodb_adaptive_hash_index是全局变量,会影响其他连接,不能在共享开发库或生产库里为了读一篇文章随手切换。需要能设置全局变量和读取指标的实验账号。
下面先创建新库;如果同名库已存在,CREATE DATABASE会报错,请检查环境,不要直接改成删库重来。本文未提供删除业务数据的命令。
SET GLOBAL innodb_monitor_enable='module_adaptive_hash'; CREATE DATABASE blog_ahi; USE blog_ahi; CREATE TABLE hot ( id INT PRIMARY KEY, v INT NOT NULL, pad CHAR(200) NOT NULL ) ENGINE=InnoDB; INSERT INTO hot (id, v, pad) WITH digits AS ( SELECT 0 AS d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 ) SELECT 1+a.d+10*b.d+100*c.d+1000*d.d, 2*(1+a.d+10*b.d+100*c.d+1000*d.d), REPEAT('x',200) FROM digits a CROSS JOIN digits b CROSS JOIN digits c CROSS JOIN digits d;下面保存为probe_ahi.py。这是完整探针,不负责安装或启动 MySQL,也不会删库。它默认连接本机 Unix socket;无密码 root 只适用于这里的隔离临时实例,不是配置生产账号的建议。
import json import os import subprocess if os.environ.get('BLOG_AHI_LAB') != '1': raise SystemExit('Only run against a dedicated disposable MySQL instance; set BLOG_AHI_LAB=1') client = [os.environ.get('MYSQL_CLIENT', 'mysql'), '--no-defaults', '--protocol=SOCKET', '--socket=' + os.environ['MYSQL_SOCKET'], '-u', os.environ.get('MYSQL_USER', 'root'), '--batch', '--skip-column-names', '--connect-timeout=2'] def sql(text): result = subprocess.run(client, input=text, capture_output=True, text=True, timeout=120) if result.returncode != 0 or result.stderr.strip(): raise RuntimeError(result.stdout + result.stderr) return result.stdout.splitlines() def snapshot(): lines = sql("SELECT NAME, COUNT FROM information_schema.innodb_metrics WHERE NAME IN " "('adaptive_hash_searches','adaptive_hash_searches_btree') ORDER BY NAME;" "SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME IN " "('Innodb_buffer_pool_read_requests','Innodb_buffer_pool_reads') ORDER BY VARIABLE_NAME;") return {name.lower(): int(count) for name, count in (line.split('\t') for line in lines)} def phase(name, count): before = snapshot() results = sql('USE blog_ahi;\n' + 'SELECT id, v FROM hot WHERE id=777;\n' * count) after = snapshot() if results != ['777\t1554'] * count: raise AssertionError('Query result or count changed') return {'phase': name, 'queries': count, 'allResultsMatch': True, 'before': before, 'after': after, 'delta': {key: after[key] - before[key] for key in before}} original = sql('SELECT @@global.innodb_adaptive_hash_index;')[0] record = {'version': sql('SELECT VERSION();')[0], 'phases': []} try: sql('SET GLOBAL innodb_adaptive_hash_index=ON;') record['planOn'] = sql('USE blog_ahi; EXPLAIN SELECT id,v FROM hot WHERE id=777;') record['phases'].append(phase('on_training', 4000)) record['phases'].append(phase('on_measured', 2000)) sql('SET GLOBAL innodb_adaptive_hash_index=OFF;') record['offValue'] = sql('SELECT @@global.innodb_adaptive_hash_index;')[0] record['planOff'] = sql('USE blog_ahi; EXPLAIN SELECT id,v FROM hot WHERE id=777;') record['phases'].append(phase('off_measured', 2000)) sql('SET GLOBAL innodb_adaptive_hash_index=ON;') record['phases'].append(phase('reenabled_training', 4000)) record['phases'].append(phase('reenabled_measured', 2000)) record['metricsStatus'] = sql("SELECT NAME, STATUS FROM information_schema.innodb_metrics " "WHERE NAME IN ('adaptive_hash_searches','adaptive_hash_searches_btree') ORDER BY NAME;") finally: sql('SET GLOBAL innodb_adaptive_hash_index=' + ('ON' if original == '1' else 'OFF') + ';') record['restoredAhi'] = sql('SELECT @@global.innodb_adaptive_hash_index;')[0] if record['restoredAhi'] != original: raise AssertionError('AHI setting not restored') print(json.dumps(record, indent=2))在 Linux/WSL 中,把MYSQL_SOCKET改成实验实例的真实套接字,确认没有指向日常数据库,再运行:
BLOG_AHI_LAB=1 MYSQL_SOCKET=/path/to/lab/mysql.sock \ MYSQL_USER=root python3 probe_ahi.py若mysql不在 PATH,可另设MYSQL_CLIENT为客户端程序的绝对路径。代码不在命令行写入密码;需要密码的实例应先自行采用安全认证配置改造连接部分,不能把此例直接套过去。
探针会先读取原 AHI 开关,依次执行 ON 预热、ON 测量、OFF 测量、再次 ON 预热和测量,最后恢复开关。结果里保留每轮前后累计值和差值,也检查每次返回的记录没有变。指标模块在建表步骤开启,独立临时实例用完后一起停止;本例没有承诺恢复所有监控配置。
我们本次用的是不监听 TCP、64 MiB Buffer Pool、64 MiB redo 容量、关闭 binlog 的临时实例。关闭 binlog 是这个实验环境的条件,不是 AHI 的必需条件。每轮都新建数据目录;测试结束后 MySQL 已停止,没有修改日常数据库配置。
如果你的开启阶段没有观察到 AHI 增长,先看版本、开关值、指标是否 enabled,以及预热是否足够。不要因为执行了SET ...=ON就在报告里写“已经命中”;ON 只是允许使用,不保证每一条查询都用上。
那我该打开还是关闭?
这次只证明了一个区分:AHI 的搜索近路,与数据页是否已经缓存,不是一回事。
我们没有用单条热点查询的耗时推荐开关,也没有测并发下的争用和维护成本。实际要调参,应在自己的代表性负载下比较延迟、吞吐和等待,不能把“有命中”直接当成“整体一定更快”。
这篇来自原 InnoDB 内存笔记中尚未展开的两种 hash。原稿里固定分配80%内存等经验值,不是本实验的前提,也不应当作所有机器通用的设置。先分清它们各自在解决什么问题,后面看配置才不会把一个开关当成整个缓存系统的开关。