ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

主键索引与普通索引差在哪?InnoDB聚簇索引与回表原理揭秘

主键索引与普通索引差在哪?InnoDB聚簇索引与回表原理揭秘 上周同事拿一条慢SQL来找我查询很简单SELECT * FROM user WHERE name 张三。表里不到30万行name字段上建了普通索引但响应时间一直稳定在50ms上下。我让他改成SELECT * FROM user WHERE id 12345结果一下子变成0.2ms。他盯着两个都“走索引”的查询看了半天问了一句都是索引凭什么差这么多这个问题正好戳中了MySQL索引最核心的一个点主键索引和其他索引在InnoDB里根本不是同一种东西。如果你只会回答“主键索引更快”那面试官和业务方都不会满意。这篇文章我结合一次真实的排查过程把主键索引和其他索引在结构、约束、查询路径上的区别一次说清楚。1. 一条慢SQL牵出的问题普通索引和主键索引的差距从哪来1.1 现场几十万行的表查普通索引比主键慢了两个数量级先还原一下当时的表结构很常见的一张用户表CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(50) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, age INT DEFAULT NULL, PRIMARY KEY (id), KEY idx_name (name) ) ENGINEInnoDB;表里不到30万行数据id是自增主键name上建了一个普通索引。同事反馈的慢查询是SELECT * FROM user WHERE name 张三;我让他先跑一下EXPLAIN结果是这样的id | select_type | table | type | key | rows | Extra 1 | SIMPLE | user | ref | idx_name | 700 | NULLtyperefkeyidx_name这表示SQL走了name上的普通索引而且预估扫描700行。问题就出在这里name有重名比如叫“张三”的用户有700个那么查询会先从idx_name这棵B树里找到700个主键ID然后拿着这700个ID再去主键索引的B树里把完整行数据取出来。这个“拿着主键ID再回主键树取行数据”的操作就是回表。700次回表每次都是一次随机IO30万行的表InnoDB的缓存池如果没有完全预热响应时间变成50ms一点都不奇怪。如果这700行分布在不同的数据页里IO代价会更高。1.2 改成主键查询为什么秒回我让同事再跑这条SELECT * FROM user WHERE id 12345;EXPLAIN结果id | select_type | table | type | key | rows | Extra 1 | SIMPLE | user | const | PRIMARY | 1 | NULLtypeconstkeyPRIMARYrows1。这一下速度从50ms降到了0.2ms差距接近两个数量级。原因很简单主键索引的叶子节点上直接存放了整行数据WHERE id12345在B树里定位到叶子节点后这一行的所有字段name、phone、age全部都在当前数据页里不需要再去任何其他位置找数据。一次索引查找就等价于拿到了完整记录。1.3 这不是“索引快慢”问题而是索引结构问题很多人会把上面这个case简单地理解为“主键索引比普通索引快”其实快只是表象真正的问题是结构不同。普通索引也叫二级索引的叶子节点并不存完整行数据它只存“索引字段的值 主键值”。也就是说普通索引在查询时天然多一步“根据主键去主键索引里取行数据”的操作。只要查询需要返回完整行SELECT *或者查询条件里用到的字段不在该二级索引中就必然会发生回表。我见过不少开发在排查慢SQL时看到key列有值就认为“索引已经生效了”结果反应还是很慢。这个认知偏差的根源就是对主键索引和普通索引的存储结构没有区分清楚。下面我把这两种索引在B树里的真实样子拆开讲。2. 两种索引在B树里的“藏身处”完全不同2.1 InnoDB主键索引就是聚簇索引叶子节点直接放整行数据在InnoDB存储引擎里表数据本身就是按主键顺序组织存放的。主键索引的B树它的叶子节点就是数据页数据页里存放的是一行一行的完整记录。这种“索引即数据数据即索引”的结构叫做聚簇索引。聚簇索引有一颗特征一张InnoDB表只能有一个聚簇索引因为物理上的行数据只有一种排列顺序。如果你建表时没有显式定义主键InnoDB会尝试找一个非空的唯一索引作为聚簇索引如果连唯一索引都没有它会生成一个隐藏的ROW_ID作为聚簇索引键。无论哪种方式聚簇索引是必然存在的它承载了整张表的全部数据。所以你可以这么理解主键索引的B树它的叶子节点不是“索引项指向数据”而是“索引项就是数据”。定位到主键值的那一刻完整记录就摆在你面前。2.2 普通索引二级索引的叶子节点只放“主键值索引字段”普通索引包括唯一索引、联合索引、前缀索引在InnoDB中都属于二级索引。二级索引的B树结构是这样的非叶子节点存“索引字段值 指向下一层子节点的指针”叶子节点存的是“索引字段值 主键值”并不存完整行记录。举个例子表里idx_name这棵B树的叶子节点大概长这样(李四, id102) (张飞, id88) (张三, id36) (张三, id57) (张三, id128)也就是说二级索引的叶子节点里对每一个索引值都会额外存一个对应行的主键值。当你要根据name张三查完整行时InnoDB先在idx_name这棵树上找到所有匹配的叶子节点拿到主键值列表比如[36, 57, 128, ...]然后再拿着这些主键值去聚簇索引里查完整行。这一步就是回表。2.3 所以一张表只能有一个主键索引但普通索引可以有多个主键索引只有一个是因为聚簇索引只能有一个——行数据只能按一种物理顺序存放。普通索引则可以随意加很多个因为每个二级索引都独立维护一棵B树它不影响表数据的物理排列只是在叶子节点里额外记录主键值作为和聚簇索引之间的“桥梁”。这个桥梁关系是理解InnoDB索引的钥匙所有二级索引最终都要依赖主键索引来定位完整数据行。所以主键选得好不好不只是“唯一约束”的问题它直接决定了所有二级索引回表时的效率。有一个容易被忽略的点主键如果设计得过大比如用varchar(64)的UUID做主键那么每一个二级索引的叶子节点里都要多存这个64字节的主键值。索引占用的空间会显著膨胀缓冲池里能放下的索引页变少查询性能自然受影响。2.4 类比主键索引像正文普通索引像书后主题索引我经常用书来类比。主键索引就像一本按页码顺序排好的书正文就在书页上你翻到某一页内容直接读。普通索引就像书最后附的“主题索引”它告诉你“张三”这个词出现在第36页、第57页、第128页你想看具体内容还得翻回正文对应页码。这个类比还能解释一个现象为什么要避免SELECT *走普通索引因为SELECT *意味着你要把整行内容都取出来而普通索引的叶子节点上没有完整内容必须回主键索引翻正文。如果查询的字段恰好全部包含在普通索引里那么连“翻正文”都省了这就是下一章要讲的覆盖索引。3. 普通索引查询多出来的那一步回表是怎么发生的怎么让引擎不回表3.1 普通索引的完整查询路径搜二级索引树-拿主键-再到主键树取数据一次走普通索引的查询在InnoDB内部至少经历三步从普通索引的B树根节点开始按索引字段值比较一直下探到叶子节点找到所有匹配的索引记录。从叶子节点里取出对应行的主键值。用主键值再次进入聚簇索引B树从根节点重新搜索最终在叶子节点的数据页里拿到完整行记录。第3步就是所谓的回表。回表次数等于第一步匹配到的行数。可以这么说普通索引等值查询的代价不是“一次索引查找”而是“一次索引查找 N次主键查找”。N越大慢的概率越高。3.2 回表不是每次都致命取决于命中的行数和数据分布回表本身不是洪水猛兽。如果普通索引的筛选度极高比如idx_phone查询独一份的身份证号匹配1行回表1次那性能完全没问题。真正怕的是索引筛选度低比如性别字段、状态字段一次匹配几千上万行那回表就是灾难。还有一点很多人不知道如果分页查询LIMIT 10InnoDB的回表是有“止损”机制的。比如用普通索引排序后再分页引擎可能先按二级索引顺序扫描拿到足够的主键值之后才回表但MySQL优化器不一定每次都这么做。更常见的情况是优化器发现普通索引回表代价太高干脆放弃普通索引改用全表扫描。我遇到过好几次明明条件字段上有索引EXPLAIN却显示typeALL就是优化器在“全表扫描”和“普通索引回表”之间做了成本权衡。3.3 覆盖索引把目标字段直接塞进二级索引里既然二级索引叶子节点只存“索引字段主键”那如果查询需要的所有字段恰好都在这两部分里就不需要回表了。这种“查询所需字段被索引完全覆盖”的情况就叫覆盖索引。例如SELECT id, name FROM user WHERE name 张三;因为id是主键name是索引字段而这棵二级索引的叶子节点里正好有id和name不需要回表去取phone和ageInnoDB直接扫二级索引就搞定了。如果查询是SELECT id, name, phone FROM user WHERE name张三phone不在idx_name索引里那就得回表了。明白这个原理后就可以用联合索引来“定制”覆盖索引。比如我们经常查“根据name查phone和age”那就可以建KEY idx_name_age_phone(name, age, phone)让这三个字段都进入二级索引。覆盖索引不是银弹索引字段越多占用空间越大写入和维护成本越高但它确实是优化高频查询最常用的手段。3.4 用EXPLAIN识别回表和覆盖索引判断一条查询是否回表最直接的方式是看EXPLAIN的Extra列Extra NULL -- 二级索引查到主键后需要回表取完整行 Extra Using index -- 查询字段被索引覆盖不需要回表注意Using index和Using where不一样。Using where表示在索引定位后还需要对记录做条件过滤Using index表示本次查询的数据全部来自索引不会回表。两者同时出现也是常见的表示覆盖索引内有WHERE过滤条件。我习惯在优化SQL时把EXPLAIN输出贴到笔记里重点关注三列type、key、Extra。type从好到差依次是system const eq_ref ref range index ALLkey一定要是期望的那个索引Extra则尽量追求Using index。如果Extra出现Using filesort或Using temporary那又是另一类性能问题通常和排序、分组、去重有关。4. 主键索引和唯一索引、普通索引在约束和使用上的几个关键区别4.1 主键是“唯一约束非空约束聚簇索引”唯一索引只是索引从语义层面看主键和唯一索引都能保证字段值不重复但主键多了两个硬性要求非空、唯一、且是聚簇索引。唯一索引本质上就是一个普通的二级索引只不过多了“唯一”这个逻辑约束它并不承载表数据。所以在建表设计时这两者的定位完全不同主键是用来唯一标识每一行数据的它决定了数据的物理组织唯一索引是用来保证某个业务字段唯一性的比如身份证号、微信unionid它并不会改变表数据本身的物理排列。如果你的业务字段既有唯一性需求又不希望它影响表结构应该建唯一索引而不是把业务字段抬成主键。4.2 NULL值的处理规则不同主键绝对非空唯一索引允许NULL这一个区别在面试里经常被追问。InnoDB的主键列不允许为NULL即使你在建表时没写NOT NULLMySQL也会自动把主键列设为NOT NULL。而唯一索引允许NULL值并且多个NULL值不会被视为重复。举例来说表里有唯一索引uk_phone那么你可以插入两条phoneNULL的记录唯一约束不会阻止你因为MySQL认为每个NULL都是“未知”未知和未知不相等。这在某些业务场景会造成数据重复的坑如果需要“手机号不能重复但可以为空”唯一索引做不到完全兜底还需要在应用层做防护。而主键列永远没有这个烦恼它就是非空唯一的。4.3 主键顺序和物理顺序绑定普通索引有自己的逻辑顺序因为主键索引是聚簇索引所以主键的排列顺序基本决定了物理行的排列顺序。插入的主键值越有序比如自增ID数据写入越接近“追加写”page分裂和页重排的代价越低。反过来如果主键值乱序插入比如UUID字符串每次插入都可能让数据页发生分裂产生碎片导致写放大和查询性能下降。普通索引则完全不受这个约束。普通索引的B树内部按照索引字段值排序它和主键顺序、物理行顺序都没有强绑定关系。一个普通索引内部可能是name按字典序排列同一个name下再按主键值排列。这就是为什么一张表可以有多个普通索引每个索引各自按不同字段排序互不干扰。4.4 实际踩坑业务字段当主键、UUID主键和自增主键的选择我在项目中见过不少主键设计翻车的情况最典型的有两种。第一种是拿有业务含义的字段当主键比如phone、email。初期没问题但业务一旦扩展比如手机号可能变更而所有二级索引的叶子节点里都保存着这个主键值手机号一改所有索引都要跟着更新代价极大。更麻烦的是如果手机号作为主键表的物理顺序就跟着手机号排列查询范围扫描时很难做到高效有序的追加写。第二种是拿UUID或者雪花ID当主键但没有处理有序性问题。UUID天然乱序写入时InnoDB要不断调整聚簇索引位置来维护B树平衡会产生大量页分裂。页分裂不仅拖慢写入还会让表产生碎片扫描性能变差。后来我改用INT/BIGINT自增主键或者对雪花ID做改造把时间戳放在高位生成有序ID写入性能立刻恢复正常。这里给一个相对通用的建议业务字段只做主键的备选但不要直接做主键分布式场景需要用全局唯一ID时优先考虑有序ID生成算法单机自增主键依然是最省心、最稳妥的选择之一。5. 存储引擎差异MyISAM里“主键索引”名字变了但本质还是普通索引5.1 MyISAM索引文件和数据文件分离前面讲的聚簇索引都是InnoDB特有的行为。在MyISAM存储引擎里情况完全不同。MyISAM将数据文件和索引文件分开存储表的.MYD文件存数据.MYI文件存索引。索引文件里的B树叶子节点存放的是指向数据行的指针通常是数据文件中的行偏移量或记录地址而不是完整行数据。所以即使在MyISAM里定义了一个主键索引它和普通索引在结构上几乎没有区别都是独立于数据的索引结构叶子节点都存指针查询时需要根据指针再去数据文件里取行记录。5.2 MyISAM主键索引的叶子节点存指针而不是数据这意味着在MyISAM中主键索引本质上也是一种“非聚簇索引”。你去查主键值索引定位后拿到的是行指针还需要额外的一次磁盘IO去数据文件里捞数据。这和林果树的“翻目录找页码”更像而不是“翻到正文直接读”。这张对比表可以帮你快速理清两个引擎的差异项目InnoDB主键索引MyISAM主键索引数据存储位置主键索引叶子节点直接存完整行数据数据文件单独存储索引叶子节点存行指针是否聚簇是否二级索引叶子节点存索引字段 主键值存索引字段 行指针回表操作二级索引根据主键值回聚簇索引二级索引根据行指针回数据文件表数据物理顺序受主键顺序影响数据按插入顺序追加不受主键影响5.3 InnoDB和MyISAM的主键索引性能特征完全不同因为InnoDB的数据“长在”聚簇索引上所以按主键范围查询时比如WHERE id BETWEEN 1000 AND 2000数据的物理连续性很好扫描效率很高。MyISAM的主键范围查询在不考虑缓存的情况下每次定位数据都要通过指针去跳随机IO的概率更大。二级索引上的差别也很明显。InnoDB的二级索引叶子节点存主键值回表是拿主键值去聚簇索引里找行MyISAM的二级索引叶子节点直接存行指针回表是拿指针去数据文件里找行。单次定位上MyISAM可能少一次B树搜索但InnoDB因为数据页和索引页往往都在同一表空间中配合缓冲池和主键的有序性整体性能在绝大多数业务场景下都更好。5.4 现代生产环境为什么默认InnoDB现在MySQL 8.0已经把InnoDB设为默认存储引擎MyISAM逐渐成为历史选项。原因包括但不限于InnoDB支持事务和崩溃恢复MyISAM不支持InnoDB支持行级锁MyISAM只有表级锁InnoDB有聚簇索引数据组织更紧凑二级索引依赖主键后续维护更清晰。所以这篇文章讲“主键索引和其他索引的区别”默认语境就是InnoDB。如果你在面试或实际工作中遇到MyISAM的表需要先指出“引擎不一样主键索引的结构就不一样”这反而能体现你对底层机制的了解程度。6. 给技术管理和面试备考的统一回答框架6.1 面试官问这个问题时真正想考察什么“MySQL主键索引和其他索引的区别在哪里”是数据库面试的高频题但很多人背了一堆结论没有建立起完整认知。面试官真正想考察的是三个层次第一层你知不知道InnoDB的主键索引是聚簇索引叶子节点存完整行数据。第二层你知不知道普通索引是二级索引叶子节点存“索引字段主键值”。第三层你能不能引申出回表、覆盖索引、主键设计对二级索引空间的影响。只回答了第一层只能算及格能讲到第三层面试官才会觉得你是真的用过、真的排查过优化过。6.2 一段可以借鉴的回答框架我自己整理过一个回答模板大概这么讲InnoDB里主键索引就是聚簇索引表数据按主键顺序组织主键索引的叶子节点直接存放完整行记录。普通索引属于二级索引叶子节点只存放索引字段和对应主键值所以走普通索引查询时通常要先在二级索引里找到主键值再回聚簇索引取完整行这就是回表。如果查询需要的字段恰好都在二级索引里就能用覆盖索引避免回表。另外主键非空唯一唯一索引允许NULL主键顺序影响物理存储和页分裂UUID这类无序主键会造成额外IO。不同存储引擎也有区别MyISAM的主键索引不是聚簇索引叶子节点存的是行指针。这段话逻辑上是层层递进的既回答了定义又点出了查询路径、约束、设计取舍和引擎差异。当然面试官如果追问细节你一定要真的懂上面每个术语而不是背稿子。6.3 联合索引里的主键“隐性存在”容易被忽略联合索引也是二级索引所以在联合索引的B树中叶子节点除了保存联合索引定义的那些字段值还会额外保存主键值。这一点在做联合索引设计时非常有用。比如建了一个联合索引KEY idx_city_age(city, age)那它的叶子节点实际上保存的是(city, age, id)三个值。如果有一条查询SELECT id, city, age FROM user WHERE city 杭州 AND age 20;所需要的id、city、age全都在idx_city_age这个索引里根本不需要回表Extra会显示Using index。但如果你在这个查询里再加一个phone字段phone不在索引里就必须回表了。所以设计联合索引时可以故意把高频查询需要的字段加进来让二级索引“扩容”从而减少回表次数。代价是索引文件变大、写入变慢。这是一个权衡不能贪多。6.4 我的经验做索引优化时如何用好“主键优先”这条原则最后聊一点实操体会。我优化慢SQL时如果发现一条查询走了普通索引还要大量回表通常按这个顺序调整先看能不能改成查主键。如果业务上允许走id查询比如后台详情页直接用WHERE id...没有任何二级索引能比主键聚簇索引更快。再看能不能用覆盖索引。把高频返回字段加进联合索引减少回表次数尤其是分页查询和列表查询效果明显。最后才是考虑换查询方式。比如大量OR条件导致索引失效或者LIKE %xxx%无法走索引先改写SQL再考虑新建索引。有一次线上慢查询一个月被DBA提醒了三次就是SELECT *走了一个普通索引回表几千行。后来我把SELECT *改成了只查必要的字段并建了一个匹配的联合索引查询时间从80ms降到2ms。这个问题的根因本质上还是主键索引和普通索引的结构差异——没有回表就没有这几十倍的差距。MySQL索引的学习不能停在“加索引会快”这个层面。当你真正理解主键索引是聚簇索引、其他索引是二级索引这两句话时后面很多优化决策都会变得顺理成章。下次再有人问你这个问题希望这篇内容能帮你讲得更透彻。
返回列表