为什么我设置的 contentLength 到了消费端变成了 0?

------ 一次 Spring 抽象泄漏的排查

感谢 Trinity-AjitaniHifumi (あじたに ひふみ),你为这篇博客提供了重要的 Pull Request.

引子

我正在做一个 Linux 内核邮件列表分析器

在给邮件消费端死信日志补上下文的时候,我想把消息体大小也记进去。于是在 MessagePostProcessor 里写了这么一行:

java 复制代码
final MessagePostProcessor messagePostProcessor
    = (message) -> {
        final MessageProperties messageProperties
            = message.getMessageProperties();
       
       // 手动写入序列化后消息体的长度
       messageProperties.setContentLength(message.getBody().length);
       messageProperties.setMessageId(messageId);
       messageProperties.setHeader("email-snowflake-id", emailId);

      return message;
};

this.rabbitTemplate.convertAndSend(
    this.properties.getExchangeName(),
    this.properties.getRoutingKey(),
    kernelEmail,
    messagePostProcessor,
    correlationData
);

生产端日志确认它执行了:

log 复制代码
15:08:10.030 DEBUG KernelEmailPusherImpl - message content length: 2153, body: [123, 34, 50, 48, 57, 48, ....]

2153 字节,没问题。

消费端打印出来的消息元数据:

json 复制代码
{
  "messageId" : "<2bf142b04caf4c578cfbc57740490632@infineon.com>",
  "exchange" : "lkml.exchange",
  "routingKey" : "lkml.key",
  "contentType" : "application/json",
  "contentEncoding" : "UTF-8",
  "contentLength" : 0,          ← ???
  "keyHeaders" : {
    "email-snowflake-id" : 2090335061846261760
  }
}

contentLength 是 0。

一、排查的关键:看什么幸存了

第一反应是「post-processor 没生效」。但这个假设立刻被推翻了------同一个 post-processor 里设置的另外两样东西都到了

java 复制代码
messageProperties.setContentLength(message.getBody().length);        // ❌ 变成 0
messageProperties.setMessageId(messageId);                            // ✅ 到了
messageProperties.setHeader("email-snowflake-id", emailId);          // ✅ 到了

这个对比就是定位的全部。

如果是执行路径的问题,三个字段应该全都 丢失。只丢一个,说明问题出在这个字段的特殊性上。

这个思路在排查序列化、跨进程传输、ORM 映射时都好使:先看什么幸存了,差异就在幸存者和遇难者之间。

二、根因:content-length 不是 AMQP 协议字段

AMQP 0-9-1 规范里,basic class 定义了 14 个 properties

xml 复制代码
<!--   These are the properties for a Basic content   -->
<!--   MIME typing  -->
<field name="content-type" domain="shortstr" label="MIME content type"/>
<!--   MIME typing  -->
<field name="content-encoding" domain="shortstr" label="MIME content encoding"/>
<!--   For applications, and for header exchange routing  -->
<field name="headers" domain="table" label="message header field table"/>
<!--   For queues that implement persistence  -->
<field name="delivery-mode" domain="octet" label="non-persistent (1) or persistent (2)"/>
<!--   For queues that implement priorities  -->
<field name="priority" domain="octet" label="message priority, 0 to 9"/>
<!--   For application use, no formal behaviour  -->
<field name="correlation-id" domain="shortstr" label="application correlation identifier"/>
<!--   For application use, no formal behaviour but may hold the
          name of a private response queue, when used in request messages  -->
<field name="reply-to" domain="shortstr" label="address to reply to"/>
<!--   For implementation use, no formal behaviour  -->
<field name="expiration" domain="shortstr" label="message expiration specification"/>
<!--   For application use, no formal behaviour  -->
<field name="message-id" domain="shortstr" label="application message identifier"/>
<!--   For application use, no formal behaviour  -->
<field name="timestamp" domain="timestamp" label="message timestamp"/>
<!--   For application use, no formal behaviour  -->
<field name="type" domain="shortstr" label="message type name"/>
<!--   For application use, no formal behaviour  -->
<field name="user-id" domain="shortstr" label="creating user id"/>
<!--   For application use, no formal behaviour  -->
<field name="app-id" domain="shortstr" label="creating application id"/>
<!--   Deprecated, was old cluster-id property  -->
<field name="reserved" domain="shortstr" label="reserved, must be empty"/>

没有 content-length

权威出处:

那消息体长度在哪?

它在 Content Header Frame 里,是 frame 结构的一部分,不是 property

txt 复制代码
Content Header Frame:
  class-id        (short)
  weight          (short, 恒为 0)
  body-size       (longlong)   ← 消息体长度在这
  property-flags  (short)
  property-list   (...)        ← 上面那 14 个 properties

可以从两个独立实现交叉验证这个结构:

rust 复制代码
/// A content header frame payload (§4.2.6.1) carrying `props`: `class-id`,
/// `weight`(0), `body-size`(longlong), `property-flags`(short), properties.
///
/// # Errors
/// [`WireError`] on a `shortstr` overflow.
pub fn content_header_with_props(
    class_id: u16,
    body_size: u64,
    props: &ContentProperties,
) -> Result<Vec<u8>, WireError> {
    let (flags, body) = props.encode()?;
    let mut w = Writer::new();
    w.u16(class_id)
        .u16(0)
        .u64(body_size)
        .u16(flags)
        .bytes(&body);
    Ok(w.into_bytes())
}
zig 复制代码
pub const Header = struct {
    class: u16,
    weight: u16,
    body_size: u64,
    property_flags: u16,
    properties: []const u8,
};

两个毫无关联的项目独立实现出相同结构,这个交叉验证比单一文档引用更有说服力。

所以长度信息协议层面确实传了,只是客户端库不把它暴露成 property------因为你拿到 body 之后 body.length 就是它,没必要重复。

三、Spring 为什么会有这个字段

MessageProperties 是 Spring AMQP 的抽象层 ,设计意图是"消息属性的通用表示"。它的字段是 AMQP 协议字段的超集

txt 复制代码
// 有 AMQP 协议对应字段的(能传输)
messageId, correlationId, contentType, contentEncoding,
headers, deliveryMode, priority, expiration, timestamp,
type, userId, appId, clusterId, replyTo

// Spring 自己加的(不能传输,仅本地)
contentLength          ← 我踩的这个
deliveryTag            ← 消费端由 Envelope 填充
consumerTag, consumerQueue
redelivered
receivedExchange, receivedRoutingKey, receivedDeliveryMode
lastInBatch

后面这批字段都是「接收侧」的元信息 ------由 Spring 在消费消息时从 EnvelopeChannel 上下文里填充,用来告诉你"这条消息是怎么来的"。它们从设计上就是单向的(broker → 应用)。

contentLength 混在这批里,但它更尴尬:连接收侧都没人给它赋值。

翻 Spring AMQP 源码,它的实际用途只剩下:

  1. SimpleMessageConverter 转换时顺手记一下长度,纯本地元信息
  2. toString() 时打印出来方便调试

DefaultMessagePropertiesConverter.fromMessageProperties()MessageProperties 转成 BasicProperties 时,压根没有读 contentLength 这一行代码------因为无处可放。

为什么 Spring 不修?

答案是兼容性。 MessageProperties 是 Spring AMQP 最核心的公开 API 之一,用了十几年。删掉 setContentLength() 会破坏无数现有代码的编译;加 @Deprecated 会让一大批项目的构建输出满屏警告。

于是它就这么留着了------一个谁也不用、但谁也不敢删的字段

四、同类的坑

MessageProperties 里还有几个"生产端设了也没用"的 setter:

方法 为什么无效
setDeliveryTag() 消费端由 broker 的 Envelope 覆盖
setRedelivered() 同上
setReceivedExchange() 名字里带 Received,但依然给了 public setter
setReceivedRoutingKey() 同上
setContentLength() 无对应协议字段

经验法则 :只要看到字段名带 received、或者语义上属于「接收结果」的,在生产端设置就是无效的。contentLength 虽然名字没提示,但属于同一类。

五、正确做法

如果只是想知道消息体大小------直接量:

java 复制代码
final int bodyLength = message.getBody().length;

消费端拿到的 Message 对象里 body 就在手上,长度是现成的。这也是为什么 Spring 没费心去传它。

如果一定要放进元数据------用 header:

java 复制代码
messageProperties.setHeader("email-content-length", message.getBody().length);

headers 是 AMQP 的 field table,任意键值都能传。这也是我传业务 ID 的方式:

java 复制代码
messageProperties.setHeader("email-snowflake-id", emailId);

这个 header 特别有用 ------消费端不用反序列化 body 就能拿到业务 ID,死信队列排查时直接从 header 读,比 messageId 更贴合业务。

六、一个通用的查证方法

将来遇到类似疑问,最快的验证路径:

1. 看 Java 客户端的生成类

java 复制代码
com.rabbitmq.client.AMQP.BasicProperties

这个类是从 AMQP 规范的 XML 自动生成 的,它有什么字段,协议就有什么字段------一个不多一个不少。IDE 里点进去看一眼,比查任何文档都快。

2. 和 Spring 的类做 diff

MessagePropertiesBasicProperties 多出来的那些字段,就是"Spring 自己加的、不参与协议传输的"。这个 diff 就是那份「哪些 setter 是假的」的清单。

3. 用 RabbitMQ Management UI 验证

发一条消息,在队列里 Get message,界面会列出这条消息的所有 properties------那就是真正传过去的东西

七、这件事的普遍教训

这是 抽象泄漏(Leaky Abstraction) 的一个典型样本。

Spring AMQP 想提供一个"统一的消息属性对象",屏蔽 AMQP 协议细节。但协议的约束是真实存在的------不是所有字段都能传输。抽象层没能屏蔽掉这个事实,只是把它藏起来了,于是你在运行时才发现。

最糟糕的部分是它静默失败:不报错、不警告、不抛异常,只是让一个字段悄悄变成 0。

这和 Java 里另一个常见的抽象泄漏很像------在 90% 阻塞的系统里用 WebFlux,然后到处 block()。抽象承诺了它兑现不了的东西,代价由使用者在运行时承担。

判断这类问题的通用方法,就是本文开头那个:看什么幸存了。

附:最终的实现

顺带一提,这个坑是在给死信日志补上下文时发现的。最终的工具类里,contentLength 被彻底移除了------因为它从来就没有意义:


相关 PR#58 修复:死信日志上下文不足问题

相关推荐
vipxieliang1 小时前
ValidX 的 Date 和 DateTime vs JPA 的 Temporal 对比
java·后端
杜touch1 小时前
Spring框架的AnnotationConfigApplicationContext类启动的流程
java
AI人工智能+电脑小能手2 小时前
大白话说Java设计模式-26-策略模式(业务实战篇)
java·spring·设计模式·策略模式·支付系统·算法切换
不是光头 强2 小时前
Java 后端 AI 技术选型与学习路线
java·人工智能·学习
paopaokaka_luck3 小时前
基于springboot3+vue3的文山民族文化资源展示与推广平台(协同过滤算法、Echarts 图形化分析)
java·前端·spring boot·学习·echarts
eralong3 小时前
Java IO 与 NIO
java·后端
Jesse_EC3 小时前
我给消息推送加了 Publisher Confirm,然后亲手引入了一个竞态
java
S3rein3 小时前
JAVA多线程手撕
java·多线程
用户8181870627463 小时前
第7章 死锁排查实录:jstack定位死锁的完整流程
java·后端