在之前的文章里,我们聊过主键选自增ID还是UUID的问题,当时提到使用UUID会导致严重的"页分裂"。很多刚接触数据库的新手朋友可能会感到疑惑:页分裂到底是个什么概念?为什么它会让数据库变慢?数据碎片又是怎么产生的?
今天,我们就避开枯燥的底层源码,用大白话和生活中的比喻,把"页分裂"和它的副产品"数据碎片"彻底讲透。
一、 什么是"页分裂"?它是怎么被触发的?
在理解页分裂之前,我们需要知道数据库(比如MySQL的InnoDB引擎)是怎么存数据的。它并不是把数据一条一条散着放,而是把数据打包放在一个个"数据页"里。你可以把"数据页"想象成一个个固定大小的"房间",每个房间最多只能住固定数量的人(数据行)。
当房间住满了,还要往里塞人时,就会触发"页分裂"。主要有以下三种常见原因:
-
随机主键插入(最常见的罪魁祸首)
数据库要求同一个房间里的数据,必须按照主键的顺序排好。如果你用的是自增ID,新来的数据ID最大,直接安排到队伍最后面的空房间就行。但如果你用的是UUID(随机字符串),新数据的ID是随机的,它可能非要插到某个已经住满的房间中间。为了保持顺序,数据库只能把这个房间从中间劈开,分成两个房间,把一部分人挪到新房间,腾出位置给新人。这就是页分裂。
-
更新操作导致数据"长胖"
如果你更新了表里的可变长度字段(比如VARCHAR),导致这一行数据变大了。原本的房间已经塞不下这个"长胖"的数据,数据库就只能把它搬到其他有空位的房间,原来的位置就空出来了。这种"搬家"操作同样可能触发页内部的重新整理和分裂。
-
批量乱序导入
有时候我们会用脚本批量导入数据。如果导入的数据主键是乱序的,哪怕是用批量插入命令,也会因为不断在已满的页面中间寻找插入点,而频繁触发页分裂。
二、 页分裂的"案发现场"还原
为了让大家更有画面感,我们还原一下最常见的叶子节点分裂过程:
- 找位置:数据库拿着新数据的ID,找到了它应该入住的房间(比如存放ID 1500到1600的房间),发现里面已经没空位了。
- 申请新房:数据库向系统申请了一个全新的空房间。
- 数据搬家:把原房间里大约一半的人(比如ID 1550到1600)搬到新房间。为什么是一半?因为如果搬得太少,新房间马上又会满;搬得太多,原房间又太空。分一半能最大程度保持平衡。
- 安排新人:根据新数据的ID,决定把它留在原房间还是放到新房间。
- 更新目录:房间拆分后,数据库需要更新上层的"目录"(父节点),加上指向新房间的指引。如果目录页也满了,就会继续向上分裂,甚至导致整棵树变高。
- 写日记:最后,数据库会把这次折腾的过程写进日志(Redo Log),防止突然断电导致数据丢失。
三、 页分裂的副产品:数据碎片
页分裂虽然解决了数据放不下的问题,但它会带来一个非常头疼的后遗症:数据碎片。碎片就是数据在物理磁盘上变得不连续、空间没被有效利用的现象。
-
页分裂直接制造的碎片
内部碎片:房间劈成两半后,通常两个房间都不会被完全住满,每个房间都空出一部分面积,这就是内部空间浪费。
外部碎片:新申请的空房间,在物理磁盘上可能离原来的房间十万八千里。逻辑上ID是连续的,但在磁盘上却东一块西一块。
-
删除操作留下的"空床位"
当你执行DELETE删除一条数据时,数据库并不会立刻把空间还给操作系统,而是给这条数据打个"已删除"的标记。如果后续没有大小合适的新数据来填补这个空位,这个"空床位"就会一直变成碎片。
-
更新操作留下的"旧址"
前面提到,数据"长胖"搬家后,原来的位置就空出来了,这也是一种碎片。
四、 这些现象会带来什么危害?
不要觉得碎片和页分裂只是底层的小事,它们对性能的影响是实打实的。
-
写入性能暴跌(写放大)
本来你只想插入一条数据,结果底层触发了页分裂。数据库需要读取原页、写入新页、更新父节点目录、写日志。一次简单的插入,变成了多次磁盘读写,数据库会被活活累死。
-
查询速度变慢
因为外部碎片的存在,逻辑上连续的数据在物理磁盘上分散在各处。当你执行范围查询(比如查询ID从1000到2000的数据)时,数据库的磁头需要在磁盘上到处乱跑(随机I/O),而不是顺序读取,查询速度自然大打折扣。同时,碎片也会导致内存缓存的命中率降低。
-
存储空间严重浪费
碎片化严重的表,物理文件大小可能比实际数据大30%以上。你看着表文件占了100G,其实真实数据只有60G,剩下的40G全是没用的碎片空间。
五、 新手如何预防和解决?
了解了原理,我们在实际开发中就可以对症下药了。
策略一:从源头预防,老老实实用自增主键
对于绝大多数业务表,强烈建议使用自增ID作为主键。这样可以保证新数据永远是在队伍的末尾顺序追加,完全避免了"中间插队"导致的页分裂。这是成本最低、效果最好的优化手段。
策略二:定期大扫除,清理历史碎片
对于长期运行、频繁进行增删改的表,碎片是不可避免的。我们需要定期给数据库"大扫除"。
在MySQL中,可以通过执行 OPTIMIZE TABLE 表名,或者执行 ALTER TABLE 表名 ENGINE=InnoDB 来重建表。这个操作的原理是,数据库会创建一个干净的新表,把老表里有效的数据按顺序重新写入新表,然后删掉老表。这样不仅清除了碎片,还让数据在物理磁盘上重新变得连续。建议在业务低峰期(比如凌晨)通过定时任务来执行。
总结
作为新手,我们在学习数据库时,往往只关注SQL怎么写,而忽略了数据在底层是怎么存储的。
理解了"页分裂"和"数据碎片",你就能明白为什么老手总是强调要用自增主键,为什么表用久了需要维护。希望这篇博客能帮你建立起对数据库底层存储的直观认识,在以后的表结构设计和性能优化中,少走弯路,写出更健壮的代码。