某天凌晨收到告警:一条核心业务的发送耗时 P99 从个位数毫秒飙到两百多毫秒,消息队列服务端毫无异常,网络侧也没有抖动,线程池监控看着也正常。折腾了快两个小时,最后发现问题出在三个月前上线的一个统计拦截器上——里面塞了一次同步的外部接口上报,网络稍微一抖,整个发送主链路就跟着抖。这件事之后我把生产者拦截器(ProducerInterceptor)从接口定义到源码调用点完整捋了一遍,才发现之前对它的理解有多粗放:很多人以为它只是个“发消息前处理一下数据”的钩子,实际上它是嵌在发送主链路上、跑在特定线程里、异常还会被静默吞掉的一段业务代码。
这篇就聊聊生产者拦截器这个机制:它到底插在发送链路的哪个环节、四个回调方法分别跑在什么线程上、源码里对异常是怎么处理的、写一个能上生产的拦截器要注意哪些边界,以及它在不同消息中间件里的形状差异。适合已经用过消息队列、但还没认真翻过客户端源码的后端同学;也适合正准备用拦截器做埋点、脱敏、灰度打标的同学,看完至少能避开我踩过的那些坑。
1. 生产者拦截器究竟挂在发送链路的哪一格
1.1 send() 为什么不等服务端回执:异步链路的两个阶段
要理解拦截器,先得理解发送这个动作本身被切成了两段。调用send()的时候,客户端做的是把消息序列化、选分区、塞进一个内存缓冲区(RecordAccumulator),然后立刻返回一个 Future。真正把数据推给服务端的,是一个独立的后台线程——它不停地从缓冲区里捞批、组包、发网络请求、处理响应。这两个阶段是解耦的,前一段在调用方线程执行,后一段在发送线程执行。
这个两段式结构决定了一件很关键的事:“发送成功”这个语义在客户端是分层的。你在send()返回时只是“入了队”,离“服务端确认”还差着一次网络往返、一次服务端写入、可能还有一次副本同步。拦截器恰好横跨了这两段——它提供了一前一后两个回调,前面那个在入队前,后面那个在响应回来时。
所以判断一个拦截器该在哪个回调里干活,本质上是问:这件事需要知道结果吗?不需要结果的(打标签、改字段、记时间戳)放前面;需要结果的(统计成功率、记录分区和偏移量、算端到端耗时)放后面。搞混了就会出现“在 onSend 里打印分区信息”这种拿不到数据的写法。
1.2 四个回调方法分别跑在谁家的线程上
这是最容易出事的一点,很多人默认所有回调都在一个线程里顺序执行。实际情况是:
| 回调方法 | 执行线程 | 触发时机 | 异常是否向外抛 |
|---|---|---|---|
构造/configure | 创建生产者实例的线程 | new KafkaProducer(...)期间 | 是,会导致构造失败 |
onSend | 调用send()的业务线程 | 消息进入缓冲区之前 | 否,被捕获后打 warn |
onAcknowledgement | 发送线程(Sender) | 服务端响应返回、或本地失败时 | 否,被捕获后打 warn |
close | 调用close()的线程 | 生产者关闭时 | 视版本而定,多数被捕获 |
这张表里最要命的是第二行和第三行。onSend跑在业务线程上,意味着你在这里做的任何阻塞操作——同步 HTTP、查数据库、抢一把热点锁——都会直接变成业务接口的 RT。而onAcknowledgement跑在唯一的发送线程上,这里更危险:发送线程是全局单例的,一旦它被阻塞,这个生产者实例下所有 topic、所有分区的消息全部停摆,比阻塞业务线程严重得多。
我在生产上见过一个反面案例:某同学在onAcknowledgement里写了一条失败消息落库的逻辑,用的是同步 JDBC。平时没问题,某次数据库慢查询,发送线程卡住十几秒,积压瞬间涨到几万条,触发内存告警。这个坑的根因就是没意识到自己站在唯一的那条线程上。
1.3 拦截链为什么是数组而不是树,以及 close 顺序的一个细节
配置里传的是一个拦截器类名列表,客户端在构造时会按顺序实例化成一串,然后依次调用,上一个的返回值作为下一个的入参。这个“链式传递”的语义很重要:后一个拦截器看到的是前一个处理过的结果,而不是原始消息。
// 链式传递的等效逻辑 ProducerRecord<K, V> current = record; for (ProducerInterceptor<K, V> it : interceptors) { current = it.onSend(current); // 注意:返回值会覆盖 }这就带来两个必须记住的约定。第一,onSend的返回值如果是null,后面所有拦截器拿到的都是null,大概率直接 NPE,而因为异常被吞掉,你只会看到一条 warn 日志,消息照发但内容是错的。第二,链上任何一个环节改了消息的 key 或分区,后续环节看到的就是改过的值,包括最后真正用来算分区的那个 key——因为拦截发生在分区计算之前。
至于close,绝大多数实现是按配置顺序正序关闭,而不是像很多过滤器链那样逆序。这跟资源依赖关系不太匹配:如果 B 拦截器依赖 A 拦截器初始化的东西,那按正序关闭时 A 先没了,B 的close里再用 A 就可能出问题。我一般的做法是让每个拦截器自包含,不跨实例共享状态,从根上绕开这个顺序问题。
2. 从零写一个能上生产的拦截器
2.1 骨架与配置注入:反射实例化的三条硬约束
先把最容易卡住人的地方说清楚:拦截器是通过反射无参构造实例化的,所以有三个硬约束。类必须是public的;必须有无参构造函数;不能是内部非静态类。我见过有人写了个匿名内部类或者带参构造的拦截器,编译通过、启动直接报实例化失败,排查半天。
配置项怎么传进去?接口本身继承了一个可配置的父接口,提供了一个configure(Map<String, ?> configs)方法,参数是整个生产者的完整配置。你可以从里面读自定义的 key:
public class TraceInterceptor<K, V> implements ProducerInterceptor<K, V> { private String appName; private volatile boolean enabled = true; @Override public void configure(Map<String, ?> configs) { Object name = configs.get("trace.app.name"); this.appName = name == null ? "unknown" : name.toString(); Object flag = configs.get("trace.enabled"); this.enabled = flag == null || Boolean.parseBoolean(flag.toString()); } @Override public ProducerRecord<K, V> onSend(ProducerRecord<K, V> record) { return record; // 不改内容时,原样返回,别返回 null } @Override public void onAcknowledgement(RecordMetadata metadata, Exception exception) { // 埋点主逻辑放这里 } @Override public void close() { // 释放自己持有的线程池、缓冲区 } }这里有个细节值得单独说:configure里拿到的是整个生产者配置的拷贝,你可以读到bootstrap.servers这类内置配置,也能读到自定义的任意 key。但别在里面做重活——它是在构造函数里同步执行的,慢一秒就是启动慢一秒。我一般只做参数解析和轻量初始化,重资源(线程池、连接)延迟到第一次实际使用时再建。
还有一点:拦截器实例是每个生产者实例一份,不是全局单例。如果你的应用里创建了多个生产者(比如不同业务线各一个),拦截器就会被实例化多次,configure也会执行多次。如果你在拦截器里用了静态变量做累加,记得考虑并发;如果想做全局唯一的上报队列,也要保证不会因为这个被初始化 N 次。
2.2 端到端耗时统计:时间戳打在哪个位置才有意义
用拦截器做耗时统计是最常见的需求,但时间戳打在哪儿,决定了你统计出来的到底是什么。常见的三个点位:
- 在
onSend里打时间戳,塞进消息头,onAcknowledgement里取出来做差。这算的是“入队到收到响应”,是客户端视角的端到端耗时,包含排队时间、网络往返、服务端处理。业务方最关心的就是这个数。 - 在业务代码调用
send()之前打时间戳。这算的是“业务进入到收到响应”,比上面多了onSend本身的执行耗时,以及send()到入队的那段。差别通常很小,除非拦截器链很长。 - 在服务端侧打。这算的是纯服务端处理耗时。拿它跟客户端耗时对比,差值就是客户端排队 + 网络的时间,这个差值在排查“消息延迟高”问题时非常好用。
实现上有个坑:时间戳别塞在消息体(value)里,那会污染业务数据,下游消费者还得解析;塞在消息头(header)里更干净,但要注意消息头会被原样投递给消费者,别把内部字段名暴露出去。还有一点,消息头的值是字节数组,long需要手动序列化:
long sendTs = System.currentTimeMillis(); record.headers().add("x-send-ts", ByteBuffer.allocate(8).putLong(sendTs).array()); // onAcknowledgement 里 Header h = metadata != null ? null : null; // metadata 里拿不到 header!注意上面这行注释——onAcknowledgement的入参只有元数据和异常,拿不到原始消息头。所以如果你的时间戳是存在消息头里的,在这个回调里根本读不出来。这是很多人的第一反应写法,然后发现拿不到数据。
正确做法是在onSend里把record和时间戳存成一对,用一个WeakHashMap或者带过期清理的本地缓存,在onAcknowledgement里通过metadata.topic() + metadata.partition() + metadata.offset()反查。但这里要小心:失败的消息没有 metadata(是 null),而且批量发送时 offset 是在响应回来后才分配的,反查不一定能命中。
我自己的做法更简单粗暴:在onSend里把时间戳和业务标识拼成一个字符串,写进消息头,同时用一个限长的环形缓冲区记录 “topic-partition-key → 时间戳”。命中就算,不命中就只统计服务端耗时。统计嘛,有 95% 的样本就足够说明问题了,为了 100% 的准确性引入一个会内存泄漏的缓存,不值。
2.3 改写消息体时的线程安全与“返回值不要给 null”
如果拦截器的职责是改写消息内容(脱敏、加签、补字段),有几个约定必须守住。
第一,不要原地修改传入的record再返回它。这个对象在批量场景下可能被多处引用,原地改会影响到别的逻辑。虽然多数实现里ProducerRecord是不可变字段 + 可变 headers,改 header 相对安全,但改 value 一定要新建对象返回。
第二,onSend会在多个业务线程上并发执行,拦截器实例是共享的。任何可变状态都必须自己做同步。我见过用普通HashMap做统计的写法,高并发下直接把 CPU 打满甚至死循环。要么用LongAdder、ConcurrentHashMap,要么把状态收敛到单线程消费的队列里。
第三,性能预算。onSend是在主链路上的,你在这里多花 1 毫秒,业务接口就多 1 毫秒。脱敏这种纯 CPU 操作还行,正则替换就要小心了——复杂正则在大消息体上可能耗时几毫秒。我的经验是:能在生产端做的预处理放在业务侧,拦截器只做兜底。比如手机号脱敏,别指望用正则扫全量 JSON,而是在消息的固定字段上做定向处理。
还有一个隐蔽的坑:如果你的拦截器改写了 key,分区结果会跟着变。原本按 key 哈希到固定分区的消息,因为 key 变了,可能落到别的分区,同一业务实体的消息顺序就乱了。要改 key 又想保序,只能同时指定分区,但那又失去了负载均衡的意义。所以改 key 这件事,除非业务明确知道后果,否则别碰。
3. 翻 KafkaProducer 源码:那些被吞掉的异常
3.1 构造期:instantiate、configure 与 metrics 的绑定
打开KafkaProducer的构造函数,能看到拦截器的初始化夹在一堆组件之间:先解析配置、创建 metrics、创建序列化器,然后是拦截器列表,再是元数据、网络客户端、发送线程。这个顺序不是随便排的——拦截器在构造时被包成一个容器对象(ProducerInterceptors),这个容器后续被doSend和发送线程共同引用。
实例化的方式是通过配置工具类反射创建,所以前面说的“必须 public + 无参构造”不是建议,是硬性要求。紧接着每个实例的configure被调用,传入完整的生产者配置。
这里有个值得注意的细节:拦截器的实例化发生在序列化器之后、发送线程启动之前。这意味着你在configure里读不到任何运行时状态(比如集群元数据),也别指望能拿到分区数。想拿这些,只能在onSend或onAcknowledgement里,从metadata入参间接获取。
还有一点容易被忽略:如果拦截器实例化失败,整个生产者的构造会直接抛异常,应用可能启动失败。这个“快速失败”的设计是对的,但意味着拦截器的依赖必须随客户端一起就绪。我见过把配置中心客户端初始化放在拦截器configure里的写法,配置中心不可用时整个生产者构造不起来,消息发不出去——这属于把非核心依赖做成了硬依赖。
3.2 doSend 里的那行 onSend:位置决定了它能改什么
在doSend方法里,拦截器调用发生在分区计算之前、序列化之前。这个位置选择非常讲究,它直接决定了拦截器能做什么:
- 因为在分区计算之前,所以能改 key 和 partition;
- 因为在序列化之前,所以能改 value 对象(还能改类型,只要最终能被序列化器处理);
- 因为在写入累加器之前,所以改完的内容就是真正被发送的内容。
源码里的写法大致是先把record过一遍拦截链,拿到interceptedRecord,后续所有逻辑(校验、分区、序列化、入队)都用这个新对象。这也解释了为什么拦截器返回null会导致严重的空指针——后面所有步骤都基于它。
然后是异常处理,这是全篇最关键的一段。源码里对每个拦截器的调用都包了 try-catch,捕获到异常后只打一条 warn 日志,然后继续用上一次的结果往下走。也就是说:
// 源码逻辑示意 ProducerRecord<K, V> current = record; for (ProducerInterceptor<K, V> it : interceptors) { try { current = it.onSend(current); } catch (Exception e) { log.warn("Error executing interceptor onSend callback", e); // 注意:current 保持上一次的值,继续下一个 } }这个设计的意图是“拦截器的故障不应阻断业务发送”,理念上是对的。但副作用是:你的拦截器如果一直抛异常,业务侧完全无感,只是日志里刷屏。如果你没配日志采集、或者 warn 级别被过滤掉了,这个功能就等于静默失效。所以我建议在自己的拦截器内部也做一层计数(成功/失败),输出到监控里,别指望 warn 日志。
顺带说一句,onAcknowledgement的异常处理一模一样,也是捕获后打日志。这意味着发送线程不会因为你的回调挂掉,这是好事。
3.3 onAcknowledgement 的触发路径与“它不代表 broker 一定落盘”
这个回调的触发点在发送线程处理响应的路径上:发送线程从网络层拿到响应,按批次遍历,对每一条消息构造元数据(topic、分区、偏移量、时间戳),然后依次调用用户回调和拦截器的onAcknowledgement。失败的批次则以异常的形式回调。
很多人把这个回调理解成“消息已成功写入”,这个理解需要打个折扣。默认的确认级别下,它只代表主副本接收成功,不代表所有副本都同步完了。如果你的业务对持久性有要求,需要在生产者配置里把确认级别调高——但那是配置的事,不是拦截器能兜住的。
另一个必须明确的是:这个回调不保证“一条消息一次”。重试场景下,同一条消息可能产生多次响应(一次失败 + 一次成功),拦截器的onAcknowledgement就会被调用两次。如果你在这里做计数统计,数字会偏大;做去重逻辑,需要自己按业务键做幂等。我一般会在统计口径里明确写“按发送尝试次数统计”,避免和业务方的“按消息条数统计”对不上账。
还有一点关于耗时的:metadata里能拿到消息被追加到服务端日志的时间戳,用当前时间 - 该时间戳可以粗算出服务端处理 + 网络的时间。但这个时间戳的精度受服务端配置影响,某些场景下可能不准确,做大盘监控够用,做精确计费就别用了。
3.4 close() 什么时候不会被执行
拦截器的close在生产者关闭时被调用,但不是所有路径都会走到这里。进程被强杀、容器被驱逐、JVM 直接退出的场景下,close都不会执行。所以任何写在close里的关键逻辑(比如把内存里的统计数据刷出去)都必须有兜底:要么定期刷,要么在数据积到一定量时刷。
我在一个项目里就吃过这个亏:拦截器里攒了一批统计数据,想着close时统一上报,结果容器滚动更新,close压根没机会跑,数据全丢。后来改成“每 200 条或者每 5 秒触发一次上报”,close里只做最后一次强制刷,问题才解决。
close里的另一个注意事项是超时。如果拦截器的close里做了什么阻塞操作(比如等待队列里的数据消费完),它会拖慢整个生产者的关闭流程。生产者关闭本身是有超时预算的,你的close执行太久,可能导致生产者关闭超时,进而影响应用优雅下线的整体时间。经验值是单个拦截器的close控制在 500 毫秒以内,超时就放弃。
4. 三类高频场景的落地写法与各自的暗礁
4.1 埋点上报:把统计做成不阻塞的旁路
埋点是最普遍的需求,也是最容易做成性能瓶颈的需求。核心原则只有一条:onAcknowledgement里绝对不做任何可能阻塞的事。
我的标准做法是:拦截器里持有一个有界队列(容量几百到几千),回调里只做offer,满了就丢弃并计数;另起一个单线程的消费协程,从队列里取数据,攒够一批或者等够时间再上报。整条路径上没有锁竞争,也不依赖外部系统的响应时间。
private final BlockingQueue<TraceItem> queue = new ArrayBlockingQueue<>(2048); private final AtomicLong dropped = new AtomicLong(); @Override public void onAcknowledgement(RecordMetadata metadata, Exception exception) { if (!enabled) return; TraceItem item = new TraceItem( metadata == null ? null : metadata.topic(), metadata == null ? -1 : metadata.partition(), metadata == null ? -1L : metadata.offset(), exception == null, System.currentTimeMillis() ); if (!queue.offer(item)) { dropped.incrementAndGet(); // 满了直接丢,绝不阻塞 } }这个写法有两个关键决策值得解释。为什么要用有界队列而不是无界队列:无界队列在下游慢的时候会无限膨胀,最后 OOM,问题从一个组件扩散到整个进程。有界队列 + 丢弃策略,代价是统计有损,收益是进程活着——统计本来就是抽样性质的东西,丢一部分完全可接受。
为什么要用offer而不是put:put在队满时会阻塞,而这段代码跑在唯一的发送线程上,阻塞它等于让整个生产者停摆。这个区别在平时根本看不出来,只有在压力大的时候才致命,而那时候已经在救火了。
至于丢弃计数,一定要暴露到监控里。“丢弃率突然升高”往往比“发送失败率升高”更早反映出下游系统的问题。
4.2 灰度打标与消息头注入:能改 header 就别改 key
灰度发布场景下,经常需要给消息打上版本标签,让下游按标签做差异化处理。实现方式就是在拦截器里往消息头里塞一个字段:
@Override public ProducerRecord<K, V> onSend(ProducerRecord<K, V> record) { if (record == null) return null; String tag = GrayContext.current(); // 从上下文取灰度标记 if (tag == null) return record; record.headers().add("x-gray-tag", tag.getBytes(StandardCharsets.UTF_8)); return record; }这里有个必须提醒的点:重复添加同名 header 不会覆盖,而是追加。消息被重发、或者在多层拦截器里都加了同一个头,消费端读到的会是第一个(不同客户端读取行为可能不同)。要覆盖得用移除再添加的方式。我一般会约定一个前缀,并且在注入前先检查是否已存在。
另一个决策点是“打标签用 header 还是用 key”。用 header 的好处是不影响分区、不影响消息体、消费端可选读;用 key 的坏处前面说过——分区会变、顺序会乱。除非你的下游就是按 key 做路由的,否则一律用 header。
还有一个容易被忽略的场景:拦截器注入的 header 会在消费端可见。如果你的标签里带了内部信息(比如具体的灰度批次号、内部实验 ID),相当于把内部细节暴露给了所有消费方。要么用不透明的编码,要么约定好这些字段属于内部协议、下游不得依赖。
4.3 脱敏改写:为什么改 value 比改 key 安全得多
脱敏类的拦截器要改消息体,风险点集中在三个方面。
一是类型问题。生产者的序列化器是针对原始类型配的(比如 JSON 序列化器接受对象,字符串序列化器接受 String)。你在拦截器里如果把对象改成了字符串,序列化阶段就会直接抛类型转换异常——而这个异常发生在send的调用栈里(序列化在doSend内部),会以异常形式抛回业务线程。所以改 value 时,返回的对象类型必须和序列化器兼容,最好是同一个类的另一个实例。
二是不可变性。如果消息体是不可变对象,你得构造一个新实例,逐字段复制并替换敏感字段。如果消息体是可变对象,直接在拦截器里改字段,那么……原始对象也被改了,业务代码手里拿的那份数据跟着变,可能引发意料之外的副作用。我倾向于一律新建对象,哪怕多一次对象分配。
三是大消息的性能。如果是敏感字段在整个 JSON 文本里随机分布,你可能得做全量扫描。一个几百 KB 的消息体做一次正则扫描,耗时在毫秒级,乘上每秒几千条的发送量,CPU 就吃紧了。更实际的做法是在业务侧做脱敏,拦截器只做兜底校验(比如扫描到疑似身份证号就告警),把重活从主链路上挪走。
有个判断标准可以参考:如果你的脱敏逻辑需要解析整个消息体结构,那它大概率不该放在拦截器里;如果它只是对某几个已知字段做固定变换,放在拦截器里是合适的。
5. 换个中间件看同一件事:hook 与 interceptor 的形状差异
5.1 接口形状:三个方法与两个时机
不同消息中间件对“发送前后插入逻辑”这件事的抽象方式不太一样,对比着看能更清楚这套机制的本质。
| 维度 | 一种实现(拦截器) | 另一种实现(钩子) |
|---|---|---|
| 接口方法数 | 2 个时机 + 配置 + 关闭 | 前后各 1 个方法,另有独立的追踪入口 |
| 参数形态 | 直接收发消息对象和元数据 | 通过一个上下文对象传递全部信息 |
| 顺序控制 | 由配置列表的顺序决定 | 通过排序方法/注解显式声明 |
| 异常处理 | 内部捕获,不影响发送 | 内部捕获,不影响发送 |
| 上下文能力 | 回调参数较少,信息有限 | 上下文对象信息更丰富 |
上下文对象这个差异看似小,实际影响挺大。拿到完整上下文的那种实现,你能在回调里读到消息、broker 地址、请求 ID、耗时、发送状态等一整套信息,做全链路追踪时非常顺手;而参数比较精简的那种实现,很多信息得自己在前后回调之间传递。这也是为什么做跨中间件的统一埋点时,往往要给每个客户端写一套适配。
5.2 顺序控制:注解排序与配置数组排序
顺序控制的方式差异也值得一说。配置数组排序的方式很直观——配在前面的先执行,但缺点是顺序信息散落在配置文件里,改顺序得改配置,而且多个团队各自加拦截器时容易冲突。
显式排序(实现排序方法或者加排序注解)的好处是顺序信息跟着代码走,拦截器自己声明“我应该在什么位置执行”,冲突时由框架统一排序。缺点是如果两个拦截器声明了相同的序号,行为就取决于框架的稳定排序实现,可能出现“这次 A 在前、下次 B 在前”的诡异情况。我一般要求团队内部约定:序号按十位分配,每类拦截器占一个段位,比如 10-19 是埋点类、20-29 是改写类、30-39 是校验类,从制度上规避冲突。
还有一个实战中的坑:不同大版本的接口形状可能变过(有的从三个方法收敛成两个,有的把追踪入口拆出去)。写通用组件时如果直接按某个版本的接口写死,升级客户端就编译不过。落地前一定先确认团队用的客户端版本,对着那个版本的接口写,别照着网上搜到的示例抄。
6. 拦截器引发的三类线上故障怎么查
6.1 发送 RT 毛刺:先把拦截器摘掉做二分
发送耗时出现毛刺时,排查顺序我一般是这样的:先看服务端指标(有没有异常、有没有限流、写入延迟是否正常),再看客户端侧指标(缓冲区是否满、发送线程是否打满、重试次数是否上升),最后才怀疑拦截器。因为拦截器的问题通常表现为“服务端一切正常但客户端慢”,这个特征比较明显。
确定是客户端侧问题之后的动作要果断:把拦截器配置临时清空,重启一个实例做对比。如果摘掉拦截器后毛刺消失,锁定;如果没消失,说明是别的原因(比如网络、序列化、分区热点),别在拦截器上浪费时间。
这个“二分”操作要提前演练,别等到出事才现查配置项叫什么。我见过团队在故障中现查文档,耽误了十几分钟。
6.2 内存缓慢增长:拦截器里那点“缓存”最要命
拦截器相关的内存问题,几乎都是“为了做统计而引入的本地缓存”造成的。典型的写法是Map<业务键, 统计对象>,随着业务键的基数增长无限膨胀。这类问题在压测时根本发现不了(压测跑几十分钟就结束了),上线一个月后开始告警。
几个防线:第一,任何本地缓存都必须有上限,用带容量限制的结构或者定期清空;第二,键的基数要评估,如果键里包含用户 ID、订单号这类高基数字段,缓存大小会直接爆掉;第三,给拦截器单独打一个内存指标(比如队列长度、缓存条目数),别和业务指标混在一起看。
还有个更隐蔽的:拦截器里持有ThreadLocal或者线程池但不释放。因为拦截器的实例生命周期跟着生产者走,生产者不关,线程池就在。多实例场景下(每个业务线一个生产者),线程池数量会翻倍。我的规矩是拦截器里不建线程池,需要异步上报就复用应用里已有的公共线程池,或者用单线程 + 有界队列的最简结构。
6.3 一份可以照着走的排查清单
表格整理一下我实际排查时用的清单,按怀疑程度排序:
| 现象 | 优先怀疑 | 验证方式 |
|---|---|---|
| 发送 RT 上涨,服务端正常 | onSend里有阻塞调用 | 摘掉拦截器对比 |
| 发送 RT 上涨,且伴随积压 | onAcknowledgement阻塞发送线程 | 抓发送线程栈看卡在哪 |
| 内存持续增长 | 拦截器本地缓存无上限 | dump 堆看拦截器实例的字段 |
| 消息内容异常但不报错 | onSend抛异常被吞,或返回 null | 打开 warn 日志/Counter |
| 分区分布不均 | 拦截器改了 key | 对比原始 key 和实际分区 |
| 统计数字比实际条数多 | 重试导致重复回调 | 按消息唯一 ID 去重对比 |
用这张表的时候有个技巧:先看日志里有没有拦截器相关的 warn。源码里捕获异常后打的日志通常带着拦截器的类名,grep 一下类名就能快速确认是不是它在抛异常。这个动作只要几秒钟,但很多人一上来就去看 JVM 指标,绕远了。
我个人在这套机制上攒的几条经验
用了几年下来,最深的体会是:拦截器的“方便”本身就是它的风险。它让你能在不改业务代码的前提下给所有消息加逻辑,但代价是这段代码跑在主链路上、跑在关键线程里、出错了还不吭声。我现在的习惯是给每个拦截器写三个东西:一个开关(出问题能一键关掉)、一组计数(调用次数/异常次数/丢弃次数)、一个耗时上限(超过就跳过)。这三样东西加起来不到五十行代码,但每次都是它们先把问题暴露出来的。
另外关于使用边界,我的判断标准是:只做纯内存的、微秒级的、无外部依赖的变换。埋点可以,但必须异步;脱敏可以,但必须简单;读取动态配置可以,但必须有本地缓存和兜底值。凡是需要一次网络往返、需要等锁、需要查库的事情,都放到业务侧或者旁路线程里去做。这条线守住了,拦截器就不会成为那个半夜把你叫起来的东西。