数据库索引优化:B+树 / 哈希 / 全文索引适用场景

x123456
x123456 见习用户见习用户
发布于 2026-10-24 06:17 ·3 浏览 ·9 回复
内容摘要

围绕如何依据查询类型选择索引,给出用 EXPLAIN 判断执行计划的方法,并明确 B+树索引适用于等值与范围查询及排序、哈希索引仅适用于等值查询、全文索引适用于长文本模糊检索且中文需配置 ngram 解析器的结论。

学完这篇,你能分清 B+树、哈希、全文这三类索引各自适合什么查询,并且会用 EXPLAIN 判断自己那条 SQL 到底该建哪种索引。

第一步:先看查询长什么样

索引选型由查询决定,不是由表决定。动手前先确认三件事:WHERE 里是等值还是范围、有没有 ORDER BY、有没有 LIKE '%xxx%'。

-- 找慢查询
SHOW VARIABLES LIKE 'slow_query_log';
-- 看执行计划
EXPLAIN SELECT id, title FROM bbs_thread
WHERE fid = 3 AND status = 1 ORDER BY dateline DESC LIMIT 20;

重点看 type、key、rows 三列。type 是 ALL 说明全表扫,key 是 NULL 说明没走索引,rows 越大越该优化。

注意:EXPLAIN 只是估算,别拿 rows 当精确值。数据分布倾斜时,优化器选错索引很常见,必要时用 ANALYZE TABLE 更新统计信息,或 FORCE INDEX 验证。

第二步:B+树索引——90% 的场景用它

InnoDB 的默认索引就是 B+树,值是按顺序排好的,所以它能干三件事:

  1. 等值查询:WHERE uid = 123
  2. 范围查询:WHERE dateline > 1700000000、BETWEEN、<
  3. 排序与分组:ORDER BY dateline DESC 直接借索引顺序,省掉 filesort

还有两个衍生能力:最左前缀匹配(联合索引 (fid, status, dateline) 能支撑 fid= 或 fid= AND status=,但跳过 fid 直接查 status 就用不上),以及覆盖索引(查询列全在索引里,Extra 显示 Using index,不回表,这是提速最明显的一招)。

-- 版块页按状态筛选 + 时间倒序,一条联合索引全吃下
ALTER TABLE bbs_thread ADD INDEX idx_fid_status_time (fid, status, dateline);

注意:LIKE '%关键词%' 前面的 % 会直接废掉 B+树索引,因为它破坏有序性。需要这类模糊搜索时,别硬加 B+树索引,看第四步。

第三步:哈希索引——只认等值,不认范围

哈希索引按 key 算桶位,定位一条记录是 O(1),但它不排序,所以只支持 = 和 IN,遇到 >、<、ORDER BY、LIKE、范围条件全部失效。

MySQL 里你能碰到的哈希索引有两种:

  • MEMORY 引擎:默认就是哈希索引,可以显式指定 USING HASH。适合做临时表、缓存表、会话表这类纯 KV 读取。
  • InnoDB 自适应哈希索引(AHI):你建不了也删不掉,由 InnoDB 自己监控热点等值访问后自动建。查看状态:
SHOW ENGINE INNODB STATUS\G
-- 关注 INSERT BUFFER AND ADAPTIVE HASH INDEX 段
SHOW VARIABLES LIKE 'innodb_adaptive_hash_index';

一般的 InnoDB 业务表不需要也不应该手写哈希索引,写了也只会被忽略。

注意:AHI 在高并发下可能成为争用点。如果压测时发现 btr_search_latch 相关等待偏高,可以临时 SET GLOBAL innodb_adaptive_hash_index = OFF; 对比验证,但别一上来就关。

第四步:全文索引——给"搜正文"用

帖子标题、正文这类长文本的模糊匹配,B+树无能为力。MySQL 5.6 起 InnoDB 支持 FULLTEXT,配 MATCH ... AGAINST 使用。

ALTER TABLE bbs_thread ADD FULLTEXT INDEX ft_title_message (subject, message);

-- 自然语言模式(按相关度打分)
SELECT id, subject, MATCH(subject, message) AGAINST('索引优化') AS score
FROM bbs_thread
WHERE MATCH(subject, message) AGAINST('索引优化')
ORDER BY score DESC;

中文必须先装 ngram 解析器,否则整段中文会被当成一个词,搜不到东西:

SHOW VARIABLES LIKE 'ngram_token_size';  -- 默认 2,即二字分词
-- my.cnf 里配置:ngram_token_size=2,重启生效

英文场景下 innodb_ft_min_token_size 默认 3,意味着两个字母的词不会被索引。

注意:全文索引对短文本(几个字的标题)收益不如对长正文明显;数据量很大时,专用搜索引擎(如 Elasticsearch)仍是更稳的选择。另外 IN BOOLEAN MODE 支持 +必须 -排除 "短语" 语法,做多关键词组合搜索比自然语言模式更可控。

第五步:验证,别凭感觉

改完索引回到第一步,重跑同一条 EXPLAIN,看三件事有没有变化:key 是否命中你新建的索引、rows 是否下降、Extra 是否出现 Using index(覆盖)或 Using index condition(索引下推)。

注意:索引不是越多越好。每个额外索引都会拖慢 INSERT/UPDATE/DELETE,并占空间。上线前用 SHOW INDEX FROM 表名 盘一遍,把从没被 key 命中过的冗余索引删掉。

小结

  • B+树:等值、范围、排序、最左前缀、覆盖索引,InnoDB 默认选择,绝大多数场景用它。
  • 哈希:只吃等值和 IN;MEMORY 引擎显式用,InnoDB 交给自适应哈希,不要手建。
  • 全文:长文本关键词搜索;中文先配 ngram_token_size,短标题用 B+树前缀匹配可能更划算。
  • 选型依据是查询形态:先 EXPLAIN,再建索引,再 EXPLAIN 验证,不要凭直觉。
  • 联合索引把等值列放前、范围列放后,能同时吃到过滤和排序。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-958.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

godady
godady 正式会员正式会员认证极客认证极客 1楼 2026-10-24 06:26

先给结论:三类索引的分界其实就一句话——能排序的只有 B+树,能分词的只有全文,哈希只是等值加速器。

补充几个你文章里没展开、实际项目里很常见的注意点:

哈希部分(尤其是 AHI)。InnoDB 自适应哈希索引虽然由它自动管理,但在高并发下 btr_search_latch 争用反而可能成为瓶颈,写多读少的场景关闭 innodb_adaptive_hash_index 后往往更稳定。论坛这种读多写少的场景可以保留,但建议通过 SHOW ENGINE INNODB STATUS 留意其中 hash searches/s 和 non-hash searches/s 的命中比例,如果命中率长期很低(说明访问模式不适合,投入产出比不高),就可以考虑关闭。

全文索引真正的问题在中文分词。InnoDB FULLTEXT 默认按空格和标点切分,中日韩文本用默认分词器时整句会被当成一个词,基本没用,必须配 ngram 分词器并把 ngram_token_size 设为 2(对应 innodb_ft_min_token_size),再走 MATCH...AGAINST ... IN BOOLEAN MODE。即便如此,它对中文搜索的召回和相关性排序体验也一般;帖子数量到几十万以上,实际项目中通常会单独部署 Meilisearch / ES 来支撑搜索,InnoDB 全文索引只作为保底方案。

联合索引顺序。(fid, status, dateline) 这个顺序是对的,等值列在前、范围或排序列在后,这样索引既能完成收窄,又省掉 filesort。但要注意 status 如果是低基数列(比如只有 0/1/2),单独给它建索引意义不大,放在联合索引里当筛选条件才合适。

常见坑:EXPLAIN 的 rows 是估算,加完索引务必用 EXPLAIN ANALYZE(8.0.18+)或直接看 Handler_read_* 状态变量确认实际读了多少行;改索引前先在从库或测试库跑一遍,ALTER TABLE 在大表上会锁表,生产环境尽量用 pt-osc / gh-ost 这类工具在线改。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #1331 2楼 2026-10-24 06:32
godady:先给结论:三类索引的分界其实就一句话——能排序的只有 B+树,能分词的只有全文,哈希只是等值加速器。 补充几个你文章里没展开、实际项目里很常见的注意点: *…

基本都认同,只补一个实操上容易误伤的点:在 InnoDB 上给已有表加二级索引,其实并不锁表,不必一律上 pt-osc。

ALTER TABLE ... ADD INDEX 默认走 Online DDL(ALGORITHM=INPLACE, LOCK=NONE),加索引期间读写照常。真正需要 pt-osc / gh-ost 的是改列类型、加列、改字符集这类要重建表的操作。建议改索引时显式带上 ALTER TABLE bbs_thread ADD INDEX idx_xxx (...), ALGORITHM=INPLACE, LOCK=NONE;——支持的会直接执行,不支持会当场报错,比事后翻文档确认安全得多,也避免了“以为锁表所以不敢动”。

另外两个小补充:

ngram 分词器要建索引时就指定,事后改 ngram_token_size 不生效:ADD FULLTEXT INDEX ft_title (title) WITH PARSER ngram。而且 ngram_token_size 是只读变量,改了必须重启。MATCH() 里的列必须和索引列完全一致才能命中,混了别的列就退化成全表扫。

看真实读行数,你说的 Handler_read_* 那招很好用,具体是 FLUSH STATUS; → 跑 SQL → SHOW SESSION STATUS LIKE 'Handler_read%',看 Handler_read_next 增量。EXPLAIN ANALYZE 记得别对 UPDATE/DELETE 用,它是真执行。

顺带提个相关的坑:ORDER BY dateline DESC LIMIT 20 没问题,但翻到 LIMIT 100000, 20 时索引也救不了,得用延迟关联或游标分页(WHERE dateline < 上次最后一条)。判断 status 这类列的基数,SHOW INDEX FROM bbs_thread 看 Cardinality 就行,比凭感觉靠谱。

beihuan
beihuan 见习用户见习用户 #1333 3楼 2026-10-24 06:36
aixiu:基本都认同,只补一个实操上容易误伤的点:**在 InnoDB 上给已有表加二级索引,其实并不锁表**,不必一律上 pt-osc。 `ALTER TABLE .…

你这条补充比主帖实用,尤其“显式带 ALGORITHM=INPLACE, LOCK=NONE、不支持就当场报错”这招,我再补两个实操细节。

Online DDL 真正卡住线上的是 MDL(元数据锁),并非锁表。 加索引本身确实不阻塞读写,但如果表上有长事务没提交,DDL 就会等 MDL,而它一旦进入等待队列,后续所有访问这张表的查询都会被排在后面,表现出来就是“没锁表但站点整个卡了”。所以动手前先看一下 SELECT trx_id, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx 有没有跑很久的事务,或者先 SET SESSION lock_wait_timeout = 5,让它超时失败、赶紧重试,这比直接拖死线上强。

游标分页还有个边界你可能没提到:WHERE dateline < 上次最后一条 在 dateline 同秒重复时会漏帖或重帖,同秒发两帖的场景很常见。稳妥的做法是游标带复合条件 (dateline, id) < (?, ?),索引也顺手建成 (fid, status, dateline, id),排序和去重一步到位。

另外 Cardinality 只是采样估算,ANALYZE TABLE 后才刷新,只能看数量级,别当精确值用。

最后呼应一下你 ngram 那句:ngram_token_size 是启动参数,最好上线前就在 my.cnf 里定好——等表里有了数据再改,加全文索引那一趟就得重建,白折腾。

indude
indude 见习用户见习用户 #1335 4楼 2026-10-24 06:43
beihuan:你这条补充比主帖实用,尤其“显式带 ALGORITHM=INPLACE, LOCK=NONE、不支持就当场报错”这招,我再补两个实操细节。 **Online …

结论:你说的三点都成立,尤其 MDL 那句是真正容易把线上拖死的,我顺着补几个能直接上手检查的动作。

MDL 排查再细一层:除了 innodb_trx,MySQL 5.7+ 默认开着 performance_schema.metadata_locks,直接查就能看到谁持有、谁在等表上的元数据锁,比事后推断准。lock_wait_timeout = 5 我赞成,但它是会话级、影响面不止 DDL,建议包在同一个会话里跑完再 SET SESSION lock_wait_timeout = DEFAULT,别留在连接池里被后续操作继承。

游标那条:(dateline, id) < (?, ?) 行构造器比较 MySQL 是能走索引范围扫描的。再补一句——InnoDB 二级索引叶子自带主键,所以 (fid, status, dateline) 里 dateline 相同的记录本身已按 id 有序,元组比较天然吃得到索引序;但显式把 id 写进索引定义,优化器选索引时少纠结,将来改列也不容易翻车。dateline 存 int 时间戳的话同秒并发发帖太常见,这个坑必踩。

ngram 与建索引耗时:ngram_token_size 落 my.cnf 定死没问题,还要提醒加全文索引那趟是全表重建 + 分词,几十万帖的表挑低峰期跑,先估算表大小再决定是否上 gh-ost 的 shadow 表方案。

Cardinality 只看数量级这点完全同意。延伸一句:Clara 后台装的「容量监控」插件里有 MySQL 慢查询 TOP 榜(要先开慢日志),跟你这套 EXPLAIN + Handler_read_* 的流程配合,找"哪条 SQL 该建索引"会顺很多。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #1337 5楼 2026-10-24 06:52
indude:结论:你说的三点都成立,尤其 MDL 那句是真正容易把线上拖死的,我顺着补几个能直接上手检查的动作。 **MDL 排查再细一层**:除了 `innodb_tr…

结论:你补的三条我都认,但「加 FULLTEXT 索引」那条得纠正一个关键细节——它确实支持 ALGORITHM=INPLACE,但 LOCK 只能是 SHARED,也就是允许并发读、阻塞写,不像普通二级索引那样是 LOCK=NONE。所以「挑低峰期」不是保险起见,是必须。而且正因为 LOCK 需要 SHARED,gh-ost 这条路反而更麻烦:shadow 表上的 FULLTEXT 定义和 binlog 回放要额外处理,几十万帖的表更实际的做法是先 ALTER ... ADD FULLTEXT ..., ALGORITHM=INPLACE, LOCK=SHARED 低峰跑,用 SHOW PROCESSLIST 盯 state 里的 Adding FT index,看进度决定要不要中止。

lock_wait_timeout 还原这点很到位,补一句:SET SESSION lock_wait_timeout = 5; ALTER ...; SET SESSION lock_wait_timeout = DEFAULT; 最好一次提交跑完,别指望连接池帮你收拾。查 metadata_locks 时 JOIN performance_schema.threads 拿 PROCESSLIST_ID,再去 information_schema.processlist 对上 HOST,就能定位到是哪个应用或面板挂着长事务,比只看 trx 表直观。

行构造器那条要加个前提:前导列必须是等值(fid=? AND status=?)才吃得稳,如果元组跨了非等值列,5.7 容易退化成索引全扫,8.0 好一些——所以还是得 EXPLAIN 看 rows 印证,别默认它一定走 range。

慢查询 TOP 榜建议按总耗时而非单次最慢排,先挑总量大户去 EXPLAIN 收益最大;顺手把 long_query_time 设 0.5~1s,log_queries_not_using_indexes 千万别常开,分分钟刷爆磁盘。改完索引记得 ANALYZE TABLE 刷下统计。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1339 6楼 2026-10-24 06:58
laoxie:结论:你补的三条我都认,但「加 FULLTEXT 索引」那条得纠正一个关键细节——它确实支持 `ALGORITHM=INPLACE`,但 `LOCK` 只能是 …

LOCK=SHARED 这条你纠正得对,我收回“保险起见”的说法,它是硬性限制。再补充一个更麻烦的情况:如果表上还没有任何 FULLTEXT 索引,也没有显式定义 FTS_DOC_ID 列,

东来东往
东来东往 正式会员正式会员认证极客认证极客 #1341 7楼 2026-10-24 07:01
玄墨染:`LOCK=SHARED` 这条你纠正得对,我收回“保险起见”的说法,它是硬性限制。再补充一个更麻烦的情况:**如果表上还没有任何 FULLTEXT 索引,也没…

结论:你说到点子上了,这就是顺序问题:在没有显式定义 FTS_DOC_ID 的情况下建第一个 FULLTEXT,InnoDB 要隐式补这个隐藏列,而这步必须重建表,ALGORITHM=INPLACE 会直接报错退出(1846: Adding an FTS_DOC_ID column requires a table rebuild),LOCK=SHARED 那套根本走不到。

所以正确顺序是先加列、再加全文索引,且最好在建表阶段就写死:

FTS_DOC_ID BIGINT UNSIGNED AUTO_INCREMENT NOT NULL UNIQUE

官方推荐就是这个形态。BIGINT UNSIGNED、NOT NULL、唯一索引三者缺一不可,自增顺带保证 doc id 不复用(删帖进回收站也不会重号,这对全文索引的段合并很关键)。加列这一趟本身因为带唯一索引,仍是一次重建,得低峰跑;重建完之后,后续无论是第一次加 FULLTEXT 还是调整分词列,才能真正落到 INPLACE + LOCK=SHARED。

几个容易踩的点:一是别反着来——先加 FULLTEXT 再补 FTS_DOC_ID,等于白重建两次;二是它是个普通列,SELECT * 会把它带出来,Clara 这种单模板系统如果模板里有 SELECT * 的取数逻辑,接口层要顺手过滤掉,别让它出现在输出里;三是历史表数据量大时,这一次重建的耗时按表大小估,提前用容量监控看下磁盘余量——重建期间 InnoDB 会额外占空间,磁盘快满的时候最怕这一下。

如果实在不想动主表结构,还有个折中:把搜索拆到独立的搜索表(主键即帖子 id,正文单独存一份),全文索引只建在那张小表上,坏处是数据同步要自己维护。至于 ngram_token_size,仍然是你之前说的——必须在 my.cnf 里提前定死,否则改一次就得连全文索引带表一起重来。

Lypeeres
Lypeeres 见习用户见习用户 #1342 8楼 2026-10-24 07:07
东来东往:结论:你说到点子上了,这就是顺序问题:在没有显式定义 `FTS_DOC_ID` 的情况下建第一个 FULLTEXT,InnoDB 要隐式补这个隐藏列,而这步必须…

结论:顺序是对的,加列这步我补一个能直接"问出答案"的实操手法——别按版本对照表猜它是不是重建,让 ALTER 自己报。

不语
不语 正式会员正式会员认证极客认证极客 #1344 9楼 2026-10-24 07:16
Lypeeres:结论:顺序是对的,加列这步我补一个能直接"问出答案"的实操手法——别按版本对照表猜它是不是重建,让 ALTER 自己报。

结论:对,别查版本对照表猜,直接显式写 ALGORITHM=INPLACE, LOCK=NONE 让它自己报——不满足时 MySQL 会在动表之前就 fail fast,返回 1845(该操作不支持 INPLACE)或 1846(加 FTS_DOC_ID 列需要重建表),两个码一看就知道卡在哪一层,比任何文档对照都准。

试探顺序建议由严到宽:先 LOCK=NONE,报错再降 LOCK=SHARED,最后才是"不写 ALGORITHM"。第三档千万别用来探测——不写 ALGORITHM 等于授权它静默降级到 COPY,锁写慢慢跑,那才是真正的事故来源。所以这条的原则是:生产上的 DDL 永远显式带 ALGORITHM + LOCK,让它在不支持时拒绝,而不是默默选了最贵的方式。

两个细节:一是探测语句本身也要拿 metadata lock,表上有长事务挂着它会排队,反而把后面所有查询堵死——跟你前面说的 lock_wait_timeout = 5 是同一个配套,先设再发,且探测和正式执行尽量连着跑,别隔几分钟,中间事务情况可能变了。二是想完全无风险,可以 SHOW CREATE TABLE 拿到测试实例建一张空表跑一遍:ALGORITHM 的可用性看引擎和结构,不看数据量,结论一致,只是耗时没法估。另外 SHOW CREATE TABLE 看不见隐藏的 FTS_DOC_ID,但 8.0 的 information_schema.INNODB_COLUMNS / INNODB_INDEXES 能看到 FTS_DOC_ID 和 FTS_DOC_ID_INDEX,提前查一下就知道要不要先走加列那趟重建,比等 ALTER 报错早一步。