Skip to content
Go back

MySQL 自适应哈希索引(AHI):BufferPool 内的自动加速器

一句话结论(30s)

AHI 的本质是 InnoDB 在 BufferPool 内自动构建的一张内存哈希表,把频繁访问的索引键映射到「叶子页地址」而非行,从而把等值查询的 O(log n) 层级遍历降为一次哈希 + 页内二分。关键设计是「自适应」——访问计数达阈值才建、长期不用或页分裂时自动删,且分 8 个分区锁分离;核心权衡是它只加速等值查询、范围/LIKE 不受益,且高并发写入时每次页修改都要维护哈希键,维护开销可能反超收益,故写入密集场景建议关闭。

核心原理(2min)

B+Tree 即使全命中 BufferPool 仍需 2-4 层页内二分定位,AHI 用取模散列(ut_fold_binary 折叠)把键映射到叶子页地址,跳过层级遍历后仅在页内二分。哈希键 = index_id + key_bytes,因此同一键值在不同索引上是不同哈希键。AHI 分 8 个分区(innodb_adaptive_hash_index_parts)实现锁分离并行访问。构建触发条件是某索引页被等值查找计数达阈值,范围扫描与 LIKE 不计数;当键长期未用或页大量修改(分裂/合并)时自动删除。代价是占用 BufferPool 内存、每次写页都需检查并可能删键;用 hash searches/s 与 non-hash searches/s 计算命中率,命中率持续很低或写入密集时应关闭。

底层深入(5-10min)

一、AHI 是什么?为什么需要它?

InnoDB 的数据访问路径:根页 → B+Tree 内部节点 → 叶子节点。即使在 BufferPool 中全部命中,定位一行数据也需要 O(log n) 次页内二分查找(log 以页的扇出因子为底,通常在 2-4 层)。

对于高频的等值查询(如 WHERE pk = 100WHERE unique_key = 'xxx'),每次都要走 B+Tree 路径是一种浪费。自适应哈希索引(Adaptive Hash Index, AHI) 就是为此设计的:InnoDB 自动在 BufferPool 中构建一张哈希表,将频繁访问的索引键直接映射到 BufferPool 中的 页地址

关键理解:AHI 不是传统意义上的持久化索引。它不存储在磁盘上,不占用独立磁盘空间,完全是内存结构。它随 BufferPool 一起加载和清除,崩溃后重建。

💭 想一想:既然 B+Tree 已经能 O(log n) 定位了,为什么还要再造一张哈希表?——因为”在内存里走 2-4 层 B+Tree、每层一次页内二分”对高频等值查询仍是浪费;哈希能把”多步层级遍历”压成”一次取模 + 一次页内二分”。它赌的是”很多查询反复命中同样的键”,命中时省掉的那几层遍历累积起来就很可观。

1.1 哈希键 → 页地址,不等于哈希键 → 行

这是一个常被误解的点。AHI 不直接将索引值映射到行数据,而是映射到 包含该键的叶子页。原因是一个叶子页包含多条相邻记录(B+Tree 的有序扇出),映射到页后仍需在页内二分查找具体记录,但跳过了 B+Tree 的层级遍历(一般跳过 2-3 层)。

💭 想一想:为什么宁可”映射到页再页内二分”,也不一步到位映射到”行”?——因为一个叶子页里有几十上百条相邻记录,若每个键都单独建一条”键→行”映射,哈希表会膨胀到和索引本身一样大、维护成本爆炸;映射到页既能大幅减少表项数量,又能把最贵的”层级遍历”省掉,剩下的页内二分已经很快。这是一次”表大小”与”省多少步”之间的折衷。

二、AHI 的哈希结构

2.1 哈希函数

InnoDB 使用的哈希函数是 取模散列

// InnoDB 源码中的哈希计算(简化)
hash_value = ut_fold_binary(key, key_len) % hash_table_size;
// ut_fold_binary:将字节序列折叠为整数,类似 CRC32-lite

不是通用安全的 SHA/CRC,而是速度优先的折叠函数——兼顾分布均匀性和计算速度。

2.2 哈希表组织

AHI 使用 分区的锁分离设计:将哈希表划分为多个分区(默认 8 个,由 innodb_adaptive_hash_index_parts 控制),每个分区有独立的读写锁。这样多个查询可以并行访问 AHI,减少锁竞争。

AHI 哈希表(内存):
Partition 0: Bucket 0 → (key=0x01, page=0x200) → (key=0x03, page=0x201) → ...
             Bucket 1 → ...
Partition 1: Bucket 0 → ...
...
Partition 7: Bucket 0 → ...

2.3 键的构成

// AHI 的哈希键(源码结构简化)
struct ha_key {
    byte[]  index_id;   // 索引 ID(表级别唯一)
    byte[]  key_bytes;  // 索引键的二进制值
};

这意味着同一个主键值在不同的索引上产生不同的哈希键——因为 index_id 不同。

三、自动创建与触发条件

AHI 的”自适应”体现在它不手动创建,而是 InnoDB 根据访问模式自动决策。

3.1 创建触发条件

InnoDB 对每个索引页维护一个访问计数器。当一个索引页被 等值查找 的次数达到阈值时,该页上的键值被添加到 AHI 中:

1. B+Tree 等值查找命中某叶子页
2. 该页的访问计数 +1
3. 当计数 >= innodb_adaptive_hash_index_parts * 某个内部阈值
4. 将该页中频繁访问的键写入 AHI 哈希表

注意是 等值查找 才会被计数。范围扫描(WHERE id BETWEEN 100 AND 200)、LIKE 前缀查询不会触发 AHI 构建——因为它们无法用哈希表加速(哈希只能做精确匹配)。

💭 想一想:为什么范围/LIKE 天然与哈希无缘?——因为哈希的本质是”精确键 → 地址”的点查找;范围查询要的是”一个区间内所有连续记录”,而哈希把有序关系打散了,区间里的记录散落在不同桶里,没法顺序遍历。所以 AHI 只对 =IN、联合索引完整等值这类”点查”有效,这决定了它的适用边界。

3.2 自动删除条件

AHI 是动态的——如果某个键长期未被使用,当哈希表空间不足时会被踢出。此外,当索引页发生大量修改(分裂、合并)时,相关键也会被自动删除。

3.3 哪些查询受益?

查询类型AHI 是否生效原因
WHERE pk = 1✅ 完全受益等值查询,主键哈希直接定位页
WHERE uk = 'X'✅ 完全受益等值查询,唯一索引哈希定位页
WHERE col IN (1,2,3)✅ 部分受益每个值走一次哈希
WHERE col BETWEEN 1 AND 100❌ 不受益范围查询,AHI 只支持等值
INSERT/UPDATE/DELETE❌ 不受益写入路径不走 AHI(但可能触发删除)
WHERE col LIKE 'abc%'❌ 不受益前缀匹配不是等值
联合索引 WHERE a=1 AND b=2✅ 受益等值查询,键 = (a,b) 的完整值

四、监控与查看

4.1 SHOW ENGINE INNODB STATUS

SHOW ENGINE INNODB STATUS\G
-- 查看 INSERT BUFFER AND ADAPTIVE HASH INDEX 段

输出示例:

-------------------------------------
INSERT BUFFER AND ADAPTIVE HASH INDEX
-------------------------------------
Ibuf: size 1, free list len 0, seg size 2, 0 merges
Hash table size 276671, node heap has 1 buffer(s)
Hash table size 276671, node heap has 2 buffer(s)
0.00 hash searches/s, 0.00 non-hash searches/s

关键指标解读:

指标含义健康值
hash searches/s走 AHI 的查询次数越高越好
non-hash searches/s没走 AHI 的查询次数越低越好
Hash table sizeAHI 桶数量自适应增长
node heap has N buffer(s)AHI 节点占用的 BufferPool 页数应保持在合理范围

4.2 AHI 命中率计算

-- 两次采样之间的 AHI 命中率
-- 第一次采样
SHOW ENGINE INNODB STATUS\G  
-- 记录 hash_searches_1, non_hash_searches_1
-- 等待 10 秒
SHOW ENGINE INNODB STATUS\G
-- 记录 hash_searches_2, non_hash_searches_2

-- 命中率 = (hash_searches_2 - hash_searches_1) 
--        / ((hash_searches_2 - hash_searches_1) + (non_hash_searches_2 - non_hash_searches_1))

4.3 动态开关

-- 关闭 AHI(临时)
SET GLOBAL innodb_adaptive_hash_index = OFF;

-- 开启 AHI
SET GLOBAL innodb_adaptive_hash_index = ON;

-- 调整分区数(重启生效)
-- innodb_adaptive_hash_index_parts = 8  # 默认值

五、AHI 的代价与限制

5.1 内存占用

AHI 占用的内存来自 BufferPool,与数据页、索引页竞争 [1]。在高连接数下,AHI 可能占用数百 MB 甚至数 GB 内存。当 BufferPool 紧张时,InnoDB 会优先淘汰 AHI 节点。

-- 查看 Buffer Pool 总大小
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
-- 查看 Buffer Pool 中 AHI 的占用(通过 information_schema)
SELECT * FROM information_schema.innodb_buffer_page 
WHERE page_type = 'ADAPTIVE_HASH_INDEX';

5.2 写入性能下降

每次 B+Tree 的页修改(INSERT/UPDATE/DELETE 导致页分裂、合并或记录变更),都需要检查并可能删除 AHI 中对应的键。在高并发写入场景下,AHI 的维护开销可能超过其加速收益。

典型反模式:OLTP 写入密集型业务 + 大量等值查询。AHI 降低了单条查询的延迟,但每次写入都伴随着 AHI 的维护操作。需要根据 hash searches/s 命中率判断是否值得。

💭 想一想:AHI 明明是”加速器”,为什么高并发写场景反而建议关掉?——因为加速只发生在读的那一侧,而每次写(页分裂、合并、记录变更)都要反过来维护哈希键、甚至删键;写入一密集,维护开销就压过了省下的那几层遍历。这是”读收益”与”写成本”的天平,命中率低或写密集时天平就倒向”关掉更划算”。

5.3 何时关闭

章末提问

追问 1:AHI 为什么映射到”叶子页地址”而不是”行地址”?

回答思路:结论先行——为了控制哈希表规模:映射到页只省”层级遍历”,页内仍二分。因为一个叶子页包含几十上百条相邻记录,若每个键都建”键→行”映射,哈希表会膨胀到和索引一样大、维护成本爆炸;映射到页能让表项数量大幅减少,同时把最贵的 2-3 层 B+Tree 遍历省掉,剩下的页内二分已经足够快。

追问 2:AHI 的”自适应”体现在哪里?哪些查询受益、哪些不受益?

回答思路:结论先行——体现在”按访问计数自动建、长期不用或页分裂自动删、分 8 分区锁分离”;只有等值查询受益,范围/LIKE/写入不受益。因为哈希只能做”精确键→地址”的点查,=IN、联合索引完整等值能受益,BETWEENLIKE 'abc%' 需要顺序遍历、与哈希的有序性相悖。建触发靠”索引页等值查找计数达阈值”,删靠”长期不用或页大量修改”。

追问 3:什么场景应该关闭 AHI?为什么写密集时反而有害?

回答思路:结论先行——范围/LIKE 为主、命中率持续很低、高并发写、BufferPool 紧张(<512MB)时关闭。因为 AHI 的收益只在”读”这一侧省层级遍历,而每次写(页分裂/合并/记录变更)都要维护或删除哈希键,写一密集维护成本就反超读收益;命中率靠 hash searches/snon-hash searches/s 的比值判断,持续很低就说明白建了,不如关掉省内存、省写维护开销。


Share this post on:

Previous Post
Redis Cluster无感扩容——MOVED和ASK重定向
Next Post
MySQL 索引下推(ICP):让二级索引在引擎层就完成过滤