前段时间在一台 Linux 服务器上做 DM8 单机实例部署,顺手把表空间、用户、权限、基础表这些对象创建流程都走了一遍,最后用匿名块批量造数时撞上了一个数组越界异常。整个过程里让我花时间最多的地方,反而不是官方文档写得很全的部署步骤,而是disable_jit这个参数和 JAVA 内嵌函数之间的关系,以及 DM 管理工具自身的 JAVA 运行环境问题。这些内容分散在文档的不同章节里,不实际跑一遍很难把它们串起来。
这篇就当一份实操问题记录,写给正在做 DM8 初始化部署、准备用 DM 管理工具建基础对象,或者想了解匿名块异常处理写法的同行。我尽量把排查路径、SQL 示例和当时踩坑的原因都交代清楚,方便你直接照着复现或避开。
1. 单机实例部署中官方文档容易略过的 disable_jit
1.1 先交代部署环境和基本流程
我这次的部署环境是 Linux x86_64,8C16G 的虚拟机,DM8 开发版,系统账号用的dmdba。整体流程和官方《DM8 单机部署》文档基本一致:解压安装包、用dminit初始化实例、注册系统服务、启动实例、最后用disql验证连接。
初始化实例时我用的是下面这组参数:
./dminit PATH=/dm/data PAGE_SIZE=8 EXTENT_SIZE=16 CASE_SENSITIVE=Y CHARSET=1 PORT_NUM=5236这里提醒一下,PAGE_SIZE、CASE_SENSITIVE、CHARSET这几个参数一旦初始化完成,后面基本改不动或者要付出很大代价去改。CASE_SENSITIVE=Y表示数据库对象名区分大小写,CHARSET=1表示 UTF-8 字符集。很多从 Oracle 迁过来的同事习惯性认为表名不区分大小写,恰恰会在这一步埋下隐患。所以部署阶段就确认好这些参数,比事后查半天文档要省事得多。
服务注册和启动就不多说了,按文档执行DmServiceDMSERVER相关的注册脚本即可。启动后先用最简单的方式确认实例状态:
ps -ef | grep dmserver ss -lnt | grep 5236这两条命令能快速确认进程是否存活、端口是否在监听。接着用disql SYSDBA/SYSDBA@localhost:5236登录,能顺利进入 SQL 提示符,说明单机实例的基本骨架已经搭起来了。
1.2 disable_jit 是怎么和 JAVA 内嵌函数扯上关系的
部署完成后我开始做功能验证,想建一个测试用的存储过程,结果在 disql 里执行到涉及 JAVA 静态方法调用的语句时,直接报错,提示内容大概是“JAVA 环境不可用”或“内嵌 JAVA 函数执行失败”。我第一反应是系统里 JDK 没装好,可这台服务器明明已经装了 JDK,并且java -version完全正常。
后来对照官方文档逐项排查,才发现问题出在dm.ini里的disable_jit参数。文档里对它的描述很简短,大致意思是“是否禁用即时编译,启用 JIT 后可提升部分复杂表达式的执行效率”。它被归在系统参数那一类,部署章节里基本不会提到。但实际环境下,JIT 开启后会导致服务端内嵌 JAVA 函数的执行路径出问题,具体表现就是 SQL 里调用 JAVA 内嵌函数时功能不可用或直接异常。
处理方式很直接,把这个参数改为 1,也就是禁用 JIT:
ALTER SYSTEM SET 'disable_jit'=1;需要注意,不同版本对参数是否动态生效的定义不太一样,稳妥做法是在dm.ini配置文件中直接修改该参数,然后重启实例。我这边就是改完配置文件后重启,再执行之前失败的 SQL,功能就恢复正常了。
按官方文档的说法,这个参数的本意是性能优化,但它在某些版本下的实际影响已经超出了性能范畴,直接关系到 JAVA 内嵌函数的可用性。这也是我为什么单独把这个问题拎出来讲:文档把参数归类在“性能”里,但你在功能验证阶段就可能会碰到它。
1.3 部署完成后立刻验证的三件事
经过这次教训,我把部署完成后的验证清单固定成了三件事,每次装完 DM8 都会先跑一遍:
- 用
disql正常登录并执行SELECT * FROM V$VERSION;,确认版本符合预期。 - 调用一个简单内置包确认 JAVA 相关链路正常,比如
SELECT DBMS_RANDOM.VALUE FROM DUAL;。如果这一步抛出异常,优先回头看disable_jit。 - 检查
dm.ini里的CASE_SENSITIVE、CHARSET、disable_jit是否和初始化设计一致。
这三件事看着简单,但都能在五分钟内帮你定位部署阶段最典型的几类问题:实例没起来、参数配置不对、JAVA 链路异常。
2. DM 管理工具连接与 JAVA 运行环境的连带问题
2.1 管理工具连不上实例时的排查顺序
实例部署好了,接下来要用 DM 管理工具做图形化操作。我第一次连的时候也踩了坑,工具一直提示连接失败。很多人的第一反应是查网络、查防火墙,但实际上对于本机或同网段访问,网络通常不是首要嫌疑。
我的排查顺序是这样的:先确认实例服务和端口没问题,也就是前面提到的ps和ss两条命令;再确认账号口令是否正确;然后看数据库服务端字符集和客户端字符集是否匹配;最后才看网络和防火墙。
DM 管理工具的连接配置其实很简单,主机名、端口、用户名、口令,端口默认 5236。如果连接时报“网络通信失败”之外的其他错误,比如登录被拒绝、口令错误,那多半是账号权限或密码策略问题,这类错误信息已经写得很明确了,照着改就行。真正容易忽略的是服务端和客户端的字符集不一致导致的乱码或连接异常,这个在创建实例时就应该提前想好。
2.2 管理工具自身也是 JAVA 应用,资源不足会连坐
这里要特别说一个容易和第一节问题混淆的地方:DM 管理工具是 JAVA 写的图形客户端,它自身需要一套可用的 JAVA 运行环境。如果服务器或本机的默认 JDK 版本比较乱,工具可能启动不了、界面空白,甚至连上后操作一会儿就卡死。
我当初把管理工具启动不了的问题误判成数据库实例问题,排查了半天。后来发现是系统默认的 JAVA 版本和管理工具自带的 JRE 不一致导致。处理方式有两种:一是直接用安装包自带的 JRE,二是在管理工具启动脚本里显式指定 JAVA 路径。
如果你的管理工具在操作大对象或跑复杂 SQL 时卡顿,也可以适当调大启动脚本里的-Xmx参数,给 JVM 分配更多内存。这里和服务器端disable_jit参数是两条独立的 JAVA 链路,服务器端管的是数据库进程内嵌 JAVA 虚拟机,管理工具管的是客户端进程自己的 JVM,排查时不要混在一起。
2.3 我的习惯:管理工具建对象,disql 跑脚本
在实际操作中,我通常把 DM 管理工具和 disql 分开用:管理工具适合单对象创建、查看表结构、图形化授权、查看执行计划;disql 适合跑批量脚本、反复执行匿名块、验证异常处理逻辑。
原因很简单,管理工具虽然方便,但在处理大量脚本时,复制粘贴和错误定位都不如命令行顺手。特别是匿名块这类带异常处理的过程性代码,在 disql 里跑能看到更完整的错误输出,出问题时也更容易复现。所以我接下来的基础对象创建,虽然都在管理工具里操作,但关键 SQL 我都是先在文本编辑器里写好后,再粘贴到工具或 disql 里执行。
3. 基础对象创建的标准顺序:表空间先行、用户权限随后
3.1 为什么先建表空间而不是先建用户
达梦里创建用户时需要指定默认表空间,所以建表空间必须先于用户创建。这个顺序如果搞反了,后面再调整用户默认表空间会麻烦很多。
我创建了一个独立表空间MYTBS,专门给业务用户zm使用:
CREATE TABLESPACE MYTBS DATAFILE '/dm/data/DMSERVER/MYTBS01.DBF' SIZE 512 AUTOEXTEND ON NEXT 64 MAXSIZE 2048;这里SIZE 512表示初始大小 512MB,AUTOEXTEND ON NEXT 64表示每次自动扩展 64MB,MAXSIZE 2048表示上限 2048MB。如果你不建独立表空间,业务对象会默认落在 MAIN 表空间,和系统对象混在一起。一旦后续要迁移数据、清理表空间,就会变得非常被动。
表空间建好之后,建议用管理工具左侧树形菜单刷新一下,确认MYTBS状态正常。如果数据文件路径不存在,创建会直接报错,这种情况优先检查目录权限,确保dmdba用户对目标目录有写权限。
3.2 创建业务用户 zm 并做最小授权
表空间就绪后,创建用户:
CREATE USER ZM IDENTIFIED BY "ZhongWenPwd@2024" DEFAULT TABLESPACE MYTBS;这里有一个细节:DM 在CASE_SENSITIVE=Y的情况下,用户名和口令的大小写都是敏感的。如果你把口令用双引号包起来,口令会严格按照大小写存储;如果不加双引号,口令会被统一转为大写。很多连接失败的问题,就是口令大小写没有对上。
创建用户之后做权限授予,我的原则是最小授权,绝不直接给 DBA 角色。这次我只是用zm做基础对象创建和造数测试,所以给了以下权限:
GRANT RESOURCE TO ZM; GRANT CREATE TABLE TO ZM; GRANT CREATE VIEW TO ZM; GRANT CREATE PROCEDURE TO ZM; GRANT CREATE SEQUENCE TO ZM;RESOURCE角色在达梦里已经包含了建表、建索引等基础权限,额外再显式加CREATE VIEW、CREATE PROCEDURE是保证后续测试时不会因为缺权限中断。生产环境建议在业务用户上只保留刚够用的权限,能不动RESOURCE就不要动,因为它的边界常常比你想象的大。
3.3 建一张用于后续验证的基础表 ZS_RESULT_TEST
有了用户和权限,接下来创建测试表。我建的是ZM.ZS_RESULT_TEST,专门用于后面的批量造数和异常处理验证:
CREATE TABLE ZM.ZS_RESULT_TEST( ID INT PRIMARY KEY, COL_NAME VARCHAR(100), COL_SCORE NUMBER(6,2), CREATE_TIME TIMESTAMP DEFAULT SYSDATE );字段设计很简单:ID 主键,COL_NAME 存随机字符串,COL_SCORE 存分数,CREATE_TIME 存插入时间。这里我特意让 CREATE_TIME 有默认值,方便在造数时少写一个字段。
建表后记得查一下表的归属和表空间:
SELECT OWNER, TABLE_NAME, TABLESPACE_NAME FROM USER_TABLES WHERE TABLE_NAME = 'ZS_RESULT_TEST';如果当时连接的用户不是 ZM,看到的可能就是空结果。这个不需要惊讶,是权限视角的问题。表默认会创建在用户默认表空间 MYTBS 上,如果你希望表和索引分开存放,可以在建表语句里加上TABLESPACE MYTBS和INDEX TABLESPACE MYTBS_IDX之类的指定,生产环境通常会把表和索引放在不同物理文件或者不同磁盘上以降低 IO 竞争。
到这里,基础对象创建的流程就闭环了:表空间 -> 用户 -> 权限 -> 表。每一步都依赖前一步的结果,顺序错了就会遇到“权限不足”或“默认表空间不存在”这类问题。
4. For 循环与关联数组批量造数:Good 案例复盘
4.1 造数场景与常见写法的差异
基础表建好后,我需要往里插入大量测试数据,大概十万行左右。字段要求带随机字符串、随机分数和时间戳,用来做后续查询性能验证。
很多人第一反应是写一个简单的循环逐行 INSERT,比如FOR I IN 1..100000 LOOP INSERT ...; END LOOP;。但如果你在循环里不做任何提交,最后统一 COMMIT,这种方式本身没问题,问题在于它不够灵活,而且边界条件写错时连异常都来不及处理。
我当时用的是 FOR 循环加关联数组的写法,先把主键 ID 放进一个数组里,再遍历数组执行插入:
DECLARE TYPE T_ID_LIST IS TABLE OF INT INDEX BY INT; V_IDS T_ID_LIST; V_TOTAL INT := 100000; BEGIN FOR I IN 1..V_TOTAL LOOP V_IDS(I) := I; END LOOP; FOR IND IN 1..V_IDS.COUNT LOOP INSERT INTO ZM.ZS_RESULT_TEST(ID, COL_NAME, COL_SCORE) VALUES(V_IDS(IND), DBMS_RANDOM.STRING('U', 10), ROUND(DBMS_RANDOM.VALUE(60, 100), 2)); END LOOP; COMMIT; END; /这段代码里DBMS_RANDOM.STRING('U', 10)生成 10 位大写随机字符串,DBMS_RANDOM.VALUE(60, 100)生成 60 到 100 之间的随机数,外层再用ROUND(..., 2)保留两位小数。
4.2 为什么这是 Good 案例
这个写法好在三个地方。第一,循环上界用的是V_IDS.COUNT,不是硬编码的 100000。数组元素是前面一个循环动态填充的,这样即使总数变了,遍历逻辑也不需要改。第二,先把所有 ID 准备好再统一插入,数据源可控,适合后面追加其他随机字段。第三,整个匿名块内的事务边界很清晰,要么全部提交,要么全部回滚,不会出现插了一半数据再手工清理的尴尬局面。
当然,如果你只是为了快速造数,不管字段内容多复杂,SQL 层面还有更快的方法,比如:
INSERT INTO ZM.ZS_RESULT_TEST(ID, COL_NAME, COL_SCORE) SELECT LEVEL, DBMS_RANDOM.STRING('U', 10), ROUND(DBMS_RANDOM.VALUE(60, 100), 2) FROM DUAL CONNECT BY LEVEL <= 100000;这种写法比 FOR 循环要快不少,因为它在一次 SQL 语句里完成所有数据生成。但它的短板也明显:一旦你要对每一条数据做不同的逻辑处理,或者根据外部变量决定是否插入,纯 SQL 就力不从心了。FOR 循环加关联数组的定位是“可编程的批量造数”,适合那些数据生成规则复杂的场景。
4.3 这个写法最常见的隐患:循环边界越界
回到 FOR 循环写法的隐患,最典型的就是数组下标越界。达梦的关联数组下标默认从 1 开始,这一点和 Oracle 一致。如果你下意识写了FOR IND IN 0..V_IDS.COUNT,第一次循环就会访问V_IDS(0),而数组里根本没有这个下标的元素,直接触发数组上界越界异常。
还有一种更隐蔽的情况:如果V_IDS是空数组,V_IDS.COUNT等于 0,此时FOR IND IN 1..0这种递减区间在达梦里同样是非法操作。这些边界问题,在没有异常处理块包裹时,整个匿名块会直接中断,之前已经执行成功的 INSERT 全部回滚。
这也是为什么我每次写匿名块之前都会先提醒自己:循环边界和异常处理,至少要有一个是靠谱的。最稳的做法是两个都做,也就是接下来要讲的内容。
5. 越界异常 NUO_ARRAY_EXCEPTION 的完整排查与异常处理块改造
5.1 先把错误现场完整捞出来
为了验证越界问题,我故意写了一个从 0 开始的循环:
DECLARE TYPE T_ID_LIST IS TABLE OF INT INDEX BY INT; V_IDS T_ID_LIST; V_IND INT; BEGIN FOR I IN 1..100 LOOP V_IDS(I) := I; END LOOP; FOR V_IND IN 0..V_IDS.COUNT LOOP NULL; END LOOP; END; /执行后,disql 给出的报错信息包含两个关键内容:错误码和错误描述。我这边看到的是数组上界越界 NUO_ARRAY_EXCEPTION,对应的错误码为-4029。这个环节最重要的是先确认错误码,因为后面的异常处理块要根据错误码来做精确捕获。
定位时我先把循环体改成NULL,最小化出错范围,确认问题不在 INSERT 语句本身,而是循环到达V_IDS(0)时才触发。接着把起点改成 1,异常立刻消失。这就能确认是下标越界,而不是表结构或权限问题。
5.2 用 DECLARE...BEGIN...EXCEPTION...END 改造匿名块
达梦的 PLSQL 匿名块和 Oracle 类似,整体结构是:
DECLARE 变量声明、异常声明 BEGIN 业务逻辑 EXCEPTION 异常处理逻辑 END;或:
DECLARE ... BEGIN ... EXCEPTION ... END; /EXCEPTION段就是专门承接异常处理的 SECTION。异常发生时,控制权会跳到这一段。我先用最通用的WHEN OTHERS把错误捞出来,写进日志表:
先建一张错误日志表:
CREATE TABLE ZM.ERR_LOG( ID BIGINT IDENTITY(1,1) PRIMARY KEY, ERR_CODE INT, ERR_MSG VARCHAR(500), OCCUR_TIME TIMESTAMP );然后是带异常处理的匿名块:
DECLARE TYPE T_ID_LIST IS TABLE OF INT INDEX BY INT; V_IDS T_ID_LIST; V_IND INT; V_ERR_CODE INT; V_ERR_MSG VARCHAR(500); BEGIN FOR I IN 1..10 LOOP V_IDS(I) := I; END LOOP; FOR V_IND IN 0..V_IDS.COUNT LOOP INSERT INTO ZM.ZS_RESULT_TEST(ID, COL_NAME) VALUES(V_IDS(V_IND), 'TEST_' || V_IND); END LOOP; COMMIT; EXCEPTION WHEN OTHERS THEN V_ERR_CODE := SQLCODE; V_ERR_MSG := SQLERRM; INSERT INTO ZM.ERR_LOG(ERR_CODE, ERR_MSG, OCCUR_TIME) VALUES(V_ERR_CODE, V_ERR_MSG, SYSDATE); ROLLBACK; END; /执行后虽然 INSERT 没有成功,但ERR_LOG表里会多一条记录,把-4029这个错误码和对应的错误消息完整记录下来。这个习惯非常重要,尤其是在匿名块比较长、业务逻辑复杂时,没有日志几乎等于瞎猜。
5.3 PRAGMA EXCEPTION_INIT 绑定具名异常
如果你想针对某个特定错误码做不同的处理,可以在声明段用PRAGMA EXCEPTION_INIT把错误码绑定到一个具名异常上:
DECLARE NUO_ARRAY_EXCEPTION EXCEPTION; PRAGMA EXCEPTION_INIT(NUO_ARRAY_EXCEPTION, -4029); TYPE T_ID_LIST IS TABLE OF INT INDEX BY INT; V_IDS T_ID_LIST; V_IND INT; BEGIN FOR I IN 1..10 LOOP V_IDS(I) := I; END LOOP; FOR V_IND IN 0..V_IDS.COUNT LOOP INSERT INTO ZM.ZS_RESULT_TEST(ID, COL_NAME) VALUES(V_IDS(V_IND), 'TEST_' || V_IND); END LOOP; COMMIT; EXCEPTION WHEN NUO_ARRAY_EXCEPTION THEN INSERT INTO ZM.ERR_LOG(ERR_CODE, ERR_MSG, OCCUR_TIME) VALUES(-4029, '数组下标越界,请检查循环起点和边界', SYSDATE); ROLLBACK; WHEN OTHERS THEN INSERT INTO ZM.ERR_LOG(ERR_CODE, ERR_MSG, OCCUR_TIME) VALUES(SQLCODE, SQLERRM, SYSDATE); ROLLBACK; END; /这里PRAGMA EXCEPTION_INIT(NUO_ARRAY_EXCEPTION, -4029)的含义是:把错误码-4029绑定到自定义异常名NUO_ARRAY_EXCEPTION上。之后在EXCEPTION段就可以用WHEN NUO_ARRAY_EXCEPTION精确捕获这个错误,而不会被WHEN OTHERS抢先接走。
要注意,PRAGMA EXCEPTION_INIT必须放在声明段,并且要在变量声明之后、BEGIN之前。绑定时错误码要和实际报错一致,不同小版本下错误码可能有差异,保险做法是先用WHEN OTHERS配合SQLCODE查一次实际错误码,再决定绑定值。
如果你不想用具名异常,也可以直接在WHEN OTHERS里判断SQLCODE = -4029后做分支处理。两种方式效果类似,区别在于具名异常在代码可读性上更好,适合同一段逻辑里对不同错误做差异化响应的场景。
5.4 实际处理策略:继续执行还是整体回滚
最后一个关键问题:异常发生后,业务到底是继续还是中断。这取决于场景。
如果是一次性造数任务,推荐在EXCEPTION段记录日志后整体回滚,修正代码再跑。因为测试数据要求完整性,部分插入会导致后续统计结果失真。
如果你在做批量数据加工,希望跳过坏数据处理后续数据,就不要在顶层用大而全的异常块,而是把异常处理下沉到循环内部。在循环体里再套一个内层 BEGIN...EXCEPTION 块:
FOR V_IND IN 1..V_COUNT LOOP BEGIN INSERT INTO ZM.ZS_RESULT_TEST(ID, COL_NAME) VALUES(V_IDS(V_IND), 'TEST_' || V_IND); EXCEPTION WHEN OTHERS THEN INSERT INTO ZM.ERR_LOG(ERR_CODE, ERR_MSG, OCCUR_TIME) VALUES(SQLCODE, SQLERRM, SYSDATE); CONTINUE; END; END LOOP;内层捕获异常后CONTINUE,循环会继续跑,但外层不会感知到这条失败记录。如果你希望内层失败只记录、最终整体仍然回滚,可以在内层异常处理中不做提交,并在循环结束后判断日志表是否有新增记录,再决定是否 ROLLBACK。这算是我实际项目中用得比较多的处理思路,逻辑清晰,也方便事后核对失败数据。
状态比较理想的结构是:一个可重复执行的匿名块,内层负责单条数据的失败跳过和数据记录,外层负责事务边界和最终提交决策。这样无论造数还是批量加工,都能在出问题时快速定位到具体数据,而不是整体黑屏。
最后分享一个实际体会:这一轮操作下来,我最大的收获是把部署检查清单从“能启动就算成功”改成了“启动后必须验证关键功能链路”。disable_jit这种藏在性能参数里的开关,不实际执行 JAVA 内嵌函数调用根本发现不了问题;匿名块也尽量从一开始就包好异常处理,日志表提前建好,不要等报错后再补。造数这种看起来简单的需求,往往就是循环边界和异常处理这种小地方最容易翻车。