硬盘这东西,平时大家关心的都是"我能不能塞下4K电影",今天反过来了,被人问到一个挺刁钻的问题:1G的硬盘可以存储多少条MySQL数据?
我第一反应是冷笑一下,这问题能答?表结构不一样,一行大小能差几百倍。但细一想,这个问题背后其实藏着一连串真正值得搞明白的事:MySQL到底怎么在硬盘上组织数据?一条记录在物理存储里占多少字节?字符集、索引、碎片又在中间偷走了多少空间?把这些问题拆透了,"1G能存多少条"就不再是一个需要背的答案,而是一套你自己能算的账。
这篇文章我就把这套账从头捋一遍,从InnoDB存储原理、行格式、页结构讲到实际建表测算和容量规划的经验。不管你是刚接触MySQL的新手,还是在为线上库容量发愁的运维,看完应该都能自己估个八九不离十。
1. 先搞清楚MySQL在硬盘上到底存的是什么
1.1 一张表背后是B+树,不是"一行一条记录"的简单账
很多人想当然地以为,MySQL表在硬盘上就是顺序一行压一行存放,像Excel表格一样。实际上InnoDB存储引擎的默认结构是一棵B+树,表数据本身是聚簇索引的叶子节点,也就是说每行记录真实存在B+树最底层的叶子页里。
B+树的特点是:数据按主键有序排列,非叶子节点只存主键值和指向下一层页的指针,真正的数据行全部排在叶子层。这意味着就算你只存了一行数据,B+树也要有根节点页、分支节点页、叶子页这一整套骨架。每一页(Page)默认16KB,页里面除了数据行,还有页头、页尾、页目录这些管理结构,永远不会被填满到100%。
所以"1G能存多少行"取决于"每16KB的页里平均能塞下多少行",而不是简单拿1G除以单行字节数。想准确一点,必须把页头尾开销、填充率这些因素都算进去。
1.2 页、区、段与碎片,这些结构偷偷吃掉了空间
InnoDB把存储空间组织成多层:最小的单位是页(Page,16KB),相邻的64个页组成一个区(Extent,约1MB),再往上还有段(Segment)。这种分层不是闲着没事干,区是为了保证顺序扫描的性能,段是为了区分索引段和叶子数据段。
这里就出现了一个很多人不关心但很影响容量判断的事实:一张表的最小存储单位不是"一条数据",而是一个区。哪怕你只插入一行,也可能占用一个完整的区。不过好在MySQL在数据量小的时候会用碎片区(Fragment Extent)共享空间,不会真的为一行数据就独占1MB。但从几十万行往上走,表开始以区为单位申请空间,空间的浪费比例就会稳定下来。
还有一个很隐蔽的开销:每个页的尾部有校验值记录,页头有偏移链表,InnoDB为了崩溃恢复和MVCC保留的版本信息,都占空间。实践中大约有6%~10%的页空间是"管理税"。更别说写满的页在DELETE之后不会立刻归还空间,会在页里留下空洞,这些都是后面要仔细算的。
2. 单行数据的精确"体重":行格式拆解
2.1 定长与变长字段的实际占用
要估算行大小,先得有张表。我给一个比较典型的用户表,大家顺着这个思路去套自己的表就行:
CREATE TABLE `user_info` ( `id` int NOT NULL, `name` varchar(50) DEFAULT NULL, `age` tinyint DEFAULT NULL, `email` varchar(100) DEFAULT NULL, `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;一行数据的原始内容怎么算:
id:int类型,4字节name:varchar(50),这是变长字段,不能按50个字符算。如果用户名平均10个字符,在utf8mb4字符集下最多一个字符4字节,平均一个中文名3~4个字节,通常10个字符约30字节,极端拼音或者数字可能就10~40字节,先估40字节保险age:tinyint,1字节email:varchar(100),平均按25个英文字符算,utf8mb4下25字节,保守按60字节算created_at:datetime类型,MySQL 5.6后对时间戳压缩存储,DATETIME通常占用5字节(不带小数秒精度时)
按这个情况,用户数据裸重约110字节。但这还远远不是一条记录真实的硬盘占用,因为InnoDB会对每行额外加两样东西。
2.2 隐藏列与记录头:每行都有"管理费"
InnoDB的每行记录在物理存储时,除了业务字段,还会带上:
- 记录头信息(Record Header):约5字节,包含记录类型、下一条记录的偏移量、是否已删除标记等
- DB_TRX_ID(事务ID):6字节,InnoDB用它做MVCC并发控制
- DB_ROLL_PTR(回滚指针):7字节,指向undo日志中该行的旧版本
- DB_ROW_ID(行ID):6字节,只在表没有主键时才会额外生成,这里表有主键就不用
这三项加起来约18字节,和业务数据110字节加在一起,一行就是128字节左右。
还没完。varchar变长字段需要用变长字段长度列表记录每个变长字段的长度,至少2字节;NULL值字段需要在NULL标志位里占位,每8个可空字段1字节;还有utf8mb4字符集的字符集转换信息。这又得加上5~10字节。
这么一算,user_info表中"平均一行实际存储在数据页里的字节数"大约在135~145字节。我们取140字节作基准往下算。
2.3 字符集陷阱:utf8mb4比你想的更吃空间
上面那个例子,如果用户名和邮箱都是中文,每个汉字在utf8mb4下占4字节,一个varchar(50)的字段如果50个全是中文,那就是200字节,单人行的体重大概能翻两倍。
这些年做业务遇到过太多次这种翻车现场:开发图省事,全库统一utf8mb4,结果一张只有五六个字段的短文本表,单行居然奔着300字节去了。在MySQL 8.0里utf8mb4还是默认字符集,这个坑更隐蔽。
反过来,如果字段内容是纯英文和数字,用latin1或ascii一个字符只占1字节,比utf8mb4省3倍。所以估算容量前,先确认你用的字符集,这个直接决定一行能差几倍。
2.4 varchar超过"半个页",存储方式就变了
那如果一行数据里有个大字段,比如Text类型的文章内容,或者特别长的varchar呢?InnoDB在DYNAMIC行格式下,当单行数据过大、一页16KB放不下的时候,会把大字段部分内容溢出到独立的溢出页存储,原数据页里只保留20字节的指针和前缀信息。
这意味着:存10KB文章和存1MB文章,实际对单行的"页内体积"影响并没有想象中大,因为大头都被挪到溢出页了,溢出页也是16KB一页结算的。但千万不要因此觉得大字段不要钱,多一个大字段,等于每行额外多出至少一个溢出页,而一个溢出页被一行独占,空间浪费非常夸张。
所以估算行大小的时候,如果表里有TEXT或超长varchar,建议按原始字节/16KB后向上取整重新计算单行的实际页成本,而不是直接拿原始长度相加。
3. 一个真实的估算:从建表到跑数据
3.1 先给出一个可以套用的估算公式
核对了InnoDB页结构和行格式后,我自己的估算公式是:
可存储行数 ≈ 可用空间字节数 × 页填充系数 ÷ 单行实际占用字节数- 可用空间字节数:1GB = 1,073,741,824字节
- 页填充系数:InnoDB的页不会写满,受页目录和填充策略影响,留大约0.9比较稳
- 单行实际占用字节数:按上面的方法把业务字段、记录头、隐藏列、变长长度、NULL位图全部算进去
如果用我们那张user_info表,一行按140字节算:
1,073,741,824 × 0.9 ÷ 140 ≈ 6,898,000 行也就是说1GB大概能放接近700万行。这个数值和实际情况其实是吻合的,我拿类似结构的表跑过测试,单行较小的表1GB确实能存到百万行量级,只是不同表结构偏差很大。
3.2 不同类型表在1GB下的参考值
给几类常见业务表做个估算,方便大家心里有个锚点:
| 表类型 | 典型单行页内占用 | 1GB可存储行数(约) | 说明 |
|---|---|---|---|
| 精简日志表(bigint主键 + 短varchar) | 80字节 | 1200万行 | 纯英文、字段极少 |
| 用户表(int主键 + 姓名邮箱时间) | 140字节 | 690万行 | 中文字符集、常规字段 |
| 订单表(多关联字段 + 状态 + 金额) | 400字节 | 240万行 | 索引多、字段十来个 |
| 文章内容表(含TEXT大字段) | 1个16KB溢出页 | 6万行以内 | 大字段严重拉低容量 |
这里的文章内容表最极端:假设每篇文章正文平均2KB,非要用TEXT存,InnoDB通常会为超过页容量阈值的大字段分配单独页,几万行就能吃掉1GB。所以"1GB能存多少条MySQL数据"从几万到上千万都有,核心变量就两个字:行重和索引开销。
3.3 辅助索引:容量估算中最容易漏算的一项
很多人在估算表大小时只看数据行,却忘了主键之外建的那些索引(二级索引)也是要占硬盘的。
二级索引的B+树叶子节点并不存整行数据,而是存"索引列 + 主键值"。表面上看起来省空间,但索引数量一多,空间占用就会非常可观。比如一张订单表,你给订单号、用户ID、商品ID、支付状态各建一个索引,四个二级索引加起来可能会让整张表的物理文件膨胀60%~100%。
更麻烦的是,辅助索引对varchar字段比较敏感。我给一个案例:一张日志表原本只有数据16GB(按数据行算),后来给一个冗余的长request_id字段加了索引,全部大小直接从16GB涨到27GB。容量看起来是够的,但因为一个索引直接爆了。
所以在做"1G能存多少行"这类估算时,别只看SELECT COUNT(*)那样的逻辑行数,要看data_length + index_length。有条件的话直接用SHOW TABLE STATUS或者查information_schema.tables查看实际占用,这是最靠谱的。
4. 容量是算出来了,但还有一堆你没想到的"隐形硬盘消耗"
4.1 共享表空间、redo log、undo log也要吃蛋糕
就算你精确算出了某张表在某个行格式下能存多少行,实际部署MySQL时,1GB的硬盘还不能全都分给用户表:
ibdata1系统表空间:存数据字典、change buffer等,MySQL 8.0里初始化后占用约12MB起步,后续可能自动增长redo log:MySQL 8.0默认innodb_redo_log_capacity是100MB,一启动就占了undo log:默认会分配独立的undo表空间,初始约16MB,事务量大还要扩展binlog:如果开启,写满的日志不是自动清除,而是累积的,这是最容易被忽略的空间炸弹doublewrite buffer:默认开启时,每个数据页写盘时要先写双写缓冲区,通常占用2个区约2MB
一圈扣下来,1GB的实际可用空间保守要打八到九折,真正能用来存业务数据的大概只有800~900MB,甚至更少。
我自己在低配置测试环境里踩过一次:给一个1GB小盘装MySQL 8.0,还没建业务表,先是看du -sh /var/lib/mysql已经占掉200多MB,把redo和undo以及系统表全算进去,可用空间比想象中紧张得多。所以严格讲,标题里的"1G硬盘"指的是MySQL数据目录总体容量时,用户表可用的部分必须扣除这些固定开销。
4.2 频繁DELETE和UPDATE是"空间刺客"
InnoDB删除数据时,并不会立刻把物理空间归还给操作系统。删除的行只是被标记为"已删除",留下的空间可以被新数据复用。如果业务有大量随机删除,或者按时间范围大批量清数据,表文件可能一直保持一个"虚胖"的状态。
统计信息更离谱:DELETE后information_schema.tables.data_length不会马上下降,因为页里的空洞还在。如果删完马上继续插入,新数据可能会填充这些空洞,那还可以;但如果删完不写,那个空出来的空间就闲置了,数据库文件大小没变,实际可用容量却已经被"地主"圈了地。
解决方法是OPTIMIZE TABLE或者ALTER TABLE ... ENGINE=InnoDB重建表,但重建需要额外的临时磁盘空间,在容量紧张的场景要特别注意,重建失败反而会雪上加霜。
还有一个细节:随机UPDATE变长字段(比如varchar改成更长的值),如果新值放不进原位置,InnoDB会尝试在页内移动或者把行标记删除后另起新位置,同样会制造页碎片。这些碎片累积下来,1GB物理空间能容纳的行数比"干净表"少10%~20%。
4.3 怎么用information_schema算真实的账
不想凭感觉估算,直接查数据库给的统计值最稳:
SELECT table_name, engine, ROUND(data_length/1024/1024, 2) AS data_mb, ROUND(index_length/1024/1024, 2) AS index_mb, ROUND((data_length + index_length)/1024/1024, 2) AS total_mb, table_rows, ROUND(avg_row_length, 0) AS avg_row_bytes FROM information_schema.tables WHERE table_schema = 'your_database';avg_row_length是MySQL根据data_length / table_rows算出来的平均行字节数。这个值和我在第二节手工拆解出的140字节大致能对上。但要注意,它是包含数据页内管理开销在内的平均值,比纯业务字段裸重要大。
拿这个表的avg_row_length做容量估算,比手工算要简单得多:剩余可用空间 / avg_row_length,得出的行数就是基于当前碎片状态下的真实容纳量。如果表还没建好,那就只能手工按我们前面的公式估了。
5. 我的经验:怎么让1G装下更多行且不翻车
5.1 字段设计上能抠就抠
在存储资源紧张时,字段设计是最大的杠杆:
- 能用
smallint/tinyint的就不用int,状态码、枚举值这类根本不需要4字节 - 主键尽量用
bigint自从增或业务ID,不要用varchar类型的UUID做主键,UDPATE前缀这样的字符主键不仅让聚簇索引膨胀,每个二级索引都会带上一份,吃空间极为凶残 - 字符串字段看清需求,
varchar(255)在索引列上会导致部分字符集下索引失效,还会让行格式里的变长长度列表多占用字节。能用varchar(20)就绝不varchar(255) - IP地址用
INET_ATON()转成无符号INT存,比varchar(15)省一半还多 - 日期时间能用
DATE就不用DATETIME,精度用不到秒就不要DATETIME(6)
我用一个实时统计表做过对比:无脑int + varchar + datetime设计,单行裸重约180字节;优化后单行裸重压到不到80字节,容量直接翻倍,读性能也更好。
5.2 行格式选择:DYNAMIC还是COMPRESSED
当前主流行格式是DYNAMIC(MySQL 8.0默认),适合大部分OLTP场景,大字段溢出做得高效,行大小控制得好。
如果1GB空间实在紧张,可以考虑把归档类表改成COMPRESSED行格式,它会对数据页做压缩,空间节省可观。但压缩有代价:写入时额外消耗CPU,读取时也要解压。OLTP高频表用了压缩反而容易成为性能瓶颈,这个折中要权衡清楚。我一般只在冷数据表或用ZOOKEEPER这样的归档场景才启用压缩。
对于1GB这种小空间极限场景,还有个土办法:引文.``类数据量很小的监控/流水表,直接把ROW_FORMAT=COMPRESSED和KEY_BLOCK_SIZE=8`写上,有时候表体积能缩小40%以上。注意KEY_BLOCK_SIZE不是越小越好,太小会导致压缩后还是超页,InnoDB性能下降。
5.3 给容量规划留20%余量是不成文的规矩
最后一个经验:任何容量规划,都别按"装满"来设计。数据库的空间是动态波动的,binlog、临时表、多版本数据、后台刷盘都可能临时占用空间。一旦磁盘满了,MySQL拒绝写入会产生大量报警,甚至导致复制中断,恢复起来比扩容麻烦得多。
我个人习惯的规划公式是:
实际可用容量 = 硬盘总容量 × 0.8100GB盘就按80GB可用去设计,1GB盘实际可用就当800MB。这个余量不仅给系统开销兜底,也给突发事件留退路。
还有一个小技巧:给每个业务表预估一个月的数据增长量,把容量规划从"今天能不能装下"升级成"半年后会不会存满"。一张月增500万行的日志表,每行200字节,每个月就要吃掉1GB。如果不提前规划,三个月后再来看,反应时间都没有。
最后再分享一个我自己的测算过程
我最近帮人评估一个内存极其有限的小机器,准备把一部分数据从大库导到一个磁盘只有1GB的从库上做离线分析。当时表里有大字段,有多个索引,还开了一个月binlog,用前面的方法粗略算了一下,单行页内占用超过300字节,六级评估下来1GB最多容纳200万行,实际导入了190万行后表空间已经占到88%,正好卡在预警线内。
过程里踩了几个坑:一是没把redo log提前计入,二是低估了二级索引,三是忘了binlog也在同一块盘上增长。把这三项补进去后,估算和实际误差基本在5%以内。
这个项目的后续是,我建议把几张大字段表拆分出来,文章正文单独存到对象存储,MySQL里只留ID和摘要,这样单行立刻回到120字节以内,同样1GB就能继续扛几百万行。如果你也遇到"小硬盘装大表"的问题,优先检查的应该就是这三个方向:冗余的大字段、贪多的索引、开着却不留余量的binlog。把它们剪干净了,你会发现1GB能比你想象中装下更多数据。