凌晨三点,告警群里弹出一条磁盘使用率超过百分之九十的通知。登上那台库一看,业务表其实只有七八张,其中一张操作日志表独占了两百多 G,而业务真正还在读的,只有最近两周的数据。剩下的十几亿行记录,除了占着磁盘、拖慢备份、让统计信息失真、把 DDL 拖成几小时之外,几乎没有任何价值。那次之后,我开始把数据归档当成一件正经的运维工程来做,而不是每次磁盘告急时临时敲一条 DELETE 顶上去——后者往往带来更麻烦的后果。
MySQL 数据归档策略,说白了就是一套完整的流程:判断哪些数据该搬走、搬到哪去、用什么方式搬、搬完之后怎么把磁盘空间真正还给操作系统、以及怎么让这套动作在每个月的固定时间自己跑起来。它要解决的是很具体的痛点:单表行数冲到千万甚至亿级之后,索引层级变高、查询变慢、备份窗口拉长、DDL 基本做不动,监控曲线也会因为统计信息过期而失真。这篇内容适合手里管着一两套业务库、被大表折磨过的后端开发和运维同学,也适合正在设计数据生命周期管理方案、还没想清楚从哪下手的人。下面这些方案和脚本,都是我在真实环境里反复调过的,参数不一定通用,但推导过程可以直接套。
1. 动手之前:先把"该不该归档"这件事判断清楚
很多人一看到大表就想着归档,结果忙活两天发现根本没必要——表大但增长稳定、查询都走覆盖索引、备份也能接受,那它只是"看着大",不是"真的病"。归档动作本身有成本:脚本要写、要测、要监控,误删的风险要兜底,从库延迟要盯。所以第一件事永远是把账算清楚,而不是先写 DELETE。
1.1 从系统表里把数据增长的真实曲线拉出来
判断一张表是否进入"该归档"区间,我通常先跑三条查询,拿到的信息比看单表行数有用得多。第一条看体量与碎片:
SELECT TABLE_NAME, TABLE_ROWS, ROUND(DATA_LENGTH / 1024 / 1024 / 1024, 2) AS data_gb, ROUND(INDEX_LENGTH / 1024 / 1024 / 1024, 2) AS index_gb, ROUND(DATA_FREE / 1024 / 1024 / 1024, 2) AS free_gb, ROUND(DATA_FREE / (DATA_LENGTH + INDEX_LENGTH + 1) * 100, 1) AS frag_pct FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'your_db' ORDER BY DATA_LENGTH + INDEX_LENGTH DESC LIMIT 20;TABLE_ROWS在 InnoDB 上是估算值,误差可能到百分之二三十,别拿它当精确计数用,但用来排序和看趋势足够了。真正有价值的是DATA_FREE这一列,它反映表空间里已经被 DELETE 释放但还没还给操作系统的空洞。如果一张表frag_pct长期高于百分之三十,说明历史上删过大量数据却没做过重建,这部分空间白白占着磁盘。
第二条看时间分布,判断"老数据"到底占多大比例。这需要表上有一个时间字段,通常是created_at或gmt_create:
SELECT DATE_FORMAT(created_at, '%Y-%m') AS ym, COUNT(*) AS cnt FROM order_log WHERE created_at >= DATE_SUB(NOW(), INTERVAL 24 MONTH) GROUP BY ym ORDER BY ym;这条查询在大表上可能扫得比较久,可以改成抽样:加WHERE id % 100 = 0或者挑几个具体日期区间分段跑。拿到按月分布之后,你就能直观看到最近三个月占了总量的多少、两年前的数据是不是几乎没人访问。我的经验是,大部分日志类、流水类表,最近三十天的数据占总量的比例往往不到百分之五,剩下的全是可以下沉的冷数据。
第三条看访问热度,这一步最容易被跳过,但它决定了归档会不会引发线上事故。开启performance_schema之后可以查events_statements_summary_by_digest,按表名过滤出高频 SQL,看有没有带旧时间范围的查询长期在跑。如果存在"查一年前某个订单"这类需求,那归档就不能简单地把数据挪走,得先在应用侧做数据源路由,或者保留一个可查的历史库。
1.2 冷热边界怎么划:三种数据要用三套处理方式
我习惯把表里的数据按访问特征分成三类,每类的处置方式完全不同,混在一起处理是最容易出事的。
| 数据类别 | 典型时间窗 | 访问特征 | 处理方式 |
|---|---|---|---|
| 热数据 | 最近 1 到 3 个月 | 高频读写、走主库 | 留在主表,重点做索引优化 |
| 温数据 | 3 个月到 1 年 | 偶尔查询、几乎不写 | 可迁到同实例的历史表,或只读从库 |
| 冷数据 | 1 年以上 | 极低频、多为审计抽查 | 迁到独立归档库或对象存储,主库不留 |
这条边界不是拍脑袋定的,要结合业务的真实查询习惯。有个做订单系统的朋友,一开始按"一年"划冷热,上线后发现财务对账要查十三个月的数据,结果归档表天天被 join,性能反而更差。后来改成按"十三个月"划,问题立刻消失。所以边界值一定要去问业务方,或者干脆从慢查询日志里统计时间范围分布,让数据说话。
还有一个容易忽略的点:归档不是只有"删"这一条路。有些表的数据量来自字段本身太宽,比如一个存了完整请求体和响应体的日志表,单行两三 K,一个月的量就能到几十 G。这种情况优先考虑的是压缩——把大字段用COMPRESS()或者应用层 gzip 之后再入库,或者把明细字段拆到子表,主表只留摘要。压缩做完往往能省掉一半以上的空间,比归档省事得多,风险也低。
1.3 开跑之前必须落实的三道保险
第一条是备份。归档脚本第一次上线,一定要先在从库或者测试实例上完整跑一遍,把执行前后的行数、校验和对上。正式环境执行前,至少保证目标表有一个可恢复的物理备份,逻辑备份在亿级表上恢复时间长到没有意义。我自己的习惯是归档动作之前先做一次全量备份,即使脚本逻辑再简单也不省这一步。
第二条是确认复制链路的状态。主库上大批量删除会写大量 binlog,从库回放时是单线程(或者有限并行)的,很容易造成延迟。归档前必须确认SHOW REPLICA STATUS里的延迟为 0,并且预留出足够的时间窗口——按我的经验,删除一千万行的 binlog 事件量在几百兆到一两 G 之间,具体取决于行宽和binlog_row_image的设置。
第三条是权限与开关。执行归档的账号不要用 root,给它目标表的 SELECT、DELETE、INSERT 权限即可,避免脚本写错库名时误伤别的表。同时在会话级别打开sql_safe_updates,这样任何不带 WHERE 或者 WHERE 里没用索引的 UPDATE/DELETE 都会被直接拦下来:
SET SESSION sql_safe_updates = 1;这个开关救过我一次。当时脚本里一个变量拼错,DELETE 语句的 WHERE 条件变成空字符串,正是sql_safe_updates把它挡在了执行之前。这类防线不花什么成本,但关键时刻能避免一次事故。
2. 归档路线的四条岔路:从裸 DELETE 到分区交换
方案选型这块,网上的说法往往很极端,要么说 DELETE 不能用,要么说必须上分区表。实际情况是三条路线各有适用场景,选错的代价远比想象中大。我按"数据量级 + 可停机窗口 + 是否允许改表结构"这三个维度来做判断,下面逐条拆。
2.1 裸 DELETE:为什么它总是第一个被想到,又第一个被放弃
最直觉的做法就是一条语句删掉历史数据:
DELETE FROM order_log WHERE created_at < '2024-01-01';在小表上它没问题,几百万行以内,几秒钟就完事。但一旦表到了千万级以上,这条语句会带来一连串麻烦。
首先是长事务。InnoDB 的删除并不是立刻释放空间,而是把行标记为删除,事务提交后才由 purge 线程慢慢清理。一个大 DELETE 会生成巨量的 undo 记录,undo 表空间可能瞬间涨到几十 G,purge 跟不上时还会拖慢整个实例的写入性能。其次是锁。DELETE 会对扫描到的所有行加行锁,虽然不阻塞其他不冲突的行,但扫描本身要持有间隙锁,范围越宽锁的范围越大,业务侧的插入很容易被卡住。再次是 binlog。行格式下每删一行都要记一条事件,一千万行就是千万条记录,从库回放的时间可能长达几十分钟。
还有一个更隐蔽的问题:DELETE 之后磁盘空间并不会下降。表空间文件只会变大不会变小,被删的行留下的是内部空洞,DATA_FREE上去了,du看到的文件大小纹丝不动。很多人删了几十 G 数据,一看磁盘还是满的,以为删失败了,其实数据真没了,只是空间没还回去。这个问题后面单独说。
所以裸 DELETE 的适用边界很窄:数据量在百万级以内、业务低峰期执行、删完立刻做表重建。超出这个范围,就该考虑下一种方式。
2.2 分批删除:把一个大事务切成无数把小刀
分批删除的核心思路是把一条长事务拆成若干条短事务,每条只处理固定行数,中间留出间隔让 purge 线程和从库跟上来。最常见的写法是基于主键游标推进:
DELETE FROM order_log WHERE created_at < '2024-01-01' ORDER BY id LIMIT 2000;配合脚本循环执行,每批之间sleep一段时间。这里有两个细节值得展开。
第一个是ORDER BY id不能省。不加排序时,MySQL 可能选择走created_at索引,而这个索引上还挂着大量"未到归档线"的行,扫描成本高且会产生大量回表。强制按主键顺序推进,可以让每批都精确命中一段连续的聚簇索引页,IO 更局部,锁的粒度也更可控。
第二个是批次大小怎么定。这个值不是拍出来的,要从从库延迟反推。假设单行 binlog 事件平均 200 字节,一批 2000 行产生约 400 KB 的 binlog,从库回放这个量大概需要几十毫秒。如果每批之间 sleep 0.2 秒,那么从库有充足的追赶时间,延迟基本稳定在 1 秒以内。数据行比较宽的时候(比如带大字段),单行事件可能到 2 KB,那批次就该降到 500 甚至更小。
| 单行平均宽度 | 建议批次 | 批间 sleep | 从库延迟预期 |
|---|---|---|---|
| < 300 B | 2000 到 5000 | 0.1 到 0.2 s | < 1 s |
| 300 B 到 1 KB | 1000 到 2000 | 0.2 到 0.5 s | < 2 s |
| 1 KB 到 5 KB | 200 到 500 | 0.5 到 1 s | < 3 s |
| > 5 KB | 100 以内 | 1 s 以上 | 需实时观察 |
分批删除的优点是实现简单、不需要改表结构、随时可以中断,缺点是总耗时很长。删一亿行,按每批 2000 行、每批 0.2 秒算,光 sleep 就要接近三个小时,加上实际执行时间,跑一整夜是常事。而且删完之后还是要做空间回收,等于多了一道工序。
2.3 分区表加 DROP PARTITION:归档速度的天花板
如果一张表天生就是按时间维度写入(日志、流水、事件记录),那我强烈建议一上来就做成 RANGE 分区。归档动作从"删数据"变成"扔分区",速度是毫秒级的:
ALTER TABLE order_log PARTITION BY RANGE (TO_DAYS(created_at)) ( PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')), PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')), PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')), PARTITION pmax VALUES LESS THAN MAXVALUE );归档时直接ALTER TABLE order_log DROP PARTITION p202401;,文件被整体删除,不产生 binlog 行事件,不产生 undo,从库几乎无感。这是所有方案里效率最高的一种,没有之一。
但它有几个硬性约束,必须在改表之前想清楚。
第一,分区键必须是所有唯一索引(包括主键)的组成部分。如果主键是自增id,表上还有UNIQUE KEY uk_order_no(order_no),那么直接按created_at分区会失败,必须把created_at加进主键和每个唯一索引,变成PRIMARY KEY(id, created_at)。这会改变索引结构和存储顺序,对业务代码里的某些写法(比如依赖主键唯一性的 INSERT ... ON DUPLICATE KEY)可能有影响,必须提前评估。
第二,MAXVALUE分区是个坑。一旦有数据落进pmax,你就再也无法ADD PARTITION了,只能先REORGANIZE PARTITION把pmax拆开,而这个操作相当于重建整个pmax分区的数据,代价极高。正确做法是永远不写MAXVALUE,提前用脚本把未来三到六个月的分区建好,用一个定时任务每月补充。如果确实需要兜底,就保留pmax但保证它永远是空的,并且加监控——pmax一旦有行数就告警。
第三,分区表对某些查询不友好。WHERE created_at BETWEEN ...这类带分区键的条件能做分区裁剪,性能很好;但如果是按order_no查询,就要扫所有分区,开销等于把 N 张表都查一遍。所以分区表的前提是:绝大多数查询都带时间条件。做不到这一点,就别用分区。
2.4 三种路线的横向对照与选型判断
把三条路线的关键维度摆在一起,选型就清楚了。
| 维度 | 裸 DELETE | 分批 DELETE | 分区表 DROP |
|---|---|---|---|
| 适用数据量 | 百万级以下 | 千万到亿级 | 亿级以上 |
| 是否需改表结构 | 否 | 否 | 是 |
| 对业务的影响 | 大(锁 + 长事务) | 小(可控) | 极小 |
| 归档耗时 | 分钟到小时 | 数小时到数天 | 秒级 |
| 空间回收 | 需额外重建 | 需额外重建 | 自动完成 |
| 实施成本 | 最低 | 中 | 高(前期改造) |
我自己的判断顺序是:新表一律优先考虑分区,因为改造窗口只有建表那一次,后面越拖越难改;存量表看数据量和查询模式,如果查询都带时间条件且能做结构变更,就排期改成分区;如果表结构动不了(比如被多个系统共用、有历史遗留的唯一索引),就在分批删除这条路上做扎实。
还有一条介于两者之间的路线值得提一句:用工具做在线归档。Percona Toolkit 里的pt-archiver就是专门干这个的,它把"查一批、插入目标表、删源表"这个循环封装好了,支持限速、支持只迁不删、支持在主库还是从库执行。类似的思路也可以用gh-ost或者自研脚本实现。工具的好处是细节考虑得比较全,坏处是出问题时排查链路长,我更倾向于先把原理搞明白,再决定用不用工具。
3. 分批迁移脚本的完整落地:先迁后删、断点续跑
方案定了之后,真正的工作量在脚本上。一个能长期跑的归档脚本,要满足几个条件:数据先落到归档表再删源表(不能先删后补)、中途挂掉能接着跑、重复执行结果不变、出错能定位到具体批次。下面按这个顺序拆。
3.1 先迁后删的执行骨架
绝对不要先 DELETE 再把数据从 binlog 或者备份里捞出来,那是最危险的做法。正确顺序永远是:把要归档的行插入归档表,确认插入行数和查询行数一致,再删源表对应的行。
-- 第一步:迁移 INSERT INTO order_log_archive SELECT * FROM order_log WHERE created_at < '2024-01-01' ORDER BY id LIMIT 2000; -- 第二步:核对影响行数后删除 DELETE FROM order_log WHERE created_at < '2024-01-01' ORDER BY id LIMIT 2000;这里有个细节:INSERT ... SELECT和DELETE之间理论上存在数据变化的窗口,如果这期间有旧数据被更新,可能出现"归档表是旧值、源表是新值"或者"删掉了没归档的新值"。对日志类只写不改的表,这个窗口无所谓;对会被更新的表,必须用主键列表来锁定同一批数据:
-- 先查出这一批的主键 SELECT id FROM order_log WHERE created_at < '2024-01-01' ORDER BY id LIMIT 2000; -- 用主键做精确迁移和删除 INSERT INTO order_log_archive SELECT * FROM order_log WHERE id IN (...); DELETE FROM order_log WHERE id IN (...);主键列表不要拼成超长的 IN,两千个 id 拼出来的 SQL 文本可能上百 KB,解析开销不小。可以改成一个临时表方案:把 id 批量写进临时表,然后用 join 完成迁移和删除。这个做法在 id 不连续的场景下特别有效。
3.2 批次大小与休眠间隔的换算方法
前面给了一张经验表,但真实环境里参数还是要现算。算法很简单:先测单批耗时,再看从库延迟,然后调 sleep。
假设一批 2000 行耗时 0.15 秒,产生的 binlog 约 400 KB,从库回放需要 0.1 秒,那么每批 sleep 0.05 秒就够从库追平。但这是理想情况,实际要从最坏情况倒推:如果从库上有大查询在跑,回放速度会下降几倍,所以 sleep 时间通常取回放耗时的 2 到 3 倍作为安全余量。
监控延迟的方式,新版本用:
SHOW REPLICA STATUS\G关注Seconds_Behind_Source和Replica_SQL_Running_State两个字段。老版本字段名是Seconds_Behind_Master,写法不同但含义一样。如果延迟超过阈值(我一般设 5 秒),脚本就应该自动暂停,等延迟归零再继续。这个逻辑一定要写进脚本,靠人工盯是盯不住的。
3.3 断点续跑:让脚本挂掉之后能接着干
归档脚本跑几个小时,中间因为网络抖动、连接超时、从库延迟过高而中断,是常态而不是意外。没有断点续跑能力的脚本,一旦中断就得从头再来,而重头再来意味着前面几小时白干。
最简单的做法是用主键游标。维护一张进度表:
CREATE TABLE archive_progress ( task_name VARCHAR(64) NOT NULL PRIMARY KEY, last_id BIGINT NOT NULL DEFAULT 0, last_run_at DATETIME NOT NULL, archived_rows BIGINT NOT NULL DEFAULT 0 ) ENGINE=InnoDB;每次循环开始先读last_id,处理完一批后更新它。这样无论在哪一步中断,下次启动都能从断点继续。注意更新进度要和数据处理在同一个事务里,否则可能出现"数据处理了但进度没更新"导致重复处理,或者反过来的数据丢失。
配合这个游标,主循环的 SQL 要改成:
SELECT id, created_at, ... FROM order_log WHERE id > :last_id AND created_at < '2024-01-01' ORDER BY id LIMIT 2000;用id > last_id而不是LIMIT offset,避免深分页。深分页在亿级表上是灾难——LIMIT 10000000, 2000需要扫描一千万行再丢弃,越到后面越慢。
3.4 归档表的字段与索引该怎么精简
归档表不是源表的复制粘贴,它应该针对"只读、低频、按时间查"这个特征做裁剪。我通常会做三件事。
第一,去掉业务上不再需要的字段。源表里可能有一堆状态标记、临时字段、扩展 JSON,归档之后这些字段的查询价值基本为零,留着只是占空间。
第二,索引砍到最少。源表可能有七八个索引支撑各种查询,归档表保留一到两个就够了,通常是一个主键加一个时间字段的索引。每少一个索引,插入速度就快一截,归档表本身的体积也能小三分之一以上。
第三,考虑压缩。如果用的是支持压缩的存储引擎或表空间,归档表开页压缩收益很明显,因为归档数据写入之后几乎不再更新,压缩率通常能到 2 到 3 倍。代价是读取时要做解压,对低频访问的场景完全可以接受。
CREATE TABLE order_log_archive ( id BIGINT NOT NULL, order_no VARCHAR(64) NOT NULL, payload MEDIUMTEXT, created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPRESSED;4. DELETE 之后磁盘不降:InnoDB 空间回收的真相
这是被问得最多的一个问题:明明删了几十 G 数据,du -sh看数据文件一点没变。不理解这个机制,就会反复做无用功;理解了之后,就知道该在什么时候、用什么方式把空间拿回来。
4.1 表空间里的"空洞"到底是怎么形成的
InnoDB 的表空间是一组按页组织的文件,默认页大小 16 KB。数据按主键顺序存放在聚簇索引的叶子页里,删除一行只是把页内的记录标记为删除,页本身不会消失。当一整页的记录都被删光,这个页才会被标记为空闲页,进入表空间的空闲页链表,可以被后续的 INSERT 复用——但文件大小不会因此缩小。
所以删除之后的状态是:逻辑上数据没了,物理上文件还是那么大,只是内部多了一堆空闲页。information_schema.TABLES.DATA_FREE显示的就是这部分空闲空间的大小。它有个好处:后续插入新数据时会优先复用这些空闲页,所以表不会无限膨胀。但对"我要把磁盘还给操作系统"这个诉求,它无能为力。
还有一种情况会造成更严重的碎片:随机删除。如果删除的不是连续的主键区间,而是散落在各处的行,那么几乎每个页都只删掉一部分记录,页不会被标记为空闲,只是变得稀疏。这种情况下DATA_FREE可能不大,但实际的空间浪费很严重。判断方法是看平均行密度,或者直接对比DATA_LENGTH和真实行数乘以平均行宽。
4.2 OPTIMIZE TABLE 到底做了什么,代价在哪
回收空间的标准动作是重建表。OPTIMIZE TABLE在 InnoDB 上等价于ALTER TABLE ... FORCE加ANALYZE TABLE,它做的事情是:创建一个新的临时表空间,把原表的数据按主键顺序逐行复制过去(只复制有效行,跳过空闲页),复制完成后用新文件替换旧文件。因为空闲页没有被复制,新文件自然就小了。
这个过程有几个代价必须知道。
第一是空间。重建期间新旧两份文件同时存在,磁盘占用峰值是原表的两倍。如果原表 200 G,你得先有 400 G 的可用空间才能做。磁盘已经快满的时候,这条路直接走不通。
第二是时间。复制两百 G 数据,即使磁盘性能不错,也要几个小时。虽然 Online DDL 支持并发读写(ALGORITHM=INPLACE, LOCK=NONE),但复制期间的写操作要记到 row log 里,row log 有大小上限(由innodb_online_alter_log_max_size控制,默认 128 MB),写入量大时会撑爆导致 DDL 失败回滚——而回滚本身又要花掉同样的时间。
第三是 MDL 锁。DDL 开始和结束时要拿排他元数据锁,如果这时候有长事务在跑,DDL 会一直等着,而它等待期间又会阻塞后续所有对该表的访问,形成雪崩。执行前必须确认没有长事务:
SELECT trx_id, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx WHERE trx_started < DATE_SUB(NOW(), INTERVAL 60 SECOND);有超过一分钟的空闲事务,就先 kill 掉或者等它结束,再动 DDL。
4.3 硬链接重建法:把风险压到最低的空间回收方式
磁盘紧张又必须回收空间的时候,我用的是硬链接加 rename 的方案。核心思路是:先建一张结构相同的新表,把有效数据导进去,然后用 rename 做原子替换,旧文件用硬链接保留一份以防万一,最后再物理删掉。
步骤大致是这样。先确认innodb_file_per_table是开启的,否则所有表共用一个系统表空间,单独回收某张表不可能。
SHOW VARIABLES LIKE 'innodb_file_per_table';然后建新表、导数据:
CREATE TABLE order_log_new LIKE order_log; INSERT INTO order_log_new SELECT * FROM order_log;如果数据量特别大,这一步可以分批做,避免一个超大事务。导完之后核对行数:
SELECT (SELECT COUNT(*) FROM order_log) AS src_cnt, (SELECT COUNT(*) FROM order_log_new) AS dst_cnt;两个数一致之后,做原子替换:
RENAME TABLE order_log TO order_log_old, order_log_new TO order_log;这一步是原子的,业务侧几乎无感知。此时旧表的物理文件还在,先给它做个硬链接备份,再删表:
ln /var/lib/mysql/your_db/order_log_old.ibd \ /var/lib/mysql/your_db/order_log_old.ibd.bak然后在 MySQL 里DROP TABLE order_log_old;。因为还有硬链接指向同一个 inode,物理文件不会被真正删除,空间暂时也没释放。观察一段时间(我一般留三天),确认业务没问题,再手动删掉.ibd.bak文件,空间才真正还给操作系统。
这个方案的好处是把"不可逆的操作"拆成了两步:rename 之后可以随时改回去(再 rename 一次即可),drop 之后还有硬链接兜底,只有删掉备份文件才是最终不可逆。相比直接OPTIMIZE TABLE,它的每一步都可控、可回滚,出问题的概率低得多。
5. 让归档自己跑起来:调度、监控与异常处置
脚本写好只是第一步,归档真正的价值在于"定期自动执行"——人工操作一定会忘,一忘就是半年,等想起来的时候数据量已经翻倍了。所以调度、监控、异常处置这一套必须配齐。
5.1 调度方式的选择与时间窗口设计
常见的三种调度方式,各有适用场景。
系统 crontab 最直接,适合单机部署的脚本。写一行即可:
0 3 1 * * /opt/scripts/archive_order_log.sh >> /var/log/archive/order_log.log 2>&1缺点是没法做复杂的依赖管理,脚本挂了也不会重试,需要自己在脚本里加重试逻辑。
MySQL 的 EVENT 调度器适合纯 SQL 的简单任务,比如每月补建分区。它的好处是不依赖外部系统,坏处是出错信息不直观,而且 EVENT 执行时如果碰上实例重启会直接丢失,不做补跑。
任务平台适合多实例、多任务的统一管理,能看执行历史、能配告警、能手动重跑。如果手上管着十几套库,这个投入是值得的。
时间窗口的选择有个原则:归档任务要放在业务最低谷,而且要预留足够的缓冲。我一般把开始时间定在凌晨两点到三点之间,因为这个点大多数业务已经停写,但从库的备份任务可能还没结束,需要错开。同时任务本身要设置一个硬性截止时间,比如"超过早上六点就自动停止、记录断点、第二天继续",避免它和早高峰抢资源。
5.2 需要盯住的几个关键指标
归档任务的监控不能只看"成功还是失败",那太粗了。我通常盯五个指标。
| 指标 | 含义 | 告警阈值 |
|---|---|---|
| 单批耗时 | 每批处理的实际耗时 | 超过基线 2 倍 |
| 复制延迟 | 从库Seconds_Behind_Source | 持续超过 5 秒 |
| 累计归档行数 | 本次任务已处理行数 | 相比上周期下降 30% |
| 源表剩余行数 | 需归档区间的剩余量 | 连续两周期未明显下降 |
| 磁盘水位 | 数据目录所在分区使用率 | 超过 75% |
单批耗时超过基线两倍,通常意味着索引失效、锁等待或者磁盘 IO 打满了,要立刻停下来查原因。累计归档行数突然下降,可能是数据增长放缓(正常),也可能是脚本提前退出了(异常),需要结合日志判断。源表剩余行数不下降,说明归档没生效或者归档条件写错了,这是最危险的一种情况——看起来每天在跑,实际一行没动。
另外建议单独建一张归档日志表,把每次执行的开始时间、结束时间、处理行数、最大延迟、是否异常都记进去。出问题时直接查这张表,比翻日志文件快得多。
CREATE TABLE archive_task_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, task_name VARCHAR(64) NOT NULL, start_at DATETIME NOT NULL, end_at DATETIME, rows_done BIGINT DEFAULT 0, max_lag_sec INT DEFAULT 0, status VARCHAR(16) NOT NULL, remark VARCHAR(512), KEY idx_task_start (task_name, start_at) ) ENGINE=InnoDB;5.3 中断、失败与回滚的处理套路
归档脚本的失败模式主要有三种,处理方式不一样。
第一种是复制延迟过高。脚本检测到延迟超过阈值就主动暂停,等延迟回落再继续。这里的关键是"暂停"不能退出,退出了就丢了断点。可以在脚本里做一个小循环,每 30 秒查一次延迟,最多等 30 分钟,超时才退出并记录状态。
第二种是连接中断或锁等待超时。这类偶发问题重试一般能解决,脚本里对这类错误码做三到五次重试,退避时间递增即可。
第三种是数据不一致,比如归档表和源表的行数对不上。这种必须立刻停止并人工介入,不能自动重试。排查方向通常是:归档期间有没有新写入落进了归档区间、字符集转换有没有丢数据、触发器有没有干扰。
回滚这块,因为采用的是"先迁后删",回滚其实很直接:只要在源表被删之前发现问题,直接停掉脚本就行,数据都还在。如果已经删了才发现归档表数据有问题,就要从备份里恢复对应区间——这也是为什么前面强调归档表写入之后要做行数核对,把问题挡在删除之前。
6. 几个真踩过的坑,值得单独拎出来说
前面讲的是方法论,这一节讲的是实际执行中被咬过的地方。这些问题在文档里基本看不到,但每一个都能让归档任务翻车。
6.1 binlog 暴涨引发磁盘二次告警
最早做分批删除的时候,我只算了表本身的空间,没算 binlog。一个晚上删了四千万行,binlog 文件从平时的 5 G 涨到了 60 G,磁盘直接从百分之七十冲到百分之九十五,二次告警比第一次还急。
原因很简单:行格式下每删除一行都要记录完整的前镜像,binlog_format=ROW加上默认的binlog_row_image=FULL,单行事件的大小接近整行数据。行宽 500 字节的话,四千万行就是 20 G 的 binlog 事件量,加上事务头、GTID 等开销,只会更多。
解决办法有三个。一是在归档会话里临时把binlog_row_image设成MINIMAL,只记录主键,事件量能降到一个数量级的十分之一。二是控制单次归档的总量,把"一次删完"改成"每天删固定行数",让 binlog 的增长可控。三是提前检查expire_logs_days或者binlog_expire_logs_seconds,确保过期清理跟得上。我现在的习惯是归档前先把 binlog 保留时间临时调到一天,归档结束后再改回来。
6.2 MDL 锁等待把业务整个卡住
有一次在业务高峰期做表重建,DDL 刚发出就卡住了。查SHOW PROCESSLIST发现一个状态是Waiting for table metadata lock,后面排着几十个业务查询。根因是一个跑了两小时的报表事务一直没提交,DDL 在等它的元数据锁,而 DDL 在等待队列里又挡住了后面所有的读请求。
这个现象的机制是:MDL 的等待队列是先进先出的,一个排他锁请求进来之后,后续的共享锁请求都要排在它后面。所以哪怕业务查询本身不冲突,也会被这个排队规则卡住。
处理办法是立刻找到长事务并 kill:
SELECT t.trx_mysql_thread_id, t.trx_started, TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) AS secs, p.info FROM information_schema.innodb_trx t JOIN information_schema.processlist p ON p.id = t.trx_mysql_thread_id ORDER BY t.trx_started LIMIT 10;事后我改了流程:所有 DDL 操作前必须跑这个查询,超过 30 秒的事务一律先处理掉;DDL 本身设置lock_wait_timeout,等不到锁就自动失败,绝不允许无限等待。
6.3 归档表忘了建索引,查询直接全表扫
这个问题出在业务侧,不是脚本。归档表建的时候为了省空间把索引砍得只剩主键,结果财务系统查历史订单是按订单号查的,每次都要全表扫几千万行,一个查询跑十几秒,直接把归档库拖垮。
教训是:砍索引之前必须搞清楚归档数据的实际查询模式。做法很简单,从慢查询日志里捞出一段时间内访问历史区间的 SQL,看它们的 WHERE 条件用到了哪些字段,按这个来设计索引。宁可多留一个索引,也不能让只读查询变成全表扫——归档表本身的写入量很小,多一个索引带来的写放大完全可以忽略。
现在我建归档表的原则是:主键加时间索引是标配,如果业务明确有按某个业务字段查历史的需求,就把那个字段的索引也加上。索引设计这一步不省,省下来的空间远不及一次全表扫造成的性能损失。
6.4 分区表忘了补建,数据全挤进兜底分区
这个坑前面提过一句,但值得再强调。有个按天分区的日志表,原本是用脚本每月补建未来三个月的分区,后来负责的同事离职,脚本跟着停了,没人注意到。三个月后pmax分区里堆了几亿行,当月的DROP PARTITION操作直接失效——因为要删的分区已经不存在了,数据全在兜底分区里。
补救的过程很难受:只能先把pmax里的数据按时间REORGANIZE拆分出来,这个操作要重建整个分区,几亿行跑了将近十个小时。之后再重新建立补建分区的任务和监控。
现在的做法是双保险:一方面补建分区的脚本用独立的调度任务,失败会告警;另一方面加一条监控,pmax分区的行数一旦大于零就立刻通知。分区表的维护不能依赖人的记性,必须让系统自己盯着。
最后分享一个我在实际工作中形成的小习惯:所有归档脚本上线前,我都会先在一个数据量接近的测试实例上完整跑一遍,把每批耗时、总耗时、binlog 增量、磁盘变化都记下来,作为正式环境的基线。有了这个基线,正式跑的时候只要实际值偏离超过两成,就能立刻判断出有问题。这个动作多花半天时间,但省下来的排查时间往往是好几倍。