jackson camunda - modric2jeff/archive GitHub Wiki

好 — 你遇到的“外部任务(External Task)启动延迟近分钟”是很常见的症状,通常不是单一原因,而是轮询/长轮询、客户端并发/线程、锁定与锁续期、回退策略、以及数据库/引擎性能几个方面共同作用的结果。下面把常见成因、可立刻试的优化项、诊断步骤和示例配置逐条给你 — 可直接照着做/验证。

一句总结(先给结论)

最常见的 culprit 是 客户端轮询策略(没有启用或误配长轮询 / backoff 策略导致等待)worker 并发/一次拉取数量太小。其次是数据库查询慢或锁竞争、引擎事务提交延迟、或 client-side 的回退(backoff)把下一次拉取推迟了。文档说明外部任务支持长轮询(asyncResponseTimeout),同时有 lockDuration、maxTasks、backoff 等要配合调优。([Camunda 7 文档]1)


1) 先做这几项“立刻能看见效果”的调整(顺序优先级高 → 低)

  1. 开启并正确配置长轮询(asyncResponseTimeout)

    • 原理:客户端发出 fetchAndLock 请求后,如果没有可用任务,服务器会把请求挂起(long-poll)。一旦有新任务,服务器会立即响应,从而减少等待轮询间隔造成的延迟。确认你已经启用了 asyncResponseTimeout(单位 ms),常见选择 10_000–30_000(10–30s)。过短会增加请求数,过长会在客户端重连/回退下出现次优表现;但不启用长轮询常会看到“每分钟才来一次”的情况。([Camunda 7 文档]2)
  2. 检查/关闭或调整 Backoff 策略

    • 如果 client 遇到“没有任务”的情况,很多实现会启用 backoff(指数退避)来降低请求频率。高延迟场景下要确保 backoff 未被误配置为很长(比如几万毫秒或更高),对实时性要求高的场景可以减小或禁用 backoff。论坛里很多类似延迟就是因为 backoff 配置导致的。([Camunda Forum]3)
  3. 提高 maxTasks 与 worker 并发线程数

    • maxTasks(一次 fetchAndLock 拉取数量)如果设置为 1,会导致频繁轮询。把 maxTasks 提高(例如 5、10、甚至更高,视 worker 能并行处理的能力)能让单次请求拿到更多任务,降低整体调度延迟。配合调整 worker 的线程池/并发数,确保抓到的任务能被并发处理,而不是排队等待本地处理线程。([Camunda 7 文档]4)
  4. 合理设置 lockDuration 与使用 Extend Lock

    • lockDuration 要覆盖 worker 处理任务的平均时间,太短会导致别的 worker 频繁抢锁;太长会降低吞吐。对于长耗时任务,使用 Extend Lock(在处理期间续锁)避免锁过期再被别的 worker 取走。([Camunda]5)
  5. 水平扩展 workers(更多实例)

    • 当触发大量流程时,多部署几台 worker 实例能显著降低单实例的响应等待。配合合理的线程池和 maxTasks,可以线性提升吞吐与降低延迟。([Camunda 8 文档]6)

2) 进一步排查和中层优化(建议按需做)

  1. 看数据库查询与索引

    • fetchAndLock 背后是对 ACT_RU_EXTERNAL_TASK 等运行时表的查询/更新。若 DB 查询慢或无合适索引、连接池耗尽,会导致 fetch 响应延迟。检查慢查询、表统计、必要时增加索引或优化 DB 参数(连接池、事务隔离、VACUUM/ANALYZE 等)。Camunda 的性能调优文档也建议关注 DB 层。([Camunda 8 文档]6)
  2. 观测引擎侧事务与提交延迟

    • 新建流程实例并创建 external task 时,external task 只有在启动事务提交后才对外可见。如果流程启动包含大量同步工作或事务很重,会延迟 external task 的“可见时间”。考虑把流程关键点拆成 asyncBefore/asyncAfter,减少单次事务重量(但这会影响一致性,需要评估)。同样,job executor 与事务配置也可影响性能。([Camunda 8 文档]6)
  3. 监控 fetch/lock 的时间线

    • 在 engine 和 client 上打开必要的 debug/metrics(或把 fetchAndLock 的耗时、返回行数、等待时间记录),确认是“客户端等待下一次轮询”还是“fetch 查询慢”还是“拿到任务后本地排队”。有针对性的指标能快速定位瓶颈点。
  4. 避免任务热点(Topic/锁集中)

    • 如果所有流程都使用同一个 topic,所有 worker 或者少数节点竞争同一类任务,会造成抢锁延迟。可以按业务拆 topic、或用多个 workerId/分片策略分散负载。

3) 具体诊断步骤(按顺序)

  1. 在 client 端打印/记录每次 fetchAndLock 的时间点与返回结果(是否挂起/阻塞了多久)。
  2. 检查 client 配置:asyncResponseTimeout、backoffStrategy、maxTasks、lockDuration、worker thread pool。
  3. 查询 DB:select * from ACT_RU_EXTERNAL_TASK order by lockexpiration_(查看创建时间 / lock info);检查是否有大量未处理任务堆积或频繁锁冲突。
  4. 在 engine 层打开 slow SQL 日志 / APM,查看 fetchAndLock/lock 更新的 SQL 是否慢。
  5. 适用小流量回放(stress test):增大并发 worker 或把 asyncResponseTimeout 改为短/长来验证延迟变化。

4) 常见配置示例(Java client / Spring Boot)

(仅示例,按你版本做小改动)

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)


5) 关于参数如何取值(经验性建议)

  • asyncResponseTimeout: 10_000–30_000 ms 常见平衡值(实时性 vs 请求数)。
  • maxTasks: 依据 worker 并发能力,10、20 也常见;不要把它设太低。
  • lockDuration: 要比平均处理耗时略长;若任务偶有长时耗,使用 extend lock。
  • backoff: 对低延迟场景,把回退降低或禁用;对节流/节省资源场景,可启用并加长回退。

6) 运营/长期优化建议

  • 熔断/限流与队列削峰:如果业务会产生突发流量(秒级大量流程),考虑在流程触发端做限流或使用消息队列做削峰,把外部任务产生平滑化。
  • 自动扩容 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 字段,并不会自动解析引用。


一、为什么直接 Map 会有问题

假设有这样一个类:

@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,不会被替换为引用。


二、正确反序列化成 Map 的方法

方案 1:先反序列化成原始类型,再转 Map

让 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 类型

方案 2:启用 DeserializationFeature.USE_JAVA_ARRAY_FOR_JSON_ARRAY 并自定义 ObjectIdResolver

如果你没有 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


方案 3:反序列化后做一次引用解析

如果不想改 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);

设计要点与说明(为什么这样做)

  1. readValue 用于字符串:因为传进来的字符串很可能是 JSON 文本(对象或数组),readValue 能正确解析 JSON。若目标类型就是 String,直接返回原字符串更合理(避免把普通文本误当 JSON)。
  2. convertValue 用于内存对象:当输入是 MapJsonNode、已有 POJO、集合等,convertValue 会在内存中进行转换(避免额外的序列化成字符串再解析),效率更高。
  3. 数组/集合特殊策略:输入数组但目标是单个 POJO 时,行为有歧义(抛错、取第一个、报不支持),因此将此行为设为可配置(allowArrayToSingle)。
  4. 回退策略:对字符串解析失败时,不直接失败——先尝试 convertValue(例如字符串 "123" 转成 Integer),但如果确实是非法 JSON 且目标是复杂类型,应把 readValue 的异常抛给上层,便于定位问题。
  5. 清晰的异常:当类型不匹配且没有合适回退时,抛出带上下文信息的异常,便于排查。

如果你愿意,我可以把上面代码封装成一个更完整的工具类,带上单元测试(JUnit)和常见场景的测试用例(字符串 JSON、空值、数组->单值、泛型 List/Map 转换等)。要我继续完善哪一部分?

⚠️ **GitHub.com Fallback** ⚠️