1. 为什么每个 MySQL 运维迟早都要面对数据归档这件事
1.1 数据无限增长,性能却不会无限容忍
我接触过不少业务系统,上线头两年一切正常,到了第三年突然开始频繁报警:慢查询变多、磁盘空间告急、备份时间越来越长。这时候打开数据库一看,最大的一张表往往已经几亿行,占了几百个 G。大多数情况下,真正频繁访问的热数据只有最近几个月,那些一年前、两年前的历史数据躺在表里,既不会被业务查询命中,又拖慢了索引维护和全表扫描的效率。
这就是典型的“数据增长拖垮性能”场景。很多团队的第一反应是直接 DELETE FROM 大表 WHERE create_time < ...,结果往往更糟:一条 DELETE 锁住几百万行,主从延迟瞬间拉满,binlog 暴涨,甚至把生产库直接干宕机。更麻烦的是,DELETE 只是逻辑删除,磁盘空间不会自动释放,InnoDB 表碎片越滚越大,问题一点没解决。
数据归档这件事,本质上不是“删数据”,而是“把冷数据搬走”。把历史数据从在线库迁移到归档库或归档表,让在线表瘦身,让热查询更快,同时历史数据仍然保留着,需要的时候还能查。这几乎是大体量 MySQL 业务绕不开的必经之路。而我这些年用下来最顺手的工具,就是 Percona Toolkit 里的 pt-archiver。
1.2 为什么是 pt-archiver,而不是 delete + 定时任务
先说明白,pt-archiver 不是什么黑魔法,它就是 Percona Toolkit 套件里的一个命令行工具,专门用来高效、安全地批量归档 MySQL 表中的数据。网上关于它的教程很多,但大多停在“会用几个参数”的层面,真正把它玩明白、做到自动化无人值守、并且能在生产环境扛住压力的文章,确实不多。这篇我就把自己从入门到实战的全过程整理出来,该踩的坑和该注意的点都摊开讲。
它和普通 DELETE 最大的区别在于:
- 分批删除,每批默认只处理少量行,不会一次性锁大量数据,对在线业务影响极小。
- 可以在归档删除的同时,把数据写入另一个库/表,相当于“搬移”而不是“删除”。
- 支持按主键或唯一键递增扫描,走索引效率高,而且天然适合超大表的轮询清理。
- 有干跑模式、限速参数、延迟监控,方便你在生产环境小心翼翼地控节奏。
我见过有人用存储过程实现同样的归档逻辑,也不是不行,但维护成本高,而且并发和异常处理都需要自己写。pt-archiver 把这些全都内置了,一条命令行就能表达清楚,脚本化、自动化都非常方便。这就是我为什么坚持推荐它的原因。
这套方法适合谁?适合正在为 MySQL 大表发愁的 DBA、后端开发、运维工程师。不管你管理的表是千万级还是亿级,只要你有“历史数据要保留但不想影响在线性能”的需求,这篇文章都能给你一套可以直接落地的方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. pt-archiver 核心原理与关键参数
2.1 它的工作方式跟普通 DELETE 有什么本质区别
先理解 pt-archiver 的底层逻辑,后面用参数才不会懵。它不像普通 DELETE 那样“一次删一片”,而是按照你指定的条件,逐行或逐小批次扫描,每扫到一行就先处理(比如写入归档表),再从原表删除。
整个过程说起来很简单,但设计上有几个非常关键的细节:
第一,它是基于主键或唯一键做“游标式”遍历的。每次处理完一批,它会记录当前批次最后一条数据的主键位置,下一批从那个位置继续往下走。这样做的好处是:即使表数据量巨大,它也不需要全表扫描,而是沿着索引有规律地推进,每一批都能快速定位。
第二,它把“读取”和“删除”分开了。默认情况下,它会先 SELECT 一批数据,处理完这批之后,再发 DELETE 删除同一批。注意,这里的 SELECT 和 DELETE 是在同一个事务里吗?不一定。取决于你是否指定了 --txn-size。这块我建议你记住一个原则:如果你希望“读出来的数据不会被业务同时改掉”,就用事务把一批操作包起来;如果你追求对在线业务干扰最小,可以把事务调小,甚至不用长事务。
第三,它还考虑到了主从延迟。工具本身提供了 --max-lag 参数,可以设置主从延迟阈值,超过阈值就自动暂停等待。这个参数在归档高峰期特别有用,能避免归档操作把从库拖垮。
2.2 必知必会的核心参数
pt-archiver 参数非常多,但真正日常高频使用的其实就十几个。我按用途给你分个类:
必选参数:
| 参数 | 作用 |
|---|---|
--source |
指定源库 DSN,格式为 h=主机名,P=端口,u=用户,p=密码,D=库名,t=表名 |
--dest |
指定目标库 DSN,如果只归档删除不保留副本,可以不加 |
--where |
归档条件,比如 create_time < '2023-01-01' |
--file |
把归档数据导出到文件,代替写入目标库 |
--limit |
每批处理的行数,默认 1,通常调大一些提升效率 |
--txn-size |
每个事务处理的行数,建议不小于 limit 的倍数 |
--commit-each |
每批提交一次事务 |
--purge |
只删除不归档,相当于高安全的 DELETE |
--dry-run |
干跑模式,只打印语句不真正执行,强烈建议每次先跑一遍 |
--max-lag |
最大主从延迟阈值,单位为秒 |
--check-interval |
检查延迟的间隔时间,默认 1 秒 |
--max-flow-ctl |
针对 PXC 集群的流控阈值,普通主从用不到 |
--sleep |
每批处理完后休眠的秒数,用于限速 |
--statistics |
输出统计信息,方便观察处理速率 |
--why-quit |
打印退出原因,排查问题时很有用 |
这些参数里,我最想提醒你注意的是 --source 和 --dest 的写法。很多人第一次用容易踩坑:DSN 里不能有空格,密码里有特殊字符要小心转义,多个参数要用逗号分隔而不是空格。
举个例子:
bash复制pt-archiver \
--source h=127.0.0.1,P=3306,u=archive_user,p='YourPass',D=shop,t=orders \
--dest h=127.0.0.1,P=3306,u=archive_user,p='YourPass',D=shop_archive,t=orders \
--where "create_time < '2023-01-01'" \
--limit 1000 \
--txn-size 1000 \
--statistics
这条命令的意思就是:把 shop 库中 orders 表里 create_time 早于 2023 年 1 月 1 日的数据,复制到 shop_archive 库的 orders 表,每批处理 1000 行,处理完后删除源表里的对应记录。
2.3 为什么默认 limit 是 1,以及什么时候该调大
官方默认 --limit 1,很多人不理解,觉得一次处理一行岂不是慢死了?其实这个默认值是有道理的:它是最保守、最安全的配置。一次只处理一行,锁冲突概率最低,对在线业务的影响几乎可以忽略。在早期 MySQL 版本或锁竞争激烈的核心表上,这个默认值能保命。
但在实际生产环境,如果表有几亿行,一行一行处理确实太慢了。我一般会在低峰期或归档专门的冷数据表时,把 limit 调到 500 到 2000。具体调多大,取决于你的表结构、行大小、服务器 IOPS 和在线业务对锁的敏感度。
我的经验值是:
- 表行数小于 100 万:limit 500,txn-size 500,sleep 0.1
- 表行数 100 万到 5000 万:limit 1000,txn-size 1000,sleep 0.1 到 0.5
- 表行数大于 5000 万:limit 1000 到 2000,txn-size 2000,sleep 0.5 到 1
注意一个容易忽略的细节:--sleep 的单位是秒,而且支持小数。加 sleep 的目的是给主从同步留缓冲时间,避免归档速度超过从库回放速度导致延迟持续拉大。如果库是 RDS 且只读副本压力大,sleep 一定要加。
3. 从零开始:安装、连接测试与第一次归档
3.1 安装 Percona Toolkit 的几种方式
在开始用 pt-archiver 之前,先把工具装上。Percona Toolkit 支持主流 Linux 发行版,安装方式也很简单。
CentOS / RHEL 系列:
bash复制yum install https://repo.percona.com/yum/percona-release-latest.noarch.rpm
percona-release enable tools release
yum install percona-toolkit
Ubuntu / Debian 系列:
bash复制wget https://repo.percona.com/apt/percona-release_latest.$(lsb_release -sc)_all.deb
dpkg -i percona-release_latest.$(lsb_release -sc)_all.deb
apt-get update
apt-get install percona-toolkit
macOS 用户如果本地开发环境需要测试,可以直接:
bash复制brew install percona-toolkit
装完之后验证一下:
bash复制pt-archiver --version
能看到版本号就说明装好了。我这边目前用的版本是 3.5.x,不同版本在参数细节上略有差异,但核心用法是通用的。
3.2 第一步:做好权限准备
很多人一上来就用 root 跑 pt-archiver,这其实是不规范的。归档任务建议单独建一个账号,只授予必要的权限。原因很简单:归档操作涉及 SELECT 和 DELETE,如果账号权限过大,一旦命令行写错或者被注入,影响面会非常广。
我习惯这样创建账号:
sql复制CREATE USER 'archive_user'@'127.0.0.1' IDENTIFIED BY 'StrongPass123';
GRANT SELECT, DELETE, INSERT, CREATE, ALTER ON shop.* TO 'archive_user'@'127.0.0.1';
GRANT SELECT, INSERT, CREATE, ALTER ON shop_archive.* TO 'archive_user'@'127.0.0.1';
注意,如果源表和目标表在同一个实例上,你只需要一个账号就能同时访问两个库。如果目标库在另一个实例,那就需要两个 DSN 各自的账号。
这里有一个权限坑:pt-archiver 默认会尝试创建目标表(如果目标表不存在的话),所以账号需要有 CREATE 和 ALTER 权限。如果你想让它完全按照源表结构建表,还要确保源表的 SHOW CREATE TABLE 权限能被读到。实际使用中,我更推荐提前手工创建好目标表,不要让工具在建表上做文章,这样结构更可控。
3.3 第一次归档:从干跑到正式执行
假设现在线上有一张 orders 表,里面有 3000 万行数据,其中 2022 年之前的数据基本已经没有业务价值,但你不想丢,需要归档到 shop_archive 库。
第一步,我先在目标库手工建一张结构一致的表:
bash复制mysqldump -h127.0.0.1 -uarchive_user -p --no-data shop orders > orders_schema.sql
mysql -h127.0.0.1 -uarchive_user -p shop_archive < orders_schema.sql
然后,先干跑一遍,确认条件没问题:
bash复制pt-archiver \
--source h=127.0.0.1,P=3306,u=archive_user,p='StrongPass123',D=shop,t=orders \
--dest h=127.0.0.1,P=3306,u=archive_user,p='StrongPass123',D=shop_archive,t=orders \
--where "create_time < '2022-01-01'" \
--limit 500 \
--txn-size 500 \
--dry-run
干跑模式下,它只会打印将要执行的 SQL 语句和统计信息,不会真正改动数据。你会看到类似这样的输出:
code复制SELECT /*!40001 SQL_NO_CACHE */ `id`, `order_no`, `create_time` FROM `shop`.`orders` FORCE INDEX(`PRIMARY`) WHERE (create_time < '2022-01-01') LIMIT 500
DELETE FROM `shop`.`orders` WHERE (`id` = ?)
确认 SQL 没问题后,去掉 --dry-run 正式执行:
bash复制pt-archiver \
--source h=127.0.0.1,P=3306,u=archive_user,p='StrongPass123',D=shop,t=orders \
--dest h=127.0.0.1,P=3306,u=archive_user,p='StrongPass123',D=shop_archive,t=orders \
--where "create_time < '2022-01-01'" \
--limit 500 \
--txn-size 500 \
--sleep 0.1 \
--statistics
执行完后,它会打印类似这样的统计信息:
code复制Started at 2024-05-20T10:00:00
Source: D=shop,t=orders
Dest: D=shop_archive,t=orders
SELECT 5000000
INSERT 5000000
DELETE 5000000
END
值得注意的是,SELECT、INSERT、DELETE 三个数字应该保持一致,如果 INSERT 数比 SELECT 少,说明有数据没有成功写入目标库,需要排查原因。
3.4 归档过程中的几个关键观察点
第一次正式归档时,建议你同时开两个终端,一个跑 pt-archiver,另一个监控数据库状态。
监控慢查询:
sql复制SHOW GLOBAL STATUS LIKE 'Slow_queries';
监控主从延迟:
sql复制SHOW SLAVE STATUS\G
重点关注 Seconds_Behind_Master 这个字段,如果它持续增长且超过 30 秒,建议立即 Ctrl+C 停掉归档,调大 --sleep 或调小 --limit 后再继续。pt-archiver 其实对这种场景有内置应对,就是 --max-lag 参数,但我还是习惯人工观察一轮再做最终参数定版。
另外,归档过程会持续产生 binlog,如果磁盘空间本来就吃紧,一定要提前确认 binlog 保留策略和磁盘剩余空间。一次归档几亿行,binlog 可能膨胀几十上百 G,这个必须心里有数。
4. 自动化实战:让归档任务定时、自动、可监控
4.1 为什么必须走向自动化
手动执行 pt-archiver 只能解决“一次性”问题,但生产环境的数据归档往往是周期性任务。比如日志表、流水表,每天都在产生新数据,如果每个月都要 DBA 半夜爬起来手动跑一次归档,既不现实,也容易遗忘。
自动化之后,至少能带来三个确定性的收益:一是定时执行,到了时间点自动跑,不会因为人为遗忘而堆积;二是执行过程有日志、有结果记录,出问题能追溯;三是可以轻松接入现有的监控告警体系,归档失败能第一时间发现。
我之前在一家电商公司的时候,订单表每季度做一次归档,之前全靠人工,后来有一次因为版本发布太忙忘了跑,结果季度大促前发现订单表已经超过 5 亿行,查询性能明显下滑。从那以后我就痛下决心,把所有归档任务全部改成了自动化脚本加定时调度。
4.2 实战:封装一个可复用的归档脚本
自动化不是简单把命令写进 crontab 就完事,你需要一个带日志、带错误处理、带锁机制的脚本。我自己常用的脚本长这样,你可以直接参考:
bash复制#!/bin/bash
# archive_orders.sh
# 订单表归档脚本,归档指定时间之前的数据到归档库
set -e
set -u
DB_HOST="127.0.0.1"
DB_PORT="3306"
DB_USER="archive_user"
DB_PASS="StrongPass123"
SRC_DB="shop"
SRC_TABLE="orders"
DST_DB="shop_archive"
ARCHIVE_DATE="$1"
LOG_DIR="/data/archive_logs"
LOCK_FILE="/tmp/archive_orders.lock"
LOG_FILE="${LOG_DIR}/archive_orders_$(date +%Y%m%d_%H%M%S).log"
# 检查是否已有实例在运行
if [ -f "$LOCK_FILE" ]; then
echo "Another archive process is running, exit."
exit 1
fi
touch "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
mkdir -p "$LOG_DIR"
# 执行归档
pt-archiver \
--source h=${DB_HOST},P=${DB_PORT},u=${DB_USER},p=${DB_PASS},D=${SRC_DB},t=${SRC_TABLE} \
--dest h=${DB_HOST},P=${DB_PORT},u=${DB_USER},p=${DB_PASS},D=${DST_DB},t=${SRC_TABLE} \
--where "create_time < '${ARCHIVE_DATE}'" \
--limit 1000 \
--txn-size 1000 \
--sleep 0.2 \
--max-lag 10 \
--check-interval 3 \
--statistics \
>> "$LOG_FILE" 2>&1
# 检查执行结果
if [ $? -eq 0 ]; then
echo "Archive completed at $(date)" >> "$LOG_FILE"
else
echo "Archive failed at $(date)" >> "$LOG_FILE"
# 这里可以接入告警,比如通过 webhook 发送企业微信/钉钉通知
curl -s -X POST "https://your-alert-webhook.example.com/archive-failed" \
-H "Content-Type: application/json" \
-d "{\"source\": \"orders\", \"date\": \"${ARCHIVE_DATE}\"}" \
|| true
fi
这个脚本我做了几件事:
- 加锁:通过
LOCK_FILE避免重复执行。如果上一次任务没跑完,下一次定时触发时直接退出,防止两个归档进程同时操作同一张表造成锁竞争。 - 日志记录:每次执
