ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL DELETE深度解析:事务、锁与分批删除实战

MySQL DELETE深度解析:事务、锁与分批删除实战 搞MySQL时间长了你会发现DELETE删除数据是所有DML里最容易被低估的一个。刚入门的时候觉得它简单不就是加个WHERE条件嘛但真正在线上跑过几年之后你会发现一条DELETE语句背后牵扯到事务、锁、undo log、redo log、binlog、主从复制任何一个环节没考虑到位都可能把数据库搞出问题。我见过因为少写一个条件把整张业务表删空的也见过一条大DELETE把生产库锁了几个小时还见过从库重放一个超大DELETE导致延迟半小时以上的现场。这篇文章不打算只讲语法我会把DELETE从内部原理到生产实践完整拆一遍包括分批删除的写法、跟TRUNCATE和DROP的取舍以及误删之后怎么止血。后端开发、DBA、运维同学都可以对照着用。1. DELETE的语法与内部执行路径剖析1.1 一条DELETE在MySQL内部到底经历了什么先看最基础的语法DELETE FROM 表名 WHERE 条件;。很多人在初学阶段只关心这个写法但一条DELETE真正执行的时候远没有这么简单。从客户端发到服务端语句会依次经过连接器、分析器、优化器、执行器。连接器负责权限校验没有对应表DELETE权限的账号会被拦在门外分析器负责语法检查比如字段是否存在、FROM后面有没有表优化器决定这条DELETE到底走哪条索引是走主键还是二级索引是范围扫描还是全表扫描。这个环节特别容易被忽视DELETE语句里如果WHERE条件用不上索引优化器可能被迫走全表扫描扫描到的每一行都要尝试加锁和删除慢得让人怀疑人生。真正进入InnoDB引擎层之后DELETE也并不是立刻把数据从磁盘上擦掉。它先在聚簇索引记录上打一个删除标记然后把这条记录放入回收链表。这个标记只对当前事务和其他读写事务表现为删除真正的物理空间回收要等后台purge线程来清理并且必须满足一个前提删除事务已经提交且没有任何旧事务还持有该版本的读视角。所以你会看到一个很常见的现象删了100万行之后ibd文件几乎没变小。这跟DROP表完全是两回事。删除每行记录时InnoDB还要写undo log把旧值留下来。这个undo log就是用来支持回滚和MVCC多版本控制的如果删除事务特别大或者旁边有个长事务一直不提交历史版本就会堆积undo表空间也会一路膨胀。与此同时还需要写redo log保证崩溃恢复最后还要生成binlog给主从复制和恢复用。一条很多行的DELETE在压力大的系统上消耗的资源通常远高于同条件下一条SELECT这也是后面所有性能问题的根源。1.2 几种常见DELETE写法与适用场景我梳理一下日常写代码最容易碰到的几种DELETE写法以及各自的坑。第一种是基础条件删除DELETE FROM orders WHERE id 100;这个写法最安全条件通常走主键或者唯一索引锁的是一行或少量记录执行速度有保障。前提是WHERE条件确实能命中索引能EXPLAIN确认一下最好。第二种是无条件删除DELETE FROM orders;意思是清空整张表。很多人以为跟TRUNCATE差不多实际差异非常大DELETE逐行删除、不重置自增值、不会释放表空间文件而且在事务内可以回滚TRUNCATE直接重建表结构更快但隐式提交不可回滚。如果只是想清空数据而且不在乎自增ID没必要用DELETE。第三种是带LIMIT和ORDER BY的删除DELETE FROM orders WHERE status expired ORDER BY id LIMIT 1000;5.7以后版本支持这种写法适合分批清理。ORDER BY id是为了让每批删除的范围尽量稳定否则LIMIT不带排序时数据库可能选出任意一批记录分批之间可能漏数据也可能重复处理同一批数据。第四种是多表关联删除DELETE t1 FROM orders t1 JOIN users t2 ON t1.user_id t2.id WHERE t2.status blocked;这个语法容易写错关键在于DELETE后面跟的是要删除的表的别名。上面这条只会删除orders表的记录不会动users。如果写成了DELETE t1, t2那两张表的数据都会被删除。多表DELETE在MySQL 8.0中不支持ORDER BY和LIMIT所以如果数据量很大还是建议先把目标主键捞出来再分批删。2. 事务、锁与日志DELETE真正昂贵的原因2.1 事务边界为什么不能开着autocommit做大删除很多应用默认autocommit1执行一条DELETE就自动提交。单条小DELETE没问题但如果你写了一条DELETE FROM orders WHERE create_time 2025-01-01假设匹配200万行这条SQL本身就是一个超大事务。事务在提交之前所有被修改的行都持有锁undo log里一直保存着旧版本binlog和redo log也在持续写入。只要执行时间稍长锁等待、磁盘IO、主从延迟都会接踵而来。所以在执行大批量删除前有一点一定要想清楚语句一旦自动提交就没办法回滚了。如果你是在客户端工具里手输SQL最好显式开启一个事务先确认影响行数再提交这样至少还有一次反悔的机会BEGIN; SELECT COUNT(*) FROM orders WHERE create_time 2025-01-01; DELETE FROM orders WHERE create_time 2025-01-01; -- 确认无误后 COMMIT; -- 如果发现行数不对或条件有问题 ROLLBACK;这种写法虽然不能让性能变好但能在误操作时救你一命。真正要解决性能问题还是得靠后续讲的分批删除。2.2 锁DELETE可能不只锁住目标行InnoDB在默认的隔离级别RR可重复读下DELETE定位记录的时候不只是锁住目标记录本身还会对扫描范围内的间隙加锁这就是间隙锁和next-key lock。换句话说如果你执行一条范围很大的DELETE其他事务想往这个范围内的间隙插入数据就可能被阻塞直到DELETE事务提交或回滚。举一个我在模拟项目X里踩过的例子。有一张业务表需要定期清理三个月前的日志当时的清理SQL是DELETE FROM user_logs WHERE create_time ?;因为create_time上没有索引优化器直接走全表扫描。结果就是这条DELETE表面上只是在做删除实际上把所有扫描过的行和间隙都锁了一遍几乎等于给整张表加了大范围的锁。活跃连接马上出现大量Lock wait timeout exceeded写入业务的响应直接飙升。后来把它改成按主键分批删除问题才解决。遇到锁等待时常用排查手段是SHOW ENGINE INNODB STATUS\G看当前事务和锁信息performance_schema.data_lock_waits看谁在等谁。实际经验里宁可把一条大DELETE拆成多次按主键范围的小DELETE也不要用一个跨很大范围的组合条件去一把梭。主键范围删除虽然也会在一定范围内加锁但每次只锁一小段并且能稳定走聚簇索引对业务的影响可控很多。2.3 日志undo、redo、binlog如何影响删除很多开发者会忽略日志对DELETE的影响但它在生产环境恰恰最能制造惊喜。先说undo log。它保存了更新前的旧值用来支持事务回滚和MVCC快照读。如果一个大批量DELETE之后另一个长事务还在读取这些数据那旧版本就不能被purge线程清理undo会越堆越多甚至导致undo表空间暴涨。哪怕最后数据删掉了如果undo长期无法回收查询也可能变慢。然后是redo log。InnoDB崩溃恢复依赖它所有页面的物理修改都要先落redo。DELETE删的行越多redo写入量越大磁盘IO压力自然越高。它和undo加在一起会让一个看起来简单的DELETE产生好几倍的写放大。最后是binlog。在MySQL 5.7及以后binlog一般默认是ROW格式每删除一行binlog里都会记录这一行的完整前镜像。一百万行的DELETEbinlog体量可能是几百MB甚至上GB具体取决于表的列数和平均行宽。主库执行一条大DELETE可能几十秒就结束但从库SQL线程要把这个超大事务完整重放一遍期间主库如果还在持续写入从库延迟就会越来越大。这就是很多主从环境被一条DELETE拖垮的核心原因。3. DELETE性能调优与分批删除的完整方案3.1 大表DELETE为什么会拖垮数据库先集中说清楚大表DELETE的代价否则你可能不知道为什么非要拆批。第一是锁问题。一条DELETE包含的行越多事务持锁时间越长其他事务的插入、更新、删除都会被阻塞。第二是undo膨胀。删除一百万行就要为这一百万行保留旧版本。如果有会话一直不提交purge线程根本清不掉这些版本。第三是redo和binlog写放大。大量物理变更和逻辑日志会占用大量磁盘IO如果磁盘本来的性能就不算好慢查询会立刻冒出来。第四是碎片问题。即使purge线程清理了记录数据页里也可能留下很多空位表空间文件不会自动收缩后续插入可能导致页分裂索引形态变差。还有一点经常被忽略DELETE语句本身在优化器眼里和SELECT一样需要选择执行计划。如果WHERE条件没有索引它必须全表扫描逐行判断是否需要删除。这时候一个delete就可能把整张表的IO都跑满业务上表现就是数据库突然卡顿慢查询日志里全是这条DELETE。3.2 分批删除实操模板按主键范围或游标逐步推进批量删除的最佳实践是把一个大事务拆成多个小事务每批只删一小部分。核心目标是控制单次锁持有时间、控制undo和binlog体量、降低主从延迟。先看一种在应用脚本里实现的方式逻辑比存储过程更灵活方便加日志、调整批次大小和失败重试。以Python伪代码为例batch_size 2000 while True: rows execute( SELECT id FROM orders WHERE create_time 2025-01-01 ORDER BY id LIMIT %s , batch_size) if not rows: break ids [row[0] for row in rows] execute( DELETE FROM orders WHERE id IN (...ids...) ) execute(COMMIT) time.sleep(1)注意这里先按条件查出主键列表再按主键删除。相比直接写DELETE ... LIMIT 2000这种做法的可控性更强因为主键删除能精准命中聚簇索引也不容易因为LIMIT取数顺序不稳造成数据漏删。每批删完后主动COMMIT让事务尽快结束再sleep一小段时间给主库和从库都留出缓冲。另一种常见的区间写法长这样-- 每批处理前先确定这一批的主键范围 SELECT MIN(id), MAX(id) FROM ( SELECT id FROM orders WHERE create_time 2025-01-01 ORDER BY id LIMIT 2000 ) t;拿到这一批的min_id和max_id之后再执行DELETE FROM orders WHERE id BETWEEN min_id AND max_id AND create_time 2025-01-01;注意区间删除一定要带上原来的过滤条件防止在这个间隙里有新符合条件的数据被漏掉或者新插入的数据被误删。这个方案适合主键连续的业务表如果主键空洞很多建议还是用前面的主键列表方案。关于批次大小没有统一标准。从1000、2000、5000小步试更稳妥观察Threads_running、慢查询、主从延迟这三个指标。如果删一批就要好几秒说明批次太大或者索引没走好赶紧调小。3.3 复杂过滤条件的安全删除写法生产环境很少只删一张表经常要根据另一张表的状态决定要不要删。最直接的是多表JOIN删除DELETE t1 FROM orders t1 JOIN users t2 ON t1.user_id t2.id WHERE t2.is_disabled 1;这条语句的含义是删除orders表中那些user_id对应用户被禁用的订单。再次强调DELETE后面跟的就是你要删除的目标表的别名。如果你想确保永远不误删多余表可以只写DELETE t1 FROM ...不要写DELETE t1, t2。等价的子查询写法是DELETE FROM orders WHERE user_id IN ( SELECT id FROM users WHERE is_disabled 1 );子查询写起来更直观但如果子查询引用的是orders表本身MySQL会限制“不能在DELETE的同时对同一张表做子查询”。比如你想删除最早5000条过期数据DELETE FROM orders WHERE id IN ( SELECT id FROM orders WHERE status expired ORDER BY id LIMIT 5000 );这条在MySQL里很可能会报错。常见解决方法是包一层派生表DELETE FROM orders WHERE id IN ( SELECT t.id FROM ( SELECT id FROM orders WHERE status expired ORDER BY id LIMIT 5000 ) t );如果过滤逻辑比较复杂或者这张表很大、频繁删除我更推荐先把要删的主键放进临时表再用JOIN删除CREATE TEMPORARY TABLE tmp_delete_ids ( PRIMARY KEY(id) ) AS SELECT id FROM orders WHERE status expired ORDER BY id LIMIT 5000; DELETE FROM orders WHERE id IN (SELECT id FROM tmp_delete_ids);临时表方案的好处是源表只需要做一次范围扫描取主键锁持有时间更短临时表本身不需要回放binlog不会增加主从压力。用完自动消失也不用担心污染线上数据。4. DELETE、TRUNCATE、DROP怎么选4.1 三者的核心差异关于清理数据很多人分不清DELETE、TRUNCATE和DROP到底用哪个。我经常用一个比喻DELETE像是用橡皮擦一行一行擦掉字迹保留本子TRUNCATE像是把本子的所有页撕掉然后给你一本空白的同款本子DROP则是把整个本子丢进碎纸机。从数据库角度看它们的关键差异可以从下面这张表快速理解维度DELETETRUNCATEDROP语句类型DMLDDLDDL是否支持WHERE支持不支持不需要是否可回滚事务内可回滚隐式提交不可回滚不可回滚是否触发触发器会触发DELETE触发器不触发不触发自增ID状态不重置重置表不存在表空间释放不释放需整理碎片独立表空间下通常释放释放执行速度最慢快最快是否保留表定义保留保留不保留另一个容易忽略的点是TRUNCATE和DROP都是DDL会对表执行隐式提交就算你在事务里执行了TRUNCATE事务也不能把它回滚。所以任何涉及TRUNCATE的操作权限和审批都要再收紧一层。4.2 使用场景建议先看日常选择逻辑只删除满足某个条件的一部分数据必然选DELETE。清空一张大表不介意自增ID重置选TRUNCATE速度快得多。保留表结构但希望从某个自增值继续增长只能选DELETE。整张表已经不再使用选DROP。如果表是按时间分区的清理大量历史数据时优先考虑TRUNCATE PARTITION或DROP PARTITION效率远高于DELETE。我特别想强调一种场景有些人为了“保留表结构”习惯用DELETE FROM t清空大表。这种做法全表扫描加上逐行binlog性能极差而且文件系统空间还不会释放。如果只是想重置表并且数据无所谓生产环境里更合适的流程是先把旧表改名再重建一张新表等确认数据不需要后DROP旧表。通过改名保留的旧表本身就是一种备份兜底。不过TRUNCATE也有自己的问题。执行期间会持有表的元数据锁并且要等所有历史事务结束才能开始重建所以不能在业务高峰期随意跑。如果你在低峰期也看到TRUNCATE卡住先查一下有没有长时间未提交的事务在阻塞它这在8.0里很常见。5. 常见问题与避坑实录5.1 DELETE误操作后如何快速止血误操作是DBA最头疼的问题但也是每一个长期接触数据库的人都可能碰到的事。先讲恢复思路再讲怎么预防。如果已经不小心执行了DELETE第一时间别慌。先确认binlog开着没有、格式是不是ROW、binlog_row_image是不是FULL。在MySQL 8.0里默认配置通常是合理的5.7里则需要现场确认。确认之后可以通过解析binlog把删除前的前镜像找回来SHOW BINARY LOGS; SHOW BINLOG EVENTS IN mysql-bin.000012;找到对应时间点的DELETE事件再用mysqlbinlog工具解析出这些事件把被删除的行反向生成INSERT语句。这里我只是提一个方向实际恢复要结合表结构和binlog的起始位置仔细核对如果删除之后表结构又发生了DDL变更恢复难度会直线上升。但说实话真到要恢复的时候最好的情况是有人已经提前做了备份。所以生产环境的习惯我建议写成这样删除重要数据前先复制一份要删的数据到备份表CREATE TABLE orders_backup_2025 AS SELECT * FROM orders WHERE create_time 2025-01-01;查询完备份行数确认和DELETE影响行数一致再执行删除。这招很笨但它不依赖任何额外工具出错时能快速回补数据。数据量如果特别大可以改成只备份主键ID后续需要再回补。5.2 主从环境中DELETE导致延迟怎么办我前面已经提过从库SQL线程重放一个大事务时是串行的。主库执行一条50万行的DELETE用了30秒从库重放可能也要30秒甚至更久如果主库还在持续产生新写入延迟就会往上涨。处理思路优先级如下把大DELETE拆成小事务分批执行每次几千行是治本的办法。清理任务尽量安排在业务低峰期减少主库新写入的叠加压力。执行过程中关注从库状态SHOW SLAVE STATUS\G;主要看Seconds_Behind_Source老版本是Seconds_Behind_Master有没有回落如果只增不减先停下来回看是不是单批太大。还有一些人会尝试临时调大并行复制线程数但如果一个事务体量很大并行复制对这个事务也于事无补。与其依赖复制架构不如让事务本身变小。5.3 高频DELETE导致的性能抖动与长期治理另一种典型问题不是一次删除太多而是业务上高频地DELETE小批量数据但DELETE条件没有索引导致每次删除都要全表扫描。这种运维噪声很隐蔽慢查询日志里会频繁出现同一条SQL活跃线程数和磁盘IO都不正常。解决方式也很直接给DELETE的WHERE条件设计合适的索引。比如DELETE FROM user_sessions WHERE status expired AND updated_at NOW() - INTERVAL 7 DAY;这种语句适合建(status, updated_at)联合索引让优化器能快速定位到需要删除的行。判断一条DELETE能不能用上索引可以先写一条等价的SELECT再EXPLAINEXPLAIN SELECT id FROM user_sessions WHERE status expired AND updated_at NOW() - INTERVAL 7 DAY;如果explain结果里type不是range或ref而是ALL那这条DELETE的性能一定好不了。长期治理上更推荐把数据按时间分区。分区表里清理历史数据可以直接ALTER TABLE user_sessions TRUNCATE PARTITION p2024;或者直接DROP PARTITION。这种方式不会产生逐行的undo和binlog也不是一个超大事务效果是DELETE完全比不了的。对于必须保留明细、但需要定期清理老数据的表分区是真正值得投入的方案。5.4 误删防御权限与操作习惯最后说点和技术无关但很关键的细节。DELETE操作最大的风险往往不是性能而是误删。想清楚下面这些再执行也不迟重要表删除前先确认是否有数据库备份和binlog保留策略。删除条件必须能被索引命中先用EXPLAIN确认。删之前先SELECT COUNT(*)看一下影响范围。大批量操作不能直接在客户端手输一条DELETE就回车建议写成受控脚本限制批次大小。账号权限要收敛只读账号不该有DELETEDML权限按最小化原则分配。审批流程不能省哪怕是自己维护的表删全量数据前也要让第二个人看一眼SQL。我自己在实际操作中养成一个习惯凡是DELETE影响行数超过1000的都会先开事务、查行数、再执行执行完看一眼结果再决定COMMIT还是ROLLBACK。这套流程看起来麻烦但能挡住绝大多数低级失误。DELETE是一门需要敬畏的功夫表面越简单背后越需要稳着来。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进