前阵子接到一个运维需求,一张自建的ZLOG表攒了差不多6000万行,客户要求把两年前的历史数据清掉,给数据库腾点空间。第一版方案很简单:写一条DELETE FROM zlog WHERE created_at < '20230101',放后台作业里跑。半小时后数据库组的同事打来电话,UNDO表空间快满了,生产库的锁等待已经飙到报警线,业务端一堆事务在排队。这种翻车现场,只要是做过ABAP大量删除大表数据的人,应该都不陌生。这篇文章是我自己反复清理大表后的复盘总结,把一次性删除为什么容易出事、批次删除该怎么拆、表重建法和分区表的适用场景讲清楚,顺便给出可以直接拿去改的ABAP代码和一份避坑清单。
如果你现在面对的是一张几万行的表,那怎么删都行,不需要看这些。真正需要这篇文章的是:单表行数到了百万级、千万级,删除量占全表一大半,而且系统不能停、不能锁、不能把日志空间写爆——这个前提下,删除动作就不再是一句话的事,而是一个需要设计的技术方案。
1. 为什么一次DELETE会让数据库“窒息”:三个隐形杀手
很多人写大表删除代码时心里想的是“数据库只要执行一条DELETE就能扫掉这些行”,实际上去掉语法层面的简单,底层要做的事情远超想象。先把这三个最容易被忽略的杀手说清楚,后面的方案选择就都好理解了。
1.1 日志与UNDO:数据库要先记一本“流水账”,才敢动你的数据
数据库的ACID特性要求:一条DELETE没提交前,系统必须能回滚,所以每条被删的行都要先写入UNDO/REDO日志。Oracle里叫UNDO和REDO,SQL Server叫事务日志,HANA也有自己的commit log机制。无论叫什么,本质都是:在真正删除数据之前,数据库先把“删除前是什么样、删除后变成什么样”完整记下来。
一次性删除3000万行,意味着数据库要写3000万份这样的撤销记录。以Oracle为例,UNDO表空间会飞速膨胀,如果配置不够大,等待你的就是快照太旧或者表空间无法扩展;即便UNDO够大,写日志本身也是巨大的IO开销,DELETE语句执行期间,数据库的redo log switch会变得非常频繁,整个实例都可能被拖慢。我在一个客户的Oracle生产库里见过一次失败案例:3000万行的DELETE跑了40分钟还没结束,最后手动回滚,回滚过程又花了1个多小时,等于一个下午整个库都在处理这一条SQL的善后。
1.2 锁等待:大事务一天不提交,其他人就得排队一天
第二条DELETE影响的是并发的业务事务。数据库里删除行是要加行锁的,在事务提交之前,这些锁不会释放。一次删几千万行,就等于锁住了表里一大片数据区域。对这个时间窗口内正好要访问这些数据的业务事务来说,等锁是必然的。如果运气不好,部分业务更新语句的访问路径要经过这些行所在的索引页,等待范围还会被进一步放大。
Oracle和其他主流数据库的锁等待还有超时机制,超出后会报锁超时错误。SAP应用层对数据库锁超时的处理各有不同,但结果通常是用户看到程序挂起、SM50里进程长时间卡在“数据库锁”状态,最后要么等、要么用户手工终止。生产系统如果出现这种情况,比删除慢更麻烦,因为你不能轻易终止一个已经执行了一半的大事务——一终止就是回滚,回滚又是一次灾难。
1.3 索引维护:删除动作的“隐藏税”
DELETE不是只把表里的行抹掉那么简单。表上有几个二级索引,每个被删的行都要同步去索引里删除对应的索引键值。索引越多,删除操作需要维护的结构就越多,IO同步放大几倍很正常。所以有些大表删除代码会想先删索引再删数据,但生产环境里删索引往往需要维护窗口,不是随便能做的。
除此之外,行存数据库还有一个高水位问题:DELETE之后,表占用的物理空间和段高水位并不会自动收缩。即使删掉了90%的数据,SELECT COUNT(*) 也可能仍然扫描那么大一片空间,后续新插入数据也不会自动复用所有碎片空间。这一点很重要,因为很多人的目标不是“删完数据”而是“释放空间”。如果只靠DELETE,空间释放效果通常远达不到预期。这个问题要跟客户提前对齐:你是只要业务查询看不到历史数据,还是数据库物理文件真的变小?两种目标对应的技术方案完全不同。
1.4 ABAP侧的额外开销,也不容小视
ABAP程序通过Open SQL与数据库交互,中间还有应用服务器这一层。一条DELETE如果写成逐行删除,比如在LOOP里反复调用DELETE,那么几百万行就是几百万次应用服务器到数据库的往返。每一次往返都有网络开销、SQL解析和游标管理,累计起来非常惊人。即便用内表批量DELETE FROM TABLE,也要注意内表容量和应用服务器内存,一次性把3000万行读进内存更是危险。所以ABAP侧永远要把“数据不要一次性堆在应用服务器上”作为一个基本设计原则。
2. 分批删除的工程化写法:从“一把梭”到“流水线”
一次性删除容易出事,解决办法就是把一个大事务拆成多个小事务。每批只删几千到几万行,删完立刻COMMIT,让锁、日志、UNDO都能被及时释放和重用。整体耗时可能会比一条大语句更久,但对生产系统是安全的。下面是我实际用过的三种拆分方式。
2.1 按主键RANGE切批:最简单可靠的方案
如果大表的主键是可比较的数值类型或类似连续编号,比如订单号、自增ID,那么按主键范围切批是最简单的。
DATA: lv_min TYPE zlog-id, lv_max TYPE zlog-id, lv_batch TYPE zlog-id, lv_batch_end TYPE zlog-id, lv_cutoff TYPE d. lv_cutoff = '20230101'. SELECT MIN( id ) FROM zlog INTO lv_min WHERE created_at < lv_cutoff. SELECT MAX( id ) FROM zlog INTO lv_max WHERE created_at < lv_cutoff. lv_batch = 10000. WHILE lv_min <= lv_max. lv_batch_end = lv_min + lv_batch - 1. IF lv_batch_end > lv_max. lv_batch_end = lv_max. ENDIF. DELETE FROM zlog WHERE id BETWEEN lv_min AND lv_batch_end AND created_at < lv_cutoff. COMMIT WORK. lv_min = lv_batch_end + 1. ENDWHILE.这里有个细节容易踩坑:主键范围是用“要删除的历史数据”算出来的,但范围覆盖到的区间里,可能有created_at大于等于截止日期的新数据夹在里面。所以DELETE的条件必须是主键范围 AND created_at < cutoff,两个条件都带,不能只写主键范围。切批的单位建议不要超过1万到2万,因为每批要删的行数不总是恰好等于范围大小,锁的规模和日志量都跟着这个数走。
2.2 按时间字段切批:符合数据特征的推荐方案
大表清理最常见的情形就是按日期归档,比如删除2023年之前的凭证。这种情况下按时间切批最贴合业务特征,代码也直观。
DATA: lv_start_date TYPE d VALUE '20200101', lv_end_date TYPE d, lv_cutoff TYPE d. lv_cutoff = '20230101'. WHILE lv_start_date <= lv_cutoff. lv_end_date = lv_start_date + 13. " 每批处理14天 IF lv_end_date > lv_cutoff. lv_end_date = lv_cutoff. ENDIF. DELETE FROM zlog WHERE created_at BETWEEN lv_start_date AND lv_end_date. COMMIT WORK. lv_start_date = lv_end_date + 1. ENDWHILE.每批的时间跨度要结合单日数据量设计。如果一天50万行,14天就是700万行,每批还是太大;建议先用SELECT COUNT(*)摸清每天行数,再决定批跨度。一般把每批行数控制在一万左右最稳,宁可多分几个批次。
2.3 游标式分批:不依赖连续值域的兜底方案
不是所有大表都有理想的连续主键。有些表的键是GUID字符串,有些是复合主键,很难直接按数值区间切分。这时候可以用“每次取一批主键/整行,删完这批,从下一批起点继续”的游标式写法。
DATA: lt_data TYPE TABLE OF zlog, lv_last_id TYPE zlog-id VALUE 0, lv_cutoff TYPE d. lv_cutoff = '20230101'. DO. CLEAR lt_data. SELECT * UP TO 5000 ROWS INTO TABLE lt_data FROM zlog WHERE created_at < lv_cutoff AND id > lv_last_id ORDER BY id. IF sy-subrc <> 0. EXIT. ENDIF. DELETE zlog FROM TABLE lt_data. COMMIT WORK. SORT lt_data BY id ASCENDING. READ TABLE lt_data INTO DATA(ls_last) INDEX lines( lt_data ). lv_last_id = ls_last-id. ENDDO.游标式写法的关键是:每次SELECT的条件都要带上id > lv_last_id,用上一批最后一条的主键作为本批的起点,避免重复处理也避免漏掉。如果主键是GUID或复合键,把这个逻辑改成“记录上一批最后一条的完整主键,下一次条件用主键组合大于它”,写法类似,但条件会更长。
这里还有个性能层面的思考:DELETE zlog FROM TABLE lt_data这种写法让ABAP把内表传给数据库做批量删除,比在LOOP里逐行DELETE省掉大量的应用服务器往返;但如果你的SAP版本或数据库对批量DML支持不好,也可以退而求其次,把内表行数控制在500到2000再逐行删,总之不要真的去逐行删几十万条。
2.4 批次大小、COMMIT节奏与运行窗口
批次大小没有绝对标准,但有一个可复用的经验区间:单批1000到10000行。5000行是我在Oracle和HANA上都比较常用的默认值。批次太小,COMMIT次数太多,总耗时会明显拉长;批次太大,事务日志和锁窗口又会回到危险区。选参数的时候可以先用开发机上的一份历史数据跑几分钟,看每批执行时间。如果单批稳定在几百毫秒到几秒之间,这个规模就可以接受;如果单批已经超过30秒,要果断调小。
COMMIT WORK在SAP里同时标记了数据库LUW边界。一批DELETE加一个COMMIT,能让数据库的redo/undo及时清理。不要把COMMIT放在循环内部靠近每条DELETE的位置,那等于把“大批小批”变成“逐行提交”,性能会急剧恶化。另外,大表删除程序尽量放SM37后台作业,用低峰期窗口运行,不要挂在对话进程里让用户等。如果删除量实在太大,宁可分多个深夜窗口分几次跑,每次跑完记录一下已删除的边界,保证可断点续跑。
3. 表重建法:当数据清理变成一次“换血手术”
如果删除的数据占全表比例极高,比如5000万行里只保留500万行,那么“DELETE掉4500万行”听起来就不太对了——为什么不是“只保留500万行”呢?这就是表重建法的思路:不把所有历史数据删除,而是把需要留下的数据搬到新表,然后换掉原表。
3.1 什么场景才值得用表重建法
表重建法的适用条件很苛刻,主要看两个指标。
第一,保留数据占比要足够低。通常我建议低于20%再考虑。如果保留数据占50%,搬数据的花销差不多等于删数据,没必要折腾。
第二,系统允许一定时间的写入停止,或者至少允许在维护窗口内短时间切换表。因为“搬出去、删原表、换新表”这个过程中,必须保证没有新的业务写入,否则过程中的增量数据会丢。
第三,原表没有大量的外键、视图、AMDP、增强等强依赖。依赖越多,换表代价越高。遇到全公司都在引用的标准表,绝对不要走这个方案;自建业务表可以考虑。
3.2 用临时表倒腾数据的完整步骤
以后缀_TMP的新表为例,完整流程大致是这样:
- 在SE11复制原表结构,创建ZLOG_TMP。复制时顺便把主键、索引、货币/数量字段的参考单位、搜索帮助这些一并带上。
- 用一条INSERT FROM SELECT把需要保留的数据搬进新表:
INSERT zlog_tmp FROM ( SELECT * FROM zlog WHERE created_at >= lv_cutoff ).这条语句在ABAP 7.40以后的版本可用,它直接在数据库内部迁移数据,不走应用服务器内存。老版本数据库和SAP版本如果不支持这个写法,只能分批SELECT到内表再分批INSERT,那样总时间会明显增加。
- 核对行数:对ZLOG和ZLOG_TMP分别执行COUNT,确认ZLOG_TMP行数等于原表保留行数,最好再抽查几个业务关键日期段的明细。
- 在低峰窗口停止相关业务程序写入,确认没有进行中的批次任务。
- 清空原表。如果条件允许,让DBA用数据库层面的快速清空手段(例如传统行存数据库的TRUNCATE)一次性清掉,比逐批DELETE快得多;如果只能由ABAP程序删,也要分批COMMIT,不要一条DELETE硬扛。
- 把ZLOG_TMP的数据搬回ZLOG(同样用INSERT FROM SELECT),然后删除ZLOG_TMP。
- 重建或刷新统计信息,让优化器拿到新的表和索引数据分布。
这里说的“搬回原表”和真正意义上的表重命名替换有差别。如果要彻底换表名,SAP数据字典里还涉及DDIC对象、授权对象、缓冲设置等一系列元数据问题,那个必须由BASIS配合做完整变更,不要指望用一段ABAP程序搞定。我上面这个版本是“临时表暂存+原表清空+数据回流”,好处是不动数据字典里的表名,很多自建表项目直接用这个套路。
3.3 换血之后必须复查的依赖对象清单
表重建法最怕的不是搬数据慢,而是搬完以后别人告诉你某个功能坏了。动手之前,把所有依赖对象拉一遍清单:
- 索引:新表要确认主键索引和二级索引都建好了,特别是原来用于查询性能的关键索引。
- 外键:如果其他表有外键引用原表主键,清空原表前必须先处理外键约束,否则数据库级别删不动。
- 视图/投影:同步视图、数据库视图、维护视图在DDIC里是否还指向有效表结构。
- 搜索帮助:附着在字段上的搜索帮助一般是跟着数据元素走的,通常不受影响,但建议验证。
- 授权:表授权对象是否需要为新表单独做。
- 程序引用:所有直接引用ZLOG的表工作区、类型、INCLUDE结构在DDIC变更后要重新激活。
- 表缓冲:如果原表开了SAP表缓冲,清空和回流后缓冲一致性由ABAP运行时管理,但新表不要忘了设置。
这些检查听着琐碎,但漏掉哪一个都可能在生产环境变成事故。我见过一次表重建后,一个查询视图没激活,导致相关报表直接报运行时错误,最后花了半天重建视图。
3.4 表重建法和分批DELETE的实测对比
用一个参考量级说明一下两者的差别。假设ZLOG表5000万行,保留500万行,单行大约500字节:
- 分批DELETE 4500万行:每批5000行,大约9000个批次。按每批1秒算,光删除就要约2.5小时;考虑到索引维护和日志IO,实际跑到5到7小时不奇怪。
- 表重建法:INSERT FROM SELECT搬500万行,在数据库内部执行,通常十几分钟到半小时;TRUNCATE清空原表,几秒到几十秒;再把500万行搬回,又半小时。总窗口通常在1小时以内。
代价是表重建需要停机窗口和依赖检查。所以在可以停写、保留数据量很少的情况下,表重建法优势明显;反过来,如果系统完全不能停、要边删边接入业务,那就只能用分批DELETE慢慢磨。
4. 大数据量删除前夜:检查清单与数据库层面的配合
方案定了、代码写好了,不要急着跑。大量删除是少数几个看起来简单、炸起来要命的数据库操作,上生产前最好把下面这些点全部过一遍。
4.1 删除条件能不能吃到索引:先做执行计划体检
很多DELETE炸掉,不是因为条件写错,而是因为WHERE条件在表上没有可用索引,数据库只能全表扫描。全表扫描除了慢,还会把这个表的所有数据页都拉进缓冲池,把其他热表的数据挤出去。用ST04或者DBACOCKPIT看一下执行计划,确认删除条件上的列有合适的索引。
有一种情况要特别注意:如果删除数据占全表比例超过20%,数据库优化器可能觉得“反正都要删这么多,全表扫描比索引访问更划算”,这是正常的。但你要观察的不只是执行计划,还有锁的范围和日志量。如果删除占比很高,且不能松口走表重建法,至少要让SELECT批次扫数据的时候吃到索引,这样每批的定位不会太慢。
4.2 分区表:“删分区”才是大表数据管理的长期解
如果是新设计的大表,或者表还有重新组织的机会,强烈建议考虑按时间分区。按分区键把每月或每年一个区,清理历史数据时就再也不是“DELETE几十万行”,而是直接“TRUNCATE PARTITION”或“DROP PARTITION”,秒级完成,而且不产生海量行级日志。
ABAP侧不用为分区表写任何特殊代码,Open SQL照常访问。但分区的定义和维护通常要在数据库层面做,SAP的DDIC和BASIS要一起介入。如果现有大表没有分区、又经常要清理,建议把“分区化改造”作为一个独立的专项列入技术债清单。这个思路治本:与其每次清理的时候想“怎么删得快点”,不如让删除这个动作本身变得微不足道。
4.3 和DBA、BASIS协作的关键沟通点
跨部门协作的大表删除,启动前至少要确认四件事:
- 备份策略:删除前确认备份已经完成,最好能锁定一个可以闪回或恢复的时间点。
- 日志空间:数据库组的同事需要监控UNDO/REDO、事务日志的剩余空间,确认计划内删除产生的量不会打爆空间。
- 锁与并发:删除程序运行窗口内,哪些业务程序会同时跑?能不能错峰?必要的话让DBA临时调整相关任务的时间。
- 统计信息与空间收缩:DELETE跑完后,表的高水位、索引碎片、统计信息都需要重新处理。这部分经常被遗忘,导致删除后性能反而更差。
和DBA沟通时,我最常说的是“我要分5000行一批提交,总删除量大约4500万行,预计这个窗口内产生多少日志、锁的窗口会有多长,你们帮我看会不会影响别的任务”。把话语权交给监控数据,比让对方“放心吧,我控制了批次”可信得多。
5. 几种方案怎么选:一张速查表与我的建议
写到这里,方案其实已经清楚了,但很多人还是会纠结具体场景用哪个。我直接给一张速查表,按自己的经验排列优先级。
5.1 方案适用场景速查表
| 场景 | 首选方案 | 原因 |
|---|---|---|
| 删除量占全表<30%,系统不能停 | 分批DELETE,按主键或时间切批 | 窗口灵活,锁可控 |
| 保留数据<20%,允许短时间停写 | 表重建法 | 总耗时短,物理空间释放彻底 |
| 表按月/年有清晰边界,未来还要持续清理 | 改造为分区表,按分区清空 | 根治,删除秒级完成 |
| 删除条件列没有索引 | 先建索引或调整条件,再分批删 | 避免全表扫描拖垮缓冲池 |
| 只是想让业务查询看不到旧数据 | 先考虑归档或软删除 | 物理删除不是唯一路径 |
这个表里的优先级不是死的。比如“不能停系统”和“保留数据少”同时出现时,我会优先选分批DELETE,因为表重建法虽然快,但切换窗口的不可控风险更高。风险优先级永远高于性能优先级。
5.2 如果业务允许,先考虑软删除
有时候“删除”只是业务上的诉求:用户不想再看到旧数据,不一定是数据库必须物理删除。这种情况下加一个DELETED标志位,查询条件里天然过滤掉,是成本最低、最安全的方案。但软删除有个明显的副作用:表数据量不会降,空间不会释放,查询性能可能更差。所以软删除只适合“数据量可控、保留价值高、空间压力小”的业务。如果表已经几千万行还在每天涨,软删除解决不了根本问题。
另外,SAP标准体系里做历史数据清理的正规路子是归档对象,事务码SARA。归档能把历史数据导出成文件并允许必要时重载,同时也能释放数据库空间。自建表同样可以创建归档对象。如果这事要长期做,别只会写DELETE脚本,认真评估一下归档方案。这不是绕路,反而是更符合SAP治理习惯的做法。
5.3 最后几句肺腑之言
清大表数据这件事,代码往往很简单,难的是对数据库行为有敬畏感。我自己的习惯是:任何超过百万行的物理删除,必须先写一份短文说明白三件事——数据从哪里来、删完怎么验证、出问题怎么回滚,然后拉上DBA和BASIS过一遍再执行。批次大小、运行窗口、停止写入的范围,每个参数都写成可配置项,别把硬编码塞进生产程序。最后,每次删完我都要亲手执行几个COUNT和抽查SQL,确认目标行数完全符合预期,绝不依赖“应该删完了吧”这种感觉。
这个经验,尤其是分批和表重建的选择逻辑,我后来在好几个项目里复用,基本都能平稳落地。现在每次听到别人说“就一条DELETE为什么不能跑”,我都能理解,但也知道他们还没经历过大表删除的第一次翻车。希望你读完这篇之后,不用经历翻车也能避开这些坑。