数据库就像你家的衣柜------你以为 1.2TB 被数据塞满了,打开一看:230GB 是两年没穿的旧衣服,310GB 是衣架之间的空气。
本文记录一次生产环境 MySQL 从磁盘告警到释放 540GB 的全过程,兼顾冷数据清理 和碎片整理 两条主线。如果你也在对"磁盘只涨不降"束手无策,或者想把
gh-ost这把大表重建的瑞士军刀搞明白,这篇应该对你有用。
本文你会读到:
- 680GB 的数据量,为什么在磁盘上占了 1.2TB?差的 520GB 去哪了
DROP、DELETE、TRUNCATE到底该用哪个(别再拿 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_schema 的 data_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)下要注意两件事:
--host选谁 :推荐连中间的 slave,gh-ost 通过SHOW SLAVE STATUS自动发现 master--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"只进不出"折磨,建议从今天开始:
- 跑一遍 7.2 的两条巡检 SQL,看你当前有多少存量债
- 给备份表加上日期后缀规范
- 给 DBA / 运维加一个"磁盘碎片率 > 30%"的告警
祝你早日也能写一篇 "释放 XXX GB 的实战复盘" ------当然,最好是防患于未然、永远不用写。