MySQL一条数据是如何存储的?InnoDB存储原理全拆解
你是不是也好奇过在MySQL里执行完一条INSERT语句后那一行数据到底被塞到磁盘的哪个角落去了很多初学者把MySQL当成一个黑盒子会写增删改查就觉得自己入门了但一旦遇到“为什么删了数据文件大小没变”“为什么这个表占用空间这么大”“为什么UUID主键会让性能变差”这类问题就完全摸不着头脑。这篇文章就把存储这件事从头到尾拆开讲清楚适合所有刚开始学MySQL、想搞懂InnoDB存储引擎原理的读者。搞清楚一条数据是怎么存储的很多数据库运维问题都能迎刃而解。先说结论MySQL里一条数据的存储不是“文件里加一行”那么简单。它会经过Server层的解析优化进入存储引擎后先在内存缓冲池和日志文件里周旋最终落盘到一个16KB大小的数据页中。这页数据再由B树索引组织起来存进表空间对应的.ibd文件。整个链条里行格式、数据页结构、索引组织方式、内存和磁盘的交互每一步都决定了这条数据的存储效率和查询性能。1. 先从一次INSERT说起一条数据的完整“旅程”1.1 Server层和存储引擎层到底谁在干活MySQL的架构经常被叫成“两层的蛋糕”。最上层是Server层负责客户端连接、词法语法分析、权限校验、优化器生成执行计划这些事下层就是存储引擎层InnoDB、MyISAM、Memory这些都是插拔式的引擎。你执行一条INSERT语句时Server层先做解析判断你要往哪个表、哪些列写入什么值然后调用InnoDB提供的接口。真正干活的是InnoDB引擎它负责把数据组织成行、放进页、写进磁盘。理解一条数据如何存储核心就是理解InnoDB引擎这一层的机制。所以在MySQL的语境下聊存储默认聊的都是InnoDB这个前提必须明确。举个例子你在Server层传递的是“往user表插入一条id1、name张三的记录”这种逻辑请求而InnoDB引擎拿到请求后要考虑的是这条记录应该放在哪个页的哪个位置页满了要不要分裂索引要不要更新日志要不要先写内存里的缓冲页和磁盘上的文件页怎么保持一致。这些才是“存储”二字的真正含义。1.2 先写日志、再写数据的WAL机制InnoDB为了保证性能和数据安全用的是一套叫WALWrite-Ahead Logging的机制。通俗讲就是“先记账、后拨钱”在真正把数据页写进磁盘之前先把这条修改操作以日志的形式记录到redo log里然后再去改内存中的缓冲页。这条redo log是追加写的顺序I/O非常快。画个流程就是INSERT进来 - 修改缓冲池中的页 - 写redo log日志 - 提交事务返回成功。注意这时候磁盘上的数据文件可能还是老样子真正的脏页要等后台线程刷盘或者等未来某个时点再写进数据文件。这听起来有点反直觉但也正是MySQL能撑住高并发写入的核心原因。这里有一个很多初学者不知道的点如果事务已经提交而数据页还没落盘数据库突然崩溃怎么办别慌。重新启动时InnoDB会重放redo log把丢失的修改重新应用到数据页上这就保证了“事务一旦提交数据不丢”的持久性承诺。1.3 为什么这些设计能保证MySQL不丢数据很多人理解不了“先日志后数据”为什么反而更安全。打个比方你开了一家小吃店顾客点了菜你不能每次备料都跑到后厨把食材改一遍那样太慢。你先把订单记在快单本上告诉顾客“收到”等有空再凭订单去后厨补做。快单本就是redo log后厨就是磁盘数据文件。只要快单本在哪怕后厨被炸了也能照着快单本重新备一份菜。逻辑一模一样。所以一条数据被INSERT成功时严格说它“已经存在但不一定已经在物理数据文件里”。它真实的位置是在内存的缓冲池页里再加一条redo log作为保险。之后被后台线程刷盘才开始真正占据磁盘空间。这也是为什么你会看到刚插入时数据文件大小变化往往滞后于你直观预期。2. 行格式解剖一行数据在磁盘上的“骨架”2.1 四种行格式怎么选InnoDB存储数据的最小逻辑单位是“行”。而一行数据怎么在页里摆放就由行格式决定。InnoDB一共提供了四种行格式COMPACT、REDUNDANT、DYNAMIC、COMPRESSED。老版本默认REDUNDANTMySQL 5.7及以后默认是DYNAMICCOMPRESSED是DYNAMIC的压缩版。行格式之间的主要差异在于NULL列怎么记录、变长字段的长度怎么记录、大字段溢出时怎么处理。日常使用中你基本不用手动改行格式但理解它们的区别对排查存储空间异常很有帮助。尤其是“大字段溢出”这一点COMPACT和DYNAMIC差异非常明显后面我会单独讲。查看表的行格式很简单SHOW TABLE STATUS LIKE user\G输出里Row_format字段会明确告诉你这个表用的是哪种行格式。如果你只是改innodb_default_row_format旧表不会跟着变这点容易踩坑。2.2 COMPACT行格式的四个组成部分以最常见的COMPACT行格式为例一行记录在物理上分为四个区域变长字段长度列表、NULL值列表、记录头信息、真实数据。先把“变长字段长度列表”说清楚。VARCHAR、VARBINARY、TEXT、BLOB这些变长类型的值长度不是固定的所以需要在数据前头记录一下“这一列占了多少字节”。如果一行里有多个变长字段这些长度值会按照列的“逆序”存放也就是最后一列的长度在最前面。这个逆序设计的目的是让某些字段长度相同的时候可以复用InnoDB内部优化用。然后是NULL值列表。表里允许NULL的列会被映射到一组bit位上1表示该列值为NULL0表示不为NULL。这个列表也是逆序排列的而且按8个位一组保存到字节里不足8位补0。关键点在于如果某一列的值是NULL它就不会在真实数据部分占用任何空间。也就是说NULL列对行的大小贡献只有bit位上那一位非常省空间。记录头信息固定占用5字节40个bit里面包含delete_mask是否被删除标记、n_owned槽管理的记录数、heap_no页内堆位置、record_type0普通记录、1目录槽、next_record下一条记录的相对位置等。这些信息是InnoDB在页内维护记录链表、实现页目录索引的基础。最后才是真实的列数据。除了我们声明字段对应的值之外InnoDB还会额外埋几个隐藏列如果没有显式主键会有一个6字节的DB_ROW_ID充当行ID6字节的DB_TRX_ID记录最后修改这个行的事务IDDB_ROLL_PTR是7字节的回滚指针指向undo log用来实现MVCC和回滚。2.3 手把手算一算一行记录有多大光看定义不够我给你算一遍。假设有张表结构如下CREATE TABLE test_row ( c1 VARCHAR(10), c2 INT, c3 CHAR(4), c4 VARCHAR(30), c5 VARCHAR(20) ) ROW_FORMATCOMPACT;插入一条记录INSERT INTO test_row VALUES (ab, 123, xyz, hello, NULL);计算过程是这样变长字段是c1、c4、c5c2是固定长度的INT4字节c3是CHAR(4)定长。c1占2字节c4占5字节c5为NULL在变长列表中记录为0但长度列表里每列对应的长度依然要占用1个字节。变长列表共3个字节逆序排列。NULL值列表可空列是c1、c3、c4、c54个列所以只需要一个字节。c5为NULL其他非NULL所以二进制是00001000对应一个字节0x08。记录头信息固定5字节。隐藏列因为表有主键c1吗没有显式主键所以要算上6字节DB_ROW_ID、6字节DB_TRX_ID、7字节DB_ROLL_PTR共19字节。实际数据c1ab两字节、c2INT四字节、c3xyz但CHAR(4)固定是4字节不足补空格、c4hello五字节、c5为NULL不占用实际数据空间。合计下来3 1 5 19 2 4 4 5 43字节加上页内记录链表指针等杂项实际会再多一点。但一个大概结论已经出来了一行数据在页里实际占用40多字节远远小于我们在逻辑层看到的“只是几个字段拼起来”。这个计算的价值在于你能粗算出一张表在理想情况下能塞多少行数据。比如16KB的页如果每行占200字节理论上一个页大约放80行1GB的表空间大概能放500万行左右。当然实际会有碎片和索引额外占用但量级是靠谱的。2.4 隐藏列看不见但必须存在前面提到过隐藏列这里单独强调一下因为它们在“一条数据如何存储”这个问题里实在太重要了。DB_ROW_ID只有在表没有定义主键时才存在你插的记录其实都悄悄带了一个自增的6字节行ID。DB_TRX_ID是每个修改这条记录的事务号它是MVCC实现多版本的核心依据每条记录的历史版本通过它来判断哪个版本对当前事务可见。DB_ROLL_PTR则形成一条undo链配合undo log支持回滚和一致性读。这三个隐藏列加起来19字节任何时候InnoDB存储一行数据都跑不了这开销。所以即使你建一张只有一个BIT字段的表行的实际最小占用也要远远高于你以为的“一个bit”。这也是为什么我建议建表时永远不要省略主键否则白白多付出6字节的ROW_ID存储还会因为无主键导致数据无法按聚簇索引组织效果更差。3. 数据页与表空间存储的物理单元3.1 为什么页大小是16KB行格式决定一行长什么样可磁盘分配的最小单位不是行而是页。InnoDB默认每个页16KB这也是一个经典的折中值。页太小同一张表就要更多页来容纳数据B树的层数就会加深查找路径变长页太大单次I/O把不需要的数据也读进来了浪费内存带宽。打个比方你寄快递时不可能把每件小商品都单独拿个包裹你得把它们按一定数量装箱箱子就是页。这个箱子大小选16KB配合常见的机械硬盘4KB扇区和SSD的读写特性InnoDB在顺序扫描和随机单点查询之间找了个平衡点。想确认当前页大小可以执行SHOW VARIABLES LIKE innodb_page_size;绝大多数情况下都是16384字节。这个参数在建库实例时固定之后不能随便修改改小会导致B树层数增加改大又浪费空间实际生产里不建议动这个值。3.2 一个数据页内部是怎么组织的一个16KB的数据页并不是一整块裸存储它有自己的结构文件头、页头、最大最小记录、用户记录区、空闲空间、页目录、文件尾。用户记录就是真正存我们数据的地方一条条行记录按主键顺序在页中排列成单向链表。但是这个链表不能直接从头遍历否则查询速度太慢。于是InnoDB在页内划分出Page Directory页目录每隔几条记录就设一个槽Slot槽里存的是对应记录的相对位置形成一组稀疏索引。查找一条数据时先通过二分法定位到某个槽再在槽内的小范围链表里顺序找速度就起来了。页头里会有页中记录数、空闲空间位置、最后插入位置等元信息。文件头和文件尾则分别记录页的校验和、页号、上下页指针形成了一个双向链表。文件尾是防止页在写入过程中损坏而设的检测机制。数据页的结构听起来复杂但你可以把它理解为“一本精装书的目录”先按目录页粗查再在某个小节里扫几行而不是从第一页逐页翻到最后一页。3.3 区、段、表空间三层物理结构数据页之上还有“区”和“段”两个概念。区由64个连续的页组成共1MB。为什么页之上还要一个区因为B树中叶子节点在物理上分散的话顺序扫描会很痛苦。InnoDB分配空间时按区为单位申请保证一部分页在磁盘上物理相邻这样全表扫描时I/O就是顺序的。段则分为叶子节点段和非叶子节点段分别存放B树的叶子节点和内部索引节点。你看到的每个索引都对应两个段。段并不对应表空间里某个连续文件区域而是不断从表空间中申请区来扩展。最上层就是表空间。InnoDB默认开启每表一个表空间也就是一个表对应一个.ibd文件。实际在文件系统里你看到一个user.ibd基本就可以认为这张表的所有数据页、索引页都在里面。共享表空间ibdata1则存放系统数据、undo log等DBA常说的“ibdata1一直涨”就是它。3.4 ibd文件里到底有什么.ibd文件本质上是页的集合里面有数据页、索引页、BLOB页、undo页等。页在文件里用“页号”唯一定位文件前若干页存放表空间头部信息。简单说整个文件就是按物理页顺序排列的大数组InnoDB通过索引结构在逻辑上组织这些页而文件系统层面则通过偏移量读取某个页。如果你用十六进制编辑器打开一个.ibd文件能看到每一页都有固定878字节的文件头里面带有页号、校验和、LSN这些信息。很多数据恢复工具就是靠扫描这些页头来找回记录的。这也解释了为什么MySQL的数据文件可以直接用工具非法恢复——因为页结构的自描述能力非常强。4. 聚簇索引与B树为什么行数据像“排好队”一样放着4.1 数据即索引索引即数据InnoDB默认用B树来组织数据。但和一般索引概念不一样的是InnoDB的主键索引就是数据本身这叫聚簇索引。B树的非叶子节点存主键值和指向子节点的指针叶子节点存完整的数据行。所以在InnoDB里“表”其实就是一棵以主键为顺序的B树每个页是树上节点行数据是叶子节点里的记录。因为叶子节点按主键顺序排列所以主键的大小直接决定物理写入位置。你要插入一行主键为100的数据而当前页最大主键是90那它就得往后面的页里插甚至引发页分裂。反之如果你用自增主键每次插入都在树的最右边追加操作成本最低。这也是为什么“数据即索引”这句话值得刻在工位上理解了它你就能推断出“为什么没有主键的表无法高效组织数据”“为什么UUID主键在数据量大时会成为灾难”这类问题。4.2 插入一条数据时B树发生了什么往一个叶子页插入新行InnoDB先按B树从根节点向下二分搜索定位到目标叶子页然后检查页里是否还有空闲空间。如果放得下直接按主键顺序插入更新页目录即可。如果页已经满了就要做页分裂从原页中挪一半记录到新页并调整父节点索引。页分裂的代价很高它涉及分配新页、移动数据、更新父节点指针还可能在父节点也触发分裂一路传导到根节点。这个操作还会让原本物理连续的数据变得分散产生外部碎片。一张表如果没有合理的主键频繁随机插入就会经常触发这样的“搬家操作”写性能自然上不去。有个经典场景随机字符串主键。因为插入的位置是随机的几乎每次都可能落在已经装满的页中间B树被迫不停分裂数据页碎片化严重最终表现为插入TPS上不去、表空间膨胀异常。4.3 主键选错的代价页分裂与碎片用一个实际体验来说明。我曾碰到过一个业务表主键用业务流水号格式类似“20240831-xxxxxx-random”。刚开始数据量小没感觉到了几千万行时一次深夜批量导入就卡得不行。后来检查发现B树叶子节点频繁分裂页的空间利用率只有50%左右很多页因为分裂半空着。优化方式也简单加一列自增id做主键业务流水号改成唯一索引。这样写入永远追加到树的最右端页分裂基本消失空间利用率提升到90%以上。所以“主键是自增整数”这个建议不只是一句教条背后是存储层的物理规律决定的。反过来也不是说业务主键绝对不能用如果你的写入模式本身就是批量顺序插入业务主键也有它的合理性。关键是你得知道随机主键会带来存储层上的哪些代价。5. 遇到超长字段行溢出与溢出页5.1 什么情况下会行溢出一个页默认16KB如果一行数据超过了页的可用空间InnoDB就不能把整行都塞进同一个页了这时候就会发生行溢出。触发条件不光看总长度还看行格式的溢出阈值。COMPACT行格式下当一个变长字段的长度超过页的一半约8KB时就会触发溢出处理机制把该字段的数据挪到单独分配的溢出页中。日常生活中最常见的超长字段就是TEXT和BLOB动不动几百KB甚至几MB。如果一个表里有一列TEXT存储大段文章内容每次查询即使只取其他普通字段InnoDB也要顺着记录里的指针去额外读溢出页I/O开销非常明显。5.2 DYNAMIC和COMPACT在溢出时的差异默认的DYNAMIC行格式比COMPACT在溢出处理上更进一步。COMPACT格式在行记录中保留大型字段的前768字节作为“前缀”这样查询时取部分内容不用跑到溢出页。DYNAMIC格式则干脆不保留前缀整行数据只保留一个20字节指针指向溢出页行本身更小一个页能装更多行记录。从空间利用角度DYNAMIC更优。因为TEXT或BLOB字段大多数场景下都需要完整读取留768字节前缀的收益几乎为零反而白白占用了行内空间。所以MySQL从5.7开始默认DYNAMIC是合理的。如果你的表还留着老旧的COMPACT格式且有大字段可以考虑ALTER TABLE重建表来收益。注意一点COMPRESSED格式会在页级别进行压缩牺牲CPU换取磁盘空间。如果你的数据重复度高比如日志备份类大表COMPRESSED效果显著但压缩和解压会让CPU负载上升高并发在线事务型系统慎用。5.3 VARCHAR到底能存多长算给你看很多人以为VARCHAR最大长度是255或65535这其实是个误会。VARCHAR的上限不是字段能存多少字符而是取决于行的总长度以及使用的字符集。在COMPACT行格式下一行所有变长字段的长度总和的存储上限是65535字节因为B树叶子节点里一行数据的长度有限制严格说是65535字节以内的BLOB和TEXT才能完全存在行内。算一个具体例子一张只有一列VARCHAR(n)的表字符集为utf8mb4一个字符最多4字节那么理论上n不能超过16383左右。如果表里还有其他字段这个值还要继续打折。实际建表时你可以直接测试CREATE TABLE test_v (v VARCHAR(16383)) CHARSETutf8mb4 ROW_FORMATDYNAMIC; -- 能成功 CREATE TABLE test_v2 (v VARCHAR(16384)) CHARSETutf8mb4 ROW_FORMATDYNAMIC; -- 大概率报错核心思想VARCHAR定义的“长度”是字符数而存储开销是字节数。同一个VARCHAR(255)列存汉语拼音和存中文占的空间完全不一样。这个坑在设计表结构时特别容易踩尤其是不同业务团队文字编码不统一时表体积会失控。6. 删除和更新存储层是怎么“磨洋工”的6.1 DELETE只是打标记不是真删除你执行DELETE FROM user WHERE id1InnoDB并不会立刻把这条记录从磁盘文件里抹掉。它只是把记录头信息里的delete_mask标记为1表示“已删除”这条记录在逻辑上对查询不可见但物理上还占着位置。这个设计是为了支持MVCC正在执行的事务可能还需要读取到这个旧版本。这些被标记删除的记录要由后台的purge线程在合适的时机真正清理。purge的时机取决于旧版本是否已经不被任何活跃事务需要了。如果事务长期未提交已删除的记录就会一直占着位置表空间看起来会异常“肿”。6.2 UPDATE本质是删除加插入更微妙的是UPDATE。InnoDB对更新数据的处理在很多场景下不是“原地改”而是先给旧记录打上删除标记再插入一条新记录。这样做是因为聚簇索引是按主键排列的如果更新导致行的实际长度变化原位置不一定放得下搬走更安全。举例来说你把一行数据里的VARCHAR字段从5个字符改成10000个字符行长度剧增原页可能完全放不下。InnoDB常见的操作就是把旧行标记删除分配新空间写入完整新行。结果就是更新一次页内可能多出空洞空间利用率和查询性能都会缓慢劣化。这里要特别提醒不要对包含大字段的表频繁做小改动式的UPDATE比如每隔几秒就更新一个用户签名那个TEXT字段可能每次都触发行重建和溢出页写入磁盘放大效应非常可怕。6.3 空洞、碎片化与表膨胀所谓“空洞”就是一个页内存放的有效行比较少剩余空间被删除标记的记录或碎片占用。产生空洞的路径很多频繁INSERTDELETE留下的已删除标记记录、UPDATE导致的行搬移、页分裂产生的半空页。表膨胀的直接表现是你删了上千万行历史数据但.ibd文件大小几乎没变。这是因为那些被删的数据虽然逻辑上不存在了但标记和残留结构还在且InnoDB预分配的区也不会自动释放回文件系统。数据库文件只会长大、很少缩小这是InnoDB的一个典型特征。6.4 什么时候用OPTIMIZE TABLE整表碎片达到一定比例后建议做一次表重建来整理空间。最直接的方法是OPTIMIZE TABLE user;这个操作本质上会重建表逐页整理数据让行紧凑排列回收空洞再重建所有索引。实测中一个膨胀严重的表在OPTIMIZE之后文件大小可能直接缩水一半以上。但OPTIMIZE TABLE会锁表MySQL 5.7后InnoDB支持了在线DDL不过仍建议在业务低峰期操作或者改用pt-online-schema-change这类工具滚动处理大表。小表无所谓大表千万小心。另外如果你的表频繁更新、删除碎片率长期很高那要考虑是不是业务模型有问题而不是频繁OPTIMIZE能解决的——它只是清理不是根治。7. 怎么看自己数据库里一行数据占多大排查实用技巧7.1 从information_schema拿到表占用空间想估算一张表平均一行占用多大一个简单方法是查information_schemaSELECT table_name, engine, table_rows, ROUND(data_length / 1024 / 1024, 2) AS data_mb, ROUND(index_length / 1024 / 1024, 2) AS index_mb, ROUND((data_length index_length) / table_rows / 1024, 2) AS avg_row_kb FROM information_schema.tables WHERE table_schema 你的库名 AND table_name user;注意table_rows在InnoDB里是估算值不精确但量级可以参考。这里还要列出data_length和index_length前者是聚簇索引数据页的总大小后者是二级索引页的总大小。围起来除以行数就能得到平均行大小。7.2 数据文件增长异常的排查思路如果一张表.ibd文件比预期大得多按这个顺序排查先看碎片率。比较data_length和实际估算的行大小如果data_length除以行数远大于单行理论大小大概率是空洞太多。检查是否有超长字段。SHOW CREATE TABLE看字段类型TEXT/BLOB/超大VARCHAR多的话行溢出页会非常大。看二级索引数量。每建一个二级索引就多一棵B树索引页也会占空间。一张表如果建了七八个索引索引空间比数据空间还大是完全正常的。确认是否有大量delete残留。查一下当前活跃事务如果有长时间未提交的长事务purge线程根本跟不上表现为“删了没缩小”。7.3 实战演示验证行格式对存储大小的影响我自己做测试时习惯建两张结构一样的表唯一区别是行格式不同再插入同样一批数据对比文件大小。比如一张用COMPACT、一张用DYNAMIC插入100万行包含少量TEXT字段的记录。实验结果是DYNAMIC表文件明显更小因为长字段全部溢出到独立页主表页内密度更高。这种验证方式很值得推广。你不要只信理论自己拿MySQL实例插几行看SHOW TABLE STATUS里的Data_length变化印象会深得多。也有人会写到一半换主键类型来对比自增整型和随机UUID的文件大小差距同样的行数UUID那张表往往大20%以上还没算上性能差距。8. 常见问题速查表与避坑心得8.1 高频问题与解决办法整理几个平时被问得最多的问题直接对照处理现象根本原因排查手段解决方案删了大量数据.ibd文件不变小InnoDB不主动收缩文件删除只是标记查碎片率及活跃事务低峰期OPTIMIZE TABLE或用pt-osc重建表表体积增长异常快随机主键导致频繁页分裂或有大量TEXT/BLOB字段看主键类型、字段类型、行格式换自增主键、DYNAMIC行格式、合理拆分大字段插入性能突然下降页分裂增多、索引碎片化、日志压力过大观察写入TPS、监控页分裂优化主键、分批插入、关闭非必要二级索引VARCHAR存中文特别占空间utf8mb4下每个汉字最多占4字节计算行实际占用字节数按业务精度选择合适的字符集和长度表里有很多可见为空的列大量NULL列其实不占数据区用行格式计算无需过度优化但可考虑用默认值代替NULL减少bit位处理这些表里每一项回到原理上都说得通不靠背答案。8.2 关于存储设计的几条经验最后分享几个我踩过坑后沉淀下来的经验。第一建表时主键一定要显式设计自增整数是绝大多数业务的最优解连表都忘记建主键的部分框架自动建表工具尤其要小心。第二能用数值类型就别用字符串用户在存储层是按字节算钱的一个字符集的差异可能让表膨胀四倍。第三大字段单拆TEXT/BLOB尽量放到单独的表或者用对象存储替代不要和频繁查询的短属性挤在同一行里否则每次查询都多承担溢出页的读取成本。还有一点容易被忽略写入模式决定存储健康度。如果你的业务是“高频写入加定时批量删除”一定要接受表会不断膨胀然后通过定期归档或分区表机制来控制单表数据量而不是指望InnoDB自己帮你优化。分区表从存储层看就是把一棵大B树切成多棵小树可以明显降低单棵树的层级和页分裂压力。我已经不止一次在深夜排查数据库“无缘无故变大”的问题最终发现根子都在当初表结构设计时对存储机制理解不够。这一章内容说白了就是让你提前把该交的学费省下来。等你在生产环境里真的面对一张几百GB的表时再回头来看这些存储原理就会知道我为什么强调“先把一条数据的存储路径搞清楚”。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →