☰
Quartz连接Kingbase8报错Bad _value for type long的根因与修复
2026/10/7 12:12:47 网站建设 项目流程

1. 问题现场:Quartz 连上 Kingbase8 一跑就报 trigger 检索失败

先交代一下背景。我是在一个 Spring Boot 项目里接的 Quartz 调度,业务量不算大,本来用的 MySQL,后来客户要求换成人大金仓 Kingbase8。数据库换了之后,项目能正常启动,Spring 容器里的 Bean 也没问题,但一旦 Quartz 真正去操作数据库里的任务数据,比如注册一个 JobDetail、挂一个 CronTrigger,控制台直接抛出一行触目惊心的异常:

org.quartz.JobPersistenceException: Couldn't 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 配置走的是JobStoreTX+org.quartz.impl.jdbcjobstore.PostgreSQLDelegate,数据源直接复用了项目里的 Druid 连接池。

出错场景有两种:

  1. 项目启动时,Quartz 的SchedulerFactoryBean会调用scheduler.isStarted()等方法检查调度器状态,这时有可能会触发一次对QRTZ_TRIGGERS表的查询。
  2. 代码里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把问题引了出来。异常堆栈里明确指向retrieveTrigger,Quartz 在把新 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()看起来简单,但驱动在背后要做两件事:第一,确认这一列在服务端是什么类型;第二,根据类型选择解码方式。如果驱动从结果集元数据里拿到的列类型是 bytea,getLong就会进入二进制解析分支,然后尝试把 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 列表里能搜到同类问题。驱动内部有几个关键类:

  • PgResultSet
  • AbstractJdbc2ResultSet
  • Oid类型常量表

当执行SELECT时,驱动会从服务端拿到结果集的字段描述信息,里面包含每个字段的 OID。比如QRTZ_TRIGGERS.NEXT_FIRE_TIME在 Kingbase8 里的 OID 应该是 int8(对应 BIGINT)。但某些 Kingbase8 驱动版本在处理结果集时,对 int8 的处理逻辑有缺陷,没有走到标准的二进制解码路径,反而走到了未知类型解码路径。而未知类型的通用回退逻辑就是把数据当成 bytea 读取——这就有了\x前缀。

错误产生的过程可以拆成四步:

  1. Quartz 执行查询 SQL,Kingbase8 返回结果集。
  2. 驱动解析字段 OID,发现NEXT_FIRE_TIME应该是某个数值类型。
  3. 驱动内部查表时,因为兼容层的问题,把这个 OID 映射到了“bytea”或“unknown”。
  4. 当 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_name
qrtz_triggersnext_fire_timebigintint8
qrtz_triggersprev_fire_timebigintint8
qrtz_triggersstart_timebigintint8
qrtz_job_detailsjob_databyteabytea
qrtz_cron_triggerscron_expressiontexttext

表结构完全正常。NEXT_FIRE_TIME列是 bigint,JOB_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 端的类型就完全变了。

于是,解决思路就变得非常清晰:让驱动放弃二进制传输,强制所有数据都用文本格式返回。只要连接串上加上binaryTransfer=false,驱动在读取 BIGINT 列时就会直接拿到字符串形式的数字(比如“1699999999999”),调用getLong()时用字符串解析,问题自然消失。

我在测试环境验证了这个参数,结果立竿见影:

String jdbcUrl = "jdbc:kingbase8://192.168.1.10:54321/quartz_db?binaryTransfer=false";

加了参数之后,rs.getObject("NEXT_FIRE_TIME")不再返回byte[],而是返回Long。Quartz 的scheduleJob也顺利通过。这个参数几乎可以算是一把万能钥匙,但使用时要注意:binaryTransfer=false会牺牲一点查询性能,因为它强制所有数据走文本编码。对于 Quartz 这种低频读写调度表的场景,性能影响几乎可以忽略不计。

4. 解决方案:三步走修复 Quartz + Kingbase8

如果你遇到的报错和我一样,那么接下来这套组合拳基本可以让你摆脱困境。不要只改参数,至少把表结构检查、连接参数、驱动版本三件事都做一遍,才能真正消除隐患。

4.1 第一步:确认并修正 Quartz 表结构

首先,把 Quartz 需要的所有表按官方脚本重新核对一遍。KingbaseES 官方没有单独的 Quartz 建表脚本,你需要在以下两个来源里选择:

  1. 从 Quartz 发布包里的docs/db/tables_postgres.sql获取 PostgreSQL 专用脚本。
  2. 使用人大金仓提供的适配脚本(如果有)。但很多客户拿到的适配脚本是从 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?binaryTransfer=false&stringtype=unspecified username: system password: 123456 driver-class-name: com.kingbase8.Driver

我在代码里同时加了两个参数:

  • binaryTransfer=false:禁止二进制传输,强制所有数据以文本形式返回。
  • stringtype=unspecified:让所有字符串参数尽量不强制绑定为 varchar,降低 PreparedStatement 传参时的隐式类型转换风险。这个参数在 PostgreSQL 和 Kingbase8 里都很实用。

注意,如果你用的是 HikariCP,只需要改jdbc-url即可,不需要额外配置。改完之后,建议把 Quartz 的driverDelegateClass也显式设成 PostgreSQL 的 delegate:

org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.PostgreSQLDelegate

这一步的目的是让 Quartz 使用 PostgreSQL 风格的 SQL 语法,比如处理布尔值、唯一约束时更贴合 Kingbase8 的兼容层。

4.3 第三步:升级/替换驱动版本

连接参数能解决眼前的问题,但如果你拿到的 kingbase8 驱动版本过旧(比如 8.2.x 或更早),我建议还是升级到官方较新的版本。为什么?因为binaryTransfer=false只是绕过了驱动层的类型误判,并没真正修复驱动对 OID 的映射错误。后续如果还有其他框架(比如 MyBatis、JPA)读取类似字段,仍然可能遇到类型问题。

我当时的驱动版本是 kingbase8-8.6.0,厂商后来发布的 8.6.2、8.6.3 都在 release notes 里提到了对 PostgreSQL 二进制传输协议的兼容性修复。你可以登录人大金仓的官网下载对应版本的 JDBC 驱动,替换掉项目里的旧 jar。替换后即使不设binaryTransfer=false,也能正常读取。

如果你的项目不方便升级驱动,那就老老实实保留binaryTransfer=false,并把它写进环境配置的注释里,防止后续同事“好心”把参数删掉。

5. 验证与之后的坑

问题修完,还得稳妥地验证一遍,尤其要模拟真实调度场景,而不是只测一次scheduleJob就以为万事大吉。

5.1 修改后的启动与调度验证

我按下面的步骤做了一轮完整验证:

  1. 重启 Spring Boot 服务,确认QuartzScheduler正常初始化。
  2. 调用scheduler.scheduler.isStarted(),确认调度状态正常。
  3. 注册一个简单的CronTrigger,频率设为每 5 秒执行一次。
  4. 等待至少 3 个调度周期,观察@DisallowConcurrentExecution注解下的任务是否按预期执行。
  5. 手动停止调度器,再重新启动,确认数据库里的 Trigger 状态恢复成 waiting。
  6. 执行一次scheduler.rescheduleJob(),这一步会再次触发retrieveTrigger,确保修改后的驱动没问题。

验证结果:所有步骤都通过,NEXT_FIRE_TIME字段能正常读取,集群模式下多个节点也能准确保留任务状态。

把验证步骤写出来,是想提醒你:不要在没验证持久化恢复流程的情况下就上线。因为scheduleJob只是写入路径,真正的读取路径在调度器重启、集群节点切换、任务恢复时才会大量触发。万一你的修复只覆盖了写入而没覆盖读取,那上线后分分钟在深夜炸出告警。

5.2 集群模式下的附加注意事项

如果你用 Quartz 集群模式,修复和验证还要多留几个心眼。

集群模式下,Quartz 会依靠QRTZ_LOCKS表做行锁,并通过QRTZ_SCHEDULER_STATE表记录各节点实例状态。这些表里也有不少 long 类型的字段,比如LAST_CHECKIN_TIME、CHECKIN_INTERVAL。一旦二进制传输误判,集群节点之间会周期性出现Couldn't retrieve trigger类的异常,但关键的是,这种异常不一定立刻导致任务停止,而是会让某个节点在retrieveTrigger时失败,从而跳过本轮的某些处理逻辑。

在集群环境里我建议做两点额外检查:

  • 查看QRTZ_SCHEDULER_STATE表里LAST_CHECKIN_TIME字段是否会在每个节点每次 check-in 后更新。如果一直不更新,说明同样存在类型误判问题。
  • 确认QRTZ_LOCKS表中的锁记录状态,Quartz 取锁和释放锁也涉及 long 字段的读写。

确保所有连接串都加上了binaryTransfer=false,不只是 Quartz 专属数据源。很多人只改了 Quartz 的数据源,没改项目主数据源,结果主业务表一切正常,唯独调度表出问题——因为两个连接串的驱动参数不一样。

5.3 个人经验:这类“类型契约”问题的排查技巧

踩过这次坑之后,我对“框架 + 数据库 + 驱动”三者的类型契约有了更深的理解。这里分享几个平时排查非常管用的技巧:

  1. 看到\x前缀,优先怀疑 bytea。在 Kingbase8 里,\x几乎是 bytea 的身份证,不管错误信息在哪个框架里出现,第一排查方向都是“哪个字段被当成了 bytea”。
  2. 不要只盯着表结构。表结构只是冰山一角,驱动和数据库服务端的“兼容模式”、连接参数、驱动版本三个变量都要纳入排查范围。
  3. 写一个小型 JDBC 直连脚本。直接绕过框架,用原生 JDBC 查同一个表的同一列,用getObject().getClass()看返回类型。这样能立即判断问题是在框架层还是驱动层。
  4. 关注compatible_mode。人大金仓的compatible_mode是很多诡异问题的真正分水岭。如果生产环境必须用 Oracle 兼容模式,那基本注定要付出一些额外的代价,比如和 PG 生态的中间件兼容性下降。
  5. 把参数写进配置注释,防止后人误删。像binaryTransfer=false这种参数,单看名字很容易让人以为“关闭二进制传输会影响性能”而删掉。在配置注释里写清楚“去掉它 Quartz 会抛 Bad _value for type long”,就是最好的告警。

最后再补一个我后来发现的小细节。Quartz 的 JobKey 在序列化存储时,如果JOB_DATA字段里的对象太大,或者自定义序列化器没实现Serializable,也会出现类似Couldn't retrieve trigger的异常,但错误信息通常是JobPersistenceException: Couldn't retrieve job或 ClassCastException。本次这个Bad _value for type long是更纯粹的驱动类型问题,千万别混淆。如果以后你看到异常信息里同时出现byte[]和long,第一反应仍然应该回到驱动这一层。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询