ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

达梦数据库TEXT/CLOB等值比较的隐蔽陷阱与解决方案

达梦数据库TEXT/CLOB等值比较的隐蔽陷阱与解决方案 上周凌晨两点跑批群里的截图直接把我炸清醒了。月度对账跑出来三万七千条差异记录全部集中在报文比对环节。我把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;按预期应该只返回ID1实际返回了ID1和ID2两条。把前面的公共前缀再拉长到接近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);这次结果正确了只返回ID1。这个对照实验基本能确定问题出在达梦对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等值比较改成了摘要列等值比较业务逻辑恢复正常跑批也回到了分钟级。大字段本身不是不能用但它有它的边界和脾气千万别因为看着像字符串就理所当然地让它承担普通字符串的职责。
返回列表