多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SAP ABAP游标分包处理大数据:原理、实践与性能优化

SAP ABAP游标分包处理大数据:原理、实践与性能优化 1. 分包处理的现实痛点与方案选型1.1 为什么要用游标做分包SAP业务表的数据量增长通常比想象中快得多。比如一张凭证表上线头两年可能只有几十万条三年后就是几千万条。这种量级下一条OPEN SQL直接SELECT全部数据进内表实际跑起来会出现三种典型状况应用服务器内存直接飙高严重时程序直接dump隔壁跑着的报表跟着遭殃。数据库层长时间锁定相关表或索引导致前端操作卡顿业务部门开始投诉。SELECT语句执行时间过长超出数据库层的某些超时限制被强制中断。这种情况不是简单把WHERE条件加严就能解决的因为业务上确实需要全量或大范围数据做批量处理。游标分包就是标准解法不一次性把数据全捞进内存而是按固定数量一批一批取处理完一批再取下一批。数据库端每批只返回一小块结果集应用层内存占用恒定整个处理过程对系统的冲击也小得多。ABAP里的Open SQL游标操作核心是这几个语句OPEN CURSOR打开游标FETCH NEXT CURSOR取数据CLOSE CURSOR关闭游标。它们的定位和ABAP内表、FOR ALL ENTRIES这些日常操作完全不同属于直接和数据库交互层的编程方式。1.2 为什么不是子查询、JOIN或UP TO n ROWS有人会问用UP TO n ROWS分页循环外层查询行不行比如先查前5000条处理完再查后5000条。这种做法在数据量小的时候确实能跑但数据量大了问题很明显每次取下一页时数据库都要重新执行一次全表扫描或索引扫描。WHERE条件里如果包含排序字段还需要记住上一次取到的位置SQL复杂度直线上升。OFFSET这种写法越到后面性能越差因为数据库要跳过前面所有记录工作量是累计的。游标打开一次数据库端就锁定了结果集的位置每次FETCH都是接着上次的位置继续不会重复扫描前面已读过的数据也不需要在应用层记录位置信息。这在处理几百万行以上数据集时性能差距是数量级的。还有一个常见误区是直接SELECT全量后分块处理。比如先取所有主键进内表然后每5000个主键用FOR ALL ENTRIES查一次明细。这种方法在数据量小的时候没问题但主键表本身就可能占几百MB内存而且FOR ALL ENTRIES一次传5000个参数已经是极限了如果主键有几十万个循环次数太多数据库压力也不小。游标分包把“取数”和“处理”分离取数路径是一条稳定的数据流应用层永远只保留一小块数据内存占用可预期这才是它能扛大数据的根本原因。2. 游标分包的核心语法与执行机制2.1 三个关键语句OPEN、FETCH、CLOSEDATA: lv_processed TYPE i. DATA: lt_data TYPE TABLE OF bkpf. DATA: ls_data LIKE LINE OF lt_data. OPEN CURSOR lv_cursor FOR SELECT bukrs belnr gjahr budat FROM bkpf WHERE budat IN s_budat ORDER BY bukrs belnr gjahr. DO. FETCH NEXT CURSOR lv_cursor INTO TABLE lt_data PACKAGE SIZE 5000. IF sy-subrc 0. EXIT. ENDIF. LOOP AT lt_data INTO ls_data. lv_processed lv_processed 1. 在这里做业务处理 ENDLOOP. CLEAR: lt_data. ENDDO. CLOSE CURSOR lv_cursor.这是最基础的分包框架。OPEN CURSOR做的事不是把数据取到应用层而是在数据库端准备一个结果集同一个事务里可以反复FETCH。FETCH NEXT CURSOR是核心取数动作PACKAGE SIZE控制每批取多少行这个值可以直接决定内存和性能的平衡点。CLOSE CURSOR负责释放数据库端游标资源不写的话当前事务结束时会自动释放但显式关闭更安全尤其在长事务里能避免资源积压。一个细节FETCH时不能省略INTO的工作区或内表类型PACKAGE SIZE后面必须跟一个整型常量或变量不能直接写字符串。ABAP里所有数据库操作都受Open SQL本身限制游标也不例外难点往往不在语法本身而在于使用场景的边界。2.2 PACKAGE SIZE怎么选才合理分包大小没有绝对标准但有几个经验值可以参考。数据行大小建议PACKAGE SIZE单批数据量估算小字段表20列多为数字/日期10000 - 20000约1-3MB中等字段表20-40列含部分字符5000 - 10000约2-5MB大字段表含长文本、RAW、大量CHAR1000 - 3000约3-8MB超大字段表含STRING类型500 - 1000约2-10MB这个表是基于常规SAP表结构的估算。实际项目中我一般先看表的行结构用DESCRIBE字段或直接看SE11的行宽估算一下每行数据大概多少字节再定PACKAGE SIZE。原则是让单批数据控制在2MB到5MB之间。太小比如几百条会导致FETCH次数过多数据库和应用层之间交互频繁反而拖慢速度太大比如几万条又回到了“一次取太多”的老路上内存峰值还是会上去。如果你处理的表有大量长文本字段比如STRING或LCHRPACKAGE SIZE要明显调小否则单批数据量会很可怕。2.3 ORDER BY不是可选项是稳定性的保证Open SQL游标有一个关键限制如果不写ORDER BY结果集顺序是不确定的。ABAP对数据库结果的顺序不做任何承诺即使你插入数据的顺序是1、2、3查出来也可能是2、1、3。对于单纯做汇总处理顺序无关紧要但下面几种情况必须有稳定顺序要用主键做增量更新且处理逻辑依赖上一行数据状态。要在处理中途断点续跑那必须用一个明确的排序字段记录上次处理位置。要对数据进行分组处理比如按公司代码分块需要保证同一公司代码的数据都在一起。写ORDER BY时要注意排序字段最好用索引字段否则数据库要为排序做额外工作反而拖慢性能。最常见的是按主键排序。如果表有复合主键排序顺序要和主键定义的字段顺序一致才能配合索引使用。3. 实操案例用游标分包处理财务凭证表3.1 业务场景和表结构分析以一个常见的财务数据归档场景为例。BKPF会计凭证抬头表有三千多万条历史数据因为超过一定年份的数据已经不在日常业务中使用但又不能直接删除需要把归档标志更新或把数据搬到归档表中。表BKPF的主键是BUKRS公司代码、BELNR凭证编号、GJAHR年度。目标是按年份逐个公司代码处理把旧年度的数据更新归档标志并写入一张日志表。这种情况下光用一条UPDATE做全表更新数据库层会锁大量记录可能把在线业务都拖住。用游标分包逐批处理每一批只锁几行到几千行数据库压力小得多而且随时可以中断、继续。3.2 完整实现主程序与子例程拆分先看主程序框架REPORT z_opensql_cursor_batch. TABLES: bkpf. DATA: lv_cursor TYPE cursor. DATA: lt_bkpf TYPE TABLE OF bkpf WITH KEY bukrs belnr gjahr. DATA: ls_bkpf TYPE bkpf. DATA: lv_package_size TYPE i VALUE 5000. DATA: lv_total_cnt TYPE i. DATA: lv_success_cnt TYPE i. DATA: lv_start_time TYPE timestampl. DATA: lv_end_time TYPE timestampl. START-OF-SELECTION. GET TIME STAMP FIELD lv_start_time. 打开游标只取需要处理的年度数据按主键排序 OPEN CURSOR lv_cursor FOR SELECT * FROM bkpf WHERE gjahr 2020 ORDER BY bukrs belnr gjahr. 循环取数处理 DO. FETCH NEXT CURSOR lv_cursor INTO TABLE lt_bkpf PACKAGE SIZE lv_package_size. IF sy-subrc 0. EXIT. ENDIF. lv_total_cnt lv_total_cnt lines( lt_bkpf ). 调用处理子例程 PERFORM process_data USING lt_bkpf CHANGING lv_success_cnt. 每批处理完都做一次COMMIT避免长事务锁表 COMMIT WORK. CLEAR: lt_bkpf. ENDDO. CLOSE CURSOR lv_cursor. GET TIME STAMP FIELD lv_end_time. 输出处理结果和耗时 WRITE: / Total processed:, lv_total_cnt. WRITE: / Success:, lv_success_cnt. WRITE: / Duration (ms):, lv_end_time - lv_start_time.这个主程序的思路是把“取数”和“处理”彻底分开。主循环只负责从游标一批批拿数据拿到后立即交给子例程处理处理完就清空内表继续下一批。这样数据流是持续的内存中任何时候最多存在5000条记录。3.3 子例程中的数据处理逻辑再来看处理子例程的写法FORM process_data USING pt_bkpf TYPE TABLE OF bkpf CHANGING cv_success_cnt TYPE i. DATA: ls_bkpf TYPE bkpf. DATA: lv_tabix TYPE sy-tabix. LOOP AT pt_bkpf INTO ls_bkpf. lv_tabix sy-tabix. 检查是否已经处理过避免重复处理 SELECT SINGLE mandt FROM zarch_log WHERE bukrs ls_bkpf-bukrs AND belnr ls_bkpf-belnr AND gjahr ls_bkpf-gjahr AND flag X. IF sy-subrc 0. CONTINUE. ENDIF. 更新归档标志 UPDATE bkpf SET archivflag X WHERE bukrs ls_bkpf-bukrs AND belnr ls_bkpf-belnr AND gjahr ls_bkpf-gjahr. IF sy-subrc 0. 写入日志表 INSERT zarch_log VALUES ( ls_bkpf-bukrs, ls_bkpf-belnr, ls_bkpf-gjahr, sy-datum, sy-uzeit, sy-uname ). cv_success_cnt cv_success_cnt 1. ENDIF. ENDLOOP. ENDFORM.这个子例程有个关键设计每一条记录处理前都先查一次日志表判断是否已经处理过。为什么要这样因为游标分包处理模式下如果程序中途崩溃重跑是常态。有了这个幂等保护重跑时已经处理的记录会自动跳过不会重复更新或插入。实际项目中还可以加一个“处理进度保存”机制每隔固定批数比如每100批就记录一次当前处理到的排序字段值。重跑时就不需要从头开始直接OPEN CURSOR时在WHERE条件加上“大于上次的记录值”可以省下很多时间。尤其对于几千万条数据的表从头跑一遍可能要几个小时断点续跑能省下大半时间。3.4 用GET TIME获取运行时间验证性能很多项目在性能对比时需要一个定量依据ABAP里最直接的方法是使用GET TIME STAMP。这个语句返回的是UTC时间戳精度可以到微秒或毫秒级别。代码里通常这么用DATA: lv_start TYPE timestampl. DATA: lv_end TYPE timestampl. DATA: lv_diff TYPE i. GET TIME STAMP FIELD lv_start. 执行需要计时的代码 GET TIME STAMP FIELD lv_end. lv_diff lv_end - lv_start. WRITE: / 耗时(单位:10^-7秒):, lv_diff.需要注意TIME STAMP的结果不是普通整数两个时间戳直接相减得到的差值单位是10^-7秒即一亿分之一秒。所以上面这段代码输出的是一个很大的数值要换算成秒需要除以10000000也就是DATA: lv_seconds TYPE f. lv_seconds lv_diff / 10000000. WRITE: / 耗时(秒):, lv_seconds LEFT-JUSTIFIED.这种计时方式比SY-UZEIT准确得多因为SY-UZEIT只精确到秒对于几秒内完成的处理根本看不出差别。TIME STAMP在ABAP里运行时会自动读取系统时间不需要额外配置权限在日常开发中可以直接用。另外也可以把开始时间、结束时间差折算后按批次数量算出每秒处理多少条用来评估当前分包大小是否合理。3.5 扩展场景CURSOR与FOR ALL ENTRIES的组合游标分包除了单表处理还能和FOR ALL ENTRIES配合处理更强的业务场景。比如BSEG会计凭证项目表中有明细数据以BKPF的BUKRS、BELNR、GJAHR作为外键关联。用游标取一批BKPF数据后可以用FOR ALL ENTRIES按主键查BSEG明细然后做聚合或核对。这样既避免了大表JOIN的性能风险又保持了内存受限。DATA: lt_bseg TYPE TABLE OF bseg. IF lt_bkpf IS NOT INITIAL. SELECT * FROM bseg INTO TABLE lt_bseg FOR ALL ENTRIES IN lt_bkpf WHERE bukrs lt_bkpf-bukrs AND belnr lt_bkpf-belnr AND gjahr lt_bkpf-gjahr. 处理BSEG数据 ENDIF.技巧在于这样每批最多带出5000个主键对应的BSEG明细数据量被缩放到一个可处理的范围。比起一次全量JOIN这种方式的风险分散度好得多。但要记得FOR ALL ENTRIES内表不能为空且内部最多不要超过10000行用5000这个PACKAGE SIZE正好卡在安全线上。4. 常见问题与排查技巧实录4.1 为什么FETCH一次后内表还是空这是最容易踩的坑。FETCH NEXT CURSOR写错成FETCH CURSOR或者没有写PACKAGE SIZE语法可能不报错但行为会变成只取一行。另外一点很关键FETCH前的SELECT字段列表和INTO的工作区或内表结构不一致时运行时可能会出问题。如果字段对不上系统不会直接报错而是把对应的字段留空数据看起来就是“缺胳膊少腿”的。排查方式很简单FETCH后用IF sy-subrc 0判断如果成功但没有数据先看字段是否匹配再看游标打开的SQL语句WHERE条件是不是真的能查出数据。有时WHERE条件写错比如年份比较方向反了也会出现游标打开成功但FETCH不到数据的现象。这种时候可以先单独跑一遍SELECT COUNT(*)验证数据量。4.2 数据量大了之后游标越来越慢很多项目用游标前段跑得很顺越到后面越慢。原因通常是排序字段没走索引。比如ORDER BY BUKRS BELNR GJAHR但WHERE条件里没有加上BUKRS数据库优化器可能选择全表排序每取一批都要重排一次。优化思路是让排序字段尽量与索引匹配。如果表的主键是BUKRS BELNR GJAHR那么WHERE条件里带上BUKRSORDER BY按主键顺序写数据库大概率会走索引扫描。如果因为业务过滤条件导致索引失效可以考虑在WHERE里增加一个限定范围的条件比如按公司代码分段处理每个游标只处理一家公司数据。还有一个隐蔽问题是在循环处理时如果对同一个表做了UPDATE或INSERT可能会影响数据库端游标的稳定性。有的AP数据库版本会将游标定位信息锁定在快照上UPDATE提交后可能导致FETCH的定位失效表现为少数据或重复数据。这种情况下干净的做法是纯读游标数据取到应用层后再做更新不要边读边改同一个表。4.3 事务控制COMMIT不能乱放前面示例代码中每批处理完就COMMIT WORK这个设计是有讲究的。游标打开后如果没有COMMIT就会一直持有一个读事务快照。对于长时间运行的批处理这个快照可能锁住其他会话的写操作。尤其Oracle数据库上长查询快照对UNDO表空间的压力很大时间长了可能报ORA-01555快照过旧。但COMMIT也不能太频繁。如果每处理一条记录就COMMIT事务开销太大性能反而下降。稳妥的节奏是每批一个COMMIT。另外需要注意COMMIT会影响游标本身吗在ABAP的标准行为里COMMIT不会关闭游标但会影响数据库层面的事务状态一些数据库上可能会触发游标的隐式关闭。为了避免意外可以在COMMIT之后判断一下游标是否仍然有效或者在打开游标前就把事务控制策略明确下来。实际项目中更稳妥的做法是游标打开后一般不在循环体内修改游标所使用的同一张表。如果一定要改就考虑先全量取主键到文件或临时表然后分批处理。这样虽然多一步数据转移但能保证整个过程的稳定性。4.4 分包大小调整的实测经验我曾经在一个日终批处理里做过一个调整实验数据量是800万行字段约30个。PACKAGE SIZE分别用1000、5000、10000跑过结果很能说明问题PACKAGE SIZE总耗时秒内存峰值MB备注1000245小于30FETCH次数多交互开销大5000158约80时间和资源均衡点10000151约150耗时接近但内存翻倍30000148约420内存有明显压力收益很小从数据可以看出5000和10000的耗时差异不到5%但内存占用翻了一倍。超过万行后再加大PACKAGE SIZE几乎没有什么额外收益反而内存风险变大。所以在没有特殊需求时5000作为一个默认值很稳。如果表字段很少、行宽小可以适当往10000靠如果表里有大量文本字段就降到2000甚至1000。个人建议做一次“以1000为起点翻倍测试”的对比实验每次只改PACKAGE SIZE用TIME STAMP计时找到你项目数据量下的最佳值。这种经验数据比网上任何推荐都靠谱。4.5 游标的其他限制游标只能在同一会话内使用不能跨RFC调用传递游标句柄。游标打开后的结果集默认是“只读”的除非数据库支持FOR UPDATE否则不能通过游标做更新操作比如UPDATE ... WHERE CURRENT OF这种方式在ABAP Open SQL里不支持。游标不支持动态SQL里用参数直接绑定整条语句地交替使用。实际上动态游标用的是OPEN CURSOR或者直接EXECUTE而且在动态语句里如果混用了内表变量做IN参数绑定数量有限制需要分批拼接。对于SELECT SINGLE不能和游标共存游标只适用于多行结果集。建议所有用游标的代码都显式写CLOSE CURSOR并且调用完成后用CLEAR清除游标变量避免垃圾对象残留。5. 把游标分包写进你的代码习惯里这个内容后续还可以这样扩展你已经掌握了基础的分包框架再往深走可以研究分段更新大表、数据迁移断点续跑、并行分包处理。比如把包裹大小固定后用SPLIT方式按公司代码或年度分段开多个工作进程并行处理每个进程跑一个游标性能还能再翻几倍。但要记住并行会带来更复杂的锁管理和事务隔离问题不是无脑开进程就行的。我个人在实际项目中体会最深的一点是游标分包这件事真正的难点从来不是语法而是“稳定”。取数逻辑、排序、事务边界、幂等保护每一个环节都要克制地设计边界条件要提前想清楚。很多时候程序第一版跑是能跑生产环境一上几千万行数据跑到一半要么内存飙、要么锁表、要么中断调试起来非常痛苦。最后分享一个实用小技巧在游标循环的主干里加一个进度日志每处理完一定批数就写一行到自定义日志表或直接输出ALV进度条而不只是结束时统计总耗时。这样一旦程序中途出问题你能快速定位到是哪个批次附近的处理逻辑出了异常排查效率会高很多。这也是游标模式比全量SELECT模式更适合上生产的一个重要原因——它的每一步都是可观测、可追踪的。
返回列表