如果你的业务表已经积累了上亿行,千万不要写个 DELETE 脚本就上去跑。这不是危言耸听,而是我见过太多次凌晨被电话叫醒的场面:慢SQL拖垮主库、主从延迟飙升、磁盘空间不仅没释放反而被binlog占满,最后只能靠备份恢复。这个场景下,pt-archiver 是绕不开的选项。它能一边分批清理旧数据,一边把数据完整搬到归档表,还能在迁移过程中控制主库负载,是MySQL数据生命周期管理里最值得先掌握的工具。
这篇文章会从最基础的命令讲起,一直到生产环境的自动化调度、参数调优、踩坑实录和事后校验,目的是让你看完就能直接在自己的库上落地,而不是只了解一堆参数但不知道怎么用。
1. 大表删除的代价:为什么DELETE不是归档的正确方式
1.1 一条DELETE造成的事故现场
有一个很典型的场景:订单表已经攒了3亿行,每天还在快速增长,DBA收到磁盘告警后发现,光是这张表就占了近200GB。业务方说“把三个月前的旧订单删掉就行了”,开发同学随手写了一句:
sql复制DELETE FROM t_order WHERE create_time < '2024-01-01';
结果SQL跑了两个小时还没结束,主库CPU持续打满,主从延迟从几十秒一路涨到几十分钟,线上订单查询开始出现超时,最终只能kill掉这条DELETE,再花更大的代价做恢复。这件事的问题并不是删除需求本身错了,而是删除方式选错了。
1.2 MySQL删除行的底层代价
为什么一句简单的DELETE会这么危险?因为InnoDB做删除,远没有想象中那么“轻”。
第一个代价是:MySQL的DELETE并不会立刻释放磁盘空间。InnoDB把记录标记为已删除,然后通过purge线程异步清理,表文件的大小不会因为DELETE而明显缩小,除非重建表。也就是说,你费了半天劲删完数据,磁盘空间可能还是快满的,这对后续操作来说是误导性的。
第二个代价是:DELETE涉及undo log的增长。每删除一行,InnoDB都要在undo log里记录足够的信息,以便事务回滚。如果你一次性删几千万行,undo log的膨胀会非常恐怖,还可能在长时间事务里拖垮purge线程,甚至撑爆undo tablespace。
第三个代价是:DELETE持有行锁,且逐行写binlog。一条DELETE影响的行数越多,持有锁的时间越长,对并发交易的阻塞就越严重。产生的大量binlog还会同步给从库,从库回放同样的DELETE也需要同样长的时间,主从延迟就是这么被拉出来的。
第四个代价是:索引维护开销。每一行删除都要同时维护主键索引和所有二级索引,表上有三四个二级索引的话,写入放大就非常明显了。数据量级越大,这个放大效应越可怕。
1.3 主流方案对比,为什么最终选了pt-archiver
面对大表清理,常见思路有三个。
- 第一种是自己写脚本分批DELETE,比如每次删1000行,sleep一下再删。这个方法简单但问题很多:id范围不好划分,create_time字段如果没有索引会导致全表扫描,而且拆出来的脚本必须要自己处理复制延迟、事务大小、异常断点续跑,本质上是重复造轮子。
- 第二种是分区表 + DROP PARTITION,这是目前最彻底的大数据清理方式,但需要提前按时间设计分区,并且业务SQL要尽量走分区键。对存量的大表改造分区,本身就是一个大工程,不适合“现在就要把空间腾出来”的紧急场景。
- 第三种就是pt-archiver,它本质是“边查边删”的归档工具,但把MySQL DELETE的三大痛点都解决了:按主键或唯一键分批切片、可以控制每次处理的行数、可以设置事务大小,还能一边删除一边把数据插入归档表,甚至能感知主从延迟并自动暂停。
从前面的对比可以看出来,如果一张表已经错过了分区表改造的最佳时机,或者你只是想把历史数据迁移到一个独立的归档库,那么 pt-archiver 是当下最稳妥、性价比最高的方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. pt-archiver参数逐项拆解:从一条命令到理解每个开关
2.1 安装与准备
pt-archiver 是 Percona Toolkit 套件中的一个工具,安装方式很简单,CentOS/Ubuntu分别用yum和apt装即可:
bash复制# CentOS/RHEL
yum install -y percona-toolkit
# Ubuntu/Debian
apt-get install -y percona-toolkit
装完验证一下版本:
bash复制pt-archiver --version
如果服务器不能直接连外网装Percona源,也可以在有网机器上下载对应系统的rpm/deb包再拷贝进去。另一个容易被忽略的点是:pt-archiver依赖DBD::mysql和DBI等Perl模块,低版本系统可能需要额外装perl-DBD-MySQL,否则运行时会直接报 Can't locate DBD/mysql.pm。
2.2 一条完整的归档命令逐行拆解
假设我们的线上表是 app_db.t_order,需要把3个月之前的数据归档到 app_db.t_order_archive,归档表结构和原表保持一致。先看一个我常用的生产级命令:
bash复制pt-archiver \
--source h=127.0.0.1,P=3306,D=app_db,t=t_order,u=archive_user,p=archive_pass \
--dest h=127.0.0.1,P=3306,D=app_db,t=t_order_archive \
--where "create_time < DATE_SUB(NOW(), INTERVAL 3 MONTH)" \
--limit 1000 \
--txn-size 1000 \
--sleep 0.1 \
--bulk-delete \
--bulk-insert \
--max-lag 5 \
--check-slave-lag h=127.0.0.1,P=3306,u=repl_user,p=repl_pass \
--statistics \
--charset=utf8mb4
这条命令看起来很复杂,拆开看每个参数都很明确。
--source:源库连接信息,必须指定库名和表名,注意这里不能用socket连接而要用host和port。--dest:目标归档表。可以是一台独立的归档库服务器,也可以是同一个实例的不同库表。如果不指定--dest,则只执行删除,相当于一个更安全的批量DELETE工具。--where:筛选条件,决定哪些行要被归档。这个条件建议在源表上有索引可用,否则pt-archiver会做全表扫描,效率极差。--limit:每次select的行数。它决定每个批次从源表取多少行,一般经验值是500到2000左右。--txn-size:事务大小,达到这个行数就提交一次事务。它不是按行数强制开启事务,而是累积到指定行数才commit,所以--txn-size不宜太大,否则undo膨胀问题又会回来。--sleep:每处理完一批后休眠多少秒。这是给主库和从库“喘气”的时间。值越大对线上影响越小,但整体耗时也越长。--bulk-delete:启用批量删除,将多个主键拼成一个DELETE ... WHERE id IN (...)语句,而不是逐行删除,这能显著减少SQL执行次数和网络往返。--bulk-insert:启用批量插入,将多行拼成一条INSERT语句,配合--bulk-delete使用时,整个归档速度会快很多。--max-lag:主从延迟的阈值,单位秒。一旦检测到从库延迟超过这个值,工具会自动暂停,直到延迟降下来再继续。--check-slave-lag:指定要检查的从库地址。如果不给这个参数,--max-lag就不生效。--statistics:归档结束后输出统计信息,包括扫描行数、插入行数、删除行数、耗时等,方便做性能评估。--charset:连接字符集,一般用utf8mb4,避免中文或emoji乱码。
2.3 生产环境必须调优的参数组合
上面的命令可以在小表上跑通,但直接上生产还差一点。生产环境我一般会额外关注以下参数:
| 参数 | 作用 | 建议值 |
|---|---|---|
| --limit | 每批SELECT行数 | 1000 ~ 2000,视行宽而定 |
| --txn-size | 提交事务的行数阈值 | 500 ~ 2000,行越大值越小 |
| --sleep | 批间休眠秒数 | 0.1 ~ 1,测试后按压力调整 |
| --max-lag | 从库最大延迟阈值 | 3 ~ 5秒 |
| --bulk-size | bulk模式下的绑定行数 | 1000 ~ 4000 |
| --purge | 只删除不归档 | 配合dest同时使用,执行时不建议 |
| --dry-run | 只打印要执行的SQL不实际执行 | 上线前必跑 |
这里有一个很重要的思路:--limit 不是越大越好。你可能会觉得一次select一万行、一次删一万行更快,但实际上,批量太大反而会让单次事务持续时间变长、锁持有时间变长、undo增长更快,一旦遇到DDL变更或大查询,冲突概率会明显上升。我见过有人把 --txn-size 设为50000跑归档,结果跑了十分钟后undo表空间直接翻倍,最后只能停机处理。宁可慢一点,也要让每个事务足够小。
2.4 一个实际可复制的归档脚本
来看一个单机归档的实操示例。假设 t_order 表有主键 id,归档条件为 create_time < '2024-01-01',我们先跑 --dry-run 确认SQL逻辑:
bash复制pt-archiver \
--source h=127.0.0.1,D=app_db,t=t_order,u=root,p=123456 \
--dest h=127.0.0.1,D=app_db,t=t_order_archive \
--where "create_time < '2024-01-01'" \
--limit 500 \
--txn-size 500 \
--dry-run
确认输出无误后,去掉 --dry-run 正式执行。执行过程中可以另开一个终端观察负载:
bash复制mysql -e "SHOW PROCESSLIST;"
你会看到pt-archiver循环执行 SELECT、INSERT、DELETE 三种语句,并且永远不会一次性处理全表数据。这种“小步快跑”的方式,才是大表归档的正确姿态。
3. 把归档变成自动化任务:调度设计、脚本与报警
3.1 归档策略设计:删除还是迁移
很多人第一次用pt-archiver的时候会纠结一个问题:到底是只删除,还是删除+迁移?
我的建议是:只要业务上没有明确说“旧数据可以永久丢弃”,一律做迁移归档。原因很简单,业务后期免不了要做数据分析、对账、审计,你永远不知道什么时候会需要半年前的一笔订单明细。直接把旧数据删掉是短视的做法,而迁到归档表代价并不高,也就是多一张表、多一份存储的事。
归档策略上,最常用的是按时间范围保留。例如:
- 线上
t_order只保留最近6个月的数据; - 超过6个月的订单迁入
t_order_archive; - 归档表再保留2年,之后由另一个定时任务清理到冷备或直接销毁。
这个策略的好处是:线上表体积可控,查询性能稳定,归档表的数据也有明确的生存周期,不会无限膨胀。
3.2 自动化脚本实现
我习惯把pt-archiver的完整命令包装成一个shell脚本,方便crontab调用,同时记录日志和退出状态。下面是一个可以直接参考的脚本:
bash复制#!/bin/bash
# archive_order.sh
# 归档三个月前的订单数据到 t_order_archive
SOURCE_HOST="127.0.0.1"
SOURCE_PORT="3306"
DB_NAME="app_db"
TABLE_NAME="t_order"
ARCH_TABLE="t_order_archive"
DB_USER="archive_user"
DB_PASS="archive_pass"
SLAVE_HOST="127.0.0.1"
SLAVE_USER="repl_user"
SLAVE_PASS="repl_pass"
LOG_DIR="/var/log/pt-archiver"
LOG_FILE="${LOG_DIR}/archive_$(date +\%Y\%m\%d_\%H\%M\%S).log"
LAST_LOG="${LOG_DIR}/archive_last.log"
mkdir -p $LOG_DIR
/usr/bin/pt-archiver \
--source h=$SOURCE_HOST,P=$SOURCE_PORT,D=$DB_NAME,t=$TABLE_NAME,u=$DB_USER,p=$DB_PASS \
--dest h=$SOURCE_HOST,P=$SOURCE_PORT,D=$DB_NAME,t=$ARCH_TABLE \
--where "create_time < DATE_SUB(NOW(), INTERVAL 3 MONTH)" \
--limit 1000 \
--txn-size 1000 \
--bulk-delete \
--bulk-insert \
--sleep 0.2 \
--max-lag 5 \
--check-slave-lag h=$SLAVE_HOST,P=3306,u=$SLAVE_USER,p=$SLAVE_PASS \
--statistics \
--charset=utf8mb4 > $LOG_FILE 2>&1
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "[ERROR] pt-archiver exit with code $EXIT_CODE, see $LOG_FILE" >> $LOG_FILE
# 这里可以接上你的告警脚本,比如企业微信/钉钉/短信
# /usr/local/bin/alert.sh "pt-archiver归档失败,退出码 $EXIT_CODE"
exit $EXIT_CODE
fi
cp $LOG_FILE $LAST_LOG
echo "[OK] archive finished at $(date)" >> $LOG_FILE
这个脚本里我特意把日志单独存了一份,并把最近一次的日志固定为 archive_last.log,方便排查问题。关键点在于检查退出码,不要傻乎乎地跑完就算,因为pt-archiver一旦中途出错,它可能已经删了一部分数据,如果没有检查机制,后续处理会非常被动。
3.3 定时调度与监控
crontab里加一行,建议选在业务低峰期执行:
cron复制# 每天凌晨 2:30 执行归档
30 2 * * * /opt/scripts/archive_order.sh >> /var/log/pt-archiver/crontab.log 2>&1
只有定时还不够,你还需要监控归档任务是否真的执行成功。最简单的监控维度有三个:
- 退出码是否为0:脚本里已经做了,非0时触发告警。
- 日志里的统计信息:归档行数是否和预期接近。如果连续几天归档行数都是0,说明业务量可能变了,或者WHERE条件有误,需要核查。
- 原表数据量变化:定期统计源表和归档表的行数,确认线上表体积在控制范围内。
我见过一个案例:归档脚本连续跑了三个月,后来业务调整了订单表结构,新增了一个 tenant_id 字段,但归档表没有同步加字段,导致pt-archiver每次都在INSERT阶段报 Field 'tenant_id' doesn't have a default value,而告警配置没有指定到错误日志,直到业务方反馈查询变慢才发现。所以,监控一定要包含错误日志关键词检测,不能只看进程在不在。
3.4 多表归档的扩展方式
如果你的数据库不止一张大表,可能还有日志表、流水表、消息表,都面临同样的归档需求。一个简单的做法是循环遍历表名,配置对应的时间字段和保留周期。比如:
bash复制declare -A TABLE_CONF=(
["t_order"]="create_time|3"
["t_order_item"]="create_time|3"
["t_oper_log"]="created_at|6"
)
for TABLE in "${!TABLE_CONF[@]}"; do
IFS='|' read -r COLUMN MONTHS <<< "${TABLE_CONF[$TABLE]}"
# 同理拼接pt-archiver命令
done
这样能减少重复脚本,但要注意,整张表归档完才能进行下一张,如果某张表数据量特别大,可能导致后面的表延迟。更合理的做法是每张表独立调度,错开执行时间,避免在同一时刻争抢数据库I/O。
4. 归档10亿行踩过的坑:主从延迟、无主键表与事务膨胀
4.1 坑一:没有--max-lag导致主从延迟报警
我记得第一次在生产环境跑pt-archiver,是在一个业务高峰期过后的凌晨,当时我很有信心地只写了 --limit 500 --txn-size 500,没有加 --max-lag。结果跑了20分钟,监控平台就报警了:主从延迟超过120秒。原因是归档产生的binlog量太大,从库的单线程回放能力跟不上,加上从库本身还在承担读流量,延迟自然就飙起来了。
问题根因不是pt-archiver删得快,而是删除操作产生的大量binlog在从库需要逐条回放,主库执行多久,从库基本也要执行多久,甚至因为单线程回放特性可能更慢。解决办法就是让主库“走两步停一步”,给从库追赶的时间。
加了 --max-lag 3 --check-slave-lag h=... 之后,pt-archiver会实时查询从库的 Seconds_Behind_Master 参数,一旦超过3秒,就暂停工作,直到延迟回落再继续。实测下来,归档总时长可能从20分钟拉长到40分钟,但对线上和从库的影响几乎为零。这个代价是完全值得的。
4.2 坑二:无主键表触发全表扫描
曾经有一个后台日志表,因为当初建表时偷懒,没有定义主键,只有几个普通索引。我直接用pt-archiver跑归档,结果发现奇慢无比,SHOW PROCESSLIST 里全是 SELECT * FROM t_log WHERE ...,每次扫描都是全表。
原因是pt-archiver的切片逻辑依赖主键或唯一键。它需要在每个批次选取“从上一批停下的位置继续处理”,如果没有主键/唯一键,它只能每次都重新扫描满足 --where 条件的所有记录,效率瞬间从“按索引切片”退化成“全表过滤”。更难受的是,这种情况下删除的位置无法精确记录,遇到并发写入,可能漏删或重复处理。
解决方法是给表加上主键或唯一键。如果业务上找不到合适的业务字段做唯一键,直接加一个自增主键也是可以的:
sql复制ALTER TABLE t_log ADD COLUMN id BIGINT AUTO_INCREMENT PRIMARY KEY;
这个ALTER在亿级表上会锁表,操作时必须特别谨慎,评估好业务低峰窗口。但一旦改造完成,后续归档效率会提升几个数量级。这也是我为什么总提醒大家:任何一张可能变大的表,建表时一定要带主键,最次也要有唯一键。
4.3 坑三:事务大小与undo膨胀
还是那位把 --txn-size 设为50000的同学,他后来遇到的问题是磁盘被undo表空间占满。InnoDB删除大量行时,undo log需要记录被删除行的原始内容,以便回滚。如果每个事务都处理50000行,这个事务的undo记录就会非常大,而且提交后还要等purge线程慢慢清理。
如果你是在一个长时间没有清理过undo的历史库上做归档,这个问题会更严重,因为旧版本的数据还残留着,新的undo又不断产生,很容易把磁盘余量瞬间吃光。
我的建议是:
- 先统计行的平均大小,估算每个事务的undo量。
--txn-size保守设置,一般在1000左右,如果单行特别大(比如有text/blob字段),再降到200~500。- 监控
information_schema.INNODB_TRX或系统磁盘空间,如果归档过程中磁盘增长过快,立刻调小事务大小。
sql复制SELECT trx_id, trx_state, trx_rows_modified, trx_start_time
FROM information_schema.INNODB_TRX;
观察 trx_rows_modified 如果长期维持在几万级别,说明事务设置过大,应该调整。
4.4 坑四:归档表的表结构不一致导致数据静默丢失
这是最隐蔽的坑。有一次我归档一个带有 remark 字段的流水表,原表字段定义为 VARCHAR(500),归档表建表时不小心把字段定义成了 VARCHAR(100)。pt-archiver执行过程中没有报错,因为插入时只截断了超长字符串,结果归档表里的备注信息大量丢失,而且没有警告。
这个问题的根源是pt-archiver默认不做字段长度校验,它只关心列名是否匹配。为了避免这种情况,建议在首次归档前做一次完整检查:
sql复制SHOW CREATE TABLE t_order;
SHOW CREATE TABLE t_order_archive;
可以用工具对比,也可以人工核对,特别关注:
- 字段名是否一致;
- 字段类型是否一致;
- 字符集是否一致;
- 默认值是否会截断;
- 是否有生成列、自增列。
如果条件允许,更推荐直接 CREATE TABLE t_order_archive LIKE t_order; 来建归档表,然后再根据需要额外加索引。这样可以从根上避免结构不一致的问题。
5. 归档后的收尾:空间回收、数据校验与过期清理
5.1 空间回收:为什么删完数据表文件还是那么大
很多人在pt-archiver跑完后发现,源表实际占用的磁盘空间几乎没有变化,于是怀疑归档没生效。其实这不是bug,而是InnoDB的行为特点:删除数据只是把页里的记录标记为已删除,磁盘上的文件不会自动收缩。要真正把空间还给操作系统,需要重建表。
重建表的常用方式是:
sql复制ALTER TABLE t_order ENGINE=InnoDB, FORCE;
这个操作在亿级表上会复制整张表,并且全程锁表,生产环境几乎不可行。所以实际生产环境更推荐两种方案:
- 使用
pt-online-schema-change在线重建表,它通过触发器同步增量数据,能在不锁写的情况下完成表重建。 - 如果表已经按时间分区,那么归档后直接
ALTER TABLE t_order DROP PARTITION p2023_01;就是最干净利落的做法,分区删除的代价远小于DELETE,而且磁盘空间会立即释放。
如果暂时不想做重建,也可以接受磁盘空间不立即释放的现实,把空间回收放到业务真正的低峰期统一处理。
5.2 归档后的数据一致性校验
归档完成后,最怕的是源表删了,归档表里的数据却不全。所以校验步骤必不可少。我的常规校验方法有以下三种。
行数对比:归档前后分别统计条件范围内的行数,看删除行数和归档表新增行数是否相等。这个方法最粗糙但最直观:
sql复制SELECT COUNT(*) FROM t_order WHERE create_time < '2024-01-01';
SELECT COUNT(*) FROM t_order_archive WHERE create_time < '2024-01-01';
checksum对比:对归档范围内数据做聚合校验。由于数据量大,直接 CHECKSUM TABLE 对整表可能太慢,可以用分组求和、计数的方式:
sql复制SELECT COUNT(*) AS cnt, SUM(id) AS sum_id, MAX(create_time) AS max_time
FROM t_order WHERE create_time < '2024-01-01';
SELECT COUNT(*) AS cnt, SUM(id) AS sum_id, MAX(create_time) AS max_time
FROM t_order_archive WHERE create_time < '2024-01-01';
对比两个查询结果,如果三个值都一致,基本可以认为归档完整。这个方法简单有效,我几乎每次都会跑。
抽样明细对比:对关键业务表,抽几个时间点的记录做全字段对比,确认没有字段截断或丢失。网上可以搜到类似“数据比对工具”的方案,但抽样对比已经能覆盖大部分风险。
5.3 归档表的过期清理与冷备转储
归档表不是终点,只是在主库和“永远删除”之间加了一层缓冲。规划得当的话,归档表自身也应该有生命周期。比如我的习惯是:
- 归档表保留1~2年的数据;
- 超过2年的归档表数据,先逻辑导出到文件冷备,再执行清理;
- 冷备文件上传到对象存储或备份服务器,至少保存一个规定的期限。
清理归档表同样可以用pt-archiver,但这次不需要 --dest 了,因为数据已经不需要往任何地方迁:
bash复制pt-archiver \
--source h=127.0.0.1,D=app_db,t=t_order_archive,u=archive_user,p=archive_pass \
--where "create_time < DATE_SUB(NOW(), INTERVAL 2 YEAR)" \
--limit 1000 \
--txn-size 1000 \
--purge \
--bulk-delete \
--max-lag 5 \
--check-slave-lag h=127.0.0.1,P=3306,u=repl_user,p=repl_pass \
--statistics
--purge 参数表示只删除不归档,非常适合清理历史归档表。当然,清理前一定要确保数据已经完整导出并检验过,否则一旦删除,再想恢复就难了。
6. 把pt-archiver嵌入整个数据生命周期的一点建议
到这里,pt-archiver从单一命令到自动化任务、从踩坑到校验的完整链路就梳理完了。就我个人的使用体验而言,真正让我觉得这个工具“香”的,不是它第一次帮我删掉几千万行数据的那一刻,而是把归档变成日常任务之后,连续几个月都没有再收到磁盘告警,线上查询响应也稳定了很多。归档这个动作本身不产生业务价值,但它能防止数据库在毫无防备的情况下被海量数据拖垮。
最后分享一个我踩过很多次坑之后形成的习惯:归档策略上线前,一定要和业务方确认保留周期和数据恢复SOP。你和DBA觉得三个月前的订单已经没用了,但财务可能下个月还要拉半年前的账单做对账。归档表虽然还在,但业务方如果不知道去哪查,线上的查询入口又查不到,到时候找过来的就是一场大事故。所以归档方案里除了技术参数,还要明文写清楚归档数据的查询入口是什么、恢复流程是什么,这样才能算一个完整的方案。
