news 2026/11/2 9:24:45

COBOL与数据库交互:嵌入式SQL、游标及批处理实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
COBOL与数据库交互:嵌入式SQL、游标及批处理实践

1. 先搞清楚一件事:COBOL 与数据库到底怎么“说话”

写这篇文章的起因很简单:最近帮一个团队梳理一套跑了十几年的核心记账系统,年轻人接手之后第一反应是“这套程序怎么到处是EXEC SQL,又到处是文件读写,到底数据从哪来的”。我一看就明白了——COBOL 与数据库的交互这件事,在很多存量系统里是一条“隐形的数据链”,代码里没有任何一层的封装,全靠宿主变量和游标一点点搬,教科书上不细讲,交接文档里也往往只写一句“程序会更新账户表”。所以我打算把自己这些年看到的、做的、踩过的坑一次性写清楚:这套交互机制到底长什么样,预编译在你背后动了什么手脚,游标为什么要那样写,提交频率怎么定,出错之后怎么查。读这篇文章的人,最好是对 COBOL 有点印象,或者正准备接手一套主机上的批处理程序,又或者是个想把老系统数据打通的后端工程师;即使你完全没写过 COBOL,跟着看下去也能明白它和数据库之间这套朴素但极其可靠的协作方式。

我经常用一个类比来解释这件事:COBOL 程序像一位老师傅,数据库像一间仓库。老师傅不会自己跑去仓库里搬货,他手里有一张“提货单”(SQL 语句),交给一个跑腿的伙计(数据库接口),伙计按单子把货一件件拿回来,放进老师傅准备好的托盘(宿主变量)里。老师傅只认托盘,不认仓库货架的摆放。这个“托盘”就是宿主变量,“提货单”就是嵌入式 SQL,而“一次搬一件、搬完再要下一件”的节奏,就是游标。理解了这一层,后面所有细节都是在这个框架上加东西。

1.1 三条常见的交互路径,以及怎么选

在真实的老系统里,COBOL 碰数据库基本就三条路,选哪条不取决于时髦,取决于耦合程度、事务边界、可回滚性这三件事。

第一条,也是最主流的,是嵌入式 SQL。COBOL 源码里直接写EXEC SQL ... END-EXEC,经过预编译器翻译成普通 COBOL 加一组接口调用,再交给 COBOL 编译器。这条路的优点是事务边界极其清晰,COMMIT、ROLLBACK就在业务流程的当前位置,出问题时一个语句就能把状态说清楚;缺点是代码和数据表绑得很死,表结构一改名就得全量重编译。适合核心交易、账务、计息这类对一致性要求到“一分钱都不能错”的场景。

第二条是调用外部子程序或接口。COBOL 通过CALL一个中间层程序,由中间层去访问数据库,COBOL 端只传参数。早年这种做法是为了屏蔽不同数据库的语法差异,现在更多是为了让新系统去接管数据访问。代价是每次调用都要跨一次边界,参数传递、错误码约定、字符集转换全得自己定规矩,一旦中间层出问题,排查会横跨两个团队。

第三条是文件交换,程序读一个顺序文件,另一个作业再把文件灌进数据库。听起来很土,但在超大批量的日终场景里它依然活着,原因只有一个:吞吐。批处理不需要毫秒级响应,把“取数”和“加工”解耦,失败重跑的成本最低。

我一般的判断顺序是这样:需要强事务保证、涉及多表更新、数据量在百万级以内,走嵌入式 SQL;需要给多个前端共用一套数据访问逻辑,走子程序层;纯批量、允许延迟、单次数据量特别大,走文件交换再灌库。这三条也可以混用,我自己做过的一个计息批处理就是“嵌入式 SQL 取账户快照 + 中间落临时文件 + 再批量入库”,目的很明确:把长事务拆成短事务,把锁的持有时间压到最短。

1.2 为什么这套看上去原始的方式至今没被淘汰

有个现象挺有意思:很多新技术栈的同事第一次看到 COBOL 里的EXEC SQL,第一反应是“这不就是拼 SQL 吗”。不完全是。它比拼字符串多了一个关键步骤——预编译期的静态校验。SQL 语句在编译阶段就要被解析、被绑定到具体的表和列,列名写错、类型不匹配,在编译时就会报,而不是等跑批跑到半夜才炸。这一条在金融、保险这类“跑批窗口只有四小时”的场景里,价值远超语法上的优雅。

另一层原因在于定长数据与精度。COBOL 的PIC S9(13)V99 COMP-3是紧凑十进制,天然对应数据库里的DECIMAL(15,2),金额计算不经过二进制浮点,不存在 0.1+0.2 的问题。这一点我在做对账的时候体会特别深——用二进制浮点存金额,跑几十万笔之后尾差一定出现,而 COBOL 端从头到尾都是十进制,入库也是十进制,链路里没有精度损失的机会。

提示:如果你的系统里 COBOL 端用COMP-3存金额,数据库列却建成了浮点类型,那这个隐患不会立刻暴露,但一定会在某个月的结息日暴露。建表时务必对齐为定点小数。

2. 核心机制拆解:预编译、游标与宿主变量

这一节讲清楚“为什么这些代码必须这么写”。不夸张地说,绝大多数线上事故不是因为业务逻辑错,而是因为有人把游标用错了、把SQLCODE判断漏了、把宿主变量定义成定长却往里塞了变长数据。

2.1 预编译器到底替你做了哪些事

你在源码里写的EXEC SQL,COBOL 编译器是看不懂的。中间必须过一道预编译器。它的动作可以拆成四步,我把它当黑盒用了好几年,后来为了排查一个奇怪的报错才去认真看它生成的中间代码,才发现这四步每一步都有可配置项。

第一步,扫描并替换。预编译器把所有EXEC SQL ... END-EXEC块抠出来,替换成一段CALL或PERFORM形式的接口调用,同时把语句文本写进一个独立的“语句包”里。所以你最终看到的可执行程序里,SQL 语句本身是作为数据存在的,不在代码段。

第二步,宿主变量声明。EXEC SQL BEGIN DECLARE SECTION和END DECLARE SECTION之间的变量,会被登记到一张对照表里,告诉数据库接口“这个 COBOL 字段对应参数列表里的第几个”。这里有个关键约束:声明区里的变量必须是 01 或 77 级,不能是 05 级以下的子项,因为接口需要拿到变量的完整地址和长度。我第一次踩这个坑就是把宿主变量定义在了一个 01 组项下面,编译直接报错,改了半天才明白道理。

第三步,SQLCA 注入。EXEC SQL INCLUDE SQLCA END-EXEC会被展开成一组共享变量,也就是通信区。所有 SQL 的执行结果都写在这块区域里,最常见的三个字段是SQLCODE(0 成功、100 无数据、负值出错)、SQLERRD(3)(受影响行数)和SQLWARN(各种警告标志)。它相当于那个“跑腿伙计”回来之后交给你的一张回执。

第四步,BIND 与执行计划。编译产出的语句包要在数据库侧做一次绑定,把语句编译成执行计划,生成一个访问入口。这一步在批处理环境里格外重要,因为它决定了 SQL 走不走索引。

# 典型的三段式构建流程,各家工具名不同,但步骤顺序基本一致 precomp -I ./copy -h ./gen -o ./gen/ACCTBAT01.cbl ./src/ACCTBAT01.sqb cobc -x -o ./bin/ACCTBAT01 ./gen/ACCTBAT01.cbl -L"$DB_LIB" -l<数据库接口库> bind ./gen/ACCTBAT01.bnd

注意:BIND时的隔离级别和并发选项会影响锁行为。日终批处理通常选“读稳定性 + 允许无主键读”这一类组合,能显著减少锁等待,但代价是可能读到未提交数据。选之前必须跟业务方确认“多读一条脏数据”和“多等二十分钟”哪个更不能接受。

2.2 游标的完整生命周期,以及为什么不能省步骤

只要有“一行一行处理”的需求,就必须用游标。很多新手图省事,用SELECT ... INTO直接取,结果第一条就报-811(返回多行),改成游标之后又忘了关,导致资源一直挂着。游标这个东西,本质上是数据库侧的一个服务端结果集句柄,它的生命周期有严格的四个动作,一步都不能少。

DECLARE只是声明,此时数据库什么也没做,甚至连语句都没解析,它只是告诉预编译器“待会儿有这么个游标”。OPEN才是真正执行查询、在服务端建结果集、并且在这一刻确定结果集的行范围。这一点极其重要:OPEN之后,即使别的会话往表里插了新行,你的游标也看不见,除非重新OPEN。批处理里经常有人抱怨“明明数据插进去了,程序就是读不到”,八成就是游标开早了。

FETCH是逐行取,每取一行,宿主变量就被覆盖一次。这里有个易错点:宿主变量的定义长度必须和列定义匹配。PIC X(12)去接VARCHAR(20),如果实际值超过 12 个字符,会被静默截断,SQLWARN(1)会给个'W',但如果代码没检查警告区,这个截断就悄无声息地发生了。我见过一个最离谱的案例:账户备注字段被截断后,两笔不同业务写成了同一条备注,对账时花了整整两天才定位到。

CLOSE释放句柄。批处理里如果循环里反复OPEN同一个游标却不CLOSE,早晚撞上“打开游标数超限”。更隐蔽的问题是WITH HOLD语义:声明时加了WITH HOLD,COMMIT之后游标不关闭,可以继续FETCH;不加的话,一旦COMMIT,游标全关,下一句FETCH直接报错。这一条是提交频率设计的前提,后面还会专门讲。

EXEC SQL DECLARE CSR-OVERDUE CURSOR WITH HOLD FOR SELECT CUST_ID, CUR_BAL, LAST_UPD FROM ACCT_MASTER WHERE BRANCH_NO = :HV-BRANCH AND CUR_BAL > :HV-LIMIT AND STS_CD IN ('A','P') ORDER BY CUST_ID FOR UPDATE OF STS_CD END-EXEC. EXEC SQL OPEN CSR-OVERDUE END-EXEC. MOVE SQLCODE TO WS-RC. IF WS-RC NOT = 0 PERFORM 9000-SQL-ERROR END-IF. PERFORM UNTIL WS-END-FLAG = 'Y' EXEC SQL FETCH CSR-OVERDUE INTO :HV-CUST-ID, :HV-CUR-BAL, :HV-LAST-UPD END-EXEC EVALUATE SQLCODE WHEN 0 PERFORM 2000-PROCESS-ROW WHEN 100 MOVE 'Y' TO WS-END-FLAG WHEN OTHER PERFORM 9000-SQL-ERROR END-EVALUATE END-PERFORM. EXEC SQL CLOSE CSR-OVERDUE END-EXEC.

上面这段里FOR UPDATE OF STS_CD是另一个值得单独说的点。加上它,说明你打算在游标里做定位更新,数据库会在取行时加行级锁,保证你读到的那一行在你更新之前不会被别人改掉。代价是锁的持有时间变长。如果只是读,一定不要加这个子句,加了就是白扛锁。

2.3 SQLCODE 判断的三个层次,漏一层就等着半夜被叫醒

我在代码评审时看得最多的疏漏,不是逻辑复杂,而是SQLCODE判断只写了“等于 0 就继续,不等于 0 就报错”。这个判断在真实环境里会漏掉三类情况。

第一类,SQLCODE = 100,无数据。它在SELECT INTO和FETCH里是正常终止信号,不是错误。如果把它当错误处理,程序会在处理完最后一行之后直接异常退出,前面的更新全部回滚。这类 bug 特别隐蔽,因为测试数据量小的时候,最后一条往往正好被处理完,只有全量跑批才暴露。

第二类,SQLCODE为负但可恢复。比如-911死锁回滚、-913锁超时,这两个在并发高的批处理里一定会遇到。正确的做法不是直接退出,而是重试有限次数(我一般设 3 次,每次先ROLLBACK再重跑当前逻辑单元)。直接报错退出的话,一个偶发超时就能让整个日终挂掉,运维半夜打电话过来。

第三类,警告不是错误。SQLWARN(0) = 'W'表示有警告,可能是数据截断、可能是空值被赋给了非空变量。这类情况程序能继续跑,但数据已经不准确了。我的习惯是统一在错误处理段里加一个判断,只要出现警告就打一条带业务主键的日志,跑批结束后统一汇总,第二天上班第一件事就是看这个汇总。

9000-SQL-ERROR. MOVE SQLCODE TO WS-ERR-CODE DISPLAY '*** SQL FAILED ***' DISPLAY 'RC=' WS-ERR-CODE DISPLAY 'STMT=' WS-CUR-STMT DISPLAY 'KEY =' HV-CUST-ID EXEC SQL ROLLBACK END-EXEC MOVE 'Y' TO WS-ABEND-FLAG .

错误处理段里一定要打业务主键和当前语句标识,这两样是排查的核心线索。只打一个错误码,运维和开发对着茫茫日志根本没法定位。

2.4 数据类型映射对照与精度陷阱

这一块是所有跨语言访问数据库的通用痛点,COBOL 只是把它放大了。我整理了一张自己常用的对照表,基本上覆盖了九成场景。

COBOL 定义建议的数据库类型说明与风险
PIC X(n)CHAR(n)定长对定长,最省心。如果对应VARCHAR,要留 1 到 2 字节余量
PIC X(n)变长场景VARCHAR(m),m ≤ n定义长度必须 ≥ 列最大长度,否则静默截断
PIC 9(n)DISPLAYNUMERIC(n)/CHAR(n)数字字符存储,适合编号类字段,参与运算要转换
PIC S9(9) COMPINTEGER4 字节二进制,范围约 ±21 亿,够用但不是万能
PIC S9(18) COMPBIGINT8 字节,做流水号、时间戳够用
PIC S9(p)V9(s) COMP-3DECIMAL(p+s, s)金额首选,精度无损,务必对齐小数位
PIC S9(p)V9(s)DISPLAYDECIMAL(p+s, s)也能用,但长度按字符数算,容易算错
空值指示变量NULL支持COBOL 没有 NULL 概念,必须配PIC S9(4) COMP指示变量

最后一行需要展开说。COBOL 的变量天然没有“空”的概念,一个PIC X(10)没赋值就是空格,一个数字没赋值就是 0。而数据库里NULL和 0 是两回事。稳妥的做法是在宿主变量后面跟一个指示变量:FETCH ... INTO :HV-AMT :HV-AMT-IND。HV-AMT-IND为负值时表示该列为NULL,此时HV-AMT的值没有意义,必须显式判断,不能用 0 代替。我见过一个报表统计把NULL当 0 汇总,导致坏账率算出来低了将近一个点,这个错误在报表上看完全看不出来。

提示:小数位对齐是最容易出问题的地方。数据库里是DECIMAL(15,2),COBOL 写成PIC S9(13)V99,看起来匹配,但如果代码中间做过DIVIDE运算又没有用ROUNDED,截位方向不同就会产生分位偏差。金额运算一律加ROUNDED,并且明确采用什么舍入规则。

3. 从建表到跑批:一套可复现的实操流程

前面讲的是原理,这一节直接上可复现的步骤。我按“环境准备 → 建表 → 写程序 → 跑批 → 调优”这个顺序走一遍,每一步都说明为什么这么设计,你可以对照自己的环境替换掉具体的工具名。

3.1 编译参数与目录结构,先把地基打对

目录结构这件事看着不重要,但老系统里最怕的就是源码、拷贝成员(copybook)、生成代码、可执行文件混在一起,出了问题不知道哪一版是对的。我自己固定用这四个目录:src放源码,copy放公共拷贝成员,gen放预编译产物,bin放可执行文件。这样做的好处是清理构建时只要删gen和bin两个目录,源码永远不会被动到。

编译参数里有几个必须显式指定的,不能靠默认值:

  • 源码格式。老代码是固定格式(第 7 列起为代码区),新代码可能是自由格式。混用会让编译器把某些行当成注释,最典型的症状是“明明写了EXEC SQL却没生效”。编译时务必明确指定格式。
  • 拷贝成员搜索路径。宿主变量声明和SQLCA一般放在拷贝成员里,路径不对就报“找不到成员”。这个错误信息往往和实际原因相差很远。
  • 预编译的宿主变量语言选项。告诉预编译器“这是 COBOL 宿主变量”,它才知道怎么解析PIC子句。选项写错,生成的参数对照表就会错位,运行时表现为“变量值莫名其妙不对”。
  • 大小写与字符集。表名列名的大小写敏感设置,以及字符集转换,必须在预编译和绑定两个阶段保持一致,否则会出现“列找不到”或者中文变乱码。
# 一次完整的构建示例(工具名以本机实际为准) set -e mkdir -p gen bin precomp -I ./copy \ -h ./gen \ -o ./gen/ACCTBAT01.cbl \ -lang cobol \ ./src/ACCTBAT01.sqb cobc -x -o ./bin/ACCTBAT01 \ ./gen/ACCTBAT01.cbl \ -I ./copy \ -L "$DB_LIB" -l<数据库接口库> bind ./gen/ACCTBAT01.bnd -m ACCTBAT01

set -e是我强烈建议加的。构建脚本里少一个失败判断,就会出现“预编译失败但旧的可执行文件还在,结果跑的是上一版程序”这种事故。这种事我亲身遇到过一次,排查了三个小时才发现跑的不是新代码。

3.2 表结构与索引设计,批处理的性能八成在这里

COBOL 批处理访问数据库,性能瓶颈基本不在程序里,在表上。我的经验是:批处理程序的 SQL 应该简单到只有一层,复杂的部分全部交给索引和分区。原因是批处理是逐行执行的,程序里每多一层逻辑,乘以几十万行就是灾难。

以账户表为例,日终扫描的典型条件是“机构号 + 余额 + 状态码”。索引怎么建,取决于选择性:

  • 机构号 + 状态码 + 账户号这种组合,是最常见的批处理入口索引。机构号把扫描范围先切小,状态码进一步过滤,账户号保证有序输出,避免排序开销。
  • 余额字段放不放进去要慎重。余额随时间不断变化,放进索引会让索引页频繁变更,反而拖慢更新。除非余额查询是最高频的只读需求,否则我一般放在索引末尾做覆盖,或者干脆不进索引。
  • 如果表特别大,按机构号或日期做分区,批处理按分区并行跑,是最有效的提速手段,比任何代码优化都管用。

还有一个细节常被忽略:ORDER BY 的列必须和索引顺序一致,否则数据库会做一次排序。几十万行的排序,内存放不下就落临时表空间,速度会掉一个数量级。我在一个结息批里把ORDER BY从“更新日期”改成“账户号”,跑批时间从 42 分钟降到 11 分钟,代码一行没改。

3.3 完整示例:带游标和分段提交的批量处理程序

下面这段是一个可运行骨架,做的事情是:按机构号扫描满足条件的账户,逐行计算并更新状态,每 2000 行提交一次,出错时回滚并记录断点。

IDENTIFICATION DIVISION. PROGRAM-ID. ACCTBAT01. ENVIRONMENT DIVISION. CONFIGURATION SECTION. INPUT-OUTPUT SECTION. FILE-CONTROL. SELECT CTL-FILE ASSIGN TO CTLIN ORGANIZATION IS SEQUENTIAL. SELECT RST-FILE ASSIGN TO RSTOUT ORGANIZATION IS SEQUENTIAL. DATA DIVISION. FILE SECTION. FD CTL-FILE. 01 CTL-REC. 05 CTL-BRANCH PIC X(6). 05 CTL-LIMIT PIC S9(13)V99 COMP-3. 05 FILLER PIC X(20). FD RST-FILE. 01 RST-REC. 05 RST-BRANCH PIC X(6). 05 RST-LAST-KEY PIC X(12). 05 RST-COMMIT-CNT PIC 9(8). WORKING-STORAGE SECTION. EXEC SQL INCLUDE SQLCA END-EXEC. 01 WS-FLAGS. 05 WS-EOF PIC X VALUE 'N'. 05 WS-DONE PIC X VALUE 'N'. 05 WS-ABEND PIC X VALUE 'N'. 01 WS-COUNTERS. 05 WS-READ-CNT PIC S9(9) COMP VALUE 0. 05 WS-UPD-CNT PIC S9(9) COMP VALUE 0. 05 WS-COMMIT-CNT PIC S9(9) COMP VALUE 0. 01 WS-CONST. 05 WS-COMMIT-LIMIT PIC S9(9) COMP VALUE 2000. 05 WS-MAX-RETRY PIC S9(4) COMP VALUE 3. EXEC SQL BEGIN DECLARE SECTION END-EXEC. 01 HV-BRANCH PIC X(6). 01 HV-LIMIT PIC S9(13)V99 COMP-3. 01 HV-CUST-ID PIC X(12). 01 HV-CUR-BAL PIC S9(13)V99 COMP-3. 01 HV-OLD-STS PIC X(2). 01 HV-NEW-STS PIC X(2). 01 HV-LAST-UPD PIC X(26). 01 HV-RESERVE-AMT PIC S9(13)V99 COMP-3. 01 HV-RESERVE-IND PIC S9(4) COMP. EXEC SQL END DECLARE SECTION END-EXEC. PROCEDURE DIVISION. 0000-MAIN. OPEN INPUT CTL-FILE OPEN OUTPUT RST-FILE PERFORM 1000-READ-CTL PERFORM UNTIL WS-EOF = 'Y' PERFORM 2000-RUN-BRANCH PERFORM 1000-READ-CTL END-PERFORM CLOSE CTL-FILE RST-FILE GOBACK. 1000-READ-CTL. READ CTL-FILE AT END MOVE 'Y' TO WS-EOF NOT AT END MOVE CTL-BRANCH TO HV-BRANCH MOVE CTL-LIMIT TO HV-LIMIT END-READ. 2000-RUN-BRANCH. EXEC SQL DECLARE CSR-ACCT CURSOR WITH HOLD FOR SELECT CUST_ID, CUR_BAL, STS_CD, LAST_UPD FROM ACCT_MASTER WHERE BRANCH_NO = :HV-BRANCH AND CUR_BAL > :HV-LIMIT AND STS_CD IN ('A','P') ORDER BY CUST_ID FOR UPDATE OF STS_CD END-EXEC EXEC SQL OPEN CSR-ACCT END-EXEC IF SQLCODE NOT = 0 PERFORM 9000-SQL-ERROR END-IF MOVE 'N' TO WS-DONE PERFORM UNTIL WS-DONE = 'Y' OR WS-ABEND = 'Y' EXEC SQL FETCH CSR-ACCT INTO :HV-CUST-ID, :HV-CUR-BAL, :HV-OLD-STS, :HV-LAST-UPD END-EXEC EVALUATE SQLCODE WHEN 0 PERFORM 3000-PROCESS-ROW WHEN 100 MOVE 'Y' TO WS-DONE WHEN OTHER PERFORM 9000-SQL-ERROR END-EVALUATE END-PERFORM EXEC SQL CLOSE CSR-ACCT END-EXEC. 3000-PROCESS-ROW. ADD 1 TO WS-READ-CNT IF HV-CUR-BAL > 100000 MOVE 'R' TO HV-NEW-STS ELSE MOVE HV-OLD-STS TO HV-NEW-STS END-IF EXEC SQL UPDATE ACCT_MASTER SET STS_CD = :HV-NEW-STS, LAST_UPD = CURRENT TIMESTAMP WHERE CUST_ID = :HV-CUST-ID AND STS_CD = :HV-OLD-STS END-EXEC IF SQLCODE = 0 ADD 1 TO WS-UPD-CNT ADD 1 TO WS-COMMIT-CNT ELSE IF SQLCODE NOT = 100 PERFORM 9000-SQL-ERROR END-IF END-IF IF WS-COMMIT-CNT >= WS-COMMIT-LIMIT PERFORM 4000-COMMIT-WORK END-IF. 4000-COMMIT-WORK. MOVE HV-CUST-ID TO RST-LAST-KEY MOVE WS-COMMIT-CNT TO RST-COMMIT-CNT WRITE RST-REC EXEC SQL COMMIT END-EXEC IF SQLCODE NOT = 0 PERFORM 9000-SQL-ERROR ELSE MOVE 0 TO WS-COMMIT-CNT END-IF. 9000-SQL-ERROR. DISPLAY '*** SQL ERROR ***' DISPLAY 'BRANCH=' HV-BRANCH DISPLAY 'KEY =' HV-CUST-ID DISPLAY 'SQLRC =' SQLCODE EXEC SQL ROLLBACK END-EXEC MOVE 'Y' TO WS-ABEND.

这段代码里有几个刻意的设计,值得逐条说。

第一,游标声明了WITH HOLD。因为中间要COMMIT,不加这个选项,提交之后游标就被关掉了,下一次FETCH直接报错。这是分段提交能成立的前提。

第二,UPDATE的WHERE条件带上了原状态码。这是乐观并发控制的一种朴素做法:只有状态还是我读到的那个值才更新,如果被别人改过,更新影响行数为 0,程序不会覆盖别人的修改。SQLCODE为 0 但SQLERRD(3)为 0 的情况,在代码里要当成“这一行没更新成功”处理,而不是“更新成功”。这一点很多代码都漏了。

第三,提交之后写一条重启记录。记录的是最后处理的业务主键和本段提交的笔数。批处理崩了之后,从这条记录之后继续跑,而不是从头再来。没有这个机制,跑三个小时的批处理挂一次就得全部重来。

第四,错误处理里直接ROLLBACK并置中断标志。没有“跳过这一行继续跑”这种处理。批处理里跳过一行,意味着这笔数据被永久遗漏,事后要人工补,成本远高于整个作业失败重跑。宁可停下来。

3.4 提交频率、锁范围与重启逻辑,这三件事必须一起设计

提交频率不是拍脑袋定的,它同时影响三件事:回滚代价、锁持有时间、日志压力。

提交太频繁(比如每 100 行),事务开销占比过高,而且COMMIT会触发日志落盘,整体速度反而下降。提交太稀疏(比如每 10 万行),一旦失败回滚量巨大,重跑时间成倍增长,同时长时间持有大量行锁,容易和其他作业死锁。我的经验区间是每 1000 到 5000 行一次,具体取多少,看单行处理耗时:单行 1 毫秒以内,取 2000;单行 10 毫秒左右,取 500;如果单行要几百毫秒,那就 100 到 200。核心原则是单段事务的执行时间控制在 5 秒以内,这个时间窗既能保证吞吐,又能把锁冲突压到很低。

锁范围的控制要靠两个东西配合:游标的FOR UPDATE OF只锁真正要改的列所在的表行,别把只读表也带上;查询条件尽量精确,把扫描范围压到最小。有些系统为了省事,直接SELECT * FROM 表 FOR UPDATE,这等于给整张表上了排他锁,并发作业必然排队。

重启逻辑的关键是幂等。重启记录只能保证“从某一笔之后继续”,但如果上一次提交之后、写重启记录之前崩溃,那条记录本身就不准。所以更新语句要设计成可重复执行:比如状态从 A 改到 R,重复执行时条件STS_CD = 'A'已经不再成立,影响行数为 0,不会造成数据错误。这就是前面说的“条件更新”的另一层价值。

参数常用取值取舍说明
单段提交笔数500 ~ 2000单行耗时越长,取值越小
单段事务时长≤ 5 秒超过就要考虑拆分或加索引
失败重试次数3 次只针对死锁、锁超时这类可恢复错误
重启粒度按业务主键记录配合条件更新实现幂等
游标持有方式WITH HOLD分段提交必须;单事务则不必

3.5 上线前的性能自检清单

批处理上线前,我固定跑一遍这张清单,能挡掉八成的问题。

先看执行计划。把程序里所有 SQL 抽出来,在测试库上逐条看执行计划,重点看两件事:有没有全表扫描,有没有额外的排序步骤。全表扫描在小表上无所谓,在千万级表上就是灾难。这一步必须在接近生产的数据量下做,测试库只有几千行的时候,全表扫描比走索引还快,看不出问题。

再看访问路径的基数。比如按机构号扫描,如果某个机构号下面有 80 万账户,而其他机构只有几千,那这一个机构就是长尾,会把整体跑批时间拖住。解决办法要么是把大机构再切片(按账户号前缀分段),要么把这一批单独拿出来并行跑。

然后看日志和临时空间。分段提交会产生大量日志,如果日志空间不够,跑一半会因为日志满而挂掉。这个错误在测试环境几乎不会出现,因为测试库日志空间小、数据量也小。我的做法是根据“单段提交笔数 × 单行日志量 × 预估总行数”粗算一下,留三倍余量。

最后是并发作业的冲突检查。日终往往有十几个批处理排队跑,彼此之间可能访问同一张表。上线前把所有作业的访问表和时段列出来,找交集,发现有交叉的,调整执行顺序或者错开时段。这个动作看着笨,但能避免大量死锁告警。

4. 踩坑实录:常见故障与排查路径

这一节是我这些年攒下来的“病历本”。每一个都是真实遇到过或者帮别人定位过的,写出来是为了让你少走一遍。

4.1 报错速查表:先看错误码,再看上下文

现象 / 错误码最常见原因排查动作处理办法
-811返回多行用SELECT INTO取了一条以上的数据把查询条件拿出来手工执行,看行数改用游标,或者在条件里补主键
-805对象未找到绑定时的表名解析失败,或搜索路径不对确认BIND时的限定名和当前用户权限补全模式名,检查授权
-818时间戳不匹配预编译产物的时间戳和绑定产物不一致检查构建流程是否增量编译导致错配全量重新预编译并重新绑定
-911死锁两个作业交叉更新同一批行看死锁报告里的两个持锁会话统一更新顺序,缩短事务,加重试
-913锁超时行锁等待超过阈值查等待链,找出持锁时间长的那一个调小提交粒度,或调大锁等待
-302数值超出范围宿主变量精度不够,或计算溢出核对PIC定义和列定义加宽变量,中间结果用大精度暂存
-404/-180日期格式错日期字符串格式和列类型不匹配抓实际传入的字符串看格式统一用标准格式,或改用日期类型
-545唯一约束冲突幂等逻辑没做,重复插入查主键值和来源改条件插入,或先查后插
静默截断宿主变量长度小于列长度检查SQLWARN(1)是否为'W'加宽变量,或加显式长度校验
数据读不到游标开得太早、隔离级别、未提交确认OPEN时机和会话隔离级别重新OPEN,核对提交状态

这张表我建议贴在工位上。真实排查中,先定位错误码,就能砍掉九成的可能性。

4.2 死锁与并发争用:定位比解决难

死锁这件事,解决起来通常很简单(改顺序、缩事务),难的是定位。我总结了一套固定的动作。

第一步,要死锁报告。数据库在检测到死锁时会回滚其中一个会话,并且通常会输出一份报告,里面有两个会话各自持有的锁和等待的锁。这份报告是唯一能直接看清“谁等了谁”的材料,没有它就只能猜。

第二步,还原两个会话的更新顺序。绝大多数死锁都是因为两个作业以不同顺序更新同一批行。比如 A 作业按账户号升序更新,B 作业按业务日期升序更新,两条路径交叉,必然死锁。解决办法是约定全局更新顺序,比如所有涉及账户表的更新,统一先按账户号排序。这个约定要写进设计规范,不能靠个人记性。

第三步,看事务边界是不是太大。有时候死锁不是因为顺序,而是因为一个事务里锁了太多次要拿别的锁。一个事务更新一千行,中途还要读另一张表并加锁,这个事务持有锁的时间就很长,被别人插进来的概率就大。分段提交是通用的解法。

还有一种情况容易被误判成死锁:锁等待超时。它的表现是程序卡住,最后报一个超时错误,没有死锁报告。这时要查的是“谁持锁不放”。常见原因是某个会话执行了更新但迟迟不提交,可能是在等用户输入,也可能是在跑一段很慢的逻辑。批处理作业里出现这种情况,多半是因为程序某处忘了COMMIT,把锁一直带到作业结束。

提示:批处理程序里所有EXEC SQL UPDATE之后,都应该能回答一个问题——“这段事务在哪里提交”。如果回答不出来,那就是漏了。

4.3 数据“看不见”的五类原因,按概率从高到低排

这一条我列了个概率排序,实际排查按这个顺序走,命中率最高。

第一,游标开早了。前面说过,OPEN那一刻结果集就定了。如果程序逻辑是先开游标,再去插入或更新数据,然后指望游标能读到新数据,那永远读不到。改法是把插入放到OPEN之前,或者处理完当前批次后重新OPEN。

第二,没提交。别的会话看不到你未提交的数据,这是隔离级别的正常行为,不算 bug。但很多人(包括我自己早年)会忘记这一点,程序里明明插入了,另一个终端查不到,就开始怀疑人生。

第三,隔离级别选错了。有些批处理为了性能选了很低的隔离级别,读到了“快照”,新数据不在快照里。这种问题的特点是“跑一会儿才出现”,而且换个时间跑就正常了,很难复现。

第四,条件写错了。这一类最常见也最难发现,因为程序不报错,就是查不出数据。典型的是字符比较:STS_CD = 'A '(补了空格)和STS_CD = 'A'在定长字段里行为不同;或者日期字段存的是字符串,比较时格式不一致。排查办法是把程序里的条件原样打印出来,粘到数据库客户端里手工执行,一看便知。

第五,权限问题。程序连的那个用户,对某张表只有读权限,插入被拒绝但错误被吞掉了。这类问题在跨环境部署时特别容易出现——测试环境用的是高权限账号,生产环境用的是受限账号。上线前一定要用生产账号跑一次全流程。

5. 老系统改造中的交互策略:不动主干,先换接口

最后聊一个绕不开的话题。现在很多团队并不打算长期维护 COBOL,而是想逐步把数据层剥出来。这件事我参与过几轮,有几个经验值得说。

5.1 保留 COBOL 主机的渐进式做法

最稳妥的做法不是重写,而是在 COBOL 和数据库之间插一层数据访问程序,COBOL 端保留CALL语句,中间层负责翻译成新库的访问逻辑。这样做的价值在于:COBOL 端一行不动,风险最低;中间层可以用新技术栈写,方便新系统复用;迁移可以按模块推进,不用一次性切完。

实施时有个硬约束必须提前定好:数据类型的边界。COBOL 传过来的定长字符串带空格,中间层要负责裁剪;COBOL 的十进制数字传过去是字节串,中间层要按压缩十进制解码;NULL语义在 COBOL 里不存在,要用额外的标志位传。这些转换规则如果不定死,后面每个模块都会各写一套,最后对不上。

还有一个容易被忽略的点:性能特征变了。原来 COBOL 直连数据库,一次FETCH就是一次本地调用;现在中间跨了一层,如果中间层做成了“每次调用都建连接”,性能会崩。中间层必须做连接复用,并且支持批量取数,不能一次只返回一行。

5.2 改造中最容易忽略的三个点

第一,日期和时间的表示。COBOL 里日期经常是PIC X(8)的YYYYMMDD,或者PIC 9(7)的“年积日”(一年中的第几天)。后一种在新系统里几乎没有对应物,转换时必须写清楚是哪种历法、闰年怎么处理。我见过一个系统把年积日当成“年月日”解析,导致跨年那天的数据全部错位。

第二,金额的小数位约定。COBOL 的V是隐含小数点,实际存储里没有小数点字符。把它导成文本时,如果直接按字节转,会丢掉小数点的位置信息,变成“金额放大一百倍”。这个问题极其常见,导数据脚本里必须有明确的除/乘。

第三,文件的记录格式。老系统里大量使用定长记录文件,字段之间没有分隔符,全靠偏移量定位。这种文件在新系统里读,必须有一份精确的“字段偏移量表”,而且要考虑字符集(单字节还是双字节)对偏移量的影响。一份偏移量表如果没写清楚字符集,读出来的数据会整体错位。

注意:改造期间新旧两套逻辑会并行跑一段时间,做数据比对。比对脚本一定要覆盖边界值——空值、最大长度、负数金额、跨年日期。只比对正常数据,等于没比对。

6. 一点个人习惯,供你参考

写到这里,说几个我自己坚持了很多年的小习惯,谈不上多高深,但确实帮我省了不少事。

第一个,每个带 SQL 的程序,第一行注释里写清楚这个程序访问哪几张表、做读还是写、事务边界在哪。这个习惯是从一次事故里养成的:一个跑了很久的批处理突然变慢,查了半天发现是三年前有人加了一句更新语句,把一张大表也纳入了事务。有这行注释,翻代码的人能在十秒内建立全局印象。

第二个,错误处理段永远打三个东西:业务主键、语句标识、错误码。只打错误码的日志,在几十万行的批处理里等于没有日志。

第三个,宿主变量统一放在一个拷贝成员里,命名统一加HV-前缀。这样做的好处是看代码时一眼能区分“这是本地变量”还是“这是要跟数据库交换的变量”,避免把不该传的变量传进去。命名混乱是这类程序最常见的维护成本来源。

第四个,每次改完 SQL,先在客户端手工跑一遍执行计划。程序里跑得慢,你在代码里找不到原因,因为原因在表上。这个动作花三十秒,能省掉几个小时。

这套东西不新鲜,也没什么技巧可言,就是“把边界说清楚、把错误留下来、把顺序定下来”。COBOL 和数据库之间的交互,本质上就是这三件事,剩下的都是细节。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 9:24:13

绿色债券基本资料与债券分类:数据口径、去重与分类树实践

做债券数据的人大概都有过这种体验&#xff1a;一份看着很简单的数据需求——“把近三年绿色债券的发行明细给我&#xff0c;按债券分类拆一下”&#xff0c;真正落到终端里&#xff0c;变成几百上千行、几十个字段、多套口径的原始表格之后&#xff0c;麻烦才刚开始。同样是“…

作者头像 李华
网站建设 2026/11/2 9:23:10

Material Design 3核心原理与跨平台实践指南

1. Material Design 设计风格概述Material Design 是由 Google 在 2014 年推出的设计语言系统&#xff0c;它不仅仅是一套视觉规范&#xff0c;更是一种将数字界面与现实世界物理特性相结合的设计哲学。这个系统最初是为了统一 Android 平台的应用体验而创建的&#xff0c;但现…

作者头像 李华
网站建设 2026/11/2 9:22:57

AI时代企业GEO优化:从SEO到知识图谱的转型

1. AI搜索时代的流量争夺战&#xff1a;企业为何需要GEO优化&#xff1f;2023年成为AI搜索的转折点。当用户开始习惯向豆包、文心一言等AI助手提问"哪家CRM系统最适合中小企业"时&#xff0c;传统的SEO策略正在失效。我最近服务的一家SaaS企业就面临这样的困境&#…

作者头像 李华
网站建设 2026/11/2 9:22:18

AI窄切片效应与知识坍缩:教育中的认知风险与重建路径

1. 这不是技术退步&#xff0c;而是认知结构的悄然位移“Chatbots give a narrow slice of knowledge: Researchers warn of knowledge collapse”——这个标题第一次跳进我视野时&#xff0c;我正调试一个教育类AI助教的检索增强模块。当时手边刚跑完一组对比实验&#xff1a;…

作者头像 李华
网站建设 2026/11/2 9:21:51

OpenAPPA:面向LangChain Agent的确定性防护栏设计

1. 项目概述&#xff1a;当“守门人”不再靠猜&#xff0c;而是靠算OpenAPPA 这个名字第一次跳进我视野时&#xff0c;我正被一个客户凌晨三点的 Slack 消息惊醒——他们刚上线的客服智能体&#xff0c;在处理“退货政策”咨询时&#xff0c;突然开始热情推荐自家新款蓝牙耳机&…

作者头像 李华
网站建设 2026/11/2 9:21:49

DeepSeek Harness与Pi不在同一层:AI Agent工程栈的分层实践

这两年做 AI Agent 工程的人&#xff0c;基本都绕不开一个词&#xff1a;Harness。但只要你多翻几个项目就会发现&#xff0c;同样叫 Harness&#xff0c;DeepSeek Harness 和 Pi 根本不在同一层。这两个东西的差异&#xff0c;不是功能强弱、不是谁比谁好用&#xff0c;而是压…

作者头像 李华