jackson camunda - modric2jeff/archive GitHub Wiki
好 — 你遇到的“外部任务(External Task)启动延迟近分钟”是很常见的症状,通常不是单一原因,而是轮询/长轮询、客户端并发/线程、锁定与锁续期、回退策略、以及数据库/引擎性能几个方面共同作用的结果。下面把常见成因、可立刻试的优化项、诊断步骤和示例配置逐条给你 — 可直接照着做/验证。
最常见的 culprit 是 客户端轮询策略(没有启用或误配长轮询 / backoff 策略导致等待) 和 worker 并发/一次拉取数量太小。其次是数据库查询慢或锁竞争、引擎事务提交延迟、或 client-side 的回退(backoff)把下一次拉取推迟了。文档说明外部任务支持长轮询(asyncResponseTimeout),同时有 lockDuration、maxTasks、backoff 等要配合调优。([Camunda 7 文档]1)
-
开启并正确配置长轮询(asyncResponseTimeout)
- 原理:客户端发出 fetchAndLock 请求后,如果没有可用任务,服务器会把请求挂起(long-poll)。一旦有新任务,服务器会立即响应,从而减少等待轮询间隔造成的延迟。确认你已经启用了
asyncResponseTimeout(单位 ms),常见选择 10_000–30_000(10–30s)。过短会增加请求数,过长会在客户端重连/回退下出现次优表现;但不启用长轮询常会看到“每分钟才来一次”的情况。([Camunda 7 文档]2)
- 原理:客户端发出 fetchAndLock 请求后,如果没有可用任务,服务器会把请求挂起(long-poll)。一旦有新任务,服务器会立即响应,从而减少等待轮询间隔造成的延迟。确认你已经启用了
-
检查/关闭或调整 Backoff 策略
- 如果 client 遇到“没有任务”的情况,很多实现会启用 backoff(指数退避)来降低请求频率。高延迟场景下要确保 backoff 未被误配置为很长(比如几万毫秒或更高),对实时性要求高的场景可以减小或禁用 backoff。论坛里很多类似延迟就是因为 backoff 配置导致的。([Camunda Forum]3)
-
提高
maxTasks与 worker 并发线程数-
maxTasks(一次 fetchAndLock 拉取数量)如果设置为 1,会导致频繁轮询。把maxTasks提高(例如 5、10、甚至更高,视 worker 能并行处理的能力)能让单次请求拿到更多任务,降低整体调度延迟。配合调整 worker 的线程池/并发数,确保抓到的任务能被并发处理,而不是排队等待本地处理线程。([Camunda 7 文档]4)
-
-
合理设置
lockDuration与使用 Extend Lock -
水平扩展 workers(更多实例)
- 当触发大量流程时,多部署几台 worker 实例能显著降低单实例的响应等待。配合合理的线程池和
maxTasks,可以线性提升吞吐与降低延迟。([Camunda 8 文档]6)
- 当触发大量流程时,多部署几台 worker 实例能显著降低单实例的响应等待。配合合理的线程池和
-
看数据库查询与索引
-
fetchAndLock背后是对ACT_RU_EXTERNAL_TASK等运行时表的查询/更新。若 DB 查询慢或无合适索引、连接池耗尽,会导致 fetch 响应延迟。检查慢查询、表统计、必要时增加索引或优化 DB 参数(连接池、事务隔离、VACUUM/ANALYZE 等)。Camunda 的性能调优文档也建议关注 DB 层。([Camunda 8 文档]6)
-
-
观测引擎侧事务与提交延迟
- 新建流程实例并创建 external task 时,external task 只有在启动事务提交后才对外可见。如果流程启动包含大量同步工作或事务很重,会延迟 external task 的“可见时间”。考虑把流程关键点拆成
asyncBefore/asyncAfter,减少单次事务重量(但这会影响一致性,需要评估)。同样,job executor 与事务配置也可影响性能。([Camunda 8 文档]6)
- 新建流程实例并创建 external task 时,external task 只有在启动事务提交后才对外可见。如果流程启动包含大量同步工作或事务很重,会延迟 external task 的“可见时间”。考虑把流程关键点拆成
-
监控 fetch/lock 的时间线
- 在 engine 和 client 上打开必要的 debug/metrics(或把 fetchAndLock 的耗时、返回行数、等待时间记录),确认是“客户端等待下一次轮询”还是“fetch 查询慢”还是“拿到任务后本地排队”。有针对性的指标能快速定位瓶颈点。
-
避免任务热点(Topic/锁集中)
- 如果所有流程都使用同一个 topic,所有 worker 或者少数节点竞争同一类任务,会造成抢锁延迟。可以按业务拆 topic、或用多个 workerId/分片策略分散负载。
- 在 client 端打印/记录每次 fetchAndLock 的时间点与返回结果(是否挂起/阻塞了多久)。
- 检查 client 配置:asyncResponseTimeout、backoffStrategy、maxTasks、lockDuration、worker thread pool。
- 查询 DB:
select * from ACT_RU_EXTERNAL_TASK order by lockexpiration_(查看创建时间 / lock info);检查是否有大量未处理任务堆积或频繁锁冲突。 - 在 engine 层打开 slow SQL 日志 / APM,查看 fetchAndLock/lock 更新的 SQL 是否慢。
- 适用小流量回放(stress test):增大并发 worker 或把 asyncResponseTimeout 改为短/长来验证延迟变化。
(仅示例,按你版本做小改动)
Java ExternalTaskClient builder 示例:
ExternalTaskClient client = ExternalTaskClient.create()
.baseUrl("http://camunda:8080/engine-rest")
.asyncResponseTimeout(20_000) // 开启长轮询,20s
.maxTasks(10) // 每次最多拉取 10 个任务
.lockDuration(120_000) // 锁定 120s(根据处理时间调整)
.build();Spring Boot properties(常见 key,部分社区 starter):
camunda.bpm.client.base-url=http://localhost:8080/engine-rest
camunda.bpm.client.async-response-timeout=20000
camunda.bpm.client.max-tasks=10
camunda.bpm.client.lock-duration=120000
camunda.bpm.client.worker-id=worker-01
提示:若你使用的是 Spring Boot Starter,请确认 starter 版本对应的 property 名称(starter 文档与源码会说明)。([CSDN博客]7)
-
asyncResponseTimeout: 10_000–30_000 ms 常见平衡值(实时性 vs 请求数)。 -
maxTasks: 依据 worker 并发能力,10、20 也常见;不要把它设太低。 -
lockDuration: 要比平均处理耗时略长;若任务偶有长时耗,使用 extend lock。 -
backoff: 对低延迟场景,把回退降低或禁用;对节流/节省资源场景,可启用并加长回退。
- 熔断/限流与队列削峰:如果业务会产生突发流量(秒级大量流程),考虑在流程触发端做限流或使用消息队列做削峰,把外部任务产生平滑化。
- 自动扩容 workers:为高峰自动扩容 worker 实例(容器/云环境)。
- 埋点与指标:记录 fetch latency、task wait time、DB query time,配 Prometheus/Grafana 做告警。
- 用 CI 压测复现:写一个并发脚本模拟 N 个流程并发创建,观察不同调参下的延迟曲线。
- Camunda External Tasks(官方,说明 long polling/asyncResponseTimeout). ([Camunda 7 文档]1)
- ExternalTaskClientBuilder javadoc(说明 asyncResponseTimeout 等). ([Camunda 7 文档]2)
- 社区论坛:关于 asyncResponseTimeout / maxTasks / backoff 的讨论(常见导致延迟的配置). ([Camunda Forum]3)
- Camunda 性能调优文档(Camunda7 性能最佳实践、DB/引擎侧建议). ([Camunda 8 文档]6)
如果你愿意,我可以帮你做下一步:
- 看看你现在的
application.yml/ ExternalTaskClient 配置并指出具体改动;或 - 给出一组压测脚本(JMeter / k6)和一份跑测方案,帮你复现并验证调优效果。
你要我先看哪部分配置或日志?
好的,这个问题其实踩坑的人挺多——
@JsonIdentityInfo 会在序列化时引入一个对象标识属性(默认是 "@id"、"@ref" 这种),用来处理对象引用和循环依赖。
反序列化成 Java Bean 时,Jackson 会根据这个标识自动解析引用关系;
但如果你只是想反序列化为 Map<String, Object>,默认行为会“原封不动”保留 @id / @ref 字段,并不会自动解析引用。
假设有这样一个类:
@Data
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class Person {
private String id;
private String name;
private Person friend;
}序列化后可能得到:
{
"id": "1",
"name": "Alice",
"friend": {
"@ref": "1"
}
}当你 readValue(json, Map.class) 时:
ObjectMapper mapper = new ObjectMapper();
Map<?, ?> map = mapper.readValue(json, Map.class);
System.out.println(map);结果:
{id=1, name=Alice, friend={@ref=1}}此时 @ref 只是普通的 key,不会被替换为引用。
让 Jackson 先走一次 POJO 反序列化(这样它会解析 @ref),然后用 ObjectMapper.convertValue 转 Map:
Person p = mapper.readValue(json, Person.class);
Map<String, Object> map = mapper.convertValue(p, new TypeReference<Map<String, Object>>() {});这样 Map 中的 "friend" 就是一个 Map,而不是 {@ref=...}。
优点:
- 利用 Jackson 的引用解析机制 缺点:
- 必须先有 POJO 类型
如果你没有 POJO 类型,可以写一个自定义 ObjectIdResolver,在解析 Map 时处理 @ref。
public class MapObjectIdResolver implements ObjectIdResolver {
private final Map<Object, Object> idMap = new HashMap<>();
@Override
public void bindItem(ObjectIdGenerator.IdKey id, Object pojo) {
idMap.put(id.key, pojo);
}
@Override
public Object resolveId(ObjectIdGenerator.IdKey id) {
return idMap.get(id.key);
}
@Override
public ObjectIdResolver newForDeserialization(Object context) {
return new MapObjectIdResolver();
}
@Override
public boolean canUseFor(ObjectIdResolver resolverType) {
return resolverType.getClass() == getClass();
}
}然后在 ObjectMapper 注册 SimpleModule,让 Map 的反序列化过程走 @JsonIdentityInfo:
SimpleModule module = new SimpleModule();
module.addDeserializer(Map.class, new JacksonMapWithIdentityDeserializer());
mapper.registerModule(module);这样你就能直接解析成 Map 并解析掉 @ref。
如果不想改 Jackson 配置,可以反序列化成 Map 后,自己写一个递归方法:
- 扫描所有 map
- 找到
@id时缓存 - 找到
@ref时替换为缓存中的对象
例:
private static void resolveRefs(Map<String, Object> map, Map<String, Map<String, Object>> idCache) {
if (map.containsKey("@id")) {
idCache.put(map.get("@id").toString(), map);
}
if (map.containsKey("@ref")) {
String ref = map.get("@ref").toString();
map.clear();
map.putAll(idCache.get(ref));
}
for (Object v : map.values()) {
if (v instanceof Map) {
resolveRefs((Map<String, Object>) v, idCache);
}
}
}如果你只是为了方便数据处理,最简单安全的做法是方案 1:
先反序列化成 Bean,让 Jackson 帮你处理
@JsonIdentityInfo的引用,再转 Map。 这样完全复用 Jackson 的解析逻辑,避免自己处理@ref。
如果你愿意,我可以帮你直接写一个 通用 Map 解析器,让 Jackson 在解析 Map 时自动处理 @JsonIdentityInfo 引用,这样你 readValue(json, Map.class) 就能直接用。
你是想让我帮你写这个通用解析器吗?这样后续就不用每次手动处理了。
好——下面给你一套简洁、健壮、可复用的 Jackson 对象转换策略(Java 代码 + 说明)。目标是满足你说的思路:
- 字符串(JSON 文本)→ 使用
readValue尝试解析(因为字符串可能是完整的 JSON)。 - 数组/集合/ArrayNode → 需要特殊处理(取决于目标类型是集合还是单个 POJO,可配置)。
- Map / JsonNode / 已经是对象 → 使用
convertValue(内存级转换,快速)。 - 其他基础类型(或无法安全转换)→ 抛出异常(或按策略降级处理)。
下面代码包含:1)通用 API(支持 Class<T> 和 JavaType),2)可选策略 allowArrayToSingle(当输入是数组但目标是单个对象时是否取第一个元素),3)清晰的错误信息与回退逻辑。
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.type.TypeFactory;
import java.io.IOException;
import java.util.Collection;
import java.util.Map;
public final class JacksonConverter {
private JacksonConverter() {}
/**
* 高级入口:接受 JavaType(支持泛型)
*/
public static <T> T convertTo(Object input, JavaType targetType, ObjectMapper mapper, boolean allowArrayToSingle)
throws IOException {
if (input == null) return null;
Class<?> raw = targetType.getRawClass();
// 已经是目标类型(避免不必要序列化/反序列化)
if (raw.isInstance(input)) {
@SuppressWarnings("unchecked")
T casted = (T) input;
return casted;
}
// JSON 文本(字符串)首选用 readValue(严格解析 JSON)
if (input instanceof String) {
String s = (String) input;
// 如果目标是 String,直接返回原始字符串(避免把普通文本当 JSON 去解析)
if (raw == String.class) {
@SuppressWarnings("unchecked")
T casted = (T) s;
return casted;
}
try {
return mapper.readValue(s, targetType);
} catch (JsonProcessingException e) {
// 如果 readValue 失败(不是合法 JSON),尝试使用 convertValue 作为后备(比如目标是简单类型)
try {
return mapper.convertValue(s, targetType);
} catch (IllegalArgumentException convEx) {
// 抛出原始解析异常以便上层知道是 JSON 解析问题
throw e;
}
}
}
// JsonNode(树) -> 使用 convertValue(比先序列化成字符串再反序列化更快)
if (input instanceof JsonNode) {
return mapper.convertValue(input, targetType);
}
// Map / Collection / Array -> convertValue(但做一些策略判断)
if (input instanceof Map) {
return mapper.convertValue(input, targetType);
}
if (input instanceof Collection || input.getClass().isArray()) {
// 如果目标类型是集合或数组,直接 convertValue
if (Collection.class.isAssignableFrom(raw) || raw.isArray()) {
return mapper.convertValue(input, targetType);
}
// 输入是数组但目标是单个 POJO:根据策略决定是取第一个元素还是抛异常
if (allowArrayToSingle) {
// 取第一个元素(如果能取到),并递归转换
Object first = firstFromArrayLike(input);
if (first == null) return null;
return convertTo(first, targetType, mapper, false);
} else {
throw new IllegalArgumentException("输入为数组/集合,但目标类型不是数组/集合;设置 allowArrayToSingle=true 才会尝试取第一个元素。");
}
}
// 基本包装类型(Number/Boolean/Character)或任意 POJO -> 使用 convertValue(内存级转换)
if (input instanceof Number || input instanceof Boolean || input instanceof Character) {
return mapper.convertValue(input, targetType);
}
// 最后一招:尝试 convertValue(适用于任意 Java 对象 -> POJO)
try {
return mapper.convertValue(input, targetType);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("无法将类型 " + input.getClass().getName() + " 转换为 " + targetType + "。", e);
}
}
/**
* 便利方法:接受 Class<T>
*/
public static <T> T convertTo(Object input, Class<T> targetClass, ObjectMapper mapper) throws IOException {
return convertTo(input, mapper.getTypeFactory().constructType(targetClass), mapper, false);
}
/**
* 兼容方法:接受 Class<T> 并允许数组->单值策略
*/
public static <T> T convertTo(Object input, Class<T> targetClass, ObjectMapper mapper, boolean allowArrayToSingle)
throws IOException {
return convertTo(input, mapper.getTypeFactory().constructType(targetClass), mapper, allowArrayToSingle);
}
// 从集合/数组中取第一个元素(若无法取到则返回 null)
private static Object firstFromArrayLike(Object arrayLike) {
if (arrayLike == null) return null;
if (arrayLike.getClass().isArray()) {
Object[] arr = (Object[]) arrayLike;
return arr.length > 0 ? arr[0] : null;
}
if (arrayLike instanceof Collection) {
Collection<?> c = (Collection<?>) arrayLike;
return c.isEmpty() ? null : c.iterator().next();
}
return null;
}
}ObjectMapper mapper = new ObjectMapper();
// JSON 字符串 -> POJO
String json = "{\"id\":1,\"name\":\"foo\"}";
MyPojo p = JacksonConverter.convertTo(json, MyPojo.class, mapper);
// Map -> POJO(内存转换)
Map<String,Object> map = Map.of("id", 1, "name", "foo");
MyPojo p2 = JacksonConverter.convertTo(map, MyPojo.class, mapper);
// Array -> List<MyPojo>
List<MyPojo> list = JacksonConverter.convertTo(someArrayObject,
mapper.getTypeFactory().constructCollectionType(List.class, MyPojo.class), mapper, false);
// 当输入是数组但你想取第一个并转成单个 POJO:
MyPojo maybe = JacksonConverter.convertTo(someArrayObject, MyPojo.class, mapper, true);-
readValue用于字符串:因为传进来的字符串很可能是 JSON 文本(对象或数组),readValue能正确解析 JSON。若目标类型就是String,直接返回原字符串更合理(避免把普通文本误当 JSON)。 -
convertValue用于内存对象:当输入是Map、JsonNode、已有 POJO、集合等,convertValue会在内存中进行转换(避免额外的序列化成字符串再解析),效率更高。 -
数组/集合特殊策略:输入数组但目标是单个 POJO 时,行为有歧义(抛错、取第一个、报不支持),因此将此行为设为可配置(
allowArrayToSingle)。 -
回退策略:对字符串解析失败时,不直接失败——先尝试
convertValue(例如字符串"123"转成Integer),但如果确实是非法 JSON 且目标是复杂类型,应把readValue的异常抛给上层,便于定位问题。 - 清晰的异常:当类型不匹配且没有合适回退时,抛出带上下文信息的异常,便于排查。
如果你愿意,我可以把上面代码封装成一个更完整的工具类,带上单元测试(JUnit)和常见场景的测试用例(字符串 JSON、空值、数组->单值、泛型 List/Map 转换等)。要我继续完善哪一部分?