归档这件事,几乎所有做后端的人都听过,但真正动手的人不多,动手之后没翻车的更少。我给不少团队处理过单表过亿之后的性能问题,最后发现最省事的解法往往不是加索引、不是换硬件,而是把不该留在这张表里的数据搬走——这就是 MySQL 数据归档策略要解决的核心问题:让热表保持在一个可控的体积内,让冷数据有地方待着并且随时能取回来,同时把归档这个动作变成可定期执行、可回溯、可中断续跑的例行维护,而不是某天夜里临时起意的一次性操作。这篇内容适合三类人看:一是手上有一张几千万行、开始出现慢查询和 DDL 卡死的表的开发;二是被要求"数据不许删,但库越来越慢"的运维;三是想提前把归档机制设计进表结构里的架构同学。下面我按"为什么要归档—怎么选方案—两种主流方案怎么落地—出问题怎么查"这条线,把踩过的坑和可直接抄的配置都摊开讲。
1. 想清楚再动手:数据膨胀到底拖垮了哪些环节
1.1 单表变大之后,慢的不是查询本身
很多人对"表大了会慢"的理解停留在"全表扫描慢",实际上数据量上来之后最先崩的是那些看起来跟数据量无关的操作。B+ 树层级从 3 层涨到 4 层,每次回表多一次随机 IO,原本 0.5ms 的点查变成 2ms,单条看不出来,QPS 上到几千就是压垮实例的最后一根稻草。更隐蔽的是buffer pool 命中率:一张 80GB 的表,热点数据只占 5%,但二级索引的随机访问会把冷页不断换进内存,把真正的热数据挤出去,命中率从 99% 掉到 92%,磁盘读 IOPS 直接翻几倍。
再往后就是运维层面的痛。加一个索引要走在线 DDL,几千万行还能接受,几亿行的时候一次 DDL 要跑几个小时,期间要拷一份全量数据到临时表,磁盘空间得预留一倍。备份也是同理,逻辑备份导出时间线性增长,物理备份虽然快,但恢复验证的窗口越来越长。还有一类容易忽略的:统计信息不准。表大了之后采样页数相对变少,优化器选错执行计划的概率明显上升,同一条 SQL 昨天走索引今天走全扫,很多人以为是数据库抽风,其实就是统计信息漂移。
最后是成本。SSD 存储不是免费的,一条三年前的历史订单还躺在高性能盘上,占着 buffer pool、占着备份带宽、占着 DDL 的时间窗口,却一年被查不到两次。归档的本质是把数据的存储成本和处理成本,跟它的访问频率对齐,这件事不做,所有优化都是在给一个持续膨胀的雪球做表面修补。
1.2 归档、删除、备份,是三件不同的事
这三个概念经常被混着说,但混着说就会出事。备份是为了应对灾难,是全量快照,目的是能恢复到某个时间点;删除是数据不再需要,物理上消失;归档介于两者之间——数据不再参与日常业务,但出于合规、审计、对账、客诉追溯等原因必须保留,并且要能按条件查回来。
我在评审归档方案时一定会先问一句:这批数据将来还要不要按什么条件查?如果答案是"要按用户 ID 查某一年的订单",那归档表就必须保留对应的索引,不能简单导成 CSV 塞进对象存储;如果答案是"只在极端情况下按主键调一条",那归档表甚至可以只保留主键和少量字段,索引全部砍掉,体积能压缩一半以上。
还有一个边界是在线业务数据的保留期。这个期限不能由技术拍脑袋,得业务和合规一起定。常见做法是按业务类型分档:交易流水保留 3 年,操作日志保留 6 个月,消息通知保留 30 天,埋点明细保留 7 天。把这个表先定死,后面的所有技术选型才有依据。没定清楚就开工,最大的风险是删完了业务方说还要查,这时候只能从备份里捞,那场面非常难看。
1.3 先算一笔容量账,再决定动不动手
动手之前先算账,这是我给自己定的规矩。不算账就上手,很容易出现"迁了三个月发现目标表也满了"的情况。算法很简单:日增行数 × 平均行长 ×(1 + 索引膨胀系数)× 保留天数。
| 指标 | 取值示例 | 说明 |
|---|---|---|
| 日均新增行数 | 50 万行 | 从监控里取近 30 天均值 |
| 平均行长(含行头) | 约 300 字节 | AVG_ROW_LENGTH会偏大,手工按字段估更准 |
| 索引膨胀系数 | 0.6 | 二级索引总量与数据量之比,可在information_schema.tables里核对 |
| 一年总量 | 1.8 亿行 / 约 86GB | 500000 × 365 × 300B × 1.6 |
| 目标保留期 | 90 天 | 业务确认后的结果 |
| 归档后目标体积 | 4500 万行 / 约 21GB | 缩到四分之一 |
算完之后你会发现两个结论特别有用:一是保留期缩短一半,体积直接减半,谈判空间比技术优化大得多;二是日增量决定了归档的执行频率,一天 50 万行的量,按每天一次、每批 2000 行来跑,也就 250 个批次,几分钟的事,不需要搞得多复杂。真正难的是存量那一亿多行,第一次全量搬迁得单独排期。
这里有个实操上的建议:第一次做存量清理时,不要一次删干净。先按历史最久的那批数据做小范围试跑,比如先归档 100 万行,观察主从延迟、IO、锁等待,跑通了再放量。归档任务和业务不同,它跑得慢一点没人投诉,跑出故障就是全站事故。
2. 方案选型:几条主流路径怎么挑
2.1 分区表 + DROP PARTITION:最省事的路径,但门槛在表结构
如果这张表的生命周期从一开始就设计成按时间分片,那归档就是最优雅的:ALTER TABLE ... DROP PARTITION是元数据操作加文件删除,几秒完成,不产生逐行 binlog,不会锁住大范围数据。这是我一直推的方案,前提是你在建表阶段就把分区键设计进去。
分区表有几个硬约束,不讲清楚会踩坑。第一,分区列必须包含在主键和所有唯一索引里,MySQL 的分区是本地分区,唯一性只能在分区内保证,所以PRIMARY KEY (id)的表现在没法按create_time分区,必须改成PRIMARY KEY (id, create_time)。这一改动会波及所有依赖单列主键的代码,包括那些用INSERT ... ON DUPLICATE KEY UPDATE的地方。第二,分区数不是越多越好,单表分区建议控制在 50 到 100 个以内,分区太多会导致打开表变慢、元数据锁竞争加剧。按月分区的话,保留 24 个月就是 24 个分区,完全够用。
还有一类常见误区:以为建了分区,查询就自动变快。实际上分区裁剪只在 WHERE 条件里出现分区列时才生效,WHERE id = 123这种查询会扫描所有分区。所以分区表的代价是非分区列查询的性能可能会退化,这一点必须在选型时评估清楚,别等上线了才发现主键点查变慢了。
2.2 归档表 + 定时搬迁:不改表结构的通用解
绝大多数线上表在建设初期都没考虑分区,这时候改表结构成本极高,通用的做法是建一张结构相同或精简的归档表,用定时任务把过期数据搬过去,搬完再删原表。这个方案对业务代码零侵入,历史数据也还在同一个实例里,能直接用 SQL 查,对账和客诉场景很友好。
它的代价是删除本身是有成本的。即使走索引,DELETE也要维护二级索引、写 undo、写 redo、写 binlog,物理空间不会立刻归还,删完之后表里留一堆空洞,还得靠重建表来整理。所以这个方案的成败全在批次控制上,一批删多少、两批之间歇多久、怎么断点续跑,这些细节决定了它是平稳维护还是灾难现场。后面的第 4 章我会把这个方案拆到可以直接照着写的程度。
2.3 逻辑删除 + 冷热分离:读多写少场景的折中
有些业务不允许物理删数据,比如涉及资金或者需要审计追溯的,这时候可以在表里加一个is_archived标记位,把过期记录标记掉,查询侧统一加上过滤条件,同时配合历史库路由:应用层按时间判断,超过保留期的查询打到另一个库或另一张表。
这个方案的优点是删改都是软的,随时可回滚,缺点是表体积一点没减。标记位只是让数据"逻辑上不存在",索引照样维护,buffer pool 照样占。所以我一般把它当过渡方案:先用逻辑删除争取时间,把历史库建起来、把查询路由改完,再做物理搬迁。直接拿它当终态,半年后你会发现表更大了,因为标记位本身还要建索引。
变种做法是冷热分表 + 读路由:热表只放最近 90 天,冷表放全量历史,写入双写或者靠异步同步。这个方案对查询性能最友好,但架构复杂度最高,同步延迟和数据一致性都要额外处理,中小规模的数据量不太值得。
2.4 导出到外部存储:成本最低,查询最麻烦
数据量大到 MySQL 已经不适合承载的时候,就得出库了。常见的落地方式是先导成文本文件,压缩后进对象存储,需要时按分区拉回来导入临时表查询;或者同步进列式存储的分析库,专门应对聚合统计类的历史查询。
这条路的成本优势非常明显,冷数据放在低成本存储上,一年能省下不少盘。但它有前提:这批数据的查询模式必须是低频、批量、可接受分钟级延迟的。如果业务方随时可能按订单号查一条三年前的记录,那出库就是给自己找麻烦,每次查询都要拉起一次导入任务。我的经验是,出库前先做三个月的访问日志统计,看看历史数据的真实查询频次和查询条件,用数据说话,别用"万一要查"这种假设绑架架构。
2.5 四种路径怎么选:一张对照表说清楚
| 维度 | 分区表 + DROP | 归档表 + 定时搬迁 | 逻辑删除 + 冷热分离 | 导出外部存储 |
|---|---|---|---|---|
| 是否需改表结构 | 需要,主键含分区列 | 不需要 | 需要加标记字段 | 不需要 |
| 归档速度 | 秒级 | 分钟到小时级 | 秒级(仅打标) | 小时级 |
| 主库压力 | 低(元数据操作) | 中到高(取决于批次) | 低 | 中(导出扫描) |
| 空间是否释放 | 是,立即 | 否,需重建表 | 否 | 是(需重建) |
| 历史数据可查性 | 需查分区或归档表 | 好,同库 SQL 直查 | 好 | 差,需导入 |
| 回滚难度 | 高(分区删了不好恢复) | 低(先写后删) | 极低 | 低 |
| 适用场景 | 新建表、时间序列类 | 存量表、通用场景 | 合规要求不能删 | 超大规模、低频查询 |
选型的判断顺序我一般是这样:新表优先考虑分区,一劳永逸;存量表走归档表搬迁,风险可控;有合规硬约束的先用逻辑删除过渡;单表超过百 GB 且历史查询极少的,认真评估出库。这几种不是互斥的,很多团队最后是分区 + 归档表 + 出库三级组合,热数据在分区表里滚动,温数据进归档表,冷数据进对象存储。
3. 实操一:分区表归档的完整落地流程
3.1 表结构改造与分区键的选择
先看一个可用的建表语句,按订单创建时间按月分区:
CREATE TABLE t_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, order_no VARCHAR(32) NOT NULL, amount DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id, create_time), UNIQUE KEY uk_order_no (order_no, create_time), KEY idx_user_time (user_id, create_time), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 PARTITION BY RANGE COLUMNS(create_time) ( PARTITION p202601 VALUES LESS THAN ('2026-02-01'), PARTITION p202602 VALUES LESS THAN ('2026-03-01'), PARTITION p202603 VALUES LESS THAN ('2026-04-01'), PARTITION pmax VALUES LESS THAN (MAXVALUE) );几个细节值得单独说。用RANGE COLUMNS(create_time)而不是RANGE(TO_DAYS(create_time)),前者可读性好、边界直观,运维手工加分区的出错概率低很多,而且TO_DAYS在写边界值时很容易算错一天,导致数据落到错误分区。另外一定要留一个pmax兜底分区,防止某天忘了提前建分区,插入直接报Table has no partition for value,那是直接写失败的业务故障。
如果这张表已经有存量数据要改成分区表,ALTER TABLE ... PARTITION BY会重建整张表,几个小时的锁表时间跑不掉,必须用在线 DDL 工具做影子表迁移,并且提前评估磁盘空间(需要预留约 1.2 倍表体积)。这个操作我建议放在业务低峰期,并且先在从库或者预发环境完整演练一遍,把耗时摸清楚。
3.2 分区滚动维护与调度方式
分区表的日常维护就两件事:提前加分区、到期删分区。加分区用REORGANIZE PARTITION把pmax拆开:
ALTER TABLE t_order REORGANIZE PARTITION pmax INTO ( PARTITION p202604 VALUES LESS THAN ('2026-05-01'), PARTITION pmax VALUES LESS THAN (MAXVALUE) );删分区就是一行:
ALTER TABLE t_order DROP PARTITION p202501;注意REORGANIZE会把pmax里已有的数据重新分配到新分区,如果pmax里已经堆了大量数据,这个操作会很慢。所以提前建分区是关键,一般提前 3 到 6 个月建好,pmax永远保持空的状态,REORGANIZE就是秒级操作。
调度方式上,我不建议用 MySQL 自带的EVENT SCHEDULER。原因很实际:event_scheduler参数在很多托管环境里默认关闭或者不允许改,事件执行失败的错误信息藏在information_schema.events里,没人会去看,等发现的时候往往已经出事了。更稳妥的是外部调度——定时任务平台、调度系统或者简单的 crontab,执行一个脚本,脚本里做三件事:检查下个月分区是否存在、不存在就创建、再检查最老的分区是否超过保留期、超了就归档后删除。脚本里每个 DDL 都要记日志,失败要告警,这样出问题第一时间能知道。
还有一个容易忽略的点:DROP PARTITION只记录 DDL 语句到 binlog,从库执行同样的 DDL 时也要删文件。如果从库磁盘空间比主库紧张,可能出现主库删成功、从库因空间不足或延迟导致复制中断的情况。所以归档操作前先确认所有从库的空间水位,这是我踩过的坑,后面第 5 章会细说。
3.3 用 EXCHANGE PARTITION 做"先备份再删"
DROP PARTITION是不可逆的,手一抖删错分区就只能从备份恢复。生产上我一般加一道保险:用EXCHANGE PARTITION把待删分区换到一张独立的归档表里,导出之后再删。
-- 建一张结构完全一致的非分区表 CREATE TABLE t_order_arch_202601 LIKE t_order; ALTER TABLE t_order_arch_202601 REMOVE PARTITIONING; -- 把分区数据"换"出来,秒级完成 ALTER TABLE t_order EXCHANGE PARTITION p202601 WITH TABLE t_order_arch_202601; -- 此时 t_order_arch_202601 里就是那个分区的全部数据 -- 导出、压缩、上传,确认无误后再删归档表 DROP TABLE t_order_arch_202601;EXCHANGE PARTITION有个硬性要求:两张表的结构、索引、存储引擎必须完全一致,LIKE建出来的表带着分区定义,必须先REMOVE PARTITIONING,否则报错。另外这个操作需要持有表的元数据锁,执行前确认没有长事务、没有大查询,否则会把整个表的写入堵住。
我通常把这一步做成"导出到文件"而不是保留在库里,因为归档表留在同一个实例上,磁盘该占还是占,起不到降本的作用。导出用SELECT ... INTO OUTFILE或者逻辑导出工具都行,关键是导出完做一次行数和校验和比对,确认文件完整再删表。这个校验环节别省,我见过导出中途磁盘写满导致文件截断,删完分区才发现备份是坏的。
4. 实操二:无分区表的定时搬迁归档
4.1 归档表结构与水位线设计
存量表不能改分区,就走搬迁路线。第一步是建归档表,原则是结构够用就好,索引能砍就砍:
CREATE TABLE t_order_archive ( id BIGINT UNSIGNED NOT NULL, user_id BIGINT UNSIGNED NOT NULL, order_no VARCHAR(32) NOT NULL, amount DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_time (user_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8;几个设计取舍要交代清楚。主键用原表的id不带自增,保证搬迁过程中重复执行不产生重复行,还能直接用INSERT IGNORE实现幂等。归档表砍掉了update_time和部分业务上不需要的字段,行更窄。ROW_FORMAT=COMPRESSED能省 30% 到 50% 的空间,代价是读取时多一次解压 CPU 开销——归档表读得少,这个交换划算。如果你的 MySQL 版本支持页压缩,也可以考虑COMPRESSION='zlib',效果取决于数据特征,上线前拿 100 万行实测一下压缩比再决定。
然后是水位线表,用来记录每张表迁到哪儿了:
CREATE TABLE archive_watermark ( table_name VARCHAR(64) NOT NULL PRIMARY KEY, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, cutoff_date DATE NOT NULL, batch_size INT NOT NULL DEFAULT 2000, status TINYINT NOT NULL DEFAULT 0, -- 0 空闲 1 运行中 2 暂停 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB;水位线的意义在于断点续跑。归档任务随时可能因为主从延迟、磁盘告警、发布重启而中断,没有水位线的话,第二天要么从头扫一遍,要么靠肉眼判断进度。有了last_id,任务重启后从上次的位置继续,逻辑简单,排查也简单。这个表虽小,但我认为它是整个方案里最关键的设计,比批次大小、索引优化都重要。
4.2 分批搬迁的执行逻辑与索引利用
先说一个反直觉的结论:按create_time分批删除,在数据分布均匀时反而更慢。原因是WHERE create_time < '2026-01-01' ORDER BY id LIMIT 2000这个写法,优化器可能选择idx_create_time扫出一大堆行,再回表排序取前 2000 条,每次都要重复扫描前面那些已经处理过的行,越往后越慢。
更稳的做法是按主键区间推进,先用一次查询确定范围,再按主键游标分批:
-- 一次性确定起点和终点,走主键 SELECT MIN(id), MAX(id) FROM t_order WHERE create_time < '2026-01-01'; -- 循环执行,每次只处理一个主键区间 SELECT id, user_id, order_no, amount, status, create_time FROM t_order WHERE id > :last_id AND id <= :last_id + :step AND create_time < '2026-01-01' ORDER BY id LIMIT 2000;拿到结果后先批量写归档表,再用这批的 id 范围做删除:
INSERT IGNORE INTO t_order_archive (id, user_id, order_no, amount, status, create_time) VALUES (...), (...), ...; DELETE FROM t_order WHERE id > :last_id AND id <= :last_id + :step AND create_time < '2026-01-01';这里有两个细节。第一,先写后删,顺序绝对不能反。写完立刻删、删完才写,中间崩了就是数据丢失,这在归档场景里是不可接受的。第二,删除条件里带上id范围,是为了让删除也走主键索引,避免优化器选错索引导致大范围扫描加锁。
批次大小怎么定?我的经验值是从 1000 到 2000 行起步,观察三项指标:单批耗时、主从延迟、锁等待。单批耗时控制在 200ms 以内比较舒服,超过 500ms 就减半;从库延迟超过 2 秒就加 sleep。一个可以抄的参数组合是:每批 2000 行,批间 sleep 0.2 秒,每秒吞吐大约 5000 到 8000 行,一亿行需要四到六个小时。这个速度不算快,但胜在平稳,业务几乎感知不到。
4.3 任务编排、限流与安全检查
把上面的逻辑包成脚本,外层做几件事:
- 只读时间窗口:只在低峰期运行,比如凌晨 1 点到 5 点,到点无论跑没跑完都主动退出,第二天从水位线继续。
- 前置检查:启动前检查从库延迟、磁盘使用率、当前是否有大事务,任意一项不达标就退出告警,不硬跑。
- 动态限流:每批结束后读取从库延迟,超过阈值就 sleep 加倍,恢复后逐渐减小,做成自适应。
- 异常熔断:连续 3 批失败直接中止并把状态置为暂停,等人介入,不要无限重试把数据库打垮。
- 交叉校验:每天归档完成后,对比源表减少行数与归档表增加行数,不一致就告警。
关于限流,还有一个更精确的做法:直接监控SHOW REPLICA STATUS里的Seconds_Behind_Source。这个值在有大事务时会跳变,单看数值容易被误导,更可靠的是看Relay_Log_Pos的推进速度,只要还在推进就说明没卡死。如果你们用的是并行复制,还要注意WRITESET模式下大事务依然是单线程回放,所以批次不能太大是根本,任何限流参数都是辅助。
最后是校验。搬迁完成后至少做三次核对:源表COUNT(*)与归档表行数之和是否等于原始总量、随机抽 100 条按主键在两边比对、对金额字段做SUM()对比。金额类字段的和如果有差异,说明有数据被少搬或者重复搬,必须停下来查清楚。我见过一次因为INSERT IGNORE遇到主键冲突静默跳过,导致少量记录被删掉而在归档表里不存在,如果没有做行数对账,这批数据就永久丢了。
5. 常见问题与排查技巧实录
5.1 主从延迟、空间不释放、锁等待:三类高频事故
主从延迟是归档场景的第一号杀手。原因不复杂:批量删除产生大事务,从库在WRITESET并行的前提下,同一个事务依然只能一个线程回放,一个删 10 万行的事务回放要几十秒,后面的日志全部堵住。排查方法是对比主库SHOW MASTER STATUS的Position和从库的Exec_Master_Log_Pos,差距在拉大就说明在追。解法就一个字:拆。批次调小、批间 sleep,必要时在从库上临时调大innodb_lock_wait_timeout和并行复制线程数,但根本还是控制事务大小。
空间不释放是新手最容易困惑的地方。删了 3000 万行,df -h一看磁盘还是满的。这是 InnoDB 的表空间管理机制决定的:删除只是把页标记为可复用,文件不会缩小,而且删除产生的空洞会让后续插入出现页分裂,索引体积反而可能变大。解决办法是重建表,ALTER TABLE ... ENGINE=InnoDB或者用在线 DDL 工具做,重建期间需要额外一份空间。所以做归档规划时,磁盘必须预留"归档前体积 + 归档后体积"的总和,不能按归档后的体积算。
锁等待和死锁主要出现在删除语句走错索引的时候。如果DELETE的 WHERE 条件没命中索引,InnoDB 会扫描大量行并给它们加锁,一旦和其他事务交叉就死锁。排查靠information_schema.innodb_trx和performance_schema.data_locks,看哪个事务持有了大量行锁。预防的办法很直接:执行删除前先EXPLAIN一遍,确认key是主键或者时间索引,rows在几百这个量级,不是几十万。
还有一类问题比较隐蔽:归档任务本身把undo撑爆了。长事务不让 purge 线程回收旧版本,history list length持续上涨,磁盘上的undo表空间疯长。监控这个指标的方法很简单,SHOW ENGINE INNODB STATUS里的History list length,超过百万就要警惕。根本解法还是缩短事务,别指望调大innodb_purge_threads能救回来。
5.2 问题速查表
| 现象 | 大概率原因 | 排查动作 | 处理办法 |
|---|---|---|---|
| 从库延迟持续增大 | 单事务过大,回放跟不上 | 对比主从位点、看事务内行数 | 批次降到 1000 以下,加 sleep |
| 删除后磁盘不降 | InnoDB 不回收文件 | 查data_free字段 | 低峰期重建表或在线 DDL |
| 删除语句长时间不返回 | 走错索引,全表扫描加锁 | EXPLAIN看key和rows | 补索引或改按主键区间删 |
| 归档表行数少于预期 | INSERT IGNORE静默跳过冲突 | 对账 + 抽主键比对 | 改用REPLACE或先查后插 |
| 归档任务重复执行插入重复 | 水位线没更新或未提交 | 查水位线updated_at | 加唯一键,写入与水位线同事务 |
undo表空间疯涨 | 长事务阻塞 purge | 看History list length | 拆分事务,缩短单次运行时长 |
| DDL 把写入堵住 | 长事务持有 MDL 等待 | 查metadata_locks表 | 先 kill 长事务,再用在线 DDL |
| 分区插入报无分区错误 | pmax被删或未提前建分区 | 查PARTITIONS表 | 立即补建pmax分区 |
| 时间边界数据算错 | 时区不一致或边界值取整 | 核对@@time_zone与数据 | 统一 UTC 存储,边界写清楚 |
5.3 几个文档里不会写的实操心得
第一个心得:归档任务要有"开关"。不要把调度时间写死在代码里,做成配置项,出问题的时候能一键停掉。我经历过一次归档任务和一次数据修复脚本撞车,两边都在删同一批数据,行数对不上,查了两个小时才发现。现在我的做法是在水位线表里加status字段,脚本每批开始前读一次,状态不是"运行中"就立刻退出,运维改一个字段就能停。
第二个心得:首次全量归档单独排期,不要放在日常调度里。存量几千万行的第一次搬迁,耗时是日常任务的几十倍,如果混在每日任务里跑,很容易跑到业务早高峰还没结束。做法是首次执行时把时间窗口放宽到整个夜间,并且临时调小批次、加大 sleep,宁可跑两个晚上,也不要在白天占资源。
第三个心得:归档之前先做索引瘦身。有些表的索引是历史遗留,五个索引里三个没人用,这些索引在归档搬迁时全都要维护一遍,白白增加成本。动手前用performance_schema.table_io_waits_summary_by_index_usage查一下索引使用情况,没用的先删掉,搬迁速度能快不少,归档表的索引也能更精简。
第四个心得:给归档数据留一条"回捞"通道。业务方偶尔会要求把某批已归档的数据恢复回主表,比如某个客户投诉需要重新核对。如果没有现成通道,每次都要手工写 SQL,很容易出错。我一般会准备一个反向脚本,按主键范围从归档表搬到主表,INSERT IGNORE保证幂等,跑之前先确认主表没有同 ID 的行。这个脚本平时不用,但需要的时候能救命。
第五个心得比较朴素:归档不是一次性工程,是例行维护。做完第一次全量清理,很多人就以为任务结束了,结果半年后表又涨回去了,因为日常归档任务没配、或者配了但没人看告警。我的建议是把归档任务的执行结果接进日常巡检报表,每天看一眼"昨天归档了多少行、有没有失败",这个动作花不了一分钟,但能避免半年后再来一次大手术。
6. 关于保留期和节奏的一点个人看法
做了这么多轮归档,我最大的感受是技术方案从来不是难点,难的是把保留期这件事说清楚并且落成制度。技术上删一批数据和留一批数据,成本差不了多少,但业务上一句"先都留着吧",就能让表在两年内涨十倍。所以我现在的习惯是,在设计阶段就把数据分级写进文档:哪些表是流水型、保留多久、归档到哪儿、谁能查、查的时候走什么通道。这份东西定下来,归档策略才有稳定的输入,否则每次都要重新讨论一遍,讨论完还是那句话——先留着吧。
另外一个体会是关于节奏。归档任务最怕的不是慢,是不稳定。一个每天跑五分钟、从不失败的任务,比一个偶尔跑两小时、时不时卡住的任务有价值得多。所以参数宁可保守,批次宁可小一点,sleep 宁可长一点,把稳定性放在吞吐量前面。真遇到存量太大跑不完的情况,多跑几天就是了,数据又不会跑。
最后分享一个我常用的检查清单,每次上线归档任务前过一遍:保留期是否经过业务确认、归档表是否有唯一键保证幂等、水位线是否和写入在同一事务、批次大小是否经过压测、从库空间是否足够、失败是否有告警、有没有一键停止开关、对账脚本是否跑通、回捞脚本是否验证过。这九条里任何一条没过,我都会把上线时间往后推。看起来繁琐,但每一条背后都是一次真实的故障。