------ 一次 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。
权威出处:
-
amqp0-9-1.xml ------ 机器可读的规范定义,所有客户端库的代码都从它生成
-
amqp0-9-1.pdf AMQP 协议文档
那消息体长度在哪?
它在 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 在消费消息时从 Envelope、Channel 上下文里填充,用来告诉你"这条消息是怎么来的"。它们从设计上就是单向的(broker → 应用)。
contentLength 混在这批里,但它更尴尬:连接收侧都没人给它赋值。
翻 Spring AMQP 源码,它的实际用途只剩下:
SimpleMessageConverter转换时顺手记一下长度,纯本地元信息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
MessageProperties 比 BasicProperties 多出来的那些字段,就是"Spring 自己加的、不参与协议传输的"。这个 diff 就是那份「哪些 setter 是假的」的清单。
3. 用 RabbitMQ Management UI 验证
发一条消息,在队列里 Get message,界面会列出这条消息的所有 properties------那就是真正传过去的东西。
七、这件事的普遍教训
这是 抽象泄漏(Leaky Abstraction) 的一个典型样本。
Spring AMQP 想提供一个"统一的消息属性对象",屏蔽 AMQP 协议细节。但协议的约束是真实存在的------不是所有字段都能传输。抽象层没能屏蔽掉这个事实,只是把它藏起来了,于是你在运行时才发现。
最糟糕的部分是它静默失败:不报错、不警告、不抛异常,只是让一个字段悄悄变成 0。
这和 Java 里另一个常见的抽象泄漏很像------在 90% 阻塞的系统里用 WebFlux,然后到处 block()。抽象承诺了它兑现不了的东西,代价由使用者在运行时承担。
判断这类问题的通用方法,就是本文开头那个:看什么幸存了。
附:最终的实现
顺带一提,这个坑是在给死信日志补上下文时发现的。最终的工具类里,contentLength 被彻底移除了------因为它从来就没有意义:
相关 PR :#58 修复:死信日志上下文不足问题