☰
COBOL与数据库交互全链路:嵌入式SQL、预编译绑定与调优实战
2026/11/2 9:24:49 网站建设 项目流程

COBOL 这门语言有个很少被外人提起的特点:语法几十年没大变,但它要处理的数据早就从磁带上那种掐着字节算的定长记录,搬进了关系型数据库的表里。我见过太多新手第一次在 COBOL 里写EXEC SQL时的表情——既有"原来这么简单"的轻松,也有"为什么编译过不了"的困惑。COBOL 与数据库的交互,看着只是几句嵌入式 SQL,实际上牵扯到预编译、绑定、宿主变量映射、事务边界、锁与隔离级别这一整条链路,任何一个环节拧不紧,程序要么跑不出数,要么跑得慢得让人怀疑人生。这篇内容我打算把这条链路从头到尾捋一遍,从"COBOL 到底有几种方式摸到数据"讲起,一直讲到 SQLCODE 报错怎么查、批处理怎么调优。适合正在做主机应用开发的同学当作落地参考,也适合刚接手老系统的朋友快速补齐这块的认知地图。

1. 先把地图画清楚:COBOL 到底怎么跟数据库说话

1.1 四条路径的取舍逻辑

在主机平台上,COBOL 程序摸数据大致走四条路,理解它们的差别,比记住语法重要得多。第一条是原生文件,也就是 VSAM 的 KSDS、ESDS、RRDS 和顺序文件 QSAM。第二条是层次或网状数据库的调用接口,典型是那种靠函数码驱动、一个段一个段往下走的 DL/I 调用。第三条是嵌入式 SQL,这是绝对的主流,EXEC SQL后面直接跟 SQL 语句,由预编译器翻译成调用。第四条是调用外部存储过程,COBOL 端只负责发参数和收结果,业务逻辑跑在数据库服务器侧。

这个排列顺序其实也反映了技术演进的方向。VSAM 的 KSDS 靠主键做索引,随机读一次大概是几次 I/O 的事,速度极快,但它没有事务、没有并发控制、没有查询优化器——你要按"余额小于零"这种非主键条件找记录,只能老老实实顺序扫一遍。嵌入式静态 SQL 的优势恰恰在这里:编译期就确定了 SQL 文本,优化器能在绑定阶段把访问路径算死,执行时直接走准备好的计划,性能稳定且可预测。动态 SQL 灵活,表名、条件列都能运行时拼,代价是每次PREPARE都要过一遍优化器,高频调用时这个开销不能忽略。

我个人的经验是:业务逻辑里 95% 的数据访问都该用静态 SQL 写死,把动态 SQL 留给报表导出、数据修复、参数化查询界面这类确实需要运行期决定的场景。至于 VSAM,别急着把它当成"落后技术"——在超低延迟的键值查找、日志流水顺序写、大批量顺序读这些场景里,它比走一遍数据库引擎还快,而且不占数据库的锁资源。老系统里两者混用是常态,后面第 6 章我会专门讲怎么让它们共处。

这里有个容易被忽略的判断依据:访问路径是否在编译期确定。如果你的查询条件、涉及的表在整个生命周期内都不会变,静态 SQL 是最优解;一旦需要根据用户输入或配置切换表、切换排序字段,就得往动态 SQL 上想。这个判断标准比"哪个更先进"有用得多。

1.2 预编译、编译、绑定:那条让人头大的链路

新手最容易卡住的不是 SQL 怎么写,而是"为什么我的程序里写了EXEC SQL,编译器说不认识"。原因很直接:COBOL 编译器不认 SQL。EXEC SQL ... END-EXEC这一段必须先被SQL 预编译器处理掉。

预编译器干的活很机械:把每一段EXEC SQL替换成对应宿主语言的 CALL 语句(或者叫运行时接口调用),同时在旁边生成注释形式的原始 SQL 方便阅读,然后把所有 SQL 语句抽取出来,生成一个叫DBRM的中间产物。输出的修改版源码再交给 COBOL 编译器,编译成目标模块,链接成加载模块。DBRM 则要经过BIND提交给数据库,优化器在这个阶段为每条 SQL 选访问路径,结果存进系统目录表里。

所以一个完整的发布流程是:预编译 → 编译 → 链接 → 绑定 → 执行。缺任何一环都不行,而且顺序不能乱。我见过最典型的翻车场景是:程序改了,重新编译链接都做了,唯独忘了重新绑定,结果线上跑的还是旧计划,新加的索引条件根本没生效。这种问题排查起来极其费劲,因为程序不报错,只是慢。

下面是一段批处理环境下的预编译 JCL 骨架,参数我按常见配置给,实际用的时候按自己环境的库名替换:

//PRECOMP EXEC PGM=DSNHPC,REGION=0M, // PARM='HOST(COBOL),MEMBER(CUSTINQ),ISOLATION(CS),SOURCE' //STEPLIB DD DISP=SHR,DSN=DB2.V12.SDSNLOAD //DBRMLIB DD DISP=SHR,DSN=APP.DBRMLIB //SYSCIN DD DSN=&&DSNHOUT,DISP=(MOD,PASS),UNIT=SYSDA, // SPACE=(800,(500,500)) //SYSPRINT DD SYSOUT=* //SYSTERM DD SYSOUT=* //SYSIN DD DISP=SHR,DSN=APP.COBOL(CUSTINQ)

几个参数值得单独说一下。HOST(COBOL)告诉预编译器目标宿主语言,不同语言生成的接口调用形式不一样,写错了链接阶段就会缺符号。ISOLATION(CS)指定默认隔离级别为游标稳定性,这是主机 OLTP 场景的事实标准,后面第 5 章细讲。SOURCE表示在输出源码里保留原始 SQL 作为注释,方便后续维护——这个选项强烈建议打开,否则几年后接手的人看到一堆 CALL 语句会完全懵。

提示:预编译报错的行号有时和源文件对不上,因为它是在处理展开后的代码(COPY成员被复制进来之后)。遇到定位不到的行号,先看SYSTERM里列出的COPY展开信息。

1.3 DBRM、PLAN、PACKAGE:三个名词一次说清

这三个词经常混着用,其实层级关系很清楚。DBRM是单个源程序预编译后产出的 SQL 集合,一个程序一个 DBRM。PACKAGE是 DBRM 绑定到数据库后的产物,包含了这批 SQL 的执行计划,它是可独立版本管理的单元。PLAN是应用计划,可以包含多个 PACKAGE,也可以直接包含 DBRM,是程序运行时真正引用的对象名。

理解这个层级的意义在于绑定粒度的选择。假如你把二十个程序全塞进一个 PLAN,那么改其中一个程序的 SQL,理论上有两种做法:只重绑那一个 PACKAGE,然后让 PLAN 指向新版本;或者整个 PLAN 重绑一遍。前者快、影响面小,后者慢、而且可能因为其他程序的 SQL 在新环境下失效而连带失败。所以现代的做法基本是PLAN 里放 PACKAGE,PACKAGE 单独绑定,用 COLLECTION 做版本隔离。

绑定命令长这样,我把它放在一个批处理作业里,顺便把执行也串上:

//BIND EXEC PGM=IKJEFT01 //SYSTSPRT DD SYSOUT=* //SYSTSIN DD * DSN SYSTEM(DB2P) BIND PACKAGE(APPCOL.CUSTINQ) MEMBER(CUSTINQ) - ISOLATION(CS) ACTION(REPLACE) - VALIDATE(BIND) EXPLAIN(YES) - REOPT(VARS) BIND PLAN(CUSTPLAN) PKLIST(APPCOL.*) - ACTION(REPLACE) ISOLATION(CS) RUN PROGRAM(CUSTINQ) PLAN(CUSTPLAN) LIB('APP.LOADLIB') END /*

ACTION(REPLACE)表示覆盖已有版本。VALIDATE(BIND)要求绑定阶段就校验表和列是否存在,如果表还没建好,绑定会直接失败——这在发布流水线里是好事,能提前暴露问题;如果你们的建表和绑定时序不确定,才考虑改成VALIDATE(RUN),让错误推迟到执行时才出现。EXPLAIN(YES)会把访问路径写进解释表,调优时全靠它。

至于REOPT(VARS),含义是"执行时根据实际变量值重新优化"。静态 SQL 里有宿主变量时,优化器绑定阶段只能用默认选择率估算,如果某个分支条件的选择率差异巨大(比如 99% 的订单是正常状态,1% 是异常状态),默认估算很可能选错路径。加了REOPT(VARS)就会在首次执行时按实际值再优化一次。它有代价——每次变量值变化都可能触发重优化,CPU 会多花一点。我的经验是:只在确实遇到路径选错的语句上加,不要全局开。

2. 宿主变量与数据类型:翻车率最高的一段

2.1 PIC 和列类型的对应关系

宿主变量就是 COBOL 里的普通变量,用冒号前缀在 SQL 里引用,比如:WS-CUST-ID。看起来简单,但类型映射错了,轻则数据截断,重则数值溢出或者日期算错。下面这张表是我总结的常用映射,按 z/OS 上的常见约定给:

数据库列类型COBOL 声明说明
CHAR(n)PIC X(n)定长,不足补空格
VARCHAR(n)01 结构,含 PIC S9(4) COMP 长度字段 + PIC X(n) 数据长度字段是 2 字节二进制
SMALLINTPIC S9(4) COMP半字,范围 ±32767
INTEGERPIC S9(9) COMP全字,注意不是 S9(10)
BIGINTPIC S9(18) COMP需要编译器支持 8 字节二进制
DECIMAL(p,s)PIC S9(p-s)V9(s) COMP-3压缩十进制,字节数 = (p+2)/2
FLOATCOMP-1 或 COMP-2单精度/双精度二进制浮点
DATEPIC X(10)ISO 格式 YYYY-MM-DD
TIMEPIC X(8)HH.MM.SS
TIMESTAMPPIC X(26)含微秒
GRAPHIC(n)PIC G(n)双字节字符

最容易错的是INTEGER 对应PIC S9(9) COMP。很多人凭直觉写成S9(10),因为整数有十位数字——这是错的。S9(9) COMP占 4 个字节,正是因为 4 字节二进制能表示约 ±21 亿,也就是 10 位数,但 COBOL 的 PICTURE 描述的是十进制位数容量,S9(9)已经是 4 字节二进制能安全承载的写法。写成S9(10)编译器会分配 8 个字节,和数据库返回的 4 字节对不上,数据就错位了。

2.2 VARCHAR 和 NULL 指示变量

VARCHAR 在 COBOL 里没有原生类型,得手工构造结构体。z/OS 上的惯例是两字节长度前缀:

01 WS-CUST-NAME-V. 05 WS-CUST-NAME-LEN PIC S9(4) COMP. 05 WS-CUST-NAME-TXT PIC X(60).

查询时把整个结构体当宿主变量传进去,数据库会先写长度字段,再把实际内容填进文本区,剩余部分不动。读出来之后,要取有效内容就得用WS-CUST-NAME-TXT(1:WS-CUST-NAME-LEN)这样的引用改写。这里有个坑:长度字段是二进制,直接当循环上界用没问题,但如果拿它去STRING或者做INSPECT,记得先转成显示型,否则算出来的位置全是乱的。

NULL 的处理更隐蔽。数据库列允许为空,COBOL 变量没有"空"的概念,所以必须额外声明一个指示变量:

01 WS-BALANCE PIC S9(11)V9(2) COMP-3. 01 WS-BALANCE-IND PIC S9(4) COMP.

SQL 里这样引用::WS-BALANCE :WS-BALANCE-IND。返回后判断指示变量的值——小于 0 表示该列为 NULL,大于等于 0 表示有值。注意这里说的是"小于 0"而不是"等于 -1",虽然实践中多数情况返回 -1,但规范允许返回其他负值,写成IF WS-BALANCE-IND < 0才是稳的。

不传指示变量而查询一个可能为 NULL 的列,数据库会返回一个负的 SQLCODE(通常是 -305),整个语句失败。这个错误在老系统改造时特别常见:原来列是 NOT NULL,后来业务放开允许为空,程序没改,第二天批量就挂了。

2.3 精度、符号和截断:三个静默杀手

DECIMAL(p,s)映射到 COBOL 的规则是PIC S9(p-s)V9(s) COMP-3。比如DECIMAL(13,2)写成PIC S9(11)V9(2) COMP-3。COMP-3 是压缩十进制,每字节存两位数字,加一位符号位,13 位数字需要 7 个字节。这个换算必须手算清楚,写宽了浪费空间还可能被数据库拒收,写窄了直接截断。

截断是最阴险的,因为它往往不报错。字符串从CHAR(50)取到PIC X(30)的变量里,默认行为是截断且只在 SQLWARN0 里置个 W,程序如果没检查警告区,就静默丢数据了。数值溢出则通常报 -302(数值转换错误)。我的做法是在每个程序的错误处理段里统一检查SQLWARN0,只要等于'W'就写日志,哪怕不影响流程——事后排查数据不一致时,这些日志能救命。

浮点数还有一层精度问题。COMP-1/COMP-2是二进制浮点,和十进制的DECIMAL互转时会出现0.1 + 0.2 != 0.3这类经典问题。金额字段绝对不要用浮点,这是常识,但接口对接时对方传过来一个浮点、你直接塞进COMP-3变量,中间就悄悄丢精度了。稳妥做法是先落到PIC X的字符串,再按位解析转定点。

注意:宿主变量数组和结构体在 SQL 里的引用方式有讲究,INTO :WS-REC要求结构体内部布局和 SELECT 列表严格一一对应,字段顺序、类型、长度全都要对上。用SELECT *是最危险的做法,一旦表结构加了列,程序立刻错位。

3. 从单行查询到游标:取数方式的等级

3.1 SELECT INTO 的边界在哪

最简单的取数就是SELECT ... INTO :HV1, :HV2,一次一行。它的限制非常硬:返回超过一行就报 -811。所以这条语句只能用在按主键或唯一键查询的场景。

写的时候有个习惯我强烈建议养成:加FETCH FIRST 1 ROW ONLY。哪怕你确信条件唯一,多这一句不花什么钱,但能防止未来数据出现重复时程序直接崩掉。更重要的是把错误处理写全:

MOVE 0 TO SQLCODE EXEC SQL SELECT CUST_NAME, BALANCE INTO :WS-CUST-NAME, :WS-BALANCE :WS-BALANCE-IND FROM CUSTOMER WHERE CUST_ID = :WS-CUST-ID FETCH FIRST 1 ROW ONLY END-EXEC EVALUATE SQLCODE WHEN 0 PERFORM 2000-PROCESS-CUST WHEN 100 PERFORM 2100-CUST-NOT-FOUND WHEN OTHER PERFORM 9000-SQL-ERROR END-EVALUATE

EVALUATE比嵌套IF可读性好太多,而且明确区分了"没找到"和"出错了"两种非零情况。SQLCODE = 100 是正常业务分支,不是错误——这点看似基础,但我在代码评审里见过太多次把 100 当异常抛出去,导致正常的"查无此客户"被记成系统故障。

3.2 游标的完整生命周期

需要多行数据时,游标是标准解法。它的动作序列固定为:DECLARE(声明,编译期的事,不产生实际动作)→OPEN(执行查询,定位到第一行之前)→FETCH(逐行取)→CLOSE(释放资源)。

EXEC SQL DECLARE CSR-OVERDUE CURSOR WITH HOLD FOR SELECT CUST_ID, CUST_NAME, BALANCE FROM CUSTOMER WHERE BALANCE < 0 AND BRANCH_CODE = :WS-BRANCH ORDER BY CUST_ID FOR FETCH ONLY END-EXEC. MOVE 0 TO SQLCODE EXEC SQL OPEN CSR-OVERDUE END-EXEC IF SQLCODE NOT = 0 PERFORM 9000-SQL-ERROR END-IF PERFORM UNTIL SQLCODE NOT = 0 EXEC SQL FETCH CSR-OVERDUE INTO :WS-CUST-ID, :WS-CUST-NAME, :WS-BALANCE END-EXEC IF SQLCODE = 0 PERFORM 3000-HANDLE-ROW ELSE IF SQLCODE NOT = 100 PERFORM 9000-SQL-ERROR END-IF END-IF END-PERFORM EXEC SQL CLOSE CSR-OVERDUE END-EXEC

这段代码里有几个细节值得抠。第一,MOVE 0 TO SQLCODE必须在OPEN之前。SQLCODE是全局通信区里的字段,上一次语句留下的值会残留,如果上一次是 100,PERFORM UNTIL SQLCODE NOT = 0直接一次都不进循环,程序"正常结束"但一行数据都没处理——这种 bug 静默得可怕。第二,WITH HOLD让游标跨过COMMIT依然保持位置,这对批处理至关重要,否则每次提交都得重新打开游标从头扫,性能直接崩。第三,FOR FETCH ONLY声明这是只读游标,数据库可以省掉更新锁的维护开销,并发性能会好一截。

DECLARE的位置也有讲究。COBOL 里它一般放在PROCEDURE DIVISION开头、所有可执行语句之前,因为预编译器会把它当声明处理。放在循环里重复声明是不合法的。

3.3 多行 FETCH:把往返次数砍下来

传统逐行FETCH的问题在于往返次数:一万行数据就是一万次调用,哪怕每次只有几十微秒,累计起来也是可观的开销,何况还有网络和引擎调度的固定成本。多行FETCH一次取一批,能把这个成本摊薄十倍以上。

01 WS-BATCH. 05 WS-BATCH-CNT PIC S9(9) COMP VALUE 100. 05 WS-ROW-COUNT PIC S9(9) COMP. 05 WS-BATCH-ROW OCCURS 100 TIMES. 10 WS-B-ID PIC S9(9) COMP. 10 WS-B-AMT PIC S9(11)V9(2) COMP-3. EXEC SQL FETCH NEXT CURSOR CSR-TX FOR :WS-BATCH-CNT ROWS INTO :WS-BATCH-ROW END-EXEC EXEC SQL GET DIAGNOSTICS :WS-ROW-COUNT = DB2_ROW_NUMBER END-EXEC PERFORM VARYING WS-I FROM 1 BY 1 UNTIL WS-I > WS-ROW-COUNT PERFORM 4000-PROCESS-TX END-PERFORM

批次大小的选择是个经验值。100 到 500 行是比较常见的甜点区:太小了摊薄效果不明显,太大了宿主变量数组占用工作存储变多,而且单次调用耗时变长,对在线交易的响应时间不友好。我一般在批处理里用 200,在线场景用 50 甚至更小。

语法上要注意数据库产品的差异:z/OS 平台上是FETCH NEXT CURSOR 游标名 FOR :n ROWS INTO :数组,某些分布式版本写成FETCH 游标名 FOR :n ROWS INTO ...,中间没有NEXT CURSOR。移植代码时这一行经常需要改。

3.4 定位更新与删除

游标取出来的行,如果要在同一事务里就地更新,用WHERE CURRENT OF比"取主键再发 UPDATE"少一次索引查找,而且能保证更新的是同一行(避免中间被别人改掉)。

EXEC SQL DECLARE CSR-UPD CURSOR FOR SELECT STATUS, LAST_UPD FROM ACCOUNT WHERE BRANCH = :WS-BRANCH AND STATUS = 'P' FOR UPDATE OF STATUS END-EXEC. ... EXEC SQL UPDATE ACCOUNT SET STATUS = :WS-NEW-STATUS, LAST_UPD = CURRENT TIMESTAMP WHERE CURRENT OF CSR-UPD END-EXEC

FOR UPDATE OF后面列的列名必须是 SELECT 列表里出现过的,而且这个游标不能带DISTINCT、GROUP BY、UNION、聚合函数——这些都会让行失去可定位性。另外,声明成可更新游标之后,数据库会对取出的行加锁,锁的范围和持有时间都比只读游标大,能用只读就别用可更新。如果只是"取出主键再单独更新",老老实实两步走反而更安全。

4. 动态 SQL:表名都在运行时才知道

4.1 三种形态,别用错

动态 SQL 按复杂度分三档。最简的是EXECUTE IMMEDIATE,把拼接好的 SQL 字符串直接丢给数据库执行,没有结果集、不能带参数标记,适合建表、授权、批量删除这类一次性的 DDL/DML。中档是PREPARE加EXECUTE,先把 SQL 文本准备好,执行时用USING传参,可以反复执行同一条语句,适合批量插入更新。最高档是PREPARE加游标,能处理有结果集的查询。

判断标准很简单:有没有结果集、要不要传参、执行几次。三个问题答完,用哪一档就定了。见过有人所有场景都上动态游标,结果一条简单 UPDATE 也走完整的声明打开流程,代码又长又慢。

4.2 一个能直接抄的动态游标骨架

01 WS-STMT. 05 WS-STMT-TXT PIC X(1000). 05 WS-STMT-LEN PIC S9(4) COMP. 01 WS-HV-BRANCH PIC X(6). MOVE SPACES TO WS-STMT-TXT MOVE 1 TO WS-STMT-LEN STRING 'SELECT CUST_ID, BALANCE FROM ' WS-TABLE-NAME DELIMITED BY SPACE ' WHERE BRANCH = ?' DELIMITED BY SIZE INTO WS-STMT-TXT WITH POINTER WS-STMT-LEN END-STRING SUBTRACT 1 FROM WS-STMT-LEN EXEC SQL PREPARE DYN-STMT FROM :WS-STMT END-EXEC IF SQLCODE NOT = 0 PERFORM 9000-SQL-ERROR END-IF EXEC SQL DECLARE DYN-CSR CURSOR FOR DYN-STMT END-EXEC EXEC SQL OPEN DYN-CSR USING :WS-HV-BRANCH END-EXEC

STRING语句的WITH POINTER用法要特别小心:指针变量必须先置 1,语句结束时指向的是下一个可写位置,所以拿到长度要减 1。这个"减一"我忘了不下五次,结果 SQL 文本末尾多带一个字节的垃圾,PREPARE报语法错误,报的错还特别含糊。

PREPARE的宿主变量如果带长度前缀(VARCHAR 结构),数据库会按长度字段截取;如果是不带长度的PIC X,它会一直读到最后一个非空格字符。这意味着拼接结果中间不能有空格填充的"洞",否则会被当成空格截断。

4.3 拼接 SQL 的边界与校验

动态 SQL 最绕不开的就是拼接。表名、列名、ORDER BY的方向这些都不能用参数标记替代,只能硬拼字符串。所以输入必须走白名单校验:表名只允许出现在预先定义的合法清单里,ORDER BY的字段只允许CUST_ID、BALANCE这几个,方向只允许ASC/DESC。别用"过滤掉特殊字符"这种黑名单思路,字符集的花样永远比你想象的多。

字符串字面量的拼接则要处理引号转义。更省事的做法是尽可能把条件值改成参数标记,只让"结构部分"参与拼接。比如WHERE BRANCH = ?里那个 ? 由OPEN ... USING传进去,就彻底避开了转义问题。

日期字面量还有个专属坑。直接拼'2024-13-01'这种非法日期,PREPARE阶段就会报 -180。所以动态 SQL 里的日期值,最好也走参数标记,让数据库在绑定阶段做类型转换,错误能定位得更清楚。

5. 事务、锁与并发:真正决定系统稳不稳的地方

5.1 工作单元和提交频率

COBOL 程序默认运行在自动提交关闭的模式下,也就是说INSERT/UPDATE执行完并不落盘,要等COMMIT才算数。这个设计给了你控制事务边界的能力,也给了你制造数据不一致的机会。

ADD 1 TO WS-ROW-COUNT IF WS-ROW-COUNT >= 5000 EXEC SQL COMMIT END-EXEC MOVE 0 TO WS-ROW-COUNT END-IF

批处理里常见的做法是每 5000 行提交一次。这个数字背后有三方权衡。提交太频繁:每次提交都要写日志、刷盘,几千次提交下来 I/O 开销相当可观。提交太少:事务持有的锁时间变长,日志文件可能撑爆,一旦中途失败回滚要重做的工作量巨大。5000 是个折中值,取决于你的行处理耗时、日志空间和回滚容忍度。

还有个更隐蔽的点:提交之后游标必须能继续。这就是前面提过的WITH HOLD。没有它,COMMIT会关闭游标,你得重新OPEN并跳过已处理的行——那个逻辑写起来极其丑陋而且容易出错。

ROLLBACK的时机同样重要。错误处理段里回滚之后,一定要把相关的计数器、累加变量一起复位,否则下一轮批处理会带着脏数据继续跑。我见过一个对账程序,回滚后没清累加器,导致最终汇总金额多算了一遍失败批次。

5.2 隔离级别怎么选

隔离级别脏读不可重复读幻读锁开销适用场景
UR(未提交读)可能可能可能最低报表统计、日志查询,容忍脏数据
CS(游标稳定性)否可能可能低OLTP 默认,绝大多数交易
RS(读稳定性)否否可能中需要同一事务内多次读一致
RR(可重复读)否否否最高对账、结算,绝不能看到新行

CS 是绝大多数场景的正确答案。它的语义是:游标停在某一行时,那一行加共享锁;游标移走,锁释放。所以它防脏读,但允许同一事务内两次查询看到不同结果。对主机 OLTP 来说这个平衡最合适。

选 RR 的时候要特别小心,因为它会对整个访问范围加锁,一个没走索引的查询可能导致表级锁,把整张表锁死。我在生产上见过一次:报表程序用了 RR,查询条件没走索引,直接把一张千万级的表锁了二十多分钟,交易全线排队。后来改成 CS 加"取快照到临时表"的方案,问题才解决。

隔离级别可以在三个层面指定:绑定时的 PACKAGE 默认值、语句级的ISOLATION子句、以及某些平台上的运行时覆盖。推荐在绑定阶段定默认值,个别语句特殊处理,这样绝大部分代码不用关心这事。

5.3 死锁与超时:必须写重试

并发环境下,两个事务互相等对方持有的锁,就是死锁。数据库会检测到并牺牲其中一个,报 -911,同时把该事务整个回滚。另一种情况是等锁超过阈值,报 -904 或 -911,具体看平台。

关键认知:这两种错误不是 bug,是并发系统的正常现象。程序必须能处理。标准套路是捕获、回滚、等一小会儿、重试,重试次数设上限(比如 3 次),超过则记日志告警。

01 WS-RETRY-CNT PIC S9(4) COMP VALUE 0. ... PERFORM UNTIL WS-RETRY-CNT > 3 PERFORM 5000-TRY-TRANSACTION IF SQLCODE = -911 OR SQLCODE = -904 EXEC SQL ROLLBACK END-EXEC ADD 1 TO WS-RETRY-CNT PERFORM 9500-BACKOFF-WAIT ELSE EXIT PERFORM END-IF END-PERFORM

重试前的等待很重要,立刻重试大概率还会撞上同一把锁。等待时长可以固定(比如 100 毫秒),也可以带点随机抖动,避免多个事务同时醒来又撞一起。

减少死锁的根本手段是统一加锁顺序。如果事务 A 先更新表 T1 再更新 T2,事务 B 也必须是这个顺序,绝不能一个正着一个反着。这个规则听起来简单,但在几十个程序共用同一批表的环境里,严格执行需要纪律。

5.4 CICS 环境下的同步点

在线交易环境里,事务边界由联机事务管理器接管。一个典型的伪会话交易模式是:接收屏幕输入 → 处理业务 → 发同步点 → 返回屏幕。同步点命令是EXEC CICS SYNCPOINT,它会让数据库的提交和事务管理器协调,保证一致性。

失败时发EXEC CICS SYNCPOINT ROLLBACK。这里有个铁律:绝对不要在等待终端输入的过程中持有数据库锁。伪会话模式就是为了解决这个问题——交易在发完屏幕之后结束,锁全部释放,用户下次按键才启动新交易。我见过把整个交互流程包在一个事务里的老代码,用户去泡了杯咖啡,表就被锁了十分钟。

6. 批处理与在线交易:两套完全不同的打法

6.1 批处理:把吞吐量榨出来

批处理的目标是"单位时间处理尽可能多的行",优化思路和在线交易完全相反。

第一,游标一律声明FOR FETCH ONLY,去掉更新锁的开销。第二,用多行 FETCH,把往返次数压下去。第三,大批量插入优先考虑批量加载工具,而不是逐行 INSERT。逐行插入每行都要走一遍引擎的插入路径、维护索引、写日志;批量加载可以绕过部分检查、延迟索引维护,吞吐能差出几倍。代价是加载工具通常要求数据先落到文件,且对表状态有要求(比如需要先置为加载中)。

第四,注意索引的维护成本。一张表上五个索引,每插一行就要更新五棵 B 树。如果批处理是"清空重灌"模式,可以先删索引、灌完再建,总耗时往往更短。

第五,排序区和临时空间。批处理里的ORDER BY、GROUP BY、DISTINCT都可能落盘排序,排序区不够会溢出到临时表空间,速度断崖式下跌。监控排序溢出的次数,是批处理调优的常规动作。

6.2 在线交易:把响应时间压住

在线交易的约束是"每次调用的耗时必须可预期",通常要求几十毫秒以内。所以:

绑定参数尽量固化访问路径。默认的绑定会按通用选择率估算,遇到倾斜数据可能选错。对这种语句加REOPT(VARS)让它在首次执行时按实际值优化。反过来,如果语句执行极其频繁且变量值分布均匀,就别开这个选项,白白多花 CPU。

结果集一定要限制行数。列表查询不加FETCH FIRST n ROWS ONLY,用户一翻页就要等好几秒,还占着数据库资源。加个上限,超过就提示"结果过多请细化条件"。

游标用完立刻关闭。CICS 交易结束时如果游标还开着,可能持有锁直到事务结束。养成CLOSE写在PERFORM后面的习惯。

6.3 VSAM 和数据库混用的场景

老系统里两者共存很常见。典型分工是:数据库存主数据和需要事务保证的业务数据,VSAM 存高频访问的代码表、流水号、日志。

代码表用 VSAM 的理由是查找极快且不占数据库锁。但代价是数据一致性要靠自己维护——数据库里的代码改了,VSAM 文件得同步重建。我建议给这类文件加一个版本号字段,程序启动时校验版本,不匹配就报错退出,比默默用旧数据强得多。

流水号用 VSAM 也有类似问题。顺序文件追加一行拿个号,看起来简单,但并发环境下多个任务同时追加就会冲突。要么靠文件独占,要么改用数据库序列——后者在并发和持久性上都更靠谱,我现在的默认选择是数据库序列。

对账场景则是另一种混用:数据库出汇总数,VSAM 出明细数,两边比对。这种程序的关键是明确谁是准的。我的习惯是以数据库为准,VSAM 差异只做告警不做自动修正,因为自动修正一旦逻辑有误,破坏面比不修还大。

7. 常见问题与排查手册

7.1 SQLCODE 速查表

SQLCODE含义常见诱因处置
0成功—继续,但要看 SQLWARN0
100无数据查询无行、游标到底走 NOT FOUND 分支,不是错误
-180日期时间格式非法拼了 '2024-02-30' 这类值校验输入或改参数标记
-302数值转换错误宿主变量 PIC 与列类型不匹配核对第 2 章映射表
-305NULL 值未指定指示变量列可空但没传指示变量补上:HV :HV-IND
-331字符串截断目标变量太短加长或先取长度再截
-530外键约束冲突插入顺序错、父行不存在调整处理顺序
-545检查约束冲突值不满足 CHECK 条件看约束定义
-803唯一键重复主键冲突、重复提交先查后插,或改用合并语义
-811SELECT INTO 返回多行谓词不足加条件或改游标
-805包或计划不存在绑定版本未发布重新绑定并核对 COLLECTION
-818时间戳不一致预编译与绑定产物不匹配重新预编译并重绑
-904资源不可用表空间停用、锁超时看 SQLERRD(6) 的原因码
-911死锁或超时被回滚并发冲突回滚后按 5.3 重试
-922授权不足缺 SELECT/UPDATE 权限补授权
-925程序未绑定到计划计划里没这个包检查 PKLIST

-904和-911光看代码不够,得看诊断信息。SQLERRD(6)里放的是资源原因码,比如锁超时、表空间不可用、日志满,各对应不同的数字。把这些原因码记下来做成对照表,排查效率会高很多。

7.2 绑定类问题:最耗时间的一类

-805(包找不到)和-818(时间戳不一致)是发布环节的两大常客。前者通常是 COLLECTION 名字拼错,或者新版本绑到了别的集合里,程序运行时还指向旧的。后者是预编译和绑定不是同一批产物——比如 DBRM 更新了但绑定用的是旧的,或者绑定做了但加载模块没重新链接。

排查顺序建议这样走:先确认 DBRM 的生成时间晚于源码修改时间;再确认绑定作业的返回码是 0 且 SYSPRINT 里没有警告;然后确认程序的 PLAN 或者 COLLECTION 指向正确;最后确认加载模块确实被替换了。这四个环节任何一环断了,症状都是"程序跑起来用旧计划"或者"直接报 -805"。

提示:把预编译、编译、链接、绑定做成一个原子化的流水线步骤,所有产物用同一个版本号打标。这样能彻底消灭"改了一半"的状态。

7.3 性能问题的五步定位法

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询