上周凌晨两点,跑批群里的截图直接把我炸清醒了。月度对账跑出来三万七千条差异记录,全部集中在报文比对环节。我把SQL拉出来看了一眼就觉得不对劲:就是一个TEXT字段做等值过滤,拿进来的目标串是我们系统里按规则生成好的固定内容,理论上最多命中一条,结果数据库给我返回了三万多条。
在这套系统里,我们用的是达梦数据库,敏感报文体统一存在TEXT字段里。这两年达梦在不少系统里替换得非常快,很多开发都是从MySQL或Oracle切过来的,下意识就把TEXT当成VARCHAR用,等值比较、去重、分组全往上招呼。今天这篇就是围绕达梦TEXT/CLOB类型在实际业务中引发的一个隐蔽bug,把现象、排查、根因、解决方案一次讲透。如果你正在用达梦,或者正准备把老库迁移到达梦,这篇文章值得收藏,尤其是那些打算用大字段参与查重、关联、分组判断的朋友,看一下能少走很多弯路。
1. 破案过程还原:一次对账跑批为什么多出了三万条差异
1.1 出事的表结构与业务SQL长什么样
先交代一下现场。这套系统叫T_SIGN_LOG,是某个核心链路的签名报文日志表,结构大概是这样的:
CREATE TABLE T_SIGN_LOG ( ID BIGINT IDENTITY(1,1) PRIMARY KEY, BIZ_NO VARCHAR(64) NOT NULL, REQ_BODY TEXT, SIGN_BODY TEXT, SIGN_HASH VARCHAR(64), RESULT_FLAG TINYINT, CREATE_TIME DATETIME DEFAULT CURRENT_TIMESTAMP );注意这里有两个TEXT类型字段,REQ_BODY是请求报文体,SIGN_BODY是系统生成的签名摘要原文。因为报文体较长,可能几KB甚至几十KB,当初建表的人很自然地选了TEXT。
业务上有一个逻辑需要核验“某条记录的签名摘要内容,是否和另一个系统返回的签名原文一致”,于是代码里先查出一条记录的SIGN_BODY,把它作为输入串,再去T_SIGN_LOG表里筛匹配记录。SQL大致是这样:
SELECT COUNT(*) FROM T_SIGN_LOG WHERE SIGN_BODY = '目标签名原文' AND RESULT_FLAG = 1 AND CREATE_TIME >= '2024-11-01 00:00:00' AND CREATE_TIME < '2024-12-01 00:00:00';按正常理解,SIGN_BODY的内容具有唯一性,过滤条件又加了时间范围和结果标记,应该出来一条,最多两条。结果跑批完成后,差异清单里出现了三万七千条,当时第一个反应就是业务侧的数据同步出了问题。
1.2 最初的三次怀疑方向,全都没踩中
排查过程其实绕了一段路,我先把三个典型误判写出来,都是后来证明“不是它”的方向。
第一个怀疑方向是参数拼接问题。因为代码里是先查库再拿字段值做条件,比较自然地怀疑是不是Java程序从结果集里读SIGN_BODY时没读全,比如只读到了前几百字符,导致拼接出来的匹配串本身就是残缺的。我们把业务日志翻出来看,发现传入SQL的字符串是完整的,从日志里肉眼可见结尾的右括号和换行符都在,这个方向排除。
第二个怀疑方向是客户端工具显示问题。因为之前用DataGrip和命令工具查出来的SIGN_BODY都只显示一部分,看起来像全一样。当时想是不是数据库里存的数据本身没错,只是工具显示不全,让我们误判了差异。后来用LENGTH()取长度才发现,返回匹配的那些行,长度和目标串完全不在一个量级,说明不是显示问题,是匹配结果本身有问题。
第三个怀疑方向是JDBC驱动。为了快速验证,把同样的SQL拿到达梦自带的DIsql工具里手工执行,结果一模一样:返回了三万多条。这就说明问题不是驱动层面,而是数据库对大字段等值比较的行为本身就不对劲。
1.3 真正触发问题的SQL长这样
排除杂音之后,我们把问题收敛到了TEXT字段做等值匹配这个动作上。为了确认不是个例,做了两组对照:
第一组,把条件改成只过滤短字符串,比如SIGN_BODY = 'abc',执行很快,结果也符合预期。
第二组,条件串超过一定长度后,比如1KB以上,并且两条数据前半段有很多相同字符,后半段不同,这时候=比较返回了“相等”。
更直接的证据是,对SIGN_BODY做DISTINCT和GROUP BY时,达梦直接报错。报错信息大意是:数据类型text不能用于DISTINCT/GROUP BY。这个限制其实很多数据库都有,Oracle的CLOB也不支持直接GROUP BY。但我们当时的代码里确实有人写了这类SQL,靠应用层二次处理才勉强跑通,现在回头看属于在雷区上反复横跳。
2. 从现场到最小复现:三条记录揪出大字段比较逻辑
2.1 把SQL拿到DIsql里手工跑,结果和客户端一致
前面提到,我为了确认是不是驱动问题,专门跑到服务器上用DIsql手工执行。这一步很关键,建议遇到类似问题的人都这么做一遍:绕开所有中间环节,直接在数据库命令行工具里执行原始SQL,能最快判断是数据库还是应用层的问题。
我当时执行的命令大概是:
SELECT ID, BIZ_NO FROM T_SIGN_LOG WHERE SIGN_BODY = '目标签名原文' AND RESULT_FLAG = 1 AND CREATE_TIME >= '2024-11-01 00:00:00' AND CREATE_TIME < '2024-12-01 00:00:00' AND ROWNUM <= 20;结果返回的20条记录里,每条SIGN_BODY的LENGTH()都比目标串短很多,有几条甚至只有几百字符。这就很离谱了:一个8000字符的目标串,去匹配一条只有600字符的字段值,居然能=匹配上,说明数据库的比较逻辑没有走“全量比对”。
2.2 最小化复现:一个三行测试表
为了快速复现,我在测试库建了一张极简表,只保留关键场景:
CREATE TABLE T_LOB_TEST ( ID INT PRIMARY KEY, VAL TEXT ); INSERT INTO T_LOB_TEST VALUES (1, '公共前缀AAAAAAAAA.....业务数据1111'); INSERT INTO T_LOB_TEST VALUES (2, '公共前缀AAAAAAAAA.....业务数据2222'); INSERT INTO T_LOB_TEST VALUES (3, '完全不同开头的内容3333');其中第1条和第2条记录的前面200个字符完全一样,后面300个字符不同,总长度约500字符。
然后执行:
SELECT * FROM T_LOB_TEST WHERE VAL = '公共前缀AAAAAAAAA.....业务数据1111';按预期应该只返回ID=1,实际返回了ID=1和ID=2两条。把前面的公共前缀再拉长到接近1000字符,依然存在这一问题。这就是当时线上三万七千条差异数据的最小化复现版本。
2.3 换个姿势查:长度加截断比较,结果立刻正常
复现之后,我试着绕开等值比较,改用长度加分段截断来匹配:
SELECT * FROM T_LOB_TEST WHERE LENGTH(VAL) = LENGTH('公共前缀AAAAAAAAA.....业务数据1111') AND DBMS_LOB.SUBSTR(VAL, 2000, 1) = DBMS_LOB.SUBSTR('公共前缀AAAAAAAAA.....业务数据1111', 2000, 1);这次结果正确了,只返回ID=1。这个对照实验基本能确定问题出在达梦对TEXT/CLOB类型做等值比较时的内部处理逻辑上,而不是数据本身的编码问题。
3. 根因分析:TEXT/CLOB的存储形态与比较策略差异
3.1 先搞明白TEXT/CLOB到底在库里怎么存
很多从MySQL切过来的同事容易忽略一个大前提:TEXT和CLOB在达梦里属于大对象类型,不是普通字符串。
普通VARCHAR是“行内数据”,值直接放在记录里,数据库拿来做比较时,走的是常规的字符串比较器,按完整值逐字节比对。而TEXT/CLOB在行内放的是一个LOB定位器,也就是一个指向真实数据的“指针”或“引用”,真正的文本内容存放在独立的LOB段里,可能需要跨多个数据页存储。
这个结构有点像你去图书馆借书,VARCHAR是把整本书抄在手上直接翻,TEXT/CLOB是给你一张索书号,你按索书号找到书架、把书搬下来、翻到指定页才知道内容。区别在于,数据库做等值比较时,它是否需要真的把“整本书”全部搬到内存里逐字比对,这就是各种实现差异的根源。
3.2 为什么等值比较会出现“前段相同即真”
从我这边实测的现象看,达梦某个版本对TEXT/CLOB的等值比较,并没有像VARCHAR那样严格做全量比对。当参与比较的大字段长度超过一定阈值后,数据库可能只提取了首段内容、或者按内部预读的LOB块进行匹配。一旦两边的前面若干字节相同,它就认为两个值相等,不再继续读取后面的块。
我用生活中的例子来帮你理解:你拿一份文件的首页去和一叠文件的所有首页比对,看到几份材料首页完全一样,就直接宣判这些文件内容一致。这在小文件场景下问题不大,文件越长、前面公共部分越多,误判概率就越高。
这也是为什么我们线上数据会出现“目标串明明有8000字符,却匹配到600字符数据”的原因:600字符的数据和目标串在首段内容上碰巧完全一致,后续内容根本没有参与比较。
3.3 GROUP BY、DISTINCT、ORDER BY为什么直接报错
等值比较还算“静默出错”,GROUP BY和DISTINCT更直接,数据库层面直接拒绝。
我在测试库执行过下面这两个语句:
SELECT COUNT(DISTINCT SIGN_BODY) FROM T_SIGN_LOG; SELECT SIGN_BODY, COUNT(*) FROM T_SIGN_LOG GROUP BY SIGN_BODY;两句都报错,错误类型都是“该类型不能用于DISTINCT/GROUP BY”。ORDER BY大字段在某些场景下也会报错或不稳定。
从数据库实现角度看,这其实是因为分组、去重、排序都需要建立哈希或键值索引,而大对象类型没有等价的可比较Key,常规的哈希函数直接作用在大字段上代价极高,数据库干脆禁用。这和Oracle CLOB不支持直接GROUP BY是同一个道理,并不是达梦独有,但很多业务开发确实不知道这个约束。
3.4 客户端工具和JDBC“读不全”的连带问题
这个bug之所以难排查,还有一个很大的帮凶:客户端工具和JDBC驱动对大字段的读取往往也不是全量。
比如我在DataGrip里查SIGN_BODY,结果显示的文本就是截断的,后面会带省略号。Navicat连接达梦时同样存在这个现象。如果你通过工具看到两条记录“看起来内容一样”,先在SQL里用LENGTH()比一下长度,别用眼睛判断。
Java侧的JDBC驱动更隐蔽。某些驱动版本下,getString()直接读CLOB只能拿到前一段预读内容。如果你在应用层把CLOB读出来之后又拿去拼SQL、存文件、做MD5,结果就是“读出来的内容已经残缺”,再往数据库里比对或者回写,自然满盘皆错。这和我们最初怀疑的“参数拼接问题”表现一样,但源头其实完全不同。
4. 稳妥绕过:SQL改写、表结构调整与版本通道
4.1 SQL层改造:截断比较与哈希比较
如果你的表结构暂时不能动,应用层改造又排期紧张,可以先在SQL层绕开等值比较。思路很简单:放弃了=比较,改用长度外加分段截断的判定。
假设你要匹配的目标串长度在2000字符以内,可以这样写:
SELECT * FROM T_SIGN_LOG WHERE LENGTH(SIGN_BODY) = LENGTH('目标字符串') AND DBMS_LOB.SUBSTR(SIGN_BODY, 2000, 1) = DBMS_LOB.SUBSTR('目标字符串', 2000, 1);如果目标串可能超过4000字符,就多拆几段:
WHERE LENGTH(SIGN_BODY) = LENGTH('目标字符串') AND DBMS_LOB.SUBSTR(SIGN_BODY, 2000, 1) = DBMS_LOB.SUBSTR('目标字符串', 2000, 1) AND DBMS_LOB.SUBSTR(SIGN_BODY, 2000, 2001) = DBMS_LOB.SUBSTR('目标字符串', 2000, 2001) AND DBMS_LOB.SUBSTR(SIGN_BODY, 2000, 4001) = DBMS_LOB.SUBSTR('目标字符串', 2000, 4001);这段SQL的思路是:先保证长度完全一致,再按段把内容逐一切出来比对。只要目标串不超过你设定的总长度范围,结果就是可靠的,而且完全不依赖TEXT等值比较的内部行为。
不过我不建议长期用这种方案。每多加一段,SQL的可读性就差一截,索引也走不上,数据量一上来性能容易崩。它是短期止血的手段,不是长期方案。
4.2 表结构层改造:加摘要列,把大字段挪出主流程
真正解决问题,我的建议是给大字段增加一个摘要列,用摘要列承担所有等值匹配、去重、关联操作。这是目前实践下来最稳的组合。
具体做法是在建表时加一个VARCHAR(64)的哈希列:
CREATE TABLE T_SIGN_LOG ( ID BIGINT IDENTITY(1,1) PRIMARY KEY, BIZ_NO VARCHAR(64) NOT NULL, REQ_BODY TEXT, SIGN_BODY TEXT, SIGN_HASH VARCHAR(64) GENERATED ALWAYS AS (HASH_MD5(SIGN_BODY)), RESULT_FLAG TINYINT, CREATE_TIME DATETIME DEFAULT CURRENT_TIMESTAMP );注意生成列这种写法对表达式有限制,如果你不想依赖生成列,就在应用层计算哈希后写入。应用层入库时,对SIGN_BODY先做一次MD5或SHA-256,把摘要和原文一起落库。后面做等值匹配、查重、对账,全部用SIGN_HASH列,不再碰SIGN_BODY字段本身:
SELECT * FROM T_SIGN_LOG WHERE SIGN_HASH = '目标字符串的MD5值' AND RESULT_FLAG = 1;这个方案的好处有三个:一是等值匹配走普通VARCHAR索引,性能好;二是彻底规避了大字段等值比较的不可靠行为;三是后续如果要定位原文,再通过主键把SIGN_BODY整条取到应用层,用Java流式读取,只此一次。
如果TEXT字段本身也不是业务主信息,还有个更极端的做法:把大字段拆到独立子表,主表保留VARCHAR类型的摘要和短描述,按需通过主键去子表取详情。这个在高并发场景下更干净,但表结构和应用查询改动量大,需要根据实际情况权衡。
4.3 驱动升级与官方反馈通道
如果你确认当前达梦版本的等值比较行为有问题,建议先查小版本和补丁情况。
检查版本可以用:
SELECT * FROM v$version;达梦的版本号一般能看出具体的build编号。如果当前是较旧的build,建议找DBA或原厂支持确认最新补丁是否修复了大字段等值比较的已知问题,再决定是否升级驱动或数据库补丁。JDBC驱动的升级尤其简单,替换jar包后验证一下getString()读取CLOB是否完整即可。
这里也提个醒:向原厂反馈问题时,最好把最小复现的三行测试表一起发过去,包含表结构、插入语句、SQL查询和实际结果。有了最小复现,对方的研发定位问题会快很多,你也能省去反复沟通的成本。如果涉及生产数据脱敏,不要直接传线上真实报文,构造几条能触发问题的测试数据就够了。
5. 经历过这次故障后,我对达梦大字段的使用习惯
5.1 建表前的自查清单
吃了一次大亏以后,我现在建表遇到TEXT/CLOB都会下意识过一遍自查清单,建议你也收藏:
| 场景 | 是否允许 | 建议方案 |
|---|---|---|
| 大字段做等值比较 | 不推荐,存在误判风险 | 改为摘要列比对 |
| 大字段做GROUP BY / DISTINCT | 不允许,直接报错 | 应用层分组,或用摘要列 |
| 大字段做ORDER BY | 视版本而定,不稳定 | 尽量不排序,或按ID、时间排序 |
| 大字段做关联键 | 不推荐 | 用VARCHAR摘要列关联 |
| 中等长度字符串(10KB以内) | 视页大小而定 | 优先VARCHAR,够用就别上TEXT |
| 真正的大文本存储与展示 | 允许 | 通过主键取原文,应用层流式读取 |
达梦的VARCHAR最大长度受数据库页大小影响,8K页面下通常能定义到8188字节左右,16K页面更大。很多业务字段其实用VARCHAR就够了,不一定非要上TEXT。能用VARCHAR解决的问题,就不要让大字段进入业务判断链路。
5.2 CLOB/TEXT字段导出与查看的几个坑
有同事问过我“CLOB字段怎么导出”,这里一起说下。
达梦管理工具或Navicat里,查询结果中的大字段默认往往只显示片段。如果你需要把CLOB内容导成文件,不建议直接在结果网格里复制粘贴,这样一是容易截断,二是编码可能出问题,导出来中文变成乱码。更可靠的方式是走达梦的数据导出功能,把大字段列按文件形式导出;或者写一个简单的Java程序,用getCharacterStream()按段读取后写入本地文件,编码指定UTF-8,基本不会出错。
另外,如果你只是临时想看某个TEXT字段的完整内容,可以在SQL里用DBMS_LOB.SUBSTR分页截取,把它拆成几段,分别查看。虽然麻烦,但至少能看到全貌,不会被工具的显示截断误导。
5.3 Java读取CLOB的正确姿势
Java里读取达梦的CLOB/TEXT字段,优先用getCharacterStream()或getClob(),不要只依赖getString()。
我自己遇到过一个真实的坑:某个版本驱动下,rs.getString("sign_body")只返回了前几千字符,后续内容丢失,但没报错没警告。这会导致你在应用层计算MD5、拼接JSON、生成对账文件时,全部基于一个残缺数据,后续排查非常痛苦。
建议的读取方式是这样的:
Clob clob = rs.getClob("sign_body"); if (clob == null) { return null; } StringBuilder content = new StringBuilder(); try (Reader reader = clob.getCharacterStream()) { char[] buffer = new char[4096]; int len; while ((len = reader.read(buffer)) != -1) { content.append(buffer, 0, len); } } return content.toString();这个思路是每次都从LOB数据流里按块读取,直到读完整个CLOB。虽然多几行代码,但拿到的一定是全量内容,不会因为驱动预读长度而丢失数据。
5.4 Navicat/IDEA连接达梦时的注意点
最后顺带说一下连接工具。Navicat连接达梦时,数据库类型要选择“达梦”,不是MySQL,也不是PostgreSQL;默认端口是5236;用户名和普通库不太一样,通常会带模式名,如果建了表和序列却查不到,先看看当前用户所属的模式对不对。
IDEA里连接达梦也是一样,需要下载达梦官方JDBC驱动,URL前缀是jdbc:dm://,驱动类名是dm.jdbc.driver.DmDriver。驱动包在达梦安装目录的drivers/jdbc下可以找到,版本尽量和数据库版本对应。
回到一开始的问题。那次三万七千条差异数据的故障,最后就是把比对口径从TEXT等值比较改成了摘要列等值比较,业务逻辑恢复正常,跑批也回到了分钟级。大字段本身不是不能用,但它有它的边界和脾气,千万别因为看着像字符串,就理所当然地让它承担普通字符串的职责。