去年做一个老业务系统向达梦数据库迁移的活儿,数据搬迁倒是顺利,真正头大的是业务逻辑层。那套系统里沉淀了大量用Java写的字符串解析、编码转换和加密校验方法,全部封装在应用代码里,数据库侧完全无法调用。如果全部改写成存储过程,工作量爆炸,而且很多逻辑本来就是为了复用Java生态库才写的,硬翻译成SQL不现实。后来我把达梦数据库的JAVA外部函数完整研究了一遍,用这种方式把Java方法直接注册成数据库函数,在SQL里像调普通函数一样调用,算是把这块硬骨头啃下来了。这篇文章就专门聊这个功能:它解决什么问题、怎么注册、类型怎么映射、实测中会踩哪些坑。
这篇内容适合正在做达梦适配、或者是第一次接触“数据库里调Java方法”这种玩意的同学。我会从环境准备讲到一个完整案例,再把我实际踩过的坑和排查思路都铺开,尽量让照着操作就能跑通。
1. 达梦JAVA外部函数到底解决了什么问题
先说清楚一个概念:外部函数,是相对于数据库内置函数而言的。内置函数比如LENGTH、SUBSTR、TO_DATE这些,是数据库自己实现的,直接用就行。外部函数则允许开发者自己提供实现逻辑,达梦负责在SQL执行时把调用转发给一段外部代码。而达梦的JAVA外部函数,就是用Java来实现这段外部代码。
1.1 SQL表达力不够时的“逃生通道”
很多业务逻辑适合放在应用层用Java写,但真正执行的时候,又希望在数据库侧直接以函数形式调用。典型场景有三个:
- 复杂的字符串处理:比如需要按自定义规则切分报文、从非结构化文本里提取字段,SQL写起来非常痛苦,Java里一个循环加正则就搞定。
- 特定的算法封装:比如加密解密、校验码生成、数据脱敏规则,这类逻辑往往已经在Java组件里实现了,不想重复造轮子。
- 调用Java生态的能力:比如JSON解析、XML解析、模板渲染,数据库内置函数没有,但Java开源库很成熟。
JAVA外部函数的价值,就是把“Java里已经写好的静态方法”直接暴露成SQL可以调用的函数,让业务规则只维护一份代码。
1.2 能力边界先说清楚
在动手之前,有几个限制必须知道:
- 达梦的JAVA外部函数本质上调用的是Java静态方法,这意味着你不能把对象的实例状态带进数据库调用。每次SQL触发调用,都相当于在数据库的JVM里执行一次静态方法。
- 方法参数和返回值必须在SQL类型和Java类型之间做映射,类型对不上,函数创建或者调用就会报错。
- Java代码的执行发生在数据库服务端进程内,所以不是“数据库去远程调用你的应用服务”,而是代码被加载到数据库的JVM里运行。
我一开始也理解偏了,以为类似通过数据库链路调远程服务,后来实测才发现它就是本地JVM执行。这个定位搞清楚之后,后面很多设计上的选择就顺理成章了。
2. 动手前要准备的三件事
这个功能不是装上达梦就能直接用,前置条件有三块,缺一个都可能在最后一步翻车。
2.1 JDK版本与编译环境
因为Java类需要在数据库的JVM里运行,所以数据库服务端必然内嵌或绑定了JVM。我建议在开发机上装一个和数据库端JVM大版本接近的JDK,比如达梦服务端如果是1.8的JVM,那编译时也尽量用1.8的编译器,避免出现“Class file version不兼容”这种问题。
一个实用的做法是用javac -source 1.8 -target 1.8来编译,保证字节码版本不要太高。我有一次用JDK 17编译的class文件往老版本达梦上加载,直接提示版本不支持,排查了半天才发现是编译版本的问题。另外,如果没有特殊需求,所有类都别用package声明,全部放默认包,后面注册时会省去很多包名相关的麻烦。
2.2 确认数据库安装了JAVA相关组件
达梦的安装选项里有JAVA支持相关的组件,如果在安装时没有勾选,后面执行JAVA相关语句会提示找不到相应的类或者JVM没有初始化。遇到这种情况,一般要么重新补装组件,要么找实施人员确认一下当前实例的JAVA支持状态。
判断方法也很简单:在达梦的SQL工具里执行一个最简单的JAVA类加载语句,如果能成功,说明基础环境是通的;如果直接就报JVM相关的错误,就别继续往下写了,先解决环境问题。这一步不要跳,我见过不少同事上来就粘贴一堆Java代码,结果环境压根没装,浪费了大把时间。
2.3 准备一张测试表
虽然外部函数可以直接在SELECT里调用,但为了后续做真实场景验证,我建议先建一张简单的测试表,模拟业务数据:
CREATE TABLE TEST_ROWS ( ID INT, RAW_TEXT VARCHAR(500) ); INSERT INTO TEST_ROWS VALUES (1, 'A#B#C#D'); INSERT INTO TEST_ROWS VALUES (2, 'hello world'); INSERT INTO TEST_ROWS VALUES (3, '2024-01-15;2024-03-20');后面所有案例都会围绕这张表展开,既方便验证函数是否正确,也方便观察函数在SQL语句里的行为。
3. 写一个能直接用的Java类,并正确编译
下面进入正题。我用一个相对有代表性的例子来演示:统计输入字符串按指定分隔符拆分后的元素个数。这个功能用SQL写很繁琐,但Java里几行代码就结束。
3.1 编写示例Java类
public class StringSplitUtil { public static int countSplits(String raw, String delimiter) { if (raw == null || delimiter == null) { return 0; } if (delimiter.isEmpty()) { return 0; } String[] parts = raw.split(java.util.regex.Pattern.quote(delimiter), -1); return parts.length; } public static String trimAndUpper(String raw) { if (raw == null) { return null; } return raw.trim().toUpperCase(); } }这个类里我写了两个静态方法,一个统计拆分后的元素数量,另一个做去空格加转大写。方法都不复杂,但足够用来演示JAVA外部函数的完整注册流程。
有几个编译细节需要注意:
- 方法必须是
public static,如果不是静态方法,注册后调用阶段大概率报错。 - 用到正则表达式时,
Pattern.quote(delimiter)很重要,否则用户传入的分隔符如果带特殊字符,split行为会和预期不一致。 trimAndUpper里对null做了判断,返回null而不是空字符串,这一点在数据库里很有讲究。因为数据库的NULL是一个特殊状态,如果你方法内部悄悄把null转成空串,后面SQL里用IS NULL判断会完全对不上。
3.2 编译并验证字节码
在开发机上把文件保存为StringSplitUtil.java,然后执行:
javac -source 1.8 -target 1.8 StringSplitUtil.java编译成功后同目录下会出现StringSplitUtil.class。此时可以用一个简单的Java程序本地验证方法行为:
java -cp . StringSplitUtil不过这个类没有main方法,所以直接用是跑不起来的。我通常写一个临时测试类来调用静态方法,确认逻辑正确后再进入数据库注册环节。这一步看似多余,但能排除“方法逻辑本身有bug”和“注册配置有问题”两个变量,排查问题时可以少走弯路。
4. 把Java类注册到达梦,两种路线实测对比
Java类编译成class之后,下一步就是要让达梦认识它。我实际用过的路线有两条,一条是直接装载.class字节码,一条是以源码方式注册。两种都能用,但适用场景不同。
4.1 路线A:字节码装载方式
这种方式的核心思路是:把编译好的.class文件里的字节码内容直接“喂”给数据库,数据库加载到JVM里。
我通常先通过达梦的SQL工具建一个Java类对象,对应到StringSplitUtil这个类。具体语法在版本之间略有差异,但总体框架是:
-- 创建JAVA类(示意写法,版本不同可能略有差异) CREATE JAVA CLASS StringSplitUtil AS '字节码内容';实际使用中,你不会手动把字节码文本粘进SQL里。更常见的做法是借助达梦自带的工具或脚本,读取class文件并生成装载语句。我用过的办法是写一个小工具,把class文件转成字节数组字符串,然后在SQL里拼接。这种方式适合简单类,操作直接,加载成功后在数据字典里就能看到类信息。
这个方式的优点是干净利落,不对源码做二次解析,class是什么样就怎么加载。缺点也很明显:class文件必须和数据库JVM的字节码版本兼容,而且如果类依赖了第三方jar包里的其他类,光装载这一个class是不够的,依赖类也得一起加载或者在数据库的CLASSPATH里能找到。
4.2 路线B:源码注册方式
第二种方式是直接把Java源代码作为一段文本提交给数据库,由数据库端的JVM来编译加载。这个方式的好处是,把依赖的多个类都可以写在同一个源码段里,数据库端统一编译,省去了手动管理class文件的麻烦。
-- 创建JAVA源码(示意写法) CREATE OR REPLACE JAVA SOURCE NAMED "StringSplitUtil" AS public class StringSplitUtil { public static int countSplits(String raw, String delimiter) { if (raw == null || delimiter == null) { return 0; } if (delimiter.isEmpty()) { return 0; } String[] parts = raw.split(java.util.regex.Pattern.quote(delimiter), -1); return parts.length; } };我印象里,源码方式在调试阶段特别方便。因为SQL里保存的是源码本身,一旦发现逻辑有问题,直接修改SQL文本重新执行即可,不用重新走编译、传class文件这一套流程。
不过有个现实限制:一段源码里如果有多个类,它们会被编译成多个class,但如果其中一个类引用了外部jar包里的类,数据库的JVM没有这个jar包,照样编译不了。所以源码方式只是解决了“多个内部类”的问题,没有解决“外部依赖”的问题。
4.3 我的选择建议
综合实测体验,我总结了一套选择逻辑:
| 场景 | 推荐路线 | 原因 |
|---|---|---|
| 单个简单类,逻辑稳定 | 字节码装载 | 部署内容就是纯class,干净 |
| 多个内部类,需要频繁调整 | 源码注册 | 改起来快,不需要管理class文件 |
| 依赖第三方jar包 | 都不行,需另行方案 | 要么把依赖类合并进同一段源码,要么在服务端CLASSPATH里塞jar,后者风险大 |
如果只是做一个两个函数的小功能,我强烈推荐用源码方式,省心。真正涉及复杂依赖的时候,我更倾向把第三方库的代码直接内联进一个类,因为往数据库服务端的CLASSPATH里塞jar包,一旦版本冲突会牵连其他功能。
5. SQL与Java的类型映射:最容易出事的环节
JAVA外部函数注册之后,SQL里使用Java方法,必须有一个“函数签名”告诉达梦:这个Java方法映射成什么SQL函数,参数怎么对应,返回值怎么对应。
5.1 常用类型映射参考
在我用过的达梦版本里,常用的映射大致如下(不同小版本也许有细微差异,以官方手册为准):
| SQL类型 | Java类型 | 注意事项 |
|---|---|---|
| INTEGER / INT | int / java.lang.Integer | 用int时,SQL传入NULL会出问题,建议用Integer方便判空 |
| BIGINT | long / java.lang.Long | 长整型字段对应 |
| VARCHAR / VARCHAR2 | java.lang.String | 最常用,NULL直接映射为Java的null |
| DOUBLE | double / java.lang.Double | 浮点类型 |
| NUMERIC / DECIMAL | java.math.BigDecimal | 避免用double算钱 |
| BOOLEAN | boolean / java.lang.Boolean | 不同版本支持度不一,简单场景能用 |
| VARBINARY / BLOB | byte[] | 二进制数据,适合加密类方法 |
| DATE / TIMESTAMP | java.sql.Date / java.sql.Timestamp | 日期时间类型,注意时区问题 |
我自己的经验是:能多用包装类型(Integer、Long、Double),就尽量少用基本类型(int、long、double)。原因很简单,SQL里的字段随时可能是NULL,如果Java方法是int参数,数据库传一个NULL进来,就会触发类型转换异常。而用Integer作为参数类型,方法内部可以自由判断是否为null,行为更可控。
5.2 NULL语义的实测体会
这一点值得单独拎出来说。数据库里NULL不是空字符串,不是0,它代表“未知”。Java方法里接收到的NULL,就应该保持NULL语义。
我有一次写了个方法把字符串转大写,一开始是这么写的:
public static String upper(String raw) { return raw.trim().toUpperCase(); }SQL里调用F_UPPER(NULL)时直接报错,因为raw是null之后调用了.trim()。后来改成:
public static String upper(String raw) { if (raw == null) { return null; } return raw.trim().toUpperCase(); }这样F_UPPER(NULL)就返回NULL,和内置函数处理NULL的方式保持一致,使用这个函数的上层SQL逻辑才不会意外。很多初用外部函数的人都会在这一步栽跟头,查了半天发现异常来自方法内部对NULL的处理。
5.3 返回值类型也要严格遵守映射
方法的返回类型同样要落在映射表范围内。如果你返回一个Java的自定义对象,数据库侧根本没有对应的SQL类型,函数创建时或者调用时就会报错。简单来说:外部函数只能返回标量值,不要试图返回ResultSet、List这类复杂结构。
如果你确实有一个方法返回List ,那要么改成返回拼接后的字符串,在SQL里再用拆分处理;要么在Java端把List转换成数组后当作字符串返回。总之,一切的复杂结构都要“压扁”成标量才能过这条边界。
6. 完整实测:从注册到SELECT调用
把整体流程串起来看一个完整案例。这次演示用源码方式注册StringSplitUtil,然后创建对应的外部函数映射,最后在SELECT里调用验证。
6.1 注册Java源码
在达梦的SQL工具里,执行源码注册语句。成功后,通过数据字典视图能看到已注册的Java类。这里强调一下:执行SQL工具本身必须使用有相应权限的账号,否则会被拒绝。
CREATE OR REPLACE JAVA SOURCE NAMED "StringSplitUtil" AS public class StringSplitUtil { public static int countSplits(String raw, String delimiter) { if (raw == null || delimiter == null) { return 0; } if (delimiter.isEmpty()) { return 0; } String[] parts = raw.split(java.util.regex.Pattern.quote(delimiter), -1); return parts.length; } public static String trimAndUpper(String raw) { if (raw == null) { return null; } return raw.trim().toUpperCase(); } };执行完之后,可以查询数据字典确认类是否注册成功。能看到类名对应的记录,就说明这一步过了。如果在这里报错,优先检查JAVA组件是否安装,其次检查语法关键字是否和当前版本一致。
6.2 创建外部函数映射
Java类注册好之后,需要创建一个数据库函数,让它知道去调用哪个Java类的哪个方法。
CREATE OR REPLACE FUNCTION F_COUNT_SPLITS( P_RAW VARCHAR2, P_DELIM VARCHAR2 ) RETURN INTEGER AS JAVA SOURCE 'StringSplitUtil.countSplits(java.lang.String, java.lang.String) return int';CREATE OR REPLACE FUNCTION F_TRIM_UPPER( P_RAW VARCHAR2 ) RETURN VARCHAR2 AS JAVA SOURCE 'StringSplitUtil.trimAndUpper(java.lang.String) return java.lang.String';这种写法含义很直白:方法名加参数类型,精确匹配Java端的方法签名。return int/return java.lang.String告诉数据库用什么SQL类型来接返回值。
这里最容易出错的是方法签名的拼写。Java的重载机制要求参数类型精确匹配,SQL里写String而Java里是java.lang.String,或者少写了包名前缀,都会导致函数创建阶段报“找不到方法”这类错误。我的经验是:绝对不用简写,一律用全限定名java.lang.String,这样最保险。
6.3 在SELECT里实际调用
函数创建成功后,验证就非常简单了:
SELECT F_COUNT_SPLITS('A#B#C#D', '#') FROM DUAL; SELECT F_TRIM_UPPER(' hello world ') FROM DUAL;第一个返回4,第二个返回HELLO WORLD。如果结果符合预期,说明整个链路已经通了。然后我们把它放到测试表里跑一下,验证在真实数据上的表现:
SELECT ID, RAW_TEXT, F_COUNT_SPLITS(RAW_TEXT, '#') AS CNT, F_TRIM_UPPER(RAW_TEXT) AS UP_TEXT FROM TEST_ROWS;这时候能看到第二条数据hello world里没有#,CNT返回1(整个字符串作为一个元素),UP_TEXT返回HELLO WORLD。行为符合预期,证明函数在SQL里能正确处理行数据。
6.4 在更复杂的SQL结构里使用
外部函数不只是能放在SELECT列表里,也可以用在WHERE条件、ORDER BY、甚至存储过程里。比如:
SELECT ID, RAW_TEXT FROM TEST_ROWS WHERE F_COUNT_SPLITS(RAW_TEXT, '#') > 1;这样就能筛选出按#拆分后至少有两个元素的记录。实际业务里,这种用法非常常见,等于把Java方法变成了SQL表达式的一部分。
不过要留意:函数放在WHERE条件里,意味着每一行都会执行一次Java方法。如果表很大且Java方法本身耗时,查询性能会明显下降。这也是外部函数和存储过程需要权衡的地方。
7. 踩坑日志:六个让我折腾半天的真实问题
这个功能第一次用的时候,坑比预想的多。下面这些我全部亲身经历过,有的光排查就花了一下午。按严重程度排序写出来。
7.1 类必须是public static,大小写必须精确
我一开始图省事,把一个Java方法写成了普通实例方法,注册毫无问题,但调用时一直提示找不到方法。后来把方法改成public static并且把所有类名、方法名的大小写和Java源码逐字核对了一遍,才算解决。
原因是达梦在匹配方法签名时是严格区分大小写的。StringSplitUtil.countSplits和stringsplitUtil.countSplits是两个完全不同的东西,Java源码里是什么大小写,SQL的SOURCE里就必须一字不差。
7.2 多个同名类被覆盖,加载顺序诡异
有一次我注册了两个不同名字的Java类,后来发现其中一个类的方法行为和我预期不一致,像是被另一个同名类覆盖了。排查后发现,问题出在同一个类名被反复注册,而且新版旧版的字节码都在,数据库加载时按某种顺序选择了旧版本。
后来我的习惯是:重新注册同一个类之前,一定先做一次清理,把已有的同名类相关信息弄干净,再注册新版。简单说就是“先删后建”,不要依赖“覆盖更新”,尤其是线上环境,宁可多一步也不要让旧类残留。
7.3 外部依赖jar包加载不进来
这是所有坑里最恼火的一个。我把一个用到了第三方JSON库的Java类做成外部函数,结果执行调用时直接报“ClassNotFoundException”。原因很清楚:数据库JVM的类路径里没有那个json库。
几种解决办法我都试过:
- 把第三方库的源码直接嵌进同一个注册源码段里,相当于把依赖“内联”。适合依赖不大的情况。
- 把第三方库的class也一起注册成数据库JAVA类。需要额外处理依赖关系,麻烦但可行。
- 在数据库服务端的启动参数里加CLASSPATH,指向jar包。风险较高,我一般不推荐,尤其不希望在别人的生产环境上动启动参数。
我的建议是:如果依赖不复杂,优先内联;如果依赖很庞大,就要重新评估“要不要用外部函数实现这个功能”了。
7.4 异常信息特别模糊,必须学会Java端兜底
外部函数在SQL里执行时,如果Java代码抛了异常,SQL层返回的错误信息往往只有一句话,根本看不出具体是哪一行代码出了问题。
我后来形成了一套固定打法:Java方法内部做全局的try-catch,把异常信息拼成一个字符串之后,要么转换为自定义错误码,要么直接抛出一个带详细消息的RuntimeException。这样SQL报错时至少能看到方法内部的上下文。
还有一种做法是,把异常信息作为函数的返回值之一返回,比如设计一个返回值对象,用特定编码表示错误。但在外部函数只能返回标量的限制下,我通常选择在异常信息上做文章,让错误信息最大化可读。
7.5 高并发调用时,JVM内存和性能问题
JAVA外部函数的执行会消耗数据库服务端的JVM资源。如果某个函数被大量并发调用,比如在报表查询里对大表逐行调用,JVM的内存占用会明显上涨,甚至有Full GC的迹象。我遇到过批量更新场景下,外部函数执行速度越来越慢的情况,最后定位到是JVM频繁GC消耗了大量时间。
解决方向有两个:
- 优化Java方法本身:减少对象创建,少用正则表达式,对热点路径做缓存。
- 控制使用范围:外部函数适合小数据量、高频复杂逻辑,不适合在数千万行的表上逐行扫。大数据量场景应该先在应用层处理,或者在数据库端用临时表分批处理,减少JVM压力的同时保留逻辑一致性。
7.6 更新Java方法的部署流程
这是运维层面的坑。开发环境下改了Java方法,重新注册一次就能生效。但生产环境有严格的变更流程,不可能随便执行DDL。
我的建议是:每一个外部函数都要有对应的脚本文件,记录Java源码、函数映射语句、验证SQL,全部纳入版本管理。变更时按顺序执行:先清理旧资源,再注册新代码,再重建函数映射,最后用验证SQL确认。这套流程固化成文档之后,生产变更就没有那么吓人了。
8. 哪些场景适合JAVA外部函数,哪些场景要三思
最后聊一些务实的判断。外部函数不是什么场景都能用,也不是所有复杂逻辑都应该用SQL重写一遍。关键看边界条件。
8.1 适合用的场景
第一类:数据清洗类。复杂的字符串解析、自定义规则提取、编码探测与转换,SQL写起来很痛苦,Java里却很自然。这类适合。
第二类:算法封装类。比如国密加密、报文校验、特定格式的序列号生成。这些逻辑通常已经封装在Java组件里,直接注册调用能避免双份实现。
第三类:特殊数据计算。比如经纬度距离计算、文本相似度计算,Java实现成熟,且通常处理的是逐行数据,数据量可控。
8.2 不适合用的场景
第一类:大表全表扫描时逐行调用。哪怕方法很简单,每一行一次JVM调用,开销叠加起来非常可观,性能会很难看。这种情况应该先在SQL里用内置函数做粗筛,最后只剩少量数据时再用外部函数精算。
第二类:有状态逻辑。Java方法内部依赖共享静态变量、需要跨调用保存上下文,这种状态在数据库JVM里非常不可控,一旦并发执行就会出现诡异问题。
第三类:需要输出复杂结构。返回结果集、返回List、返回对象数组等,都受限于外部函数的标量返回约束,应该避免。
8.3 我的取舍原则
我现在的决策逻辑非常简单:能写SQL就写SQL,内置函数解决不了的,再考虑外部函数。外部函数是补位策略,不是炫技工具。如果一个功能用内置函数写起来只是稍微长一点,我会选择内置函数;如果内置函数完全做不了,或者做起来代价极高,才上外部函数。
从我的使用体验来看,JAVA外部函数在达梦里算是“可用且实用”的功能,适合解决存量Java逻辑复用的问题。但前提是对类型映射、NULL语义、类加载机制都有足够认知,否则排查问题的时间可能比省下的开发时间还多。建议第一次上手时,先拿一两个小函数把链路跑通,再逐步扩大到复杂场景。