消息消费者的三次 receive 不一定对应三条不同的业务消息;一次回滚会把同一条消息再次交付。若硬把三次消费都写成生产 Span 的 child,就会掩盖跨时间、跨消费尝试的边界:生产与消费可以各有独立 Trace,用 Link 指向产生这条消息的 Trace。本文用真实 Java JMS 客户端和本机嵌入式 broker 重投 A,检查生产、重复消费及 Agent 发出的 Span 到底是哪种关系。

本次冻结 Java SDK 1.31.0 源码 SHA c25c0a0ee0da01ab2f74ba83052d1c249ed57020、instrumentation/Agent 1.31.0 源码 SHA 97d87f3f3b2bc61aa396d71a98387a1e857618af、完整 JDK 21.0.12。只为 E02 的测试 classpath引入 ActiveMQ Classic broker/client 5.18.3 与 jakarta.jms:jakarta.jms-api:2.0.3;后者虽然 Maven group 为 Jakarta,实际类是 javax.jms。包括传递依赖的 JAR SHA-256 及本地校验/发行信任界限见 writing-plans/opentelemetry-java/VERSIONS.md。不启动外部 broker、容器或生产存储;BrokerService 在每个实验进程内关闭持久化,用 VM transport 跑真实 JMS send/receive/rollback。

固定 SDK SHA 的 SdkSpanBuilder.java L86–116 对有效 SpanContext 建 Link 并计数/截断属性;startSpan L169–201 单独决定父 Context/Trace ID。Link 不是父指针,不能从 link 有相同 Trace ID 推出 consumer 与 producer 属于同一 Trace。上游 SdkSpanBuilderTest.java L82–103 区分有效和无效 Link,此处只静态阅读,没有运行上游测试。

固定 instrumentation SHA 的 JMS 1.1 模块 JmsMessageProducerInstrumentation.java L30–53 匹配 javax.jms.MessageProducer,包装 send;JmsMessageConsumerInstrumentation.java L28–88 匹配 receive,没有消息时不创建接收 Span。该模块 build.gradle.kts L5–21 在 muzzle 中区分 javax.jms 与 JMS 3 命名空间;上游 Jms1InstrumentationTest.java L92–150 将 receive Span 与生产 Span 做 Link 对照。这些路径本次只静态核对,没有运行上游 Gradle muzzle/check;本地兼容结论来自后续真实 Agent JVM。

两条独立运行的对照

每个进程发送 A/B 两条消息,并用一个 transacted JMS session 定序:批量发送后 commit;第一次收到 A 后 rollback;重投再次收到 A 并 commit;然后收到 B 并 commit。每次 receive(5000) 有截止;JMSMessageID 前两次相同,第二次 JMSRedelivered=true,所以三次消费只对应两个不同业务消息。

1
2
3
4
5
6
7
8
9
producer.send(session.createTextMessage("A"));
producer.send(session.createTextMessage("B"));
session.commit();
TextMessage first = (TextMessage) consumer.receive(5000);
session.rollback();
TextMessage retry = (TextMessage) consumer.receive(5000);
session.commit();
assertEquals(first.getJMSMessageID(), retry.getJMSMessageID());
assertTrue(retry.getJMSRedelivered());
实验进程 生产 Span 三次接收 Span 对照的关键 ID
不挂 Agent,SDK 手动建模 一个 manual-producer root 三个新 root,各显式 addLink(producer SpanContext) 三个消费的 parentSpanId 无效、Trace ID 不等于 producer、Link Trace ID 均等于 producer
挂 Agent 1.31.0,仅 Agent 全局 API 一个 e02-parent + 两个 e02.queue publish 三个 e02.queue receive 两个 publish 的 parentSpanId 是 e02-parent 的 Span ID;三个 receive 分属新 Trace,Link Trace ID 均指向生产者 Trace

工程目录运行 JAVA_HOME=/tmp/otel-20260930/jdk-extract/usr/lib/jvm/java-21-openjdk-amd64 ./mvnw -pl sdk-labs -Dtest=E02Test test,退出 0;JMS 消息次序、随机 ID、Agent 解析到的六个 OTLP protobuf Span 的父/Link 断言与原始 stdout 见 examples/opentelemetry-java/evidence/E02/RUN.md。Agent JVM 没有初始化第二套 SDK;parent Span 经教学 OTLP/HTTP 端点解析,未进入真实 Collector/后端。手动实验里的 producer Trace ID 与 Agent 子进程的 Trace ID 彼此独立,不能把两轮不同随机值拼成同一条分布式链。

边界与常见误解

“A 被投递两次,肯定产生两条新的 producer Span”与本次观测相反:两次 publish 对应 A/B 两次发送,重试发生在 broker 向 consumer 的重投阶段;观察三个 receive 中 A 的 JMSMessageID 一致才能判断重复消费。“消费 Span 没有 parent,因此它是孤立的”也不成立:三个新 root 的 Link 指回 producer Trace。上游还有只读消息属性导致无法写入传播字段的反例;若消息属性或协议剥离上下文,不能凭模块 matcher 就许诺 Link 一定存在。这里是嵌入式内存 broker 和 Agent 1.31.0/JDK 21/JMS 2.0.3 的一次组合验收,未覆盖真实集群、持久化崩溃恢复、批量事务跨进程一致性、JMS 3、Metric/Log OTLP 或生产查询。

两道练习:

  1. 修改 E02Probe.flow,在消费 B 前再 rollback 一次、记录每个 JMSMessageID 的接收次数和 Agent receive Span/Link 数;构造“生产 2 条但消费尝试 4 次”的反例。不要按 getText() 的值而不是 message ID 统计去重。
  2. 在 send 前将消息属性标为只读或清除传播字段,保持同样的 rollback 流程;先实际运行再判断 receive Span 的 Link 是否消失,对照上游只读属性测试,并解释为什么“不存在 Link”不等于“消息没有成功消费”。

参考资料:Java SDK addLink(固定 SHA)、JMS 1.1 模块测试(固定 SHA)、ActiveMQ Broker 5.18.3 Maven POM、examples/opentelemetry-java/evidence/E02/RUN.md。

导航:E01 Reactor 与虚拟线程传播 · 当前篇:E02 JMS 重试与 Link。