
“躲得过初一躲不过十五”这句话在我们的排障群里变成了字面意义上的现实。先给结论这次事故的问题不在于某一个定时任务写得有多烂而在于任务和任务之间那些看不见的隐式依赖。我们对每月 1 号的批量清理任务做了一版自认为“很六”的优化任务确实跑快了1 号再也没有报警。但 15 号凌晨积压了半个月的数据在归档任务里集中爆发直接把数据库连接池打满核心接口大面积超时。事后复盘真正的问题不是“1 号任务没写好”而是“我们改了一个任务却忘了它下游还有一堆任务在用老规则等数据”。这篇文章会从一次典型月度批量任务事故讲起带你完整走一遍现象、排查、根因、修复和治理思路。如果你也在负责定时清理、数据归档、报表计算这类批处理任务这篇文章值得收藏备用。1. 为什么业务会盯上每个月的 1 号我们产品里有一个很常见的业务场景用户积分过期清理。规则很简单积分从入账日开始计算有效期到期后自动清零。因为用户量比较大积分流水表points_detail已经超过 2 亿行而且每天还在快速增长。这条业务规则落到系统里就是一个定时任务每月 1 号凌晨 3 点扫描所有积分流水。把已经超过有效期的记录更新为“已过期”状态。同时把过期积分从用户余额里扣减掉。听起来很简单但在大表上执行就不是一回事了。最初的实现是一句 UPDATE直接对整张表做范围更新。这个语句在数据量小的时候没问题数据量上来之后每个月 1 号凌晨都会准时出事执行计划走全表扫描。一次大事务锁住大量行。主库 CPU 瞬间飙高从库同步延迟越来越大。业务读请求全部堆积凌晨值班电话被打爆。所以每个月 1 号团队都习惯性“守夜”。DBA 看到 3 点钟的报警甚至已经见怪不怪先翻监控再看慢查询然后手动把任务摘掉等白天业务低峰再补跑。这件事折磨了团队大半年。直到上个月我们决定好好治理一下这个“初一必炸”的问题。2. 第一次优化看起来“很六”的操作这次优化的思路并不复杂主要做了三件事补索引、分批提交、错峰执行。2.1 第一步给过滤字段补索引原来的 UPDATE 语句大致长这样UPDATE points_detail SET status EXPIRED WHERE expire_time NOW() AND status ACTIVE;问题很明显expire_time和status都没有合适的联合索引。MySQL 只能选择全表扫描然后逐行判断条件。优化第一步就是建立联合索引尽量让查询能走索引范围扫描ALTER TABLE points_detail ADD INDEX idx_expire_status (expire_time, status);这里有一个细节索引字段顺序很重要。因为查询条件是expire_time NOW() AND status ACTIVE从选择性来看expire_time是范围条件status是等值条件把等值条件的字段放在前面通常更合理。所以更稳妥的索引设计是ALTER TABLE points_detail ADD INDEX idx_status_expire (status, expire_time);到底选哪一个需要结合业务里其他查询模式综合判断不能只看这一条 SQL。我们在测试环境用实际数据量验证过(status, expire_time)在这个场景下过滤效果更好。2.2 第二步把一次性大事务改成游标分批提交只加索引还不够。就算走索引一次性 UPDATE 几百万行依然会造成长事务和锁竞争。为了降低风险我们把“一条大 UPDATE”改成了“按主键分批删除 分批更新”。这里要说明一下这个场景里的积分过期逻辑上最终是需要把过期积分数从用户余额扣掉再把流水状态改成 EXPIRED。由于余额表和流水表是分开的为了让两步操作都有据可查我们采用“先查待处理主键再按批处理”的方式。简化后的处理脚本如下# 文件路径scripts/expire_points.py import pymysql BATCH_SIZE 1000 def get_expired_ids(cursor, last_id): sql SELECT id FROM points_detail WHERE status ACTIVE AND expire_time NOW() AND id %s ORDER BY id LIMIT %s cursor.execute(sql, (last_id, BATCH_SIZE)) rows cursor.fetchall() return [row[0] for row in rows] def update_expired(cursor, ids): if not ids: return fmt ,.join([%s] * len(ids)) sql f UPDATE points_detail SET status EXPIRED WHERE id IN ({fmt}) cursor.execute(sql, ids) def main(): conn pymysql.connect(host127.0.0.1, userapp_user, password******, databaseapp_db, autocommitFalse) last_id 0 with conn.cursor() as cursor: while True: ids get_expired_ids(cursor, last_id) if not ids: break try: update_expired(cursor, ids) conn.commit() except Exception: conn.rollback() raise last_id ids[-1] print(fprocessed {len(ids)} rows, last_id{last_id}) conn.close() if __name__ __main__: main()这段代码的核心价值在于每次只取 1000 个主键避免一次性扫描全表。每批单独提交事务缩短锁持有时间。使用last_id 上次位置的游标方式保证数据不会漏也不需要在内存里保存完整 ID 列表。分批过程中如果某一批失败只回滚当前批不会影响已经提交的批次。当然这里的脚本只是最小示例。实际生产里还需要考虑重试、状态记录、日志切分等问题后面会专门讲。2.3 第三步调整执行时间和告警窗口原来任务固定在 3 点执行正好撞上其他团队凌晨的备份和报表任务。我们把它调到了 4 点 30 分避开备份窗口同时把告警阈值从“一有慢查询就报警”改成“连续 5 分钟 CPU 超过 80% 才报警”减少误报。优化上线后1 号当天非常安静数据库 CPU 曲线几乎没有明显波动。群里一片欢乐有人发了那句经典评价“你六的太狠了牛批”我嘴上说还好还好心里其实也挺得意。但这份得意只维持到了 15 号凌晨。3. 躲过了初一没躲过 15 号15 号凌晨 3 点 58 分告警群突然开始刷屏数据库活跃连接数从平时的 30 多直接涨到 800。核心接口 P99 延迟从 80ms 涨到了 3.5 秒。Redis 缓存命中率没降但数据库连接池被打满了。主库 CPU 在几分钟内一路冲到 95% 以上。第一反应是哪个突发流量打进来了。结果看网关流量一点变化都没有。再往前排查发现有一个凌晨 4 点启动的归档任务执行到一半突然开始大量扫描points_detail。这个归档任务其实已经存在很久了。它的职责是每月 15 号把上个月已经过期并且完成结算的积分流水从主表复制到归档表然后物理删除主表里对应数据。刚看到这个任务时我们第一反应是“它跟 1 号的任务有什么关系”等我们把 SQL 拉出来一看才意识到问题没有想象中那么简单。归档任务的 SQL 大概长这样INSERT INTO points_detail_archive SELECT * FROM points_detail WHERE expire_time DATE_SUB(NOW(), INTERVAL 45 DAY) AND status EXPIRED AND settled 1; DELETE FROM points_detail WHERE expire_time DATE_SUB(NOW(), INTERVAL 45 DAY) AND status EXPIRED AND settled 1;注意这里既没有分批也没有走索引。虽然我们给points_detail加了(status, expire_time)索引但归档任务的过滤条件里多了一个settled 1。MySQL 可能选择索引回表也可能因为过滤比例判断走全表扫描最终结果取决于统计信息。而在 15 号凌晨这次执行前表里积压的数据量刚好突破了某个临界点让优化器做出了全表扫描的决定。4. 详细排查从慢查询到调度平台排查过程大概花了一个多小时这里把关键路径整理出来方便你下次遇到类似问题直接对照。4.1 第一步先看慢查询日志我们首先拉取了 15 号凌晨 4 点前后的慢查询日志定位到几条耗时 300 秒以上的 SQLSELECT * FROM points_detail WHERE expire_time DATE_SUB(NOW(), INTERVAL 45 DAY) AND status EXPIRED AND settled 1;问题很清晰这条 SQL 在归档任务里执行。它的过滤条件与 1 号任务不完全一致多了一个settled。优化器没有选择新建索引而是做了全表扫描产生了大量临时文件和内存排序。4.2 第二步查调度平台里的任务血缘慢查询日志只能告诉我们“哪条 SQL 慢”不能告诉我们“为什么它今天慢”。我们打开调度平台查了任务列表很快就找到了这个每个月 15 号 4 点执行的归档任务。再往下看发现这个任务的创建时间很早早于我们上线的 1 号优化。也就是说它并不是新任务而是一个长期存在的老任务。之前它没有引发事故是因为数据量还没到临界点或者之前的执行计划还能走索引。一旦数据积压到一定程度代价估算翻转优化器就会选择全表扫描问题立刻爆发。4.3 第三步梳理完整因果链到这里事故的因果链已经很清楚了在优化之前1 号任务每天月凌晨会大批量清理过期数据虽然当时也慢但它把“未处理数据”的量压到了归档任务可接受的范围内。我们优化 1 号任务时采用分批处理但因为分批脚本里只处理了status ACTIVE的记录对已经处于EXPIRED状态但未结算的数据没有干预这部分数据继续留在主表里。1 号任务跑得快了但这部分数据在 15 号归档任务启动前越积越多。15 号归档任务在数据量达到临界点后执行计划由“索引扫描”变成“全表扫描”一次性把主库打挂。简单说1 号任务和 15 号任务之间本来就是上下游关系。我们优化了上游却没有同步优化下游导致负载压力从“初一”转移到了“十五”。5. 为什么“6”不是终点从单点优化到链路治理这次复盘给我最深的感触是批处理任务优化的难点从来不在某个任务本身而在任务之间的依赖关系。在优化单个任务时我们很容易陷入一种“局部最优”的错觉。1 号任务变快了告警消失了看起来是“很六”的结果。但如果把视角放大任务 A 的输出可能正是任务 B 的输入任务 B 的执行计划可能依赖任务 A 留下的数据分布。上游的任何变化都会在下游被放大。这类问题通常有三个信号第一个信号任务执行时间发生变化。比如原本要跑 2 小时的任务突然 10 分钟跑完这不一定是好消息可能是它漏处理了某些数据。第二个信号表的数据分布发生明显变化。大批量删除之后表的统计信息不会立刻更新优化器可能在一段时间内做出错误判断。第三个信号某个老任务开始变慢。老任务本身代码没变环境也没变唯一变的是上游任务产生的数据形态那就要往上游查。所以在真实项目里我们更推荐用“任务链路”的视角去看批处理系统而不是一个任务一个任务孤立地优化。6. 完整修复示例与代码实现这次修复涉及四块内容重写归档脚本、补充索引与统计信息维护、调整调度配置、增加告警规则。下面逐一给出可复制的示例。6.1 重写归档脚本按主键分批归档归档任务不能再用一条 INSERT 加一条 DELETE 硬扛。和 1 号清理任务一样我们也改成游标分批处理。这里用 Python 展示一个更完整的版本带有批次记录和重试标记。# 文件路径scripts/archive_points.py import pymysql from datetime import datetime BATCH_SIZE 500 ARCHIVE_BEFORE_DAYS 45 def get_archive_ids(cursor, last_id): sql SELECT id FROM points_detail WHERE expire_time DATE_SUB(NOW(), INTERVAL %s DAY) AND status EXPIRED AND settled 1 AND id %s ORDER BY id LIMIT %s cursor.execute(sql, (ARCHIVE_BEFORE_DAYS, last_id, BATCH_SIZE)) return [row[0] for row in cursor.fetchall()] def archive_batch(conn, ids): with conn.cursor() as cursor: fmt ,.join([%s] * len(ids)) cursor.execute(f INSERT INTO points_detail_archive SELECT * FROM points_detail WHERE id IN ({fmt}) , ids) cursor.execute(f UPDATE points_detail SET archived 1 WHERE id IN ({fmt}) , ids) conn.commit() def main(): conn pymysql.connect(host127.0.0.1, userarchive_user, password******, databaseapp_db, autocommitFalse) last_id 0 total 0 with conn.cursor() as cursor: while True: ids get_archive_ids(cursor, last_id) if not ids: break archive_batch(conn, ids) last_id ids[-1] total len(ids) print(f{datetime.now().isoformat()} archived batch, size{len(ids)}, last_id{last_id}) conn.close() print(fdone, total archived rows{total}) if __name__ __main__: main()和之前那次脚本的差别是这里不是直接 DELETE而是先复制到归档表再给主表记录打一个archived标记。这样可以降低“直接删数据”的风险出问题时还能从主表找回数据。实际生产建议再加一张批次执行记录表把每次归档的min_id、max_id、影响行数、执行时间都记下来方便问题回溯。6.2 补充归档任务的索引因为归档任务的过滤条件包含status、expire_time、settled我们还需要为这组条件评估索引。不要把索引一股脑加到 5 个字段先看查询模式查询里status EXPIRED是等值条件。settled 1是等值条件。expire_time ...是范围条件。排序和游标使用主键id。更合适的联合索引可以这样建ALTER TABLE points_detail ADD INDEX idx_status_settled_expire (status, settled, expire_time);这个索引把两个等值字段放在前面把范围字段放在最后能更好地支撑归档任务的扫描路径。不过要提醒一句大表加索引不是小事建议在低峰期执行或者使用在线加索引工具避免长时间锁表。加完索引后要用EXPLAIN验证执行计划是否真的走了新索引。6.3 维护统计信息避免执行计划漂移大批量数据删除、归档之后表的统计信息经常会过时。MySQL 的优化器会基于统计信息估算行数如果估算偏差太大执行计划就会“漂”到全表扫描。修复方案之一是手动更新统计信息ANALYZE TABLE points_detail;这里有一个非常重要的提醒ANALYZE TABLE会重新统计索引分布信息但在生产大表上执行也可能带来一定压力。更稳妥的方式是评估表的数据量选择业务低峰执行并且在测试环境验证后再上生产。如果是 MySQL 8.0还可以关注innodb_stats_auto_recalc参数但不要为了这次问题盲目改全局参数。先理解业务写入模式再决定是否需要调整自动重算策略。6.4 调整调度配置错峰 超时熔断原来的 15 号归档任务固定在凌晨 4 点执行正好和凌晨的备份任务重叠。我们把归档任务调整到 5 点 30 分避开备份窗口。调度平台上的配置示例# 文件路径scheduler/archive-task.yaml taskName: points-archive-monthly taskType: python-script cron: 30 5 15 * * ? timeout: 3600 retryCount: 0 alarmGroup: dba-oncall env: PYTHON_PATH: /opt/venv/archive/bin/python SCRIPT_PATH: /data/scripts/archive_points.py注意retryCount这里设置为 0。对于数据归档任务重复执行可能造成重复归档或数据错乱。我们更倾向于“宁可失败后人工介入也不要自动重试”这是一个比较关键的工程判断。6.5 增加监控告警规则告警配置也很重要。这里给一个 Prometheus 风格的规则示例groups: - name: archive-task-alert rules: - alert: ArchiveTaskSlowSQL expr: rate(mysql_slow_queries_total[5m]) 5 for: 5m labels: severity: critical annotations: summary: 归档任务引发慢查询增加 description: 最近 5 分钟慢查询数量超过阈值请检查归档任务执行情况这里的mysql_slow_queries_total是示例指标实际监控指标名取决于你的监控组件。重点是你可以把“慢 SQL 数量”“活跃连接数”“主从延迟”作为归档任务的核心观测指标而不是只盯任务本身成功或失败。7. 验证与运行效果评估修复上线后我们做了三轮验证不只是“任务能跑完”那么简单。7.1 在小数据集环境验证分批逻辑先在测试环境构造了 10 万行测试数据其中符合归档条件的数据约 3 万行运行归档脚本确认每批 500 条共处理 60 批且中途断开后可以继续执行。python scripts/archive_points.py预期输出效果类似2025-06-15T05:30:01.123456 archived batch, size500, last_id10234 2025-06-15T05:30:01.160021 archived batch, size500, last_id10734 ... done, total archived rows30000如果执行失败先看输出日志停在哪个last_id重点排查是不是出现了死锁或连接超时。7.2 验证主表数据没有丢归档完成后抽查主表和归档表的数据量。预期结果是points_detail_archive新增 3 万行。points_detail里对应记录被标记为archived 1但不会被立即物理删除。业务查询仍然能正常读到主表数据不会因为归档导致用户余额异常。这里要重点检查一个地方归档脚本中 INSERT 和 UPDATE 的逻辑是否在同一事务里是否会出现“归档表有数据主表没打标记”的不一致情况。在我们的实现里archive_batch函数在同一个事务里完成两步操作并统一 commit可以避免这个不一致。7.3 观察数据库指标曲线上线后观察一个月重点看四个指标主库 CPU 高峰是否明显下降。活跃连接数是否还有突刺。归档任务执行时长是否稳定。从库延迟是否恢复正常。从实际观察结果看15 号凌晨的 CPU 高峰从 95% 降到了 40% 以下活跃连接数没有出现超过 100 的情况归档任务在 30 分钟内稳定跑完。这轮验证说明修复方向是对的。7.4 如果还是失败第一步看什么如果归档任务仍然报警不要直接调大资源先看慢查询日志里这条 SQL 的rows估算值确认优化器是否走了新索引。大多数情况下问题出在统计信息没更新或者索引没被选中而不是机器配置不够。8. 常见问题与排查思路这是一个比较典型的批量任务排错清单建议收藏备用。问题现象可能原因排查方式解决方案任务运行到一半停住批量事务内死锁或锁等待查看SHOW ENGINE INNODB STATUS和锁等待监控缩小批量大小调整处理顺序增加重试机制归档后主表和归档表数据不一致INSERT 和标记 UPDATE 不在同一事务检查脚本事务边界核对双方数据量将两步操作放到同一事务并统一提交SQL 执行计划突然变成全表扫描统计信息过期或索引未被选择执行EXPLAIN查看执行计划查询慢日志更新统计信息补充联合索引必要时优化 SQL 写法任务执行很快但业务仍超时任务本身不是根因上下游其他任务抢占资源查看全链路监控和调度平台任务时间线错峰调度区分任务优先级同一任务重复执行导致重复归档调度平台配置了自动重试查看重试配置和任务执行日志对幂等性要求高的任务关闭自动重试归档任务执行时间飘忽不定表数据量增长或索引失效对比历史执行耗时和数据量趋势定期维护统计信息评估是否需要分区表每一个问题在实际处理时都要先确定当前影响范围再决定要不要中断任务。涉及到生产数据变更宁可让任务失败挂起也不要为了“尽快恢复”去手动跑一条不可控的大 SQL。9. 最佳实践与工程建议这次事故之后我们内部把批处理任务的治理规范重新梳理了一遍。下面这几条我认为对绝大多数团队都有参考价值。9.1 任何批量任务都必须支持断点续跑批量任务不可能永远一次成功。失败之后重新跑如果每次都从第一行开始前面的工作就浪费了。更关键的是如果任务没有断点续跑能力重试时可能重复处理同一批数据。断点续跑的常见实现方式就是“用主键或业务流水号记录当前位置”就像我们在脚本里用的last_id。每次任务启动时先读取上次执行到哪个位置再继续往后扫描。9.2 宁可分批小事务不要一次性大事务大事务是所有批量任务的万恶之源。它带来的问题包括锁范围大阻塞其他正常请求。回滚日志膨胀磁盘压力上升。主从复制延迟拉大。一旦失败回滚时间非常长。分批处理的思路虽然朴素但它是解决大事务最稳定、最不容易出错的方法。批次大小需要结合表结构和服务器配置测试不是越小越好也不是越大越快一般在 500 到 2000 之间比较稳妥。9.3 数据变更前先备份执行后要验证归档、清理、批量修改都算高风险操作。在测试环境验证只能说明逻辑正确不能保证生产环境的数据分布和测试环境一致。所以生产执行之前最好对涉及到的表做一次备份或者至少确保归档数据可以从其他表恢复。执行完成后一定要用几个维度验证影响行数是否符合预期。主表和备份表/归档表数据量对比。关键业务接口是否有异常。数据库连接数和 CPU 是否恢复正常。9.4 建立任务依赖关系文档而不是口头传递这次事故最大的教训是任务依赖关系没有被记录。调度平台上明明有 1 号任务和 15 号任务但没有人意识到它们之间存在数据层面的上下游关系。建议团队在调度平台或文档里维护一张任务依赖表至少记录以下几点任务名称和负责人。执行频率和执行时间。输入表、输出表。上游任务依赖哪些任务。下游任务有哪些。变更时的影响范围。这样当下游任务执行变慢时运维可以先查依赖关系快速判断是不是上游变更引起的。9.5 告警不只关注任务成功还是失败还要关注数据分布任务成功不代表没出问题。一个任务跑了 90 分钟和它正常时跑 20 分钟相比即使最后返回成功也意味着系统已经处于亚健康状态。更合理的做法是对任务的执行时长、处理行数、资源消耗做基线监控出现偏离时及时报警。我特别推荐把“统计信息维护”也纳入例行运维计划。对于经常做大批量变更的表可以制定周期性ANALYZE TABLE任务避免优化器因为统计信息失真而选错执行计划。9.6 安全与权限边界归档和清理类任务建议使用独立的数据库账号只授予操作目标表的权限比如 SELECT、INSERT、UPDATE不要给 DELETE 权限除非确认逻辑上必须物理删除。这样即使脚本异常也不会因为权限过大导致误删表数据。另外生产环境的调度平台要对执行人和变更记录保留审计日志。谁在什么时间改了任务配置、改了什么内容都要可追溯。10. 写给你把这次事故当成一个提醒回到开头那句话你六的太狠了牛批我真是躲得过初一躲不过15啊这句话现在成了我们团队的自嘲梗。每次有人想上线一个“很秀”的优化之前都会先问一句下游任务会不会受影响这个问题的价值远比一段漂亮的优化代码大得多。技术方案是流水任务依赖关系是河道。你只改变流水不梳理河道迟早有一天水会从你没预料到的地方漫出来。这篇文章从事故现象讲到根因分析再给出完整的脚本、索引、调度和监控示例核心是想传递一个观点批量任务的优化不是单点操作而是链路治理。看一个任务有没有被真正优化好不是看它自己跑得有多快而是看整条数据链路是不是稳定、可控、可回滚。如果你的团队也在维护定时任务、归档任务、报表任务建议从今天开始做两件事第一梳理所有任务之间的数据依赖关系形成文档第二给每个批量任务加上分批处理、断点续跑和关键指标监控。等下一次线上事故来临时你会发现提前做的这些事比任何“临场发挥”都更管用。