MySQL 磁盘告警 1.2TB:从衣柜原理到 gh-ost 卧底,一次释放 540GB 的实战复盘

数据库就像你家的衣柜------你以为 1.2TB 被数据塞满了,打开一看:230GB 是两年没穿的旧衣服,310GB 是衣架之间的空气。

本文记录一次生产环境 MySQL 从磁盘告警到释放 540GB 的全过程,兼顾冷数据清理碎片整理 两条主线。如果你也在对"磁盘只涨不降"束手无策,或者想把 gh-ost 这把大表重建的瑞士军刀搞明白,这篇应该对你有用。

本文你会读到

  • 680GB 的数据量,为什么在磁盘上占了 1.2TB?差的 520GB 去哪了
  • DROPDELETETRUNCATE 到底该用哪个(别再拿 DELETE 清全表了
  • gh-ost 的四阶段原理拆解、链式主从下的同步影响、2 亿行的执行时间估算
  • 一份每月 10 分钟搞定的磁盘巡检 SQL 模板

一、问题现象:1.2TB 是怎么来的

某天早上,监控告警群炸了:

text 复制代码
[告警] db-social-prod 磁盘使用率超过 82%,当前 1.2TB / 1.5TB

第一反应:这库才上线两年多,怎么就快吃满了?

登服务器一看 information_schema,报告的数据量只有 680GB 。可 du -sh 一算,.ibd 文件实际占了 1.2TB

680GB vs 1.2TB,中间差了 520GB,空间呢?

这一问,拉开了整个治理的序幕。


二、排查现场:三个"只进不出"

2.1 先摸清家底:680GB 都是谁的?

information_schema.tables 按表大小排序,一眼就能看到"谁是大户":

sql 复制代码
SELECT
    TABLE_NAME                                               AS '表名',
    ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2)     AS '总占用MB',
    ROUND(DATA_LENGTH / 1024 / 1024, 2)                      AS '数据MB',
    ROUND(INDEX_LENGTH / 1024 / 1024, 2)                     AS '索引MB',
    TABLE_ROWS                                               AS '行数',
    UPDATE_TIME                                              AS '最后更新'
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_database'
ORDER BY DATA_LENGTH + INDEX_LENGTH DESC;

跑完立刻发现三类可疑对象:

  • _bak / _backup 后缀的备份表,最大 21GB,里面是 7200 万条访问日志
  • 几十张按月分区的 IM 消息历史表,每张几 GB 到二十多 GB
  • _2024_2025 后缀的年份归档表

这些数据早就同步到大数据平台(Hadoop / ClickHouse / Doris 等)做离线分析了,MySQL 里的这份,纯属"精神寄托"。

2.2 冷数据盘点:那些再也不会被打开的旧衣服

update_time(为空时取 create_time)作为判断依据。

超 12 个月未更新的备份表(Top 5):

表名 大小 行数 最后更新
t_access_log_bak 21.3 GB 7,200 万 2025/1
t_access_like_bak 16.8 GB 6,830 万 2023/11
t_login_record_bak 8.2 GB 2,150 万 2023/10
t_order_bak 6.7 GB 1,780 万 2023/12
t_trade_bak 5.5 GB 2,060 万 2023/10

实际共 8 张备份表,合计约 63GB。最老的一张已经两年半没人碰过------像冰箱里放了三年的粽子,你知道它在,但永远不会打开。

超 12 个月未更新的 IM 分区表(Top 5):

表名 大小 行数 最后更新
t_im_msg_ext_202501 11.2 GB 2,240 万 2024/10
t_im_msg_ext_202410 9.3 GB 1,970 万 2024/10
t_im_msg_ext_202412 7.1 GB 1,530 万 2024/10
t_im_msg_ext_202411 5.8 GB 1,180 万 2024/10
t_im_msg_ext_202409 4.2 GB 960 万 2024/10

小计:约 38GB

6~12 个月未更新的 IM 分区表(Top 5):

表名 大小 行数 最后更新
t_im_msg_ext_202507 23.5 GB 4,380 万 2025/3
t_im_msg_ext_202508 21.2 GB 3,920 万 2025/3
t_im_msg_ext_202506 20.8 GB 3,710 万 2025/3
t_im_msg_main 14.7 GB 3,350 万 2025/7
t_im_msg_ext_202505 13.6 GB 2,560 万 2025/3

小计:约 132GB

冷数据合计:63 + 38 + 132 ≈ 230GB,这就是第一批可以清理的目标。

2.3 碎片分析:衣柜里的空气

680GB 的数据报告 vs 1.2TB 的磁盘占用,差额 520GB 。这 520GB 全是表碎片

衣柜比喻:衣服(数据)占了 680GB,衣架之间的空气(碎片)占了 520GB。

为什么会有碎片?从 InnoDB 说起

InnoDB 的最小物理存储单位是页(Page,默认 16KB),数据按 B+ 树索引组织。

  • DELETE 一行 :不会从磁盘上抹掉,只是在页内标记为"已删除"。这块空间变成"可复用"------后续 INSERT 可能会填进来,也可能一直空着
  • UPDATE 变长字段 (比如 varchar 从 10 字改成 200 字):原页装不下,数据溢出到新页,旧页留下空洞

时间一长,.ibd 文件就像被蛀空的木头------表面看着还行,里面全是洞。而且这些空洞不会自动归还给操作系统 ,只有通过重建表(ALTER TABLE ... ENGINE=InnoDB)才能回收。

⚠️ 前提:innodb_file_per_table=ON(MySQL 5.6.6+ 默认开启)。如果用共享表空间 ibdata1,那个文件永远不会自动缩小,只能全库导出重建。

碎片分布:高碎片率的"重灾区"

通过对比 .ibd 文件大小和 information_schemadata_length + index_length,挑出碎片严重的表:

表名 .ibd 文件 实际数据 碎片浪费 碎片率
t_access_log_2025 72 GB 4.5 GB 67.5 GB 94%
t_access_like_2025 38 GB 0.8 GB 37.2 GB 98%
t_user_dynamic_stat 36 GB 13.3 GB 22.7 GB 63%
t_thumb_up_2025 22 GB 4.2 GB 17.8 GB 81%
t_follow_user 13 GB 0.5 GB 12.5 GB 96%

实际共 14 张表碎片率超 50%,这批高碎片表合计 310GB,是本次回收的重点。剩余 210GB 分散在上百张中小表里,单张不超过 2GB,回收 ROI 低,暂不处理。

最离谱的是 t_access_like_2025:38GB 文件 → 0.8GB 数据 → 碎片率 98%。换句话说,这张表 98% 的空间都是空气。

这相当于你买了个 1TB 硬盘,结果 980GB 是坏道。

2.4 意外发现:DDL 留下的"尸体"

扫描数据目录时还发现两个孤儿临时文件:

文件名 大小
#sql-ib290-xxxxxxxx.ibd 815 MB
#sql-ib318-xxxxxxxx.ibd 720 MB

这是 ALTER TABLE 执行失败或被中断后遗留的------确认没有进行中的 DDL 后,可以直接删。

小计:约 1.5GB

2.5 一笔账看清全局

到这里,1.2TB 的账算清楚了:

text 复制代码
磁盘占用 1.2 TB
├── information_schema 报告数据 680 GB
│   ├── 活跃数据 ~450 GB   ← 真正有用
│   └── 冷数据   ~230 GB   ← 本次 DROP 释放
│       ├── 备份表          63 GB
│       ├── IM 分区 >12 月   38 GB
│       └── IM 分区 6-12 月 132 GB
├── 表碎片 ~520 GB
│   ├── 高碎片重灾区 ~310 GB  ← 本次 gh-ost 回收
│   └── 小表低频碎片 ~210 GB  ← 暂不处理(ROI 低)
└── 孤儿临时文件 ~1.5 GB    ← 本次直接 rm

本次目标释放:230 + 310 + 1.5 ≈ 540 GB

三、原因分析

磁盘从 680GB 膨胀到 1.2TB,根源是三个"只进不出":

原因 空间浪费
备份/归档表只建不删------数据已在大数据平台,MySQL 侧从未清理 ~230 GB
大量 DELETE / UPDATE 后碎片未回收,InnoDB 表空间持续膨胀 ~520 GB(本次治理 310 GB)
DDL 失败留下的孤儿临时文件 ~1.5 GB

说白了:数据搬到了大数据平台,但 MySQL 侧没人收尾。 就像搬了新家但旧家的东西一件没扔。


四、解决方案

4.1 分级清理策略

根据风险和收益排优先级:

优先级 目标 操作 前置条件 预计释放
P0 立即 _bak / _backup 备份表 DROP TABLE 确认大数据平台有归档 ~63 GB
P1 确认后 IM 消息月份分区表(>12 月) DROP TABLE 归档平台数据量校验一致 ~165 GB
P2 评估后 IM 消息主表 业务方确认 确认线上是否仍有读取 ~15 GB
P3 碎片回收 高碎片率活跃表 gh-ost 在线重建 低峰期 + 双倍磁盘空间 ~310 GB

4.2 DROP / DELETE / TRUNCATE,到底怎么选

这三个操作经常被搞混,选错了轻则出力不讨好,重则把从库拖垮。一张表说清楚:

对比项 DELETE TRUNCATE DROP
类型 DML DDL DDL
速度 慢(逐行删) 快(重建表结构) 最快(删文件)
磁盘空间释放 ❌ 不释放 ✅ 立即 ✅ 立即
binlog 产生量 ❗ 海量(每行一条) ✅ 一条 DDL ✅ 一条 DDL
支持 WHERE
可回滚
AUTO_INCREMENT 不重置 重置为 1 表都没了
触发器 会触发 不触发 不触发

核心原则 :清理冷数据用 DROP(整张表不要)或 TRUNCATE(保留表结构清空数据)。千万别用 DELETE 清全表------又慢、不释放空间、还产生海量 binlog 拖垮从库。

4.3 碎片回收的四种兵器

活跃表不能 DROP,得在线回收。四种方式各有优劣:

方式 原理 锁表影响 适用场景
OPTIMIZE TABLE 等价 ALTER TABLE ENGINE=InnoDB,重建表 5.6+ Online DDL,短暂 MDL 锁 小表(<10GB)
ALTER TABLE t ENGINE=InnoDB 同上 同上 同上
pt-online-schema-change 影子表 + 触发器同步增量 不锁(但触发器写放大) 中型表,支持外键
gh-ost(推荐) 影子表 + 解析 binlog 同步增量 不锁,影响最小 大表、写密集

gh-ost vs pt-osc 的关键差异

  • 写放大 :pt-osc 靠触发器捕获增量,每次 DML 多一次写;gh-ost 解析 binlog,不增加主库写负担
  • 节流控制:gh-ost 原生支持延迟感知和暂停;pt-osc 暂停困难
  • 外键支持:pt-osc 支持,gh-ost 不支持
  • binlog 要求:gh-ost 要求 ROW 格式,pt-osc 无要求

对于本次"大表 + 写密集 + 链式主从"的场景,gh-ost 是最优解。下一章会拆解到字节级别。

4.4 实战五条铁律

  • 冷数据只用 DROP TABLE:秒级释放空间,binlog 只一条 DDL
  • DROP 大表防 IO 抖动 :删 .ibd 大文件可能瞬时打满 IO。老版本可以 ln 建硬链接 → DROP 表(只删元数据)→ truncate -s 分段缩小文件
  • 孤儿文件删除前先核查SHOW PROCESSLIST 确认没有进行中的 ALTER
  • 分区表按月 DROP:一次只删一张,给从库同步留时间
  • gh-ost 安排低峰期 :配合 --max-lag-millis 控制从库延迟上限

五、gh-ost 深入剖析:卧底、替身、最后一击

上一节讲了 gh-ost 是最优选择,但面试官(和你自己)都会继续追问:

  • gh-ost 到底怎么工作的?
  • 为什么说它对主从更友好?
  • 一张 2 亿行的表用它重建,从库要追多久?

这一章把这些问题讲到位,尤其是链式主从master → slave → data 这种两级以上的复制拓扑)下的影响。

5.1 gh-ost 怎么插进你的集群

先看拓扑:

text 复制代码
              ┌──────────────┐   ① 读 binlog
              │    gh-ost    │◀───────────┐
              │  (独立进程)  │             │
              └──────┬───────┘            │
                     │ ② 写影子表          │
                     ▼                    │
       ┌────────────────────────┐         │
       │       master           │─────────┘
       │  ├─ 原表 t_access_log   │
       │  ├─ 影子表 _..._ghst    │
       │  └─ 日志表 _..._ghc     │
       └────────┬───────────────┘
                │ binlog 复制
                ▼
          slave → data

三个关键角色:

  • 原表 :业务在用的那张,比如 t_access_log_2025
  • 影子表 _t_access_log_2025_ghst:结构一致的空表,gh-ost 把数据一点点搬过去
  • changelog 表 _t_access_log_2025_ghc:记录心跳和进度

gh-ost 本身是独立进程,跑在哪里都行(建议跟 DB 同机房)。

5.2 四阶段拆解

阶段 A:初始化(秒级)

gh-ost 在 master 上建两张表:

sql 复制代码
CREATE TABLE _t_access_log_2025_ghst LIKE t_access_log_2025;
CREATE TABLE _t_access_log_2025_ghc  (...);

两条 DDL 进 binlog,传到 slave/data 也建好空表。

阶段 B:两条流水线并行(最长,占 90%+ 时间)

gh-ost 最精妙的设计:

① 历史数据流水线

按主键 chunk 小批量读原表:

sql 复制代码
SELECT * FROM t_access_log_2025 WHERE id BETWEEN 100000 AND 101000;

每批(默认 1000 行)作为独立事务写影子表:

sql 复制代码
INSERT INTO _t_access_log_2025_ghst (...) VALUES (...)
ON DUPLICATE KEY UPDATE ...;

② 增量数据流水线

与此同时,gh-ost 把自己伪装成 slave ,通过 COM_REGISTER_SLAVE + COM_BINLOG_DUMP 协议消费 binlog。

这就是"卧底"环节:MySQL 对它来说就是个普通 slave,老老实实把 binlog 事件推给它。

gh-ost 把 binlog 里的 DML 转写成对影子表的同等操作,发到 master。

冲突消解ON DUPLICATE KEY UPDATE 保证------即使 chunk 刚写完的行下一秒被增量覆盖,最终影子表里每一行都是原表当下的最新值。

这个阶段最耗时。2 亿行的表,保守估计 6-12 小时

阶段 C:最后一击(毫秒级)

等 chunk 跑完 + 增量追平后:

sql 复制代码
LOCK TABLES t_access_log_2025 WRITE, _t_access_log_2025_ghst WRITE;
RENAME TABLE t_access_log_2025 TO _t_access_log_2025_del,
             _t_access_log_2025_ghst TO t_access_log_2025;
UNLOCK TABLES;

整个 cut-over 通常 < 1 秒,业务几乎无感。

阶段 D:收尾

原表变 _t_access_log_2025_del,确认无问题后手动 DROP。

生产环境推荐加 --postpone-cut-over-flag-file:chunk 跑完先暂停,等低峰期再触发切换。这才是老司机的姿势。

5.3 三个灵魂问题

Q1:gh-ost 读 binlog,会影响主库吗?

不影响 binlog 本身的产生

  • gh-ost 在 MySQL 眼里是"普通 slave",只是多了一份 IO 读
  • binlog 本来就被真 slave、审计工具、订阅组件读,多一个消费者无感
  • 最佳实践--host=<slave>,把这点读 IO 开销也甩给从库
Q2:影子表的写入怎么回到主库?对 binlog 有什么影响?
  • gh-ost 通过普通 MySQL 连接直接写 master 的影子表
  • 这些写入正常进 binlog,通过复制链路传到 slave → data
  • binlog 体积 ≈ 原表数据量的 1-1.5 倍(历史 + 增量)
  • 强制 ROW 格式,下游 MTS(多线程复制)能很好吃下

换句话说:对从库而言,gh-ost 就是"一个写得很多的客户端",不需要任何特殊配合。

Q3:2 亿行表,从库要追多久?

主库侧

  • 默认 chunk 1000 行 + 节流,实际写入约 5,000-20,000 行/秒
  • 2 亿行 / 10,000 ≈ 5.5 小时(保守 6-12 小时)
  • 产生约 100GB binlog

从库侧(含链式 data)

  • 从库重放速度通常 > 主库写入速度(只写不算)
  • MySQL 5.7+ 开 slave_parallel_type=LOGICAL_CLOCK + slave_parallel_workers=8~16小事务可以并行重放
  • 实测:从库延迟可控在 1-5 分钟

对其他表同步的影响

场景 gh-ost 行为
从库延迟正常(< 1.5s) 全速跑,其他表同步无感
从库延迟升高(> 1.5s) 自动暂停 chunk,等追齐再继续
从库延迟超 max-lag 停下来等,可配置直接退出

所以结论是:gh-ost 会"看脸色"------从库一旦吃不消就主动让路。这也是它比 pt-osc 好用的核心原因。

5.4 链式主从的命令模板

链式拓扑(master → slave → data)下要注意两件事:

  1. --host 选谁 :推荐连中间的 slave,gh-ost 通过 SHOW SLAVE STATUS 自动发现 master
  2. --throttle-control-replicas 必须列全下游,否则 data 那端可能被悄悄拉爆

推荐命令模板:

bash 复制代码
gh-ost \
  --host=<slave-host> --port=3306 \
  --user=gh_ost --password='<password>' \
  --database=your_db \
  --table=t_access_log_2025 \
  --alter="ENGINE=INNODB" \
  --chunk-size=1000 \
  --max-load='Threads_running=25' \
  --critical-load='Threads_running=80' \
  --max-lag-millis=1500 \
  --throttle-control-replicas=<data-host>:3306 \
  --initially-drop-old-table \
  --initially-drop-ghost-table \
  --exact-rowcount \
  --concurrent-rowcount \
  --execute

不加 --allow-on-master,因为 --host 是从库------gh-ost 会自动识别拓扑、从从库拉 binlog、在主库写影子表。如果必须直连 master,记得加 --allow-on-master,并把 slave 和 data 都列进 --throttle-control-replicas


六、执行结果

按分级策略分批执行后:

阶段 操作 释放空间
P0 DROP 备份表 ~63 GB
P1 DROP IM 冷数据分区 ~165 GB
P3 gh-ost 碎片回收(14 张表) ~310 GB
- 删除孤儿临时文件 ~1.5 GB
合计 ~540 GB

磁盘使用率从 82% → 44%,告警解除。剩余 210GB 小表低频碎片没处理------单张 ROI 太低,纳入长期巡检即可。


七、经验总结:怎么让它不再发生

这次清理本质上是在还"技术债"------数据搬到了大数据平台,但 MySQL 侧清理机制一直没建立。光做一次清理没用,关键是建机制

7.1 冷数据生命周期管理

措施 说明
备份表命名规范 _bak_YYYYMMDD 带日期,超 N 天自动告警清理
分区表定期轮转 按月分区的表保留最近 N 个月,超期 DROP PARTITION
碎片率监控 data_free / (data_length + index_length) 超 30% 告警
归档工具化 pt-archiver 做小批量迁移,自带限速

一个特别建议按时间天然增长的表(日志、消息、流水),建表时就采用 RANGE PARTITION BY 按月分区 。清理时 ALTER TABLE ... DROP PARTITION p202401,比 DELETE 快几个数量级,立即释放空间,binlog 极少。

7.2 每月 10 分钟巡检(直接抄走)

SQL 1:按碎片率排序

sql 复制代码
SELECT table_name,
       ROUND((data_length + index_length) / 1024 / 1024, 2)   AS size_mb,
       table_rows,
       ROUND(data_free / 1024 / 1024, 2)                      AS fragment_mb,
       ROUND(data_free / (data_length + index_length) * 100, 1) AS fragment_pct
FROM information_schema.tables
WHERE table_schema = 'your_database'
ORDER BY (data_length + index_length) DESC
LIMIT 30;
含义
size_mb 数据 + 索引实际占用,MB
table_rows InnoDB 下是估算值,不精确
fragment_mb 碎片空间(已分配但未使用)
fragment_pct 碎片率,超 30% 关注,超 80% 重建

SQL 2:找出 6 个月没人动过的冷表

sql 复制代码
SELECT table_name,
       ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb,
       COALESCE(update_time, create_time)                   AS last_active
FROM information_schema.tables
WHERE table_schema = 'your_database'
  AND COALESCE(update_time, create_time) < DATE_SUB(NOW(), INTERVAL 6 MONTH)
ORDER BY (data_length + index_length) DESC;

⚠️ update_time 在 InnoDB 下是估算值,ANALYZE TABLE 或 DDL 会刷新它。不能单靠这个字段判断冷热,需结合业务确认。


八、一张图看懂全局

text 复制代码
治理前:磁盘占用 1.2 TB
├── information_schema 报告 680 GB
│   ├── 活跃数据 ~450 GB
│   └── 冷数据   ~230 GB  ❌ 可 DROP
└── 表碎片 ~520 GB
    ├── 高碎片重灾区 ~310 GB  ❌ 可 gh-ost
    └── 小表低频碎片 ~210 GB  ⏸ 暂不处理

本次释放:230 + 310 + 1.5(孤儿) ≈ 540 GB

治理后:磁盘占用 ~660 GB
├── 活跃数据 ~450 GB
└── 剩余小碎片 ~210 GB  (纳入月度巡检)

使用率:82% → 44%

写在最后

数据库磁盘满了不可怕,可怕的是满了之后才发现一半空间都是"历史遗留问题"

这次 540GB 里,230GB 冷数据是完全可以预防的------如果有生命周期管理;310GB 碎片也是完全可以及早发现的------如果有月度巡检。

"事后抢救 vs 事前巡检",大约是 1 小时 vs 10 分钟的区别。

如果你也在被 MySQL"只进不出"折磨,建议从今天开始:

  1. 跑一遍 7.2 的两条巡检 SQL,看你当前有多少存量债
  2. 给备份表加上日期后缀规范
  3. 给 DBA / 运维加一个"磁盘碎片率 > 30%"的告警

祝你早日也能写一篇 "释放 XXX GB 的实战复盘" ------当然,最好是防患于未然、永远不用写


参考资料

相关推荐
deviant-ART1 小时前
MySQL 实战:如何根据 ID 将表 B 的字段更新到表 A
数据库·mysql
ZenosDoron1 小时前
Linux/Unix 系统中用于创建链接的命令ln
linux·运维·unix
2401_898717661 小时前
mysql如何进行全量数据库备份_mysqldump工具的使用技巧
jvm·数据库·python
勤劳的进取家1 小时前
传输层基础
运维·开发语言·学习·php
搬码后生仔1 小时前
【navicat不安装sql server直接远程连接服务器数据库】
运维·服务器·数据库
qq_283720051 小时前
高并发场景下 Python+MySQL 性能优化最佳实践
python·mysql·性能优化
@小柯555m1 小时前
MySql(基础操作符--用where过滤空值练习)
数据库·sql·mysql
m0_748554811 小时前
SQL注入的安全架构设计_将数据库置于内网隔离区
jvm·数据库·python
007张三丰2 小时前
系统架构设计师范文5:论负载均衡设计
运维·系统架构·负载均衡·软考·软考高级论文