多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Quartz连接Kingbase8报错Bad _value for type long的根因与修复

Quartz连接Kingbase8报错Bad _value for type long的根因与修复 1. 问题现场Quartz 连上 Kingbase8 一跑就报 trigger 检索失败先交代一下背景。我是在一个 Spring Boot 项目里接的 Quartz 调度业务量不算大本来用的 MySQL后来客户要求换成人大金仓 Kingbase8。数据库换了之后项目能正常启动Spring 容器里的 Bean 也没问题但一旦 Quartz 真正去操作数据库里的任务数据比如注册一个 JobDetail、挂一个 CronTrigger控制台直接抛出一行触目惊心的异常org.quartz.JobPersistenceException: Couldnt retrieve trigger: Bad _value for type long : \x at org.quartz.impl.jdbcjobstore.JobStoreSupport.retrieveTrigger(JobStoreSupport.java:...) at org.quartz.impl.jdbcjobstore.JobStoreSupport.triggerInJobGroup(JobStoreSupport.java:...) ... Caused by: com.kingbase8.util.KBQException: Bad _value for type long : \x...这行报错里最扎眼的就是Bad _value for type long : \x。\x在 PostgreSQL/Kingbase8 里是 bytea 十六进制格式的前缀比如十六进制字符串\x1234。也就是说Quartz 期望从数据库里读出来的是一个 long 型数字结果驱动给它回了个二进制字节串于是类型转换直接炸了。当时我第一反应是“表结构建错了”毕竟 Quartz 默认要建十几张表它们负责存 JobDetail、Trigger、Calendar、锁等信息。如果某张表的字段类型和 Quartz 内部预期对不上查数据的时候很容易出这种类型不匹配的错。但检查了好几遍 QRTZ_TRIGGERS 表NEXT_FIRE_TIME、PREV_FIRE_TIME、START_TIME这些时间字段都是 BIGINT和官方建表脚本完全一致。这就让我开始怀疑问题不是出在表结构而是出在 Kingbase8 驱动本身。那段时间我翻了不少资料也对比了 PostgreSQL 社区里的类似报错。老实说Bad _value for type long在 PostgreSQL 社区更常见通常也是和 Quarkus、Hibernate 这类框架一起出现。但在人大金仓上遇到问题往往更隐蔽因为 Kingbase8 虽然兼容 PostgreSQL 协议但底层对某些 OID类型标识符的处理和官方驱动并不完全一致。这也为后面找到根因埋下了伏笔。如果你也正卡在同样的报错上这篇文章基本就是为你写的。我会把 Quartz 读取 Trigger 的底层逻辑、Kingbase8 驱动为什么会返回 bytea、以及最终怎么修复的完整链路全部捋一遍至少能帮你省下一两天的排查时间。1.1 我遇到的具体报错场景项目用的是 Quartz 2.3.2 版本Spring Boot 2.7.x数据库驱动是 kingbase8-8.6.0.jar。Quartz 配置走的是JobStoreTXorg.quartz.impl.jdbcjobstore.PostgreSQLDelegate数据源直接复用了项目里的 Druid 连接池。出错场景有两种项目启动时Quartz 的SchedulerFactoryBean会调用scheduler.isStarted()等方法检查调度器状态这时有可能会触发一次对QRTZ_TRIGGERS表的查询。代码里scheduler.scheduleJob(jobDetail, cronTrigger)明确注册一个定时任务时Quartz 会把 Trigger 插入数据库随后在内存中维护 Trigger 列表。等下次调度周期一到需要从数据库重新读取 Trigger 时就会调用retrieveTrigger(triggerKey)然后抛异常。我的情况属于第二种。因为项目启动时不一定会立即注册任务所以一开始启动是正常的等到真正执行scheduleJob的时候才炸。这也增加了迷惑性因为“启动没问题”会让人以为数据库连接和表结构都正常只有真正读写数据才暴露问题。我当时在 Service 里写了一个很简单的测试方法TriggerKey triggerKey new TriggerKey(testCron, DEFAULT); CronTrigger cronTrigger TriggerBuilder.newTrigger() .withIdentity(triggerKey) .withSchedule(CronScheduleBuilder.cronSchedule(0/5 * * * * ?)) .forJob(testJob, DEFAULT) .build(); scheduler.scheduleJob(jobDetail, cronTrigger);就是这一行scheduleJob把问题引了出来。异常堆栈里明确指向retrieveTriggerQuartz 在把新 Trigger 写入数据库后马上又执行了一次反向查询用来确认 Trigger 是否持久化成功。这个查询涉及QRTZ_TRIGGERS表里的NEXT_FIRE_TIME、PREV_FIRE_TIME、START_TIME等 long 类型字段而恰恰是这些字段从 Kingbase8 驱动侧返回了错误的数据类型。1.2 错误堆栈里最值得玩味的\x前缀先别急着去改表结构认真看一下异常信息里那个\x。在 Kingbase8 的二进制协议中bytea 类型的文本表现形式就是\x加上十六进制字符比如\x31323334其实是 ASCII 字符串“1234”的十六进制表示。Quartz 的jobStore.retrieveTrigger在执行完 SQL 后会从结果集里取出字段值。PostgreSQL 官方的驱动有一个很经典的特点它对某些数据类型会优先使用二进制传输模式。在这种模式下驱动从服务端拿到的字节流并不是普通字符串而是一段经过编码的二进制数据。正常情况下驱动知道这列是 bigint就会用二进制方式解析成 Java 的 long。但问题来了如果驱动对服务端返回的类型 OID 识别错误把 bigint 当成了 bytea那ResultSet内部拿到的就是一个 bytea 对象而 Quarkus 或 Quartz 的代码去调用getLong()时驱动想把这个 bytea 转成 long转了半天发现内容是\x开头的二进制于是直接抛出“Bad _value for type long”。所以\x不是随便出现的一个符号它几乎等于直接告诉我们某个 long 字段在传输层被当成了 bytea 处理。把排查方向锁定在“数据类型误判”上而不是一上来就去怀疑 SQL 写错了。2. 追根溯源Quartz 的 JDBC JobStore 是怎么读 trigger 的要解决这个问题得先搞清楚 Quartz 在 JDBC 模式下到底是怎么读数据的。很多人只知道 Quartz 能持久化任务但不知道它内部的读取逻辑其实很“死板”——每个字段用什么类型取write 和 read 是焊死的。如果数据库侧的底层返回类型和它预期不一致异常就会来得非常直接。2.1 Quartz 调度数据的存储与读取机制Quartz 的JobStoreSupport是所有 JDBC 存储实现的核心不管是JobStoreTX还是JobStoreCMT最终都会落到StdJDBCDelegate身上。StdJDBCDelegate里的 SQL 模板定义在org.quartz.impl.jdbcjobstore.StdJDBCDelegate中比如SELECT TRIGGER_NAME, TRIGGER_GROUP, JOB_NAME, JOB_GROUP, DESCRIPTION, NEXT_FIRE_TIME, PREV_FIRE_TIME, START_TIME, END_TIME, TRIGGER_STATE, ... FROM QRTZ_TRIGGERS WHERE SCHED_NAME ? AND TRIGGER_NAME ? AND TRIGGER_GROUP ?注意这里的NEXT_FIRE_TIME、PREV_FIRE_TIME、START_TIME、END_TIME都是数据库里的 BIGINT 列在 Java 侧会被映射成long或Long。StdJDBCDelegate.getTriggersToAcquire等方法里会直接写long nextFireTime rs.getLong(NEXT_FIRE_TIME);ResultSet.getLong()看起来简单但驱动在背后要做两件事第一确认这一列在服务端是什么类型第二根据类型选择解码方式。如果驱动从结果集元数据里拿到的列类型是 byteagetLong就会进入二进制解析分支然后尝试把 bytea 内容转成 long。Quartz 的 JDBC 持久化还有一个容易被忽略的点它不只在查询时用getLong在写数据时也用setLong。写数据时Quartz 会调用PreparedStatement.setLong(index, nextFireTime)这个调用在 Kingbase8 驱动里遇到 bigint 列是没问题的。真正的问题集中在“读”这一侧因为读链路需要驱动对元数据做类型推断。我们可以把 JDBC 驱动想象成一个翻译Service 层和数据库各说各话。服务端说“这个字段是 BIGINT”驱动翻译的时候听岔了以为人家说的是“BYTEA”于是把字节流原封不动地塞给了 Java 代码。Java 代码拿到一个byte[]类型的值却被告知“这是 long”不炸才怪。2.2Bad _value for type long是如何产生的这段不完全是我臆测在 PostgreSQL 官方 JDBC 驱动的 issue 列表里能搜到同类问题。驱动内部有几个关键类PgResultSetAbstractJdbc2ResultSetOid类型常量表当执行SELECT时驱动会从服务端拿到结果集的字段描述信息里面包含每个字段的 OID。比如QRTZ_TRIGGERS.NEXT_FIRE_TIME在 Kingbase8 里的 OID 应该是 int8对应 BIGINT。但某些 Kingbase8 驱动版本在处理结果集时对 int8 的处理逻辑有缺陷没有走到标准的二进制解码路径反而走到了未知类型解码路径。而未知类型的通用回退逻辑就是把数据当成 bytea 读取——这就有了\x前缀。错误产生的过程可以拆成四步Quartz 执行查询 SQLKingbase8 返回结果集。驱动解析字段 OID发现NEXT_FIRE_TIME应该是某个数值类型。驱动内部查表时因为兼容层的问题把这个 OID 映射到了“bytea”或“unknown”。当 Quartz 调用rs.getLong(NEXT_FIRE_TIME)时驱动尝试从 bytea 二进制内容构造 long但内容不是合法的数字于是抛出Bad _value for type long : \x...。这类错误有个显著特征不是每次都必现。如果数据表中的NEXT_FIRE_TIME恰好在某些行为下被驱动用文本模式返回错误可能延迟出现。这也是为什么很多人一开始重启应用又好了过段时间又冒出来。我在测试时还发现如果手动去数据库里把某个 Trigger 的NEXT_FIRE_TIME改成 NULL再让 Quartz 查询有时候错误就消失了——因为 NULL 走的是空值路径根本不会触发 bytea 解码。这进一步说明了问题核心就在“非 NULL 的 BIGINT 值”的传输环节。3. 真凶落网Kingbase8 驱动的二进制传输误判既然怀疑到了驱动头上接下来就是验证。这个环节最关键因为只有当你亲眼看到“同一个驱动连接把连接参数改一下问题就消失”才算真正抓到了根因。3.1 排查过程从表结构到驱动行为我先做了最常规的表结构检查。用下面的 SQL 查了一下 Quarts 相关表字段类型SELECT table_name, column_name, data_type, udt_name FROM information_schema.columns WHERE table_name IN (qrtz_triggers, qrtz_job_details, qrtz_cron_triggers) ORDER BY table_name, column_name;拿到的结果是表名字段名data_typeudt_nameqrtz_triggersnext_fire_timebigintint8qrtz_triggersprev_fire_timebigintint8qrtz_triggersstart_timebigintint8qrtz_job_detailsjob_databyteabyteaqrtz_cron_triggerscron_expressiontexttext表结构完全正常。NEXT_FIRE_TIME列是 bigintJOB_DATA列是 bytea。如果错误信息里的“long”对应到NEXT_FIRE_TIME那问题肯定不在 DDL。于是我用一个最简单的 JDBC 客户端程序直连 Kingbase8手动跑一条等价查询Connection conn DriverManager.getConnection( jdbcUrl, username, password); PreparedStatement ps conn.prepareStatement( SELECT NEXT_FIRE_TIME, PREV_FIRE_TIME FROM QRTZ_TRIGGERS WHERE SCHED_NAME ?); ps.setString(1, DefaultScheduler); ResultSet rs ps.executeQuery(); while (rs.next()) { System.out.println(NEXT_FIRE_TIME class rs.getObject(NEXT_FIRE_TIME).getClass()); }结果震惊到我了。rs.getObject(NEXT_FIRE_TIME)返回的是byte[]而不是Long。这就坐实了Kingbase8 驱动把这个 BIGINT 列的结果类型当成了 bytea。接着我又试了rs.getLong(NEXT_FIRE_TIME)异常就和 Quartz 里完全一致。因此可以确定错误不在 Quartz 配置而在 JDBC 驱动层的类型映射。3.2 为什么只有 Kingbase8 暴露这个问题这里要稍微讲一下数据库驱动底层的行为差异。PostgreSQL 官方驱动在解析结果集时会根据字段 OID 选择合适的解码器。对 int8 它会用Oid.INT8走二进制解码解码结果是 Java 的long。Kingbase8 驱动是在 PostgreSQL 驱动基础上二次开发的但它的 OID 常量表并不总是和 PostgreSQL 完全对齐。人大金仓为了兼容不同国产化环境在底层会做很多适配。某些 KingbaseES 版本对“语音数据库类型”的处理带有双重判断先看服务端返回的 OID再看数据库服务端设置的compatible_mode兼容模式。如果compatible_mode设置为postgresql一般没问题但如果设置为oracle或者某种混合模式int8 的 OID 可能在结果集元数据里被动态改成bytea。这就解释了为什么同样版本驱动、同样 Quartz 代码在不同 KingbaseES 实例上一个跑得通一个跑不通。我测试用的那个 KingbaseES 实例正好配置成了兼容 Oracle 的模式。在这种模式下Quartz 的官方建表脚本虽然还是建出了 bigint 列但服务端在返回查询结果时对 BIGINT 给客户端给的字段描述信息里写了一个不常见的扩展类型 OID驱动按扩展类型处理直接变成了 bytea。有一个判断技巧在 KingbaseES 里执行show compatible_mode;如果返回的是oracle那大概率就是这个原因。如果是pg或postgresql更可能是驱动版本本身的 bug。这个细节非常值得记住因为很多人在第一层表结构排查后就会放弃根本不会想到数据库服务端的兼容模式会影响 JDBC 底层行为。3.3 连接参数binaryTransfer的决定性作用说到根因还有一个不可忽视的角色JDBC 连接串里的binaryTransfer参数。在 PostgreSQL 官方的 JDBC 驱动里binaryTransfer默认值是true表示针对那些声明支持二进制传输的类型驱动会优先使用二进制格式从服务端接收数据。这样性能更好字节数更少。但这个机制建立在一个前提上驱动必须准确知道每个字段的服务端类型。Kingbase8 驱动继承了这套二进制传输机制但在兼容模式下它拿到的字段类型并不是标准 int8而是一种它自己定义为“未知扩展类型”的 OID。驱动在处理未知扩展类型时走了一条更保守的路把数据当作 bytea 返回。这样虽然不会丢数据但 Java 端的类型就完全变了。于是解决思路就变得非常清晰让驱动放弃二进制传输强制所有数据都用文本格式返回。只要连接串上加上binaryTransferfalse驱动在读取 BIGINT 列时就会直接拿到字符串形式的数字比如“1699999999999”调用getLong()时用字符串解析问题自然消失。我在测试环境验证了这个参数结果立竿见影String jdbcUrl jdbc:kingbase8://192.168.1.10:54321/quartz_db?binaryTransferfalse;加了参数之后rs.getObject(NEXT_FIRE_TIME)不再返回byte[]而是返回Long。Quartz 的scheduleJob也顺利通过。这个参数几乎可以算是一把万能钥匙但使用时要注意binaryTransferfalse会牺牲一点查询性能因为它强制所有数据走文本编码。对于 Quartz 这种低频读写调度表的场景性能影响几乎可以忽略不计。4. 解决方案三步走修复 Quartz Kingbase8如果你遇到的报错和我一样那么接下来这套组合拳基本可以让你摆脱困境。不要只改参数至少把表结构检查、连接参数、驱动版本三件事都做一遍才能真正消除隐患。4.1 第一步确认并修正 Quartz 表结构首先把 Quartz 需要的所有表按官方脚本重新核对一遍。KingbaseES 官方没有单独的 Quartz 建表脚本你需要在以下两个来源里选择从 Quartz 发布包里的docs/db/tables_postgres.sql获取 PostgreSQL 专用脚本。使用人大金仓提供的适配脚本如果有。但很多客户拿到的适配脚本是从 Oracle 版本的tables_oracle.sql硬改过来的这种脚本最容易出问题。在人大金仓上推荐以tables_postgres.sql为基础因为 Kingbase8 在 PostgreSQL 兼容模式下基本能直接跑。需要注意的字段类型规则如下用途推荐类型原因QRTZ_TRIGGERS.NEXT_FIRE_TIMEBIGINTQuartz 内部按 long 处理QRTZ_TRIGGERS.PREV_FIRE_TIMEBIGINT同上QRTZ_TRIGGERS.START_TIMEBIGINT同上QRTZ_TRIGGERS.END_TIMEBIGINT同上QRTZ_JOB_DETAILS.JOB_DATABYTEAQuartz 内部按 byte[] 处理QRTZ_CALENDARS.CALENDARBYTEA同上如果你是从 Oracle 脚本转过来的要尤其小心BLOB类型。Kingbase8 会把 Oracle 的BLOB映射为 bytea这本身没问题但有些工具在转换时会把所有NUMBER(19)类型的字段错误映射成BYTEA那就会出现和我们的错误一模一样的现象。所以建表完成后一定要跑一遍上面的information_schema查询确保所有 long 相关字段都是 bigint。4.2 第二步修改连接 URL 强制文本传输这是最直接、见效最快的修复手段。不管你是用 Druid、HikariCP 还是裸 JDBC都要在连接串上追加参数。以 Druid 为例数据源配置可以改成spring: datasource: url: jdbc:kingbase8://192.168.1.10:54321/quartz_db?binaryTransferfalsestringtypeunspecified username: system password: 123456 driver-class-name: com.kingbase8.Driver我在代码里同时加了两个参数binaryTransferfalse禁止二进制传输强制所有数据以文本形式返回。stringtypeunspecified让所有字符串参数尽量不强制绑定为 varchar降低 PreparedStatement 传参时的隐式类型转换风险。这个参数在 PostgreSQL 和 Kingbase8 里都很实用。注意如果你用的是 HikariCP只需要改jdbc-url即可不需要额外配置。改完之后建议把 Quartz 的driverDelegateClass也显式设成 PostgreSQL 的 delegateorg.quartz.jobStore.driverDelegateClassorg.quartz.impl.jdbcjobstore.PostgreSQLDelegate这一步的目的是让 Quartz 使用 PostgreSQL 风格的 SQL 语法比如处理布尔值、唯一约束时更贴合 Kingbase8 的兼容层。4.3 第三步升级/替换驱动版本连接参数能解决眼前的问题但如果你拿到的 kingbase8 驱动版本过旧比如 8.2.x 或更早我建议还是升级到官方较新的版本。为什么因为binaryTransferfalse只是绕过了驱动层的类型误判并没真正修复驱动对 OID 的映射错误。后续如果还有其他框架比如 MyBatis、JPA读取类似字段仍然可能遇到类型问题。我当时的驱动版本是 kingbase8-8.6.0厂商后来发布的 8.6.2、8.6.3 都在 release notes 里提到了对 PostgreSQL 二进制传输协议的兼容性修复。你可以登录人大金仓的官网下载对应版本的 JDBC 驱动替换掉项目里的旧 jar。替换后即使不设binaryTransferfalse也能正常读取。如果你的项目不方便升级驱动那就老老实实保留binaryTransferfalse并把它写进环境配置的注释里防止后续同事“好心”把参数删掉。5. 验证与之后的坑问题修完还得稳妥地验证一遍尤其要模拟真实调度场景而不是只测一次scheduleJob就以为万事大吉。5.1 修改后的启动与调度验证我按下面的步骤做了一轮完整验证重启 Spring Boot 服务确认QuartzScheduler正常初始化。调用scheduler.scheduler.isStarted()确认调度状态正常。注册一个简单的CronTrigger频率设为每 5 秒执行一次。等待至少 3 个调度周期观察DisallowConcurrentExecution注解下的任务是否按预期执行。手动停止调度器再重新启动确认数据库里的 Trigger 状态恢复成 waiting。执行一次scheduler.rescheduleJob()这一步会再次触发retrieveTrigger确保修改后的驱动没问题。验证结果所有步骤都通过NEXT_FIRE_TIME字段能正常读取集群模式下多个节点也能准确保留任务状态。把验证步骤写出来是想提醒你不要在没验证持久化恢复流程的情况下就上线。因为scheduleJob只是写入路径真正的读取路径在调度器重启、集群节点切换、任务恢复时才会大量触发。万一你的修复只覆盖了写入而没覆盖读取那上线后分分钟在深夜炸出告警。5.2 集群模式下的附加注意事项如果你用 Quartz 集群模式修复和验证还要多留几个心眼。集群模式下Quartz 会依靠QRTZ_LOCKS表做行锁并通过QRTZ_SCHEDULER_STATE表记录各节点实例状态。这些表里也有不少 long 类型的字段比如LAST_CHECKIN_TIME、CHECKIN_INTERVAL。一旦二进制传输误判集群节点之间会周期性出现Couldnt retrieve trigger类的异常但关键的是这种异常不一定立刻导致任务停止而是会让某个节点在retrieveTrigger时失败从而跳过本轮的某些处理逻辑。在集群环境里我建议做两点额外检查查看QRTZ_SCHEDULER_STATE表里LAST_CHECKIN_TIME字段是否会在每个节点每次 check-in 后更新。如果一直不更新说明同样存在类型误判问题。确认QRTZ_LOCKS表中的锁记录状态Quartz 取锁和释放锁也涉及 long 字段的读写。确保所有连接串都加上了binaryTransferfalse不只是 Quartz 专属数据源。很多人只改了 Quartz 的数据源没改项目主数据源结果主业务表一切正常唯独调度表出问题——因为两个连接串的驱动参数不一样。5.3 个人经验这类“类型契约”问题的排查技巧踩过这次坑之后我对“框架 数据库 驱动”三者的类型契约有了更深的理解。这里分享几个平时排查非常管用的技巧看到\x前缀优先怀疑 bytea。在 Kingbase8 里\x几乎是 bytea 的身份证不管错误信息在哪个框架里出现第一排查方向都是“哪个字段被当成了 bytea”。不要只盯着表结构。表结构只是冰山一角驱动和数据库服务端的“兼容模式”、连接参数、驱动版本三个变量都要纳入排查范围。写一个小型 JDBC 直连脚本。直接绕过框架用原生 JDBC 查同一个表的同一列用getObject().getClass()看返回类型。这样能立即判断问题是在框架层还是驱动层。关注compatible_mode。人大金仓的compatible_mode是很多诡异问题的真正分水岭。如果生产环境必须用 Oracle 兼容模式那基本注定要付出一些额外的代价比如和 PG 生态的中间件兼容性下降。把参数写进配置注释防止后人误删。像binaryTransferfalse这种参数单看名字很容易让人以为“关闭二进制传输会影响性能”而删掉。在配置注释里写清楚“去掉它 Quartz 会抛 Bad _value for type long”就是最好的告警。最后再补一个我后来发现的小细节。Quartz 的 JobKey 在序列化存储时如果JOB_DATA字段里的对象太大或者自定义序列化器没实现Serializable也会出现类似Couldnt retrieve trigger的异常但错误信息通常是JobPersistenceException: Couldnt retrieve job或 ClassCastException。本次这个Bad _value for type long是更纯粹的驱动类型问题千万别混淆。如果以后你看到异常信息里同时出现byte[]和long第一反应仍然应该回到驱动这一层。
返回列表