
听说 Java 反序列化很坑,直到我在线上碰到
工单完结之后,之前填写的工单内容竟然发生了变化。
我们在内部工单系统里发现了这个BUG。模板设计者可以给下拉框、表格列等字段配置动态数据源。工单填写时,用户看到的是当时的数据;流程完结后,页面仍会沿着原来的配置再次读取数据源。源数据有更新后,几个月前已经完成的工单也会跟着变。
这不是简单的显示不一致。已完结工单应该能还原当时的申请内容和审批依据。若历史页面展示的是后来变化过的数据,审计和追溯就失去了一个基本前提。
这篇记录的是一次修复过程。代码和日志均做了脱敏,但保留了当时的处理顺序、失败路径和判断依据。
先修新流程,再处理历史数据
新流程的修复不算难:在流程完结后增加处理切点,把需要冻结的动态字段保存为快照。之后查看已完结工单时,页面基于工单状态,判断是否读取快照数据,不再重新请求动态数据源。
这里有个容易遗漏的细节。快照不能只存一个 ID。动态下拉框往往同时有提交值和展示名称,表格字段还可能有多列数据。若只保留 ID,页面仍得回数据源查名称,资源一旦下线,历史记录还是解释不完整。快照应保留用户在完结时实际看到和提交的内容。
对应的处理结构大致如下。下面的变量和类名已经泛化,保留的是实际的遍历方式和判定逻辑。
Map<String, Object> formData = loadHistoricFormData(instanceId);
for (Map.Entry<String, Object> entry : formData.entrySet()) {
String fieldId = entry.getKey();
JSONObject field = findFieldDefinition(fieldId);
if (field == null) {
continue;
}
JSONObject props = field.getJSONObject("props");
JSONObject source = props == null ? null : props.getJSONObject("dataSource");
if (source == null) {
continue;
}
if ("snapshot".equals(source.getString("dataFormat"))) {
entry.setValue(formData.get(fieldId + "_value"));
} else if ("api".equals(source.getString("dsType"))) {
entry.setValue(queryOptionsFromApi(source, entry.getValue()));
}
}
saveSnapshot(instanceId, formData);代码看起来只是一次字段替换,实际依赖两个前提:拿到的必须是该流程实例当时的表单状态,字段配置也必须完整。不能拿今天的模板定义去覆盖昨天的工单,也不能假设所有旧模板都有 props.dataSource。这两个前提后来都在历史补冻结里暴露了问题。
新流程可以从发布日开始正确运行,历史工单却不会自动变好。于是我通过手动调用服务,对既有的完结流程做补冻结。这个入口有意保持可控,遇到异常可以停下、定位并重新执行,不会在后台留下半截结果。
当时的手动入口大概是这种形式。下面的流程实例 ID 和用户信息已替换,只保留调用方式:
@Test
void shouldExecuteFreezeForCompletedInstances() {
Passport passport = new Passport();
passport.setUserId("000001");
List<String> instanceList = new ArrayList<>();
instanceList.add("00449c67-2464-11ee-8be3-xxx");
instanceList.forEach(instanceId -> {
processInstanceService.afterComplete(instanceId, passport);
});
}这段代码不是一个合格的自动化测试,因为它没有断言。它更像一个受控的补偿入口。后来我会把它拆成正式的批处理任务,记录待处理实例、执行状态、失败原因和重试次数。
先后出现的异常,不能混成一个根因
第一次运行历史补冻结时,我没有马上看到最终的反序列化问题。前面先出现了几个真实但不同层面的异常。
有些更早的流程没有查到对应的模型历史记录。代码随后把空对象传给转换工具,触发了 Target object must not be null。它说明历史数据不完整时,补冻结缺少边界处理,与序列化无关。修复方式也应当简单直接:记录流程实例,跳过这条记录或进入人工处理队列,不能让它继续执行后面的快照逻辑。
FlowModelHistory history = findHistoryModel(instanceId);
if (history == null) {
log.warn("skip freeze: missing history model, instanceId={}", instanceId);
return;
}另一个问题出现在旧字段配置上。原来的判断直接调用 source.containsKey(...),默认 props 和 dataSource 一定存在。历史模板并不总满足这个假设,空指针异常因此出现。这个修复没有技巧,要注意不要用 value.equals("snapshot") 这类调用把空值再变成一次异常:
JSONObject props = field.getJSONObject("props");
JSONObject source = props == null ? null : props.getJSONObject("dataSource");
if (source != null && "snapshot".equals(source.getString("dataFormat"))) {
// 使用已经保存的展示值
}这些异常都修好后,历史补冻结仍然失败,才进入更耗时的部分。
异常发生在取值时,不代表问题发生在取值时
历史表单定义保存在 Flowable 的流程变量中。对 serializable 类型变量来说,变量表里通常只保存一条引用,字节内容在字节数组表里。脱敏后可以理解成下面这种关系:
SELECT
v.NAME_,
v.VAR_TYPE_,
v.BYTEARRAY_ID_,
b.NAME_ AS bytearray_name
FROM ACT_HI_VARINST v
LEFT JOIN ACT_GE_BYTEARRAY b
ON v.BYTEARRAY_ID_ = b.ID_
WHERE v.PROC_INST_ID_ = :processInstanceId
AND v.VAR_TYPE_ = 'serializable';读取变量值时,Flowable 会沿着 BYTEARRAY_ID_ 找到字节内容,再把它反序列化为 Java 对象。调用链并不长:
afterComplete(...)
-> HistoricVariableInstance.getValue()
-> SerializableType.getValue()
-> SerializableType.deserialize(...)日志一开始指向表单对象:
Couldn't deserialize object in variable 'FLOW_FORMS'
Read class descriptor: java.util.ArrayList
Read class descriptor: com.eflow.core.domain.process.form.Form后来又出现了字段变量无法读取:
Couldn't deserialize object in variable 'field_3454162267896'
Read class descriptor: com.alibaba.fastjson.JSONArray继续往下看,证据落在 InvalidClassException 上:
Caused by: java.io.InvalidClassException:
com.alibaba.fastjson.JSONArray;
local class incompatible:
stream classdesc serialVersionUID = 1,
local class serialVersionUID = 3642856726642946224这说明问题不是某个字段取值写错了。历史字节流里保存的 JSONArray 类描述,和当前运行时加载到的 JSONArray 类已经不兼容。
换句话说,历史变量保存的不是一份抽象的“表单数据”,而是一整个 Java 对象图。它把当时的类结构和第三方库类型一起存进了数据库。后来调整依赖,或者让多个模块带入不同版本的 JSON 库,都可能让旧字节流失去兼容性。
当时我也尝试过把读出来的对象先转成 JSON,再转回表单对象。这个方向没有解决根因,反而先暴露出表单类型和枚举类型的转换异常。原因很简单:原生反序列化尚未成功,后面再加一层 JSON 转换,只是在错误对象上继续做转换。
批量补偿时可以捕获这类异常,但不能简单吞掉。某个字段变量无法反序列化时,可以跳过这条变量并记录失败原因,避免整批任务中断;如果缺失的是生成快照必须依赖的数据,就应该把流程实例标记为“需要人工确认”,而不是继续写一份不完整快照。
Object value;
try {
value = variable.getValue();
} catch (FlowableException ex) {
if (containsCause(ex, InvalidClassException.class)) {
freezeResult.markFailed(
processInstanceId,
variable.getVariableName(),
"DESERIALIZE_FAILED",
ex.getMessage()
);
continue;
}
throw ex;
}依赖树要在排查中间就看
定位到第三方 JSON 类型后,下一步不是继续改业务代码,而是确认运行时到底加载了哪个版本。这里不能只看当前模块的 pom.xml,因为传递依赖同样会进入应用。
mvn dependency:tree -Dverbose | findstr /i "fastjson json"我用依赖树核对各模块的引入来源,再把 JSON 库版本统一并锁定到能读取历史变量的兼容版本。这个动作解决的是历史字节流可读性,不是“哪个版本更新就用哪个版本”。对已经序列化进库的数据来说,兼容性优先于新特性。
这里也不能把“统一依赖版本”理解成通用解法。它只适合当前系统还需要读取旧字节流、并且能确认旧字节流对应的类版本时使用。如果字节流来自多个历史版本,或者依赖包已经无法安全回退,就应该考虑单独写迁移工具,在隔离环境里逐批读取旧数据并转换成新的快照格式。
版本统一后,历史变量可以被读取,补冻结服务才开始处理数据。新的快照逻辑不再把第三方 JSON 对象直接放进长期保存的流程变量。需要长期保留的内容,优先转换成稳定的 JSON 文本,或只保存业务主键并从业务表读取。这样未来升级时,流程历史不会和某个运行时库版本绑得太紧。
验证要覆盖“数据源真的变了”这个场景
依赖修复后,我重新手动执行历史冻结服务,并从两个方向确认结果:页面打开已完结工单,检查字段显示;数据库查询快照记录,确认写入确实发生。
数据库侧我会至少查两类信息。一类是快照记录是否存在,另一类是同一个流程实例是否重复写入:
SELECT
process_instance_id,
COUNT(*) AS snapshot_count,
MAX(frozen_at) AS last_frozen_at
FROM wf_form_snapshot
WHERE process_instance_id IN (:instanceIds)
GROUP BY process_instance_id;如果补偿任务允许重试,还要有幂等约束。比如同一个流程实例只能有一条当前有效快照,重复执行应该返回已有结果或先做内容校验,不能无条件覆盖。
如果再做一次同类补偿,我会再补一项验证:修改测试数据源里的选项,再重新打开已经冻结的工单。旧工单仍展示完结时的值,才说明读取路径已经从动态数据源切换到快照。页面检查能发现渲染和权限问题,数据库检查能确认写入,数据源变化后的回看才能验证冻结本身。
在执行前后保留一份可核对的清单:待处理数量、抽样实例、成功数、跳过原因和失败项。历史补偿不是跑通一次接口就结束了。它要能停、能重试,也要能回答某一条工单究竟有没有被处理过。
下一步不是给老对象继续打补丁
这次能恢复历史数据,依赖的是把 JSON 库版本临时收敛到兼容旧字节流的状态。它是一次必要的迁移手段,不应该成为长期方案。只要流程变量里仍存放 Java 原生序列化对象,下一次改类、升级依赖或拆分模块时,类似问题还会回来。
更稳妥的方向是把长期保存的数据分成两类。流程引擎变量只保留流程推进需要的简单信息,例如业务主键、状态、操作人和少量标量值。需要在历史页面展示的表单快照,放到独立的业务表中,以 JSON 文本和明确的版本号保存。
public class FormSnapshotRecord {
private String processInstanceId;
private Integer schemaVersion;
private String payloadJson;
private LocalDateTime frozenAt;
}这个结构没有试图把某个运行时对象“原样保存”。payloadJson 是有意定义的持久化格式,schemaVersion 用来说明它遵循哪一版字段约定。以后表单结构变化时,可以为新版本增加读取策略或迁移脚本,而不是让 JVM 猜测十年前的对象该怎样还原。
对已经存在的历史变量,也应该有一次明确的退出计划:先在兼容环境里读取旧对象,转换为新的快照记录,记录每条迁移结果,再让新的读取路径不再依赖旧变量。这样依赖版本锁定只承担过渡职责,不会变成项目里谁也不敢动的“遗留配置”。
批量补偿本身也该成为一个可运营的任务,而不是手动调用一次服务就结束。至少需要有状态记录、幂等键、失败原因和重试入口。一个简单的处理框架可以是:
待处理 -> 执行中 -> 已冻结
|-> 可重试失败
|-> 需要人工确认同一流程实例重复执行时,应直接返回已有快照或进行内容校验,不能重复覆盖。遇到模型历史缺失、变量无法读取或字段配置不完整时,要留下可查询的失败原因,而不是只在日志里出现一段堆栈。
依赖管理也需要前移。构建阶段可以检查依赖树,禁止同一 JSON 库的多个不兼容版本同时进入最终应用;涉及序列化模型的改动,则应保留旧版本数据样本做兼容性测试。测试不只验证“新对象能写能读”,还要验证升级后的程序能否读取旧快照,或者能否把旧快照迁移到新结构。
@Test
void shouldReadOrMigrateSnapshotWrittenByPreviousVersion() {
FormSnapshotRecord legacy = fixture("snapshot-v1.json");
FormSnapshot snapshot = snapshotReader.read(legacy);
assertThat(snapshot.getSchemaVersion()).isEqualTo(CURRENT_VERSION);
assertThat(snapshot.getFields()).isNotEmpty();
}这次排查让我把“流程完结”理解得更具体了一点。接口返回成功只是一个时间点,动态内容还需要被固定下来。长期数据也要有稳定、可演进的格式,不能把当前 JVM 恰好能恢复对象当成设计前提。这样下一次升级时,历史记录才不会又变成一团无法读取的字节。
