很多业务系统都会遇到同一个问题:日志表、操作记录表、消息队列表这类数据增长极快,如果不定期清理,几个月后单表数据量就可能达到几千万甚至上亿行,查询性能急剧下降,磁盘空间也不断被侵蚀。要解决这个问题,核心思路就是基于“过期时间”字段,定期把超过保留期限的数据删掉。但具体怎么做才既安全又高效,这里面有不少讲究。

方案一:使用MySQL事件调度器Event自动清理
MySQL自带事件调度器(Event Scheduler),可以在数据库内部定时执行SQL,不需要依赖外部脚本,是最轻量的定期清理方案。使用前需要先确认调度器是否开启,执行SHOW VARIABLES LIKE 'event_scheduler';,如果结果是OFF,可以通过SET GLOBAL event_scheduler = ON;开启,或者在my.cnf配置文件的[mysqld]段中加入event_scheduler=ON让其永久生效。
假设有一张登录日志表t_login_log,其中create_time字段记录了创建时间,业务要求只保留最近90天的数据,那么可以这样创建事件:
CREATE EVENT IF NOT EXISTS ev_clean_login_log ON SCHEDULE EVERY 1 DAY STARTS CONCAT(CURDATE() + INTERVAL 1, ' 03:00:00') ON COMPLETION PRESERVE DO DELETE FROM t_login_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY) LIMIT 5000;
这里有个细节需要注意:事件里的DELETE带了LIMIT 5000,目的就是控制单次删除的量。如果一次删除几十万行,会产生一个巨大的事务,undo log膨胀、行锁持有时间长,很容易阻塞线上业务。所以更稳妥的做法是把事件设置成每分钟执行一次,每次删一小批,直到删完为止。
Event方案的优点是完全在数据库内部闭环,不依赖应用服务器,部署简单。缺点是删除逻辑藏在数据库里,团队成员如果不知道有事件存在,排查问题时容易忽略;而且Event使用的账号权限需要比较高,存在一定的安全管理成本。
方案二:crontab配合脚本定时删除
更常见的做法是把清理逻辑放在数据库外面,通过Linux的crontab定时任务,在业务低峰期(比如凌晨)调用一个SQL脚本完成删除。这种方式的好处是清理逻辑对团队完全可见,可以纳入代码版本管理,也方便记录执行日志和告警。
先写一个清理脚本clean_log.sql:
-- 分批删除过期数据,避免大事务 DELETE FROM t_login_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY) LIMIT 10000;
然后用shell脚本包装,循环执行直到没有符合条件的行被删除为止,避免数据量偶尔暴增时一次跑不完:
#!/bin/bash
while true; do
affected=$(mysql -u cleaner -p'yourpassword' mydb -e \
"DELETE FROM t_login_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY) LIMIT 10000;" \
-s -N -e "SELECT ROW_COUNT();" 2>/dev/null)
if [ "$affected" -lt 10000 ]; then
break
fi
sleep 1
done
echo "$(date) clean finished" >> /var/log/clean_login_log.log最后在crontab中注册,每天凌晨三点执行:0 3 * * * /opt/scripts/clean_login_log.sh。这里强烈建议使用专门的清理账号,只授予目标表的DELETE权限,这样即使脚本被篡改,影响范围也可控。
crontab方案的灵活性是最大的,可以在脚本里加入钉钉或邮件告警、删除量统计、慢日志分析等逻辑。缺点是多了一层运维依赖,需要保证执行脚本的服务器可用,并且要做好密码管理,避免明文密码带来的安全隐患。
方案三:大表场景使用分区表直接DROP分区
如果表的数据量已经非常大,比如单表上亿行,用DELETE逐行删除几乎不可行。因为DELETE不仅要逐行打删除标记,还会产生大量binlog,主从复制的延迟会非常明显,而且删除后的空间并不会立刻释放给操作系统(InnoDB的空间是延迟复用的)。对于这种场景,按时间分区是更优雅的方案。
可以按月对表进行分区,建表语句如下:
CREATE TABLE t_login_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
login_ip VARCHAR(45) NOT NULL,
create_time DATETIME NOT NULL,
PRIMARY KEY (id, create_time)
) ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(create_time)) (
PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
PARTITION p202501 VALUES LESS THAN (TO_DAYS('2025-02-01')),
PARTITION pmax VALUES LESS THAN MAXVALUE
);清理数据时,直接删除整个分区即可:ALTER TABLE t_login_log DROP PARTITION p202401;。这个操作是DDL,几乎是瞬间完成的,不产生行级binlog,也不会有长事务问题,磁盘空间也会立即释放。配合Event或者crontab,每个月定期删除最老的分区并预先创建下个月的新分区,就形成了一套自动化的清理机制。
需要注意两点:第一,分区键必须包含在主键和唯一键中,所以上面的建表语句把create_time放进了主键;第二,分区表对查询有影响,如果查询条件里没有分区键,会扫描所有分区,需要结合业务查询模式评估是否适合。
批量删除的核心注意事项
无论采用哪种方案,批量删除都要遵循几个原则。第一是控制事务大小,单次删除建议控制在几千到一万行,宁多跑几轮也不要一次删几十万行,否则长事务会导致undo log无法回收,甚至拖垮整个实例。第二是注意主从延迟,删除产生的binlog要在从库重放,如果从库写入能力弱,批量删除期间延迟会明显上升,可以在每批之间加入sleep缓冲。
第三是删除条件必须走索引。如果create_time上没有索引,每批DELETE都会全表扫描,在大表上代价极高。建议为时间字段单独建索引,或者利用主键范围分批:SELECT MAX(id) FROM ... WHERE ... LIMIT batch先找出边界,然后按主键区间删除,这样每批删除都能走主键索引,效率最高。
第四是要有兜底监控。定期清理一旦失效,数据就会悄悄堆积,等发现时往往已经很被动。建议对核心大表加上数据量和最早记录时间的监控指标,超过阈值就告警,这样才能保证清理机制长期稳定运行。