凌晨两点被电话叫起来处理一张日志表,业务方说一条删除脚本跑了四十分钟还没结束,磁盘水位一路飘红。登上去一看,一个会话里挂着DELETE FROM T_ORDER_LOG WHERE CREATE_TIME < ...,事务没提交,UNDO 空间已经吃掉八成。这种场景我在 DM 数据库上遇到过不止一次。DELETE 这条语句写出来只有一行,看起来是全套 SQL 里最没有技术含量的一个,真正难的部分藏在它背后:事务边界、锁的持有时间、UNDO 和日志的消耗、索引的维护代价、以及删除动作对后续查询和统计信息的影响。这篇就把我在 DM 上做删除操作的一整套经验摊开讲,从一条 DELETE 到底执行了什么,到千万级大表怎么清理,到误删之后还有没有救,尽量把能落到键盘上的细节都写清楚。刚接触 DM 的读者可以照着模板抄,已经踩过坑的同学可以重点看并发删除和收尾那几节。
1. 先弄明白一条 DELETE 在 DM 里到底做了什么
很多人对 DELETE 的理解停留在“把行删掉”,这个认知偏差是所有事故的起点。DM 里执行一条 DELETE,实际发生的动作至少有五步:解析与语义检查、生成执行计划、扫描定位目标行、对目标行加锁并写入 UNDO 与重做日志、把行标记为删除。注意最后一步是“标记”,不是“抹掉”。数据块里那行记录还在,只是行头被打了删除标记,真正的空间回收要等到后续插入复用这些块,或者整表重建。理解这一点,才能明白为什么删了 1000 万行之后,表占的空间一点没变小,全表扫描的速度也没有任何提升。
1.1 执行链条里最容易被忽略的两个环节
执行计划这一环,大部分人写 DELETE 的时候根本不看。条件列上有没有索引,决定了这条语句是精确加锁还是全表扫一遍加一遍锁。举个具体例子,T_ORDER_LOG上有CREATE_TIME的普通索引,那么WHERE CREATE_TIME < DATE '2024-01-01'会走索引范围扫描,只对命中行加锁;如果没索引,就是全表扫描,扫到的每一行都要尝试加行锁,扫描过程中还要处理并发插入带来的锁冲突。同样删 10 万行,前者几秒钟,后者可能锁住半张表还删不完。
第二个环节是日志。DM 的删除操作要保证事务可回滚,所以每删一行都要写 UNDO 记录,同时写重做日志保证崩溃恢复。这两个日志的量不是按“行数”线性算的,而是跟行的宽度、索引数量强相关。一张有 6 个索引的表,删一行要维护 6 个索引项的删除标记,日志量大概是单索引表的四五倍。这就是为什么同样删 100 万行,有些表删得很轻松,有些表直接把日志空间撑爆。
1.2 事务边界:AUTO_COMMIT 是第一个要确认的东西
DM 实例级有一个AUTO_COMMIT参数,不同部署环境下这个值可能不一样。自动提交开着的时候,每条 DELETE 执行完立刻提交,看起来省事,实际上有两个隐患:一是误删之后没有任何回滚窗口,二是大批量删除会被切成无数个小事务,虽然单次锁持有时间短,但总耗时和日志切换次数会明显上升。自动提交关着的时候,一条 DELETE 就能撑起一个巨大事务,锁和 UNDO 一直不释放。
动手之前先查一下当前配置,这是我在任何环境里执行删除操作的第一步:
SELECT PARA_NAME, PARA_VALUE FROM V$DM_INI WHERE PARA_NAME = 'AUTO_COMMIT';确认之后,再对当前会话做显式设置,不要依赖实例默认值:
-- 大批量删除时关掉自动提交,由脚本自己控制节奏 SET AUTOCOMMIT OFF;注意:会话级别的设置只影响当前连接。用客户端工具执行完记得把连接关掉或改回去,否则下一次别人接手这个会话时会莫名其妙地发现数据没生效。
1.3 UNDO 和日志的现实约束:删得起,回不起
事务回滚依赖 UNDO 数据。DM 的 UNDO 空间是有限的,回滚段被覆盖之后,长事务就没法完整回滚了。我见过最典型的事故是:一个运维同学在没有确认数据范围的情况下执行了全表 DELETE,发现删错之后立刻ROLLBACK,结果报错说回滚段不可用——因为在他犹豫的那十几分钟里,其他业务的事务已经把 UNDO 空间转了一圈。
这里的判断逻辑很简单:大事务的删除,回滚成本远高于重做成本。真正稳妥的做法不是“删错了再回滚”,而是“删之前先留一份可恢复的副本”。删 500 万行的表,我会先把目标行导出成一张备份表,或者干脆做一次物理备份,再动手。这个习惯救过我至少两次。
另外提醒一句,删除操作的日志量和事务大小直接相关。一个事务删 1000 万行,日志文件的切换频率会非常高,如果归档没配好或者日志组数量不够,可能出现等待日志切换的卡顿。清理大表之前,先看一眼日志组数量和单组大小,估算一下总日志量:
| 估算项 | 计算方式 | 示例(删 500 万行) |
|---|---|---|
| 单行平均宽度 | 查询表定义累加字段长度 | 约 240 字节 |
| 单行日志放大系数 | 基础行 + 每个索引约 0.3 倍 | 6 个索引约 2.5 倍 |
| 单行有效日志量 | 240 × 2.5 | 约 600 字节 |
| 总日志量 | 600 × 5,000,000 | 约 2.8 GB |
| 建议单事务规模 | 总日志量除以日志空间余量 | 单批控制在 1 万行以内 |
按这个算法,500 万行如果一次删完,光日志就要接近 3 GB,UNDO 还要另算。所以下面所有关于大表清理的方案,核心思路都是“拆小、控速、可中断”。
2. DELETE、TRUNCATE、DROP 三种删法怎么选
在 DM 里清数据,能用的手段不止 DELETE 一个。选错了手段,轻则慢,重则把不该动的结构一起带走。这三者的差别不是“快慢”两个字能概括的,它们处在完全不同的语义层级上。
2.1 三类删除方式的本质区别
DELETE 是 DML,操作对象是行,走事务,可回滚,会触发触发器,会维护索引,会写完整日志。TRUNCATE 是 DDL,操作对象是整张表的数据段,不走行级事务,不可回滚(在 DM 里 TRUNCATE 提交后无法用 ROLLBACK 撤销),速度快是因为它直接把数据页标记为空闲,不逐行处理。DROP 更彻底,连表结构一起删,进入回收站之前基本只能靠备份恢复。
| 对比维度 | DELETE | TRUNCATE TABLE | DROP TABLE |
|---|---|---|---|
| 语句类型 | DML | DDL | DDL |
| 是否可回滚 | 提交前可回滚 | 不可回滚 | 不可回滚(回收站可找回) |
| 是否可带条件 | 支持 WHERE | 不支持,全表 | 不支持 |
| 触发器 | 会触发 DELETE 触发器 | 不触发 | 不触发 |
| 索引维护 | 逐行维护 | 整体重置 | 对象删除 |
| 高水位线 | 不下降 | 重置 | 对象消失 |
| 自增列 | 不重置 | 通常重置,需实测确认 | 对象消失 |
| 锁粒度 | 行锁为主 | 表级排他 | 表级排他 |
| 适用场景 | 精确删部分数据 | 清空整表 | 废弃表下线 |
这张表里有两个点需要展开。第一是自增列的处理,不同版本的 DM 在 TRUNCATE 之后对 IDENTITY 列的当前值处理不完全一致,有的是重置回初始值,有的保持原值。这个差异会直接影响业务——如果业务假设 ID 从 1 开始重新编号,而实际没有重置,就会出现 ID 跳空。我的习惯是不依赖数据库的默认行为,清空表之后显式确认自增当前值,需要重置就单独执行对应的 ALTER 语句,而且一定在测试库先验证一遍语法。
第二是锁粒度。TRUNCATE 拿的是表级排他锁,执行期间这张表上任何读写都会被阻塞。如果你的表是 7×24 小时在跑的流水表,哪怕只有几秒钟的 TRUNCATE,也可能造成业务侧的连接堆积。所以 TRUNCATE 不适合放在业务高峰期执行,除非你有明确的维护窗口。
2.2 触发器、外键约束下的选择困境
表上挂了 DELETE 触发器的时候,DELETE 的代价会被放大。每删一行,触发器逻辑就要跑一次,里面如果还有跨表查询或者写日志的动作,100 万行的删除可能变成几个小时。这种情况下有一个折中做法:临时禁用触发器,删完再启用。
-- 删除前禁用触发器 ALTER TRIGGER TRG_ORDER_LOG_DEL DISABLE; -- 执行删除动作 DELETE FROM T_ORDER_LOG WHERE CREATE_TIME < DATE '2024-01-01'; COMMIT; -- 删除完成后务必恢复 ALTER TRIGGER TRG_ORDER_LOG_DEL ENABLE;注意:禁用触发器意味着这段时间内的业务删除也不会触发审计或级联逻辑。这是一把双刃剑,只在你明确知道触发器做什么、并且删除动作只由你的脚本发起时才用。生产环境上我一般会先跟业务方确认,然后在维护窗口内操作。
外键约束是另一个坑。主表被引用的时候,直接删主表数据会报错,除非外键定义了ON DELETE CASCADE。级联删除看起来方便,实际上非常危险:删一行父表数据可能连带删掉几万行子表数据,而且这些级联删除同样要写日志、加锁、维护索引,事务规模会在你不知情的情况下膨胀。我的建议是,级联删除只用在明确的一对一或小规模一对多场景,对于订单-明细这种关系,宁可写两段显式删除,先删子表再删主表,事务范围自己控制。
2.3 分区表场景下,删除逻辑要换一套思路
如果表是按时间分区的,删除历史数据的最优解根本不是 DELETE,而是分区级操作。假设T_ORDER_LOG按CREATE_TIME做了月分区,要清理 2023 年全年数据,可以直接删分区:
-- 先确认分区名 SELECT TABLE_NAME, PARTITION_NAME FROM USER_TAB_PARTITIONS WHERE TABLE_NAME = 'T_ORDER_LOG' ORDER BY PARTITION_NAME; -- 直接删掉不需要的分区 ALTER TABLE T_ORDER_LOG DROP PARTITION P202301;这个操作是秒级的,因为它是元数据变更加数据段释放,不涉及逐行扫描。代价是 DROP PARTITION 会让全局索引失效,需要重建;如果用的是局部索引,索引会跟着分区一起删掉,不需要额外处理。所以在设计分区表的时候,索引类型的选择就已经决定了后面清理方不方便——这个决策应该在建表阶段做,而不是等到清理的时候才发现全局索引要重建三个小时。
还有一种情况是分区保留但数据要清空,用TRUNCATE PARTITION:
ALTER TABLE T_ORDER_LOG TRUNCATE PARTITION P202301;比 DROP 温和一点,分区定义还在,后续可以继续往里写数据。适合那种按周期循环使用的分区。
3. 实操:从条件定位到分批删除的完整过程
讲完原理,落到实际动手。这里我用一张虚构的日志表T_ORDER_LOG做演示,场景是清理 2024 年 1 月 1 日之前的历史数据,总量大约 800 万行,业务要求不能长时间锁表,允许分多次执行。
3.1 删除前必须做完的三件事
第一件事是核对行数。不要凭感觉估,直接数:
SELECT COUNT(*) AS CNT FROM T_ORDER_LOG WHERE CREATE_TIME < DATE '2024-01-01';这个 COUNT 本身也可能很慢,如果表上没有索引。这时候可以先用采样估算,或者查系统表里的行数统计做初步判断,但正式执行前还是要拿到准确值,因为后面分批删除要靠这个总数判断进度。
第二件事是看执行计划。DM 的 EXPLAIN 对 DELETE 的支持情况各版本有差异,稳妥的做法是把 DELETE 改写成等价的 SELECT 再看计划:
EXPLAIN SELECT ROWID FROM T_ORDER_LOG WHERE CREATE_TIME < DATE '2024-01-01';看两个东西:是不是走了索引,预估的行数跟实际差多少。如果计划显示全表扫描,那就要先考虑补索引或者换方案,硬删会很难受。
第三件事是留后路。800 万行的删除,我不建议完全依赖回滚。可行的做法是把要保留的数据先落一份:
CREATE TABLE T_ORDER_LOG_KEEP AS SELECT * FROM T_ORDER_LOG WHERE CREATE_TIME >= DATE '2024-01-01';这张新表的行数加上要删的行数,应该等于原表总行数,这个等式必须成立。等删除完成、业务验证通过之后,再把新表改名接替原表,原表留几天再清。这个方案的本质是“用建表代替删除”,后面 3.3 节会详细讲。
3.2 分批删除的模板与批次大小的计算
确认完上面三件事,就可以写分批删除脚本了。DM 里可以用 ROWID 配合 LIMIT 做定点删除:
DELETE FROM T_ORDER_LOG WHERE ROWID IN ( SELECT ROWID FROM T_ORDER_LOG WHERE CREATE_TIME < DATE '2024-01-01' LIMIT 5000 ); COMMIT;把它包在循环里执行,就得到一个可控速的清理脚本:
DECLARE V_ROWS INT := 0; V_TOTAL INT := 0; V_BATCH INT := 5000; BEGIN LOOP DELETE FROM T_ORDER_LOG WHERE ROWID IN ( SELECT ROWID FROM T_ORDER_LOG WHERE CREATE_TIME < DATE '2024-01-01' LIMIT V_BATCH ); V_ROWS := SQL%ROWCOUNT; V_TOTAL := V_TOTAL + V_ROWS; COMMIT; EXIT WHEN V_ROWS = 0; -- 每批之间留出一点间隔,降低对在线业务的影响 DBMS_LOCK.SLEEP(0.2); END LOOP; PRINT '累计删除行数: ' || V_TOTAL; END;批次大小怎么定?这是有计算依据的。假设单行平均 240 字节,6 个索引,日志放大系数取 2.5,单行日志量约 600 字节。一批 5000 行对应约 3 MB 日志、1 MB 左右的 UNDO。如果你的日志空间余量在 10 GB 以上,UNDO 表空间有几个 GB,那 5000 到 10000 行一批完全安全。反过来,如果 UNDO 只剩几百 MB,批次就要压到 500 行以内。
另一个约束是锁的持续时间。一批 5000 行,如果走索引扫描,单批执行通常在几百毫秒到一两秒之间,行锁持有时间短,业务侧几乎感知不到。如果单批超过 5 秒,说明要么批次太大,要么走了全表扫描,要么锁冲突严重,这三种情况都要停下来看。
脚本里那个DBMS_LOCK.SLEEP的作用是主动限速。有些环境的 DM 版本对DBMS_LOCK的支持需要额外确认,如果执行报错找不到这个包,直接去掉睡眠语句,改成在外层用调度工具控制执行频率,比如每隔 30 秒触发一次单批删除。效果是一样的,只是实现位置不同。
3.3 超大表的清理:表重建比 DELETE 更划算
当删除比例超过表总量的 60% 到 70% 时,DELETE 的性价比会急剧下降。因为剩下 30% 的数据散落在整张表的数据块里,删完之后高水位线不降,全表扫描还是要扫过全部的块,空间也没释放。这种情况下我会直接选择重建表。
具体步骤是这样的。第一步,把要保留的数据落到新表:
CREATE TABLE T_ORDER_LOG_NEW AS SELECT * FROM T_ORDER_LOG WHERE CREATE_TIME >= DATE '2024-01-01';第二步,核对两张表的数据量。这一步必须做,而且要用同一时刻的快照做,否则业务还在写入,数据对不上:
SELECT (SELECT COUNT(*) FROM T_ORDER_LOG) AS OLD_CNT, (SELECT COUNT(*) FROM T_ORDER_LOG_NEW) AS NEW_CNT;第三步,如果新表需要索引、约束、默认值这些原表有的对象,要在这一步补齐。CREATE TABLE AS SELECT只复制数据和字段定义,不复制索引和约束,这一点经常被忘掉,导致切表之后查询性能断崖式下跌。
第四步,选在低峰期做切换,用重命名的方式:
ALTER TABLE T_ORDER_LOG RENAME TO T_ORDER_LOG_OLD; ALTER TABLE T_ORDER_LOG_NEW RENAME TO T_ORDER_LOG;如果新表上有索引,索引名会跟着表走,但索引的命名可能还带着 NEW 的痕迹,需要顺手改掉,方便后续运维识别。切换完成后,原表先别急着 DROP,保留三五天,等业务验证没问题再清理。
提示:重命名这一步在 DM 里是 DDL,执行瞬间完成,但会短暂拿表级锁。如果业务对这几毫秒也敏感,可以接受的话就选凌晨做,不要图省事放在白天。
3.4 删除之后的收尾工作清单
删除动作执行完,事情只做了一半。收尾有几件事必须做,漏了任何一件都可能在几天后引发问题。
收集统计信息。大表删完数据之后,优化器手里的行数统计还是旧的,执行计划会严重失真。比如实际只剩 50 万行,优化器以为还有 800 万行,可能选择全表扫描而不是索引。手动收集一次:
CALL DBMS_STATS.GATHER_TABLE_STATS('业务模式名', 'T_ORDER_LOG', NULL, 100);采集比例 100 是全量采样,大表耗时会久一些,但对一次大规模删除之后的场景,全量采样是值得的。
检查自增列当前值。DELETE 不会重置自增列,删除历史数据之后,新插入的数据 ID 会从原来的最大值继续往上走,不会因为中间空了 800 万个 ID 就回填。这在大多数场景下没问题,但如果业务方对 ID 的连续性有预期,就要提前说明。需要重置的话,用 ALTER 语句调整 IDENTITY 的当前值,语法各版本略有差异,执行前先在测试库验证。
确认空间回收情况。DELETE 之后表空间不会自动变小,这属于正常现象。如果确实需要把空间还给操作系统,只能走表重建或者表空间层面的收缩。不要看到磁盘水位没降就去反复删数据,那没有意义。
核对依赖对象状态。表上如果有物化视图、同义词、存储过程引用,删除数据不会影响它们,但如果你做的是表重建方案,重建之后要确认这些对象还能正常解析。物化视图在基表数据大幅变动之后可能需要刷新一次。
4. 并发删除、锁等待与死锁的现场排查
单线程删除只是慢,并发删除才是真出事。生产环境里出现删除相关的性能问题,八成以上是锁,而不是语句本身写错了。
4.1 锁等待的定位路径
DM 里定位锁问题,我一般按这个顺序走:先看会话在等什么,再看锁被谁持有,最后看持有者的 SQL 是什么。查会话和事务:
SELECT SESS_ID, TRX_ID, STATE, SQL_TEXT FROM V$SESSIONS WHERE STATE = 'WAITING';这个查询能快速捞出所有处于等待状态的会话。找到可疑的 SESS_ID,再去锁视图里看它等的是哪把锁:
SELECT TRX_ID, LTYPE, LMODE, BLOCKED, TABLE_ID FROM V$LOCK WHERE BLOCKED = 1;BLOCKED标记为 1 的行,就是当前被阻塞的锁请求。LMODE是锁模式,删除操作通常拿的是行级排他锁,如果在表级看到排他模式,说明要么是 TRUNCATE,要么是行锁升级,后者的杀伤力大得多。拿到持有锁的 TRX_ID,回到会话视图里查对应的 SQL 文本,基本就能定位到是哪条语句没提交。
注意:不同小版本的 DM 在锁视图的字段命名上可能有出入,写脚本之前先
DESC V$LOCK看一眼实际字段,别把示例代码直接贴到线上跑。
4.2 死锁是怎么产生的,怎么写才能避免
死锁的产生条件是两个事务互相持有对方需要的锁。删除场景里最典型的形态是:事务 A 按主键升序删除一批记录,事务 B 按主键降序删除同一批记录,两边各拿了一半的行锁,然后互相等。DM 有死锁检测机制,检测到之后会回滚其中一个事务,报出死锁错误,但这时候业务侧已经收到了异常。
避免的方法不复杂,核心是约定统一的加锁顺序。所有批量删除脚本都按主键升序处理,也就是在删除之前先排序:
DELETE FROM T_ORDER_DETAIL WHERE ROWID IN ( SELECT ROWID FROM ( SELECT ROWID FROM T_ORDER_DETAIL WHERE ORDER_ID = 202405001 ORDER BY ROWID ) WHERE ROWNUM <= 2000 );另一个原则是缩短事务。能一个事务删 1000 行,就不要删 10 万行。事务越长,持有的锁越多,被别人撞上的概率越大。前面写的分批脚本,本质上就是在做这件事。
还有一种容易被忽略的死锁来源:外键。删除父表数据时,数据库需要对子表的相关行加锁以检查引用完整性。如果两个事务的删除顺序相反,同样会死锁。这类问题只能从业务逻辑层面约定顺序,先删子表再删父表,而且所有调用方都遵守同一个顺序。
4.3 删除操作对下游的影响,别只盯着数据库
删除不是数据库内部的事。如果这套库有数据同步链路,比如向分析库同步或者向缓存推送,那删除动作会沿着链路传下去。我遇到过一个案例:业务侧用 DELETE 清理了 200 万行历史数据,分析侧的同步工具配置的是按时间戳增量拉取,结果这批删除根本没被感知,两边数据对不上了,查了三天才发现问题。
所以在设计删除方案的时候,要同步确认几件事:下游是逻辑删除还是物理删除,增量同步能不能识别删除动作,缓存里的对应键需不需要清理,报表口径里这些数据要不要计入历史统计。这些问题在产品经理那里往往没有明确答案,需要技术侧主动提出来。
5. 误删之后的补救路径
误删这件事,做过几年运维的人基本都经历过。区别只在于损失大小。补救手段按时间窗口从近到远,分三层。
5.1 第一层:提交之前,ROLLBACK 就是万能药
只要事务没提交,ROLLBACK就能完整撤销。这是代价最小、成功率最高的补救方式。前提是两个条件同时满足:会话没断开,UNDO 没被覆盖。所以发现误删的第一反应应该是不要关窗口、不要断连接、立刻执行 ROLLBACK,而不是去翻备份。
ROLLBACK;执行完之后立刻验证行数,确认数据回来了再继续排查原因。千万别在这个节骨眼上去做别的操作,尤其是别在同一张表上再执行任何 DML,否则容易把局面搞得更复杂。
5.2 第二层:已经提交,试试闪回查询
DM 提供了闪回查询能力,可以在一定时间窗口内查到数据的历史版本。这个能力依赖 UNDO 的保留时间,在 UNDO 还没被覆盖的前提下,可以把误删之前的数据查出来,再插回去。
SELECT * FROM T_ORDER_LOG AS OF TIMESTAMP TO_TIMESTAMP('2024-05-20 10:30:00', 'YYYY-MM-DD HH24:MI:SS') WHERE CREATE_TIME < DATE '2024-01-01';查出来之后,用INSERT INTO ... SELECT把数据补回原表。这里有个细节要注意:如果原表有自增列或者唯一约束,直接插回去可能冲突,需要先处理约束或者调整插入的字段列表。
闪回的时间窗口取决于 UNDO 表空间的配置和业务压力。业务繁忙、UNDO 转得快的库,这个窗口可能只有几十分钟。所以闪回能不能用,完全看运气,不能当作常规保障手段。真正可靠的还是备份。
如果整张表被 DROP 了,DM 的回收站机制可能帮上忙——前提是实例开启了回收站功能:
SELECT OBJECT_NAME, ORIGINAL_NAME, TYPE FROM RECYCLEBIN; FLASHBACK TABLE T_ORDER_LOG TO BEFORE DROP;回收站里能找回什么、保留多久,跟实例配置直接相关。这套机制不能替代备份,只是在有人手滑 DROP 表的时候多一层缓冲。
5.3 第三层:兜底永远是备份恢复
上面两层都失效的时候,只剩下备份恢复。这里的关键不是“有没有备份”,而是“备份能不能恢复到指定时间点”。物理备份加归档日志可以做时间点恢复,但恢复过程需要停库或者至少停业务,代价不小。
我的建议是把恢复能力做成定期演练。每隔一个季度,挑一个非核心库,实际做一次“误删全表 + 时间点恢复”的演练,记录耗时和遇到的问题。真出事的时候,你需要的不是理论上的恢复方案,而是“上次演练花了 47 分钟”这种确定性的预期,这样才能跟业务方沟通停机窗口。
6. 常见问题速查与踩坑记录
下面这张表是我这些年遇到的删除相关问题的汇总,按现象、原因、处理方式整理,遇到问题可以先在这里对一遍。
| 现象 | 可能原因 | 排查与处理 |
|---|---|---|
| DELETE 执行很久不结束 | 条件列无索引,走全表扫描 | 查执行计划,补索引或改分批 |
| 删完表空间没变小 | 高水位线未下降,空间未释放 | 属正常现象,需重建表才回收 |
| 报错提示锁等待超时 | 其他事务持有目标行锁 | 查 V$LOCK 和 V$SESSIONS 定位持有者 |
| 删除过程中出现死锁错误 | 两事务加锁顺序相反 | 统一按主键排序,缩短事务 |
| ROLLBACK 失败 | UNDO 被覆盖或事务已提交 | 转闪回查询或备份恢复 |
| 删完查询变慢 | 统计信息未更新 | 手动收集表统计信息 |
| 自增 ID 出现大段跳号 | DELETE 不重置自增列 | 属预期行为,需要则单独调整 |
| TRUNCATE 后触发器没反应 | DDL 不触发行级触发器 | 改用 DELETE 或调整业务逻辑 |
| 下游数据对不上 | 增量同步未识别物理删除 | 改用逻辑删除或补同步删除事件 |
6.1 我在实际项目里踩过的几个坑
第一个坑是没确认会话的自动提交状态。有一次在测试库写了个清理脚本,本地客户端默认关了自动提交,脚本跑完我以为已经提交了,结果切到另一个窗口查数据还在,又跑了一遍,最后发现第一个窗口的事务根本没结束,两个事务叠在一起把表锁死了。后来我养成了习惯,脚本第一行就显式设置自动提交状态,脚本最后一行显式写 COMMIT,不依赖任何默认值。
第二个坑是用 SELECT COUNT 的结果当删除进度。分批删除的时候,我用“剩余行数”判断是否删完,结果业务一直在往表里写新数据,剩余行数永远不归零,脚本死循环跑了两个小时。正确的判断条件是“本批实际删除行数为 0”,而不是“剩余总数为 0”。这个细节看着小,实际很容易翻车。
第三个坑是忽略了索引的维护成本。有张表上建了 9 个索引,删除 300 万行花了将近两个小时,我一开始以为是磁盘慢,后来把索引临时删掉几个再删,速度提升了三倍多。索引不是越多越好,尤其是高频写入的表,每个索引都是一份额外的删除成本。定期清理没人用的索引,不只是优化查询,也是在优化删除。
6.2 几条能直接落地的操作约定
最后整理几条我团队里一直在用的约定,都是踩过坑之后定下来的。
删除语句必须带 WHERE 条件,全表清理一律走 TRUNCATE 或者分区操作,不允许出现无条件的 DELETE。这条是硬性规定,代码评审的时候会重点看。
单事务删除行数上限设为 1 万行,超过就必须分批。这个数字不是拍脑袋定的,是按我们环境的日志空间和 UNDO 配置算出来的安全边界,换环境要重新算。
所有生产环境的删除脚本必须先在测试库完整跑一遍,包括回滚演练。跑通不代表安全,能回滚才算安全。
删除完成后 24 小时内保留原表或备份副本,不立即释放。多留一天,成本几乎为零,但能覆盖掉绝大部分“删完才发现有问题”的场景。
删除操作的审计要打开。DM 提供的审计能力可以对特定表的 DELETE 动作做记录,记录内容包括操作时间、会话信息和影响行数。这类日志平时没人看,出事的时候是唯一的追溯依据。
我个人在实际操作中的体会是,删除这件事的危险程度跟语句的复杂程度完全不成正比。一条DELETE FROM T WHERE ID = 1和一条TRUNCATE TABLE T,后者的破坏力可能大几个数量级,但写起来更简单。真正决定安全边界的,从来不是 SQL 写得多漂亮,而是动手之前有没有核对条件、有没有留退路、有没有想清楚这个动作会沿着链路影响到哪里。把这些前置动作做成习惯,比记住任何一条命令都管用。