在业务系统长期运行后,MySQL表里经常会出现重复数据,例如用户多次提交导致订单重复、日志采集异常引起记录冗余。当这些数据积累到影响查询性能时,我们就需要先做去重,再把有效数据搬迁到归档库,从而给主库瘦身。下面以最常见的订单表为例,说明一套可落地的操作方式。

一、去重前的准备工作
动手之前,首先要明确什么算重复。多数场景是以业务唯一键为准,比如订单表中的订单编号 order_no 加上用户ID user_id。如果表里没有唯一索引,数据库本身无法阻止重复插入,因此第一步应当是确认重复维度。我们可以通过分组计数快速定位重复量:
SELECT order_no, user_id, COUNT(*) AS cnt FROM orders GROUP BY order_no, user_id HAVING COUNT(*) > 1 LIMIT 100;
上面这条语句能列出重复组及出现次数。确认好维度后,建议先在一台从库或者备份库上演练,避免直接在主库大规模操作引发锁表或主从延迟。同时,要和下游消费方确认,重复数据中是否有某一条携带了更完整的状态,例如支付时间、发货时间,防止去重时误删有效记录。
另外,归档目标库需要提前建好结构相同的表,字符集和索引尽量保持一致。如果源表使用了自增主键,归档表可以保留原主键值,也可以使用新的自增策略,这取决于后续是否还需要用原ID做关联查询。提前规划能减少归档时的字段映射成本。
二、两种常用的去重方案
2.1 利用窗口函数去重
MySQL 8.0 及以上版本支持 ROW_NUMBER 窗口函数,可以按重复维度排序,保留每组第一条,其余视为重复。这种方式逻辑清晰,也方便在临时表中查看被剔除的数据。
CREATE TABLE orders_tmp AS
SELECT * FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY order_no, user_id
ORDER BY create_time DESC
) AS rn
FROM orders
) t
WHERE rn = 1;
上述语句新建了 orders_tmp 表,里面只保存每组中 create_time 最新的那条记录。如果业务要求保留最早记录,只需把 ORDER BY 改为 ASC。完成后,可以对比原表和临时表的行数,验证去重比例是否符合预期。
该方案的优点是可读性强,且能灵活控制保留规则;缺点是在超大表上构建临时表会消耗较多磁盘与临时空间,需要保证 tmpdir 所在盘有足够余量。对于亿级数据,建议增加 WHERE 条件按时间分段处理。
2.2 利用 INSERT IGNORE 去重
如果已经在去重维度上建立了唯一索引,那么可以借助 INSERT IGNORE 让数据库自动跳过冲突行。先建一个带唯一索引的空表,再批量插入:
CREATE TABLE orders_uniq ( id BIGINT, order_no VARCHAR(64), user_id BIGINT, create_time DATETIME, UNIQUE KEY uk_order_user (order_no, user_id) ) ENGINE=InnoDB; INSERT IGNORE INTO orders_uniq SELECT id, order_no, user_id, create_time FROM orders;
执行后,重复的行会因为唯一键冲突被忽略,表里留下的就是去重结果。这种方法写入效率高,适合重复率不高且已具备索引条件的场景。但要注意,如果原表没有唯一索引,需要先创建,而在线加索引本身也可能锁表,应配合 pt-online-schema-change 等工具。
相比窗口函数,INSERT IGNORE 无法自定义保留哪一条,默认保留第一次写入的成功记录。如果业务对保留规则敏感,应优先使用窗口函数方案。
三、去重后的数据归档流程
3.1 单批次归档示例
去重得到干净数据后,可以将其从主库搬到归档库。最简单的形式是跨库 INSERT SELECT,但大事务会阻塞主库,因此更推荐分批操作:
INSERT INTO archive_db.orders_archive SELECT * FROM orders_uniq WHERE create_time < '2023-01-01' LIMIT 5000;
每次只搬五千行,循环执行直到受影响行数为零。搬完后,再从主库删除对应时间区间的数据。由于删除也是分批进行,可以把主库压力控制在可接受范围。
若使用存储过程,可以把循环逻辑封装起来,由定时任务在低峰期调用。下面给出一个简化版存储过程骨架:
DELIMITER //
CREATE PROCEDURE archive_orders(IN batch INT)
BEGIN
DECLARE done INT DEFAULT 0;
WHILE done = 0 DO
INSERT INTO archive_db.orders_archive
SELECT * FROM orders_uniq
WHERE create_time < '2023-01-01'
LIMIT batch;
IF ROW_COUNT() = 0 THEN
SET done = 1;
END IF;
END WHILE;
END //
DELIMITER ;
该过程不断搬运直到没有更早的数据。实际生产可将时间条件参数化,并按天循环,这样即使中断也能从某天继续,不需要重头跑。
3.2 配合分区表简化归档
如果源表本身按时间做了 RANGE 分区,归档会更轻松。可以直接把整个旧分区脱离主表并挂载到归档库,这在 MySQL 中叫做 EXCHANGE PARTITION,操作秒级完成:
ALTER TABLE orders EXCHANGE PARTITION p2022 WITH TABLE orders_p2022_tmp;
交换后,原分区数据落到临时表,再把这个临时表改名并移到归档库即可。此方法几乎不写原表,对线上影响极小,非常适合按月份或年份保留数据的系统。不过分区表设计需要在建表初期就规划好,后期改造代价较大。
无论采用哪种方式,归档完成都要校验两边的数据总量与关键金额字段求和,确保没有记录丢失或错位。校验通过后再清理源端旧数据,并优化表空间。
四、注意事项与优化建议
去重归档不是一次性工作,而应纳入日常运维。建议给核心表加上合理的唯一索引,从根源减少重复;并设定保留周期,例如主库只留最近一年,更早的自动归档。同时,归档库也要做备份,防止历史数据因误删不可恢复。
在性能层面,大表去重和归档都应避开业务高峰。可以借助 pt-archiver 这类开源工具,它天生支持批量、限速、双写校验,比手写脚本更稳妥。最后,所有删除操作前务必二次确认 WHERE 条件,最好先 SELECT 出受影响行数,再执行 DELETE,以免条件写错清空整张表。
通过以上流程,我们既能清理重复数据,又能把历史记录安全转移,主库体积下降后,查询延迟和备份时间通常都会有显著改善。