
先说一个我踩过很多次之后的结论如果你的库里有“定时清除7天前数据”这种需求别急着往业务代码里塞定时任务。数据库定时任务这件事MySQL自带的事件调度器足够稳而且支持在一个任务里塞多条SQL执行语句清理过期日志、删除临时数据、更新统计标识都能一起处理。这篇文章就把我实际维护系统时用的完整方案拆开讲事件开关怎么开、权限时区有哪些坑、“7天前”这个条件到底怎么写才对、多条SQL怎么组织、大表为什么必须分批删全部附上能直接用的代码。1. 为什么我建议把清理任务放进数据库自己执行1.1 应用层定时计划的三个让我无语的场景在Spring Boot里写一个Scheduled并不难几行注解的事但我经历过几个真实场景之后对这种做法越来越谨慎。第一次踩坑是凌晨3点的清理任务结果前一天晚上发版时服务实例一直在原地重启任务根本没机会跑。第二天早上看到日志表多了几千万行磁盘告警已经刷屏。第二次是多实例部署定时任务没加分布式锁两台机器同时去执行删除结果主库锁等待直接把线上业务拖死。第三次更尴尬清理逻辑和SQL混在服务代码里DBA想帮忙排查都找不到入口改一行SQL还得重新打包发布。数据库事件调度器刚好把这些坑全避开了。只要数据库实例活着事件就跟着活着不存在多实例重复执行的问题SQL就放在库里DBA看得见摸得着想调试直接打开事件定义。当然我不是说应用层定时任务一无是处而是想强调当一个任务只涉及本库数据、不需要调用外部接口时放在库里就是复杂度最低的方案。1.2 数据库事件调度器到底适合干哪些活事件调度器在MySQL里不算新功能5.7和8.0都支持得很好。它本质上就是数据库内部的一组线程到点触发你定义好的SQL。我实际项目中主要用它做三类事情。第一类是带过期时间的数据清理比如用户登录日志、短信发送记录、临时缓存表这类表只增不减不清就爆盘。第二类是周期性的统计汇总比如把昨天的报表数据刷进汇总表凌晨算完白天直接查。第三类是维护动作比如把超过N天未更新的会话状态批量改成无效。反过来如果任务需要跨库甚至跨服务调用需要手动触发和重跑追数需要复杂流程编排那就老老实实用专业调度平台别让事件调度器硬扛。先把边界划清楚后面用起来才不会别扭。2. 动手前先确认三件事开关、权限、时区2.1 让event_scheduler真正常驻而不是重启就丢创建事件之前必须确认一件事event_scheduler到底开没开。如果没开你CREATE EVENT建得再漂亮到点也不会执行。临时开启的语句很简单SET GLOBAL event_scheduler ON;然后确认状态SHOW VARIABLES LIKE event_scheduler;返回ON才正常。但这里有一个隐藏坑SET GLOBAL只在当前实例运行期间有效MySQL服务一重启就回到OFF。想让数据库定时任务长期稳定运行必须把配置写进my.cnf在[mysqld]段落加上一行重启后自动生效[mysqld] event_schedulerON如果你用的是云数据库这类托管服务通常控制台参数组里能直接改而且不少厂商默认就是开启的。改完之后一定要看一眼别在控制台上手动执行完SET GLOBAL第二天重启就发现任务没跑查了半天日志才发现是配置没落盘。2.2 账号缺EVENT权限创建时容易栽跟头第二个坑在权限。如果不是用root管理库业务账号想创建事件、修改事件、删除事件都需要EVENT权限。我见过不少人在可视化工具里点了半天“创建事件”结果报1044权限不足就差一条授权语句GRANT EVENT ON yourdb.* TO clean_user%;如果事件里要调用存储过程还得补对应库上的EXECUTE权限否则事件执行到CALL那一步照样失败。授权完成之后建议用那个业务账号重新连一次客户端确认两件事能看到该库下的事件列表并且能对已有事件执行ALTER和DROP。不做这一步后面临时想改执行时间会发现自己连事件定义都看不到。2.3 时区问题为什么你会在错误的时间删除数据真正让人头疼的是时区它同时影响“到点执行”和“7天前”这个删除条件的正确性。MySQL的time_zone系统变量默认是SYSTEM也就是跟随操作系统时区。很多容器镜像默认跑UTC而你的业务代码和运营看板全是北京时间。这时候NOW()返回的是UTC时间你以为删的是7天前的数据实际删的是7天零8小时之前的整个节奏全乱。我的建议是一步到位在my.cnf里设置default-time-zone08:00让数据库运行时区和业务对齐。字段层面也尽量统一日志表存时间用TIMESTAMP类型它内部存UTC、展示时自动转成当前会话时区最不容易出错。如果历史表已经用DATETIME那就只能靠SQL显式转换把条件写成WHERE oper_time CONVERT_TZ(NOW(), 00:00, 08:00)总之先搞清楚你的库跑在哪个时区再对照业务含义去写条件不要想当然。3. 从一条删除SQL到一个可落地的清理事件3.1 “7天前”这个条件怎么写才不会有边界歧义清理逻辑的核心其实就一句话删掉create_time小于7天前那一刻的所有数据。SQL长得像这样DELETE FROM operation_log WHERE create_time NOW() - INTERVAL 7 DAY;这里有两个细节必须琢磨清楚。第一NOW() - INTERVAL 7 DAY和DATE_SUB(NOW(), INTERVAL 7 DAY)效果完全一样都是当前精确时间往回拨7天不存在谁优谁劣。重点是NOW()带时分秒所以“7天前”是一个非常精确的时刻不是一个日期。第二用还是。如果业务含义是“保留最近7天”通常用也就是刚好7天整那一秒的数据会被保留如果你希望7天整的数据也不要就改成。这个边界最好在需求评审时明确删多了是事故删少了顶多多占几天磁盘。另外命中删除条件的行很多时create_time字段上必须有索引否则DELETE会全表扫描、锁住一堆行具体危害放到第5章讲。3.2 单条SQL事件完整创建模板与参数拆解确认好开关、权限、时区SQL也验证过执行计划了就可以把它包成一个事件。单条SQL版本最直接CREATE EVENT IF NOT EXISTS daily_cleanup_operation_log ON SCHEDULE EVERY 1 DAY STARTS 2025-06-01 03:00:00 ON COMPLETION PRESERVE ENABLE COMMENT 每天凌晨3点清理7天前的操作日志 DO DELETE FROM operation_log WHERE create_time NOW() - INTERVAL 7 DAY;几个参数我逐个说一遍因为后悔药都在这些配置里。IF NOT EXISTS是幂等保险重复执行脚本不会报错。EVERY 1 DAY是周期想每8小时清一次就写成EVERY 8 HOUR。STARTS是首次执行的时间点注意如果不写事件创建后会立刻执行一次很多人会忽略这一点。ON COMPLETION PRESERVE务必写上含义是“执行完不销毁事件”不写的话MySQL默认事件执行一次后自动删除你的周期任务第二次就不存在了这是定时任务最常见的翻车点。ENABLE表示创建后立即启用想先建好但暂时不执行就写DISABLE。COMMENT是给人看的把业务含义写清楚后面排查能省大量时间。DO后面就是真正要跑的SQL。3.3 不用等凌晨如何快速验证事件真的会执行事件创建完你不会想干等到凌晨三点才验证。我一般按下面三步走。第一步看事件状态SHOW EVENTS\G SELECT Name, Status, Last_Executed FROM information_schema.EVENTS WHERE EVENT_SCHEMA yourdb;Status是ENABLED、事件存在说明语法和权限没问题。第二步验证SQL逻辑。在正式跑DELETE之前先手动执行它的SELECT版本确认影响行数符合预期SELECT COUNT(*) FROM operation_log WHERE create_time NOW() - INTERVAL 7 DAY;行数跟心里预期对得上再放心交给调度器。第三步如果想测试调度机制本身通不通我习惯建一个一次性测试事件CREATE EVENT test_event_delivery ON SCHEDULE AT CURRENT_TIMESTAMP INTERVAL 1 MINUTE ON COMPLETION NOT PRESERVE DO INSERT INTO test_event_log(test_time) VALUES (NOW());过一分钟看test_event_log有没有数据有就说明调度线程在工作没有就去查错误日志。这个测试事件加ON COMPLETION NOT PRESERVE跑完自动消失不会留垃圾。4. 多条SQL语句的事件体设计直接拼还是用存储过程4.1 DO后面只能跟一条语句多条必须走复合语句标题里说“可添加多条sql执行语句”这句话落到MySQL上其实有一个语法规则要先拆穿DO后面如果是一条DELETE、UPDATE单语句直接写就行如果是好几条语句必须用BEGIN...END括起来组成复合语句。同时要注意事件调度器自身不提供事务自动包裹多条语句就是按顺序执行。如果第一条删了一部分第二条报错第一条已经生效不会自动回滚。典型的多表清理事件长这样DELIMITER $$ CREATE EVENT IF NOT EXISTS daily_multi_cleanup ON SCHEDULE EVERY 1 DAY STARTS 2025-06-01 03:10:00 ON COMPLETION PRESERVE ENABLE COMMENT 每日多表清理登录日志、短信记录同步清理 DO BEGIN DELETE FROM login_log WHERE login_time DATE_SUB(NOW(), INTERVAL 7 DAY); DELETE FROM sms_log WHERE send_time DATE_SUB(NOW(), INTERVAL 7 DAY); UPDATE cleanup_status SET last_run NOW(), status DONE WHERE id 1; END$$ DELIMITER ;用命令行创建时DELIMITER $$不是装饰品。默认分隔符是分号CREATE EVENT内部每条语句遇到分号就会被客户端截断工具会误以为事件定义已经结束报一堆语法错误。切换成$$后整个BEGIN...END才被当作一个整体提交给服务端。在Navicat这类图形工具里有的会自动处理有的要求你手动规范书写老实套上DELIMITER最保险。4.2 存储过程封装什么样的事件值得这么干我的实践原则很简单单条SQL或者两三条简单删除直接写在事件里一旦满足下面条件之一立刻把逻辑挪进存储过程事件里只留一行调用。场景特征直写事件存储过程封装SQL数量3条以内超过3条或需要变量需要异常捕获基本没有必须记录错误码和错误信息需要手动执行很少DBA或运维可能随时CALL一次事件定义可读性一屏看完事件只留一行逻辑集中管理原因是三层存储过程可以被手动调用DBA想马上跑一次直接CALL sp_daily_cleanup()不用去改事件定义大段SQL从事件定义里挪走后SHOW EVENTS一眼看清是哪个作业存储过程里能用DECLARE ... HANDLER接住异常事件本身没有这么方便的自我保护机制。下面是我项目里常用的存储过程模板自带异常捕获和运行日志DELIMITER $$ CREATE PROCEDURE sp_daily_cleanup() BEGIN DECLARE v_del_rows INT DEFAULT 0; DECLARE v_sqlstate CHAR(50); DECLARE v_msg VARCHAR(500); DECLARE CONTINUE HANDLER FOR SQLEXCEPTION BEGIN GET DIAGNOSTICS CONDITION 1 v_sqlstate RETURNED_SQLSTATE, v_msg MESSAGE_TEXT; INSERT INTO cleanup_run_log(run_date, del_rows, status, error_msg, run_at) VALUES(CURRENT_DATE, v_del_rows, ERROR, CONCAT(v_sqlstate, , v_msg), NOW()); END; DELETE FROM login_log WHERE login_time DATE_SUB(NOW(), INTERVAL 7 DAY); SET v_del_rows v_del_rows ROW_COUNT(); DELETE FROM sms_log WHERE send_time DATE_SUB(NOW(), INTERVAL 7 DAY); SET v_del_rows v_del_rows ROW_COUNT(); INSERT INTO cleanup_run_log(run_date, del_rows, status, error_msg, run_at) VALUES(CURRENT_DATE, v_del_rows, OK, , NOW()); END$$ DELIMITER ;事件定义也随之变得非常干净CREATE EVENT IF NOT EXISTS daily_cleanup_by_proc ON SCHEDULE EVERY 1 DAY STARTS 2025-06-01 03:15:00 ON COMPLETION PRESERVE ENABLE COMMENT 调用存储过程执行每日清理 DO CALL sp_daily_cleanup();这种“事件调过程”的结构是我在线上维护半年后最喜欢的形态。排查问题时打开存储过程看到的就是完整的业务逻辑而不是在一大坨事件定义里翻SQL。4.3 执行顺序、失败保护与运行日志的工程细节多条SQL放在一起执行顺序不能随便排。我的默认原则有三条有主外键关系的先删子表再删父表免得外键检查拖慢速度会影响其他任务判断的字段先更新比如先把状态置为“清理中”再删历史最耗时的DELETE放在最后避免长时间持锁占用通道阻塞其他业务。失败保护方面BEGIN...END里的多条DELETE没有自动事务包裹所以一旦需要“要么全成功要么全不成功”就要显式包事务START TRANSACTION; DELETE ...; DELETE ...; COMMIT;中间任何一条出错通过异常处理器做ROLLBACK并写日志。注意一点程序里别指望COMMIT之后还能回滚测试一定提前在副本数据上跑通。运行日志是另一个容易被忽视的环节。我强烈建议每个生产环境的事件都配一张cleanup_run_log记录表字段不用复杂run_date、del_rows、status、error_msg、run_at就够。有这张表第二天看板一目了然没有它事件执行失败就只剩数据库错误日志里的一行排查成本高得多。事件本身报错不会主动推给任何人日志表至少给你留了一个查档的地方。5. 大表清理的现实一次性DELETE会教做人5.1 全表一次清空一次磁盘事故的复盘我见过一次挺惨的事故。业务日志表接近两亿行某天晚上把“清理7天前数据”的事件上线SQL最简单最直接一条DELETE不带LIMIT凌晨三点准时开跑。早上到办公室主库CPU 100%磁盘IO彻底占满从库复制延迟飙到五个小时前端接口超时报警刷了一屏。原因就是单条DELETE要锁大量行、生成海量undo日志binlog也瞬间膨胀整条链路被一条语句堵死。后来我们停掉那个事件分批删了两天才缓过来。这个教训直接改变了我对一切清理类任务的写法凡是可能删除大表的SQL一律不能一次删完必须限速分批。极端情况甚至第一步不是DELETE而是先把表改名成归档表、新建同结构空表再用事件慢慢处理避免业务写入长时间被锁阻塞。普通业务不需要这么激进但心里必须清楚一条不带LIMIT的大DELETE可能同时带走复制链路、IO和业务可用性。5.2 分批删除的循环写法与限速技巧下面是我现在线上用的分批删除事件标准写法。核心思路很简单循环删除每次只删1000行或5000行删到没有满足条件的行再退出两次删除之间加一个短SLEEP给InnoDB留出刷新缓冲池和落盘binlog的时间。DELIMITER $$ CREATE EVENT IF NOT EXISTS daily_cleanup_batch ON SCHEDULE EVERY 1 DAY STARTS 2025-06-01 03:30:00 ON COMPLETION PRESERVE ENABLE COMMENT 分批清理7天前操作日志每批1000行 DO BEGIN DECLARE v_rows INT DEFAULT 1; WHILE v_rows 0 DO DELETE FROM operation_log WHERE create_time DATE_SUB(NOW(), INTERVAL 7 DAY) LIMIT 1000; SET v_rows ROW_COUNT(); SELECT SLEEP(0.2) INTO s; END WHILE; END$$ DELIMITER ;每批次大小怎么定我的经验值是业务高峰期的库设500到1000凌晨低峰期可以到5000SLEEP(0.2)是低峰期的下限如果机器本身就忙改成0.5到1秒更稳。注意MySQL支持单表DELETE ... LIMIT n这是一个实用的扩展语法正好用来做分批。分批删除最大的好处不止是限速而是“随时可停”。如果白天突然收到磁盘压力告警直接执行ALTER EVENT daily_cleanup_batch DISABLE;把它停住已经删掉的不影响数据一致性等压力过去再ENABLE它会从剩余数据继续处理。这个随时暂停的能力就是对比一次性DELETE最大的安全感来源。5.3 归档删除比物理删除更稳妥的场景清理不等于物理删除这个认知值得单独拎出来讲。交易流水、用户操作审计这类敏感数据几个月后可能要追溯“7天前”只是从主表移除不等于从世界抹掉。更稳妥的做法是两步走先把过期数据INSERT进归档表再从主表分批删除。事件体里可以先做INSERT SELECT紧跟着做DELETE LIMIT循环。为了保证“归档多少就删多少”可以按ID记档用变量记录本次最大ID归档和删除都限定在同一个ID范围内。这样即使中间失败重跑不会漏也不会重叠。归档表本身建议单独做备份或迁移到冷存储别跟主库放同一块磁盘。只有确认后台已经有一份完整备份之后才可以让删除事件长期运行。没有备份和归档兜底的清理本质上就是裸奔。5.4 上线后的监控指标与回退手段事件上线不是终点我习惯把下面几个指标纳入日常巡检。第一个是看information_schema.tables里的TABLE_ROWS每天对比一次主表数据量确认清理任务真的在把体积往下压。如果连续几天没变化要么事件没跑要么删除条件失效得赶紧查。第二个是看cleanup_run_log表状态列出现ERROR就立刻查错误详情。事件执行失败通常没有主动告警这张表就是你的哨兵。第三个是慢查询日志如果发现DELETE耗时从几十毫秒涨到几秒多半是索引失效或表碎片化严重需要做索引维护或表重建。DELETE本身不释放表空间碎片化问题会在长期运行后逐渐显现定期用ALTER TABLE ... ENGINEInnoDB重建表可以收回大量空间。回退方面最可靠的手段是提前开启binlog并保留足够周期配合日常全量备份。万一误删用备份恢复单表再结合binlog找回删除窗口的数据。我还会在分批删除事件里顺手把每批次删到的最大ID写进日志表出岔子时至少知道数据档位在哪里。日常能做的是保证每天有备份、恢复演练过关否则任何回滚方案都只是纸上谈兵。我个人在线维护半年后对数据库定时任务的看法变得很朴素能用一行CREATE EVENT解决的事绝不上框架事件体超过5行SQL一律挪进存储过程。这里再分享一个小技巧新建事件时统一按20250601_oplog_clean这种日期_表名_用途的规范命名COMMENT里写清触发时间、保留天数和责任人。等半年后有人来问你“这个事件是干嘛的”你会发现命名规范比任何文档都好用。