1. 这不是“API文档翻译”,而是生产环境里真正跑得起来的Producer实操手册
Kafka Producer API,这个词在面试题里高频出现,在技术文档里被反复定义,但真正把它用对、用稳、用出吞吐量和可靠性的人,远比想象中少。我见过太多团队——刚上手时用默认配置发消息,测试环境一切正常,一上生产就丢数据、卡死、超时;也见过把acks=all当万能解药,结果发现端到端延迟从50ms飙到800ms,业务方直接找上门来问“你们的消息系统是不是挂了”。这根本不是API难,而是没搞懂Producer背后那套“权衡逻辑”:它不是在写一个函数调用,而是在设计一条有容错能力、可预测延迟、能扛住网络抖动的数据管道。
这篇内容,就是为那些已经知道“Kafka是分布式消息队列”、能写出最简send()代码,但一碰到重试失败、消息乱序、内存溢出、序列化报错就卡壳的开发者准备的。它不讲概念定义,不堆API参数列表,而是还原一个真实场景:某公司日均处理2000万条用户行为日志,要求99.9%的消息不丢失、单条平均延迟<100ms、突发流量(如秒杀)下不崩溃。我们从零开始,一步步搭起这个Producer实例,每一步都告诉你“为什么选这个值”、“不这么选会怎样”、“线上监控看什么指标”。你会看到buffer.memory设成32MB后GC频率翻倍的真实jstat日志,也会看到max.in.flight.requests.per.connection=1如何彻底解决乱序问题——不是理论推导,是压测截图+错误堆栈+修复前后对比。如果你正被“消息发不出去”、“消费端收不到”、“日志里全是TimeoutException”这些问题困扰,这篇就是你的排查地图和配置说明书。
2. Producer核心设计思路:三重缓冲 + 异步批处理 + 智能重试
2.1 为什么Producer必须是异步的?同步调用在生产环境里根本不可行
很多人初学时习惯写这样的代码:
ProducerRecord<String, String> record = new ProducerRecord<>("user-behavior", "uid_123", "click:product_456"); producer.send(record); // 同步发送,线程阻塞在这里这看起来简单,但实际等于给业务线程套上了一根“实时绳索”。Producer内部要完成:序列化 → 计算分区 → 查找目标Broker → 建立TCP连接 → 发送网络包 → 等待Broker返回ACK → 解析响应。这一整套流程在局域网内平均耗时3~8ms,一旦网络抖动或Broker负载高,可能卡住50ms甚至更久。而一个Web接口的SLA通常是200ms以内,你让主线程等50ms,QPS直接腰斩。更致命的是,如果Broker宕机,send()会一直阻塞直到超时(默认30秒),整个服务线程池可能被拖垮。
所以Kafka强制Producer异步化。真正的调用是:
producer.send(record, new Callback() { @Override public void onCompletion(RecordMetadata metadata, Exception exception) { if (exception != null) { log.error("Send failed for record: {}", record, exception); } else { log.debug("Sent to topic {} partition {} offset {}", metadata.topic(), metadata.partition(), metadata.offset()); } } });注意:send()方法本身毫秒级返回,后续逻辑完全不阻塞。所有耗时操作(网络IO、序列化、重试)都在后台线程池里完成。这是Producer高吞吐的根基——它把“发消息”这个动作,从同步RPC变成了事件驱动模型。
提示:永远不要在Callback里做耗时操作(如DB写入、HTTP调用)。Callback运行在Producer的IO线程上,阻塞它会导致整个Producer吞吐下降。正确做法是把成功/失败结果投递到本地队列,由另一个线程消费处理。
2.2 三重缓冲结构:内存怎么分配才不OOM?
Producer不是把消息一条条发出去的,它用了一套精巧的三层缓冲机制,这是理解所有性能参数的关键:
第一层:RecordAccumulator(记录累加器)
这是Producer的“内存暂存池”。当你调用send(),消息先被序列化,然后根据topic+key计算分区号,最后放入对应分区的Deque<RecordBatch>队列中。每个分区一个队列,队列里存的是RecordBatch(批次),不是单条Record。RecordBatch默认大小是16KB(由batch.size=16384控制),当一个批次满了,或者等待时间超过linger.ms(默认0),就会被标记为“就绪”,准备发送。第二层:InFlightRequests(飞行中请求)
就绪的RecordBatch会被交给Sender线程,由它组装成ProduceRequest网络包,通过NetworkClient发送给Broker。这些已发出但还没收到ACK的请求,就存在InFlightRequests里。它的容量由max.in.flight.requests.per.connection控制(默认5)。这个参数极其关键——它直接决定是否允许乱序。第三层:Socket Buffer(操作系统套接字缓冲区)
NetworkClient把ProduceRequest写入Java NIO的ByteBuffer,再通过SocketChannel.write()交给操作系统。Linux内核会把数据暂存在TCP发送缓冲区(由net.core.wmem_default等内核参数控制),等待网卡实际发出。Producer感知不到这一层,但它会影响request.timeout.ms的判定——如果数据卡在内核缓冲区迟迟发不出去,Producer会认为请求超时。
这三层缓冲像流水线:RecordAccumulator负责攒批,InFlightRequests负责并发控制,Socket Buffer负责网络交付。任何一层堵住,都会导致上游积压。比如RecordAccumulator满了(buffer.memory耗尽),send()会直接抛BufferExhaustedException;InFlightRequests满了,新批次会等待,直到有空位;Socket Buffer满了,write()会返回0,触发背压。
注意:
buffer.memory(默认32MB)不是越大越好。它是一块JVM堆内内存,全归Producer独占。设太大(如512MB),会导致Full GC频繁,反而降低吞吐。我们实测过:32MB支撑1000TPS很稳;64MB在2000TPS下GC Pause从12ms升到47ms;128MB直接触发每分钟一次Full GC。合理值 = (预估峰值TPS × 单条消息平均大小 × 100ms)× 2。例如2000TPS × 1KB × 0.1s × 2 ≈ 400KB,所以32MB绰绰有余。
2.3 批处理与延迟的博弈:linger.ms到底该设多少?
linger.ms是Producer最常被误用的参数。它的本意是“为了攒够一批,最多愿意多等多久”。默认是0,即有数据立刻发,不等待。这适合低延迟场景,但吞吐极低——每条消息都单独打包,网络开销巨大。
假设单条消息序列化后1KB,batch.size=16KB,那么理论上每批最多16条。如果linger.ms=0,每来1条就发1次,16条要发16次网络请求;如果linger.ms=10,Producer会等10ms,看有没有新消息进来凑满16KB,大概率能凑成1批,16条只发1次。
但linger.ms不是越大越好。我们做过压测:在2000TPS下,linger.ms=5时平均延迟42ms,吞吐1980TPS;linger.ms=10时延迟升到68ms,吞吐反降到1920TPS(因为等待导致部分批次没凑满就发了);linger.ms=100时延迟飙到135ms,吞吐跌到1650TPS(大量批次在等待中过期)。
根本原因在于:linger.ms是“最大等待时间”,不是“固定等待时间”。Producer内部有个定时器,每linger.ms检查一次所有分区队列。如果某个队列里已有数据,且距离上次发送超过linger.ms,就立即发送,不管是否满批。所以linger.ms设太大,会导致小流量时延迟不可控。
我们的经验是:
- 高吞吐低延迟场景(如日志采集):
linger.ms=5,平衡延迟与批效率; - 对延迟极度敏感(如金融交易指令):
linger.ms=0,但必须配合compression.type=lz4减小网络包; - 稳定中等流量(如用户行为):
linger.ms=10,实测最稳。
实操心得:别只看文档说“增大linger.ms提升吞吐”,一定要结合你的TPS和消息大小做压测。我们曾把
linger.ms从0改成100,结果监控显示P99延迟从80ms跳到320ms,业务方投诉后才发现——他们的流量是脉冲型的,高峰每秒5000条,低谷只有5条,linger.ms=100让低谷期消息全部卡了100ms才发。
3. Producer API核心参数详解与生产级配置
3.1 可靠性基石:acks参数的三种取值与真实代价
acks是Producer可靠性的总开关,它控制Broker端需要多少个副本确认才算发送成功。三个取值看似简单,背后却是截然不同的可用性、一致性和性能光谱:
acks=0:Producer发完就认为成功,不等任何ACK。
代价:网络丢包、Broker崩溃、甚至Broker刚收到消息还没写磁盘就宕机,消息直接丢失。吞吐最高(无等待),但可靠性为零。仅适用于“丢了也不心疼”的场景,如埋点日志的采样数据。acks=1:Leader副本写入本地Log就返回成功。
代价:如果Leader刚写完就宕机,Follower还没同步,消息就永久丢失。这是很多团队的默认选择,因为它在“不丢消息”和“低延迟”间折中。但要注意:Kafka的“写入Log”只是调用FileChannel.write(),数据还在PageCache里,没刷盘。如果机器断电,这部分数据也会丢。所以acks=1本质是“至少Leader收到”,不是“至少Leader落盘”。**acks=all
(或acks=-1):所有ISR(In-Sync Replicas)副本都写入Log才返回。 *代价*:这是真正“不丢消息”的配置,但代价巨大。首先,它要求min.insync.replicas(ISR最小数量)必须≤副本数,否则Producer会报NotEnoughReplicasException;其次,它让延迟直线上升——要等最慢的那个Follower写完。我们压测过:3副本集群,acks=all比acks=1`平均延迟高3.2倍,P99延迟从65ms升到210ms。
那么,acks=all真的万无一失吗?不。它只保证“写入ISR”,不保证“消费者一定能读到”。因为Consumer的auto.offset.reset策略、Broker的log.retention.hours设置、甚至Consumer自己提交offset的时机,都可能导致消息“写进去了,但没人读到”。真正的端到端不丢,需要Producer(acks=all)+ Broker(min.insync.replicas=2)+ Consumer(enable.auto.commit=false,手动commit)三者配合。
关键结论:
acks=all不是银弹,它是用延迟换可靠性。在2000万日志场景中,我们最终选择acks=1,但做了两件事补足:1)Broker端配置log.flush.interval.messages=10000(每1万条刷盘一次),降低断电丢数据概率;2)Consumer端实现幂等消费,对重复消息自动去重。这样既保住99.9%不丢,又把P99延迟压在95ms内。
3.2 重试的艺术:retries与retry.backoff.ms的黄金组合
retries(默认2147483647,即Integer.MAX_VALUE)和retry.backoff.ms(默认100)是Producer的“生存本能”。当发送失败(如网络超时、Leader切换),Producer不会立刻报错,而是按指数退避策略重试:第一次等100ms,第二次等200ms,第三次等400ms……直到成功或重试次数用尽。
但这里有个致命陷阱:retries默认是无限重试!这意味着只要Broker暂时不可用,Producer会一直重试,内存里的消息批次永远不会释放。我们遇到过真实案例:某Broker因磁盘满无法写入,Producer持续重试,RecordAccumulator内存涨到2GB,触发Full GC,整个应用卡死。
所以生产环境必须显式设置retries。设多少?取决于你的SLA容忍度。如果要求“10秒内必须失败并告警”,那么重试总耗时不能超10秒。按指数退避公式:总耗时 ≈retry.backoff.ms × (2^retries - 1)。代入retry.backoff.ms=100:
retries=3:总耗时≈100×(8-1)=700msretries=5:总耗时≈100×(32-1)=3100msretries=7:总耗时≈100×(128-1)=12700ms
我们选retries=5,即3.1秒内失败,既给了Broker恢复时间(网络抖动通常<1秒),又避免无限等待。同时把retry.backoff.ms从100调到200,让重试间隔更宽松,减少重试风暴。
注意:
retries只对“可重试错误”生效,如NetworkException、LeaderNotAvailableException。对SerializationException、TimeoutException(超时时间由request.timeout.ms控制)等不可重试错误,Producer直接回调失败。所以request.timeout.ms(默认30000)也要合理设置——它必须大于linger.ms + 网络RTT + Broker处理时间。我们设为request.timeout.ms=60000,确保在Broker负载高时也有足够缓冲。
3.3 序列化与分区:自定义Serializer和Partitioner的实战封装
Kafka消息的Key和Value必须是字节数组,所以需要序列化器(Serializer)。官方提供StringSerializer、ByteArraySerializer,但生产环境几乎都要自定义。
自定义Serializer要点:
- 必须实现
org.apache.kafka.common.serialization.Serializer<T>接口; serialize()方法严禁抛出异常,必须用try-catch兜底,否则Producer线程会中断;- 序列化结果要尽量紧凑。我们用Protobuf替代JSON,同样结构消息体积缩小65%,网络传输更快,
batch.size利用率更高; - 加入魔数(Magic Byte)和版本号,方便未来升级兼容。例如前2字节是
0x0102(魔数),第3字节是版本号0x01,后面才是Protobuf二进制数据。
public class UserEventSerializer implements Serializer<UserEvent> { private static final byte MAGIC = 0x01; private static final byte VERSION = 0x01; @Override public byte[] serialize(String topic, UserEvent data) { if (data == null) return null; try { byte[] protobufBytes = data.toByteArray(); ByteBuffer buffer = ByteBuffer.allocate(3 + protobufBytes.length); buffer.put(MAGIC).put(VERSION).put(protobufBytes); return buffer.array(); } catch (Exception e) { log.warn("Serialize failed for UserEvent: {}", data.getUid(), e); return new byte[0]; // 返回空数组,避免Producer崩溃 } } }分区器(Partitioner)决定消息发到哪个分区。默认DefaultPartitioner用key.hashCode() % numPartitions,但如果Key是字符串,hashCode()可能分布不均,导致某些分区热点。我们遇到过:用户UID作为Key,但UID是连续数字(如1000001,1000002…),hashCode()也是连续的,结果所有消息都打到同一个分区,那个分区CPU飙升到95%。
解决方案是自定义分区器,用MurmurHash2算法(Kafka内部也用它):
public class MurmurHashPartitioner implements Partitioner<String, byte[]> { @Override public int partition(String topic, String key, byte[] keyBytes, byte[] value, Cluster cluster, Map<String, Object> configs) { List<PartitionInfo> partitions = cluster.partitionsForTopic(topic); int numPartitions = partitions.size(); if (keyBytes == null) { return ThreadLocalRandom.current().nextInt(numPartitions); } // 使用Kafka内置的murmur2,保证和Broker端一致 return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions; } }实操心得:分区器必须保证“相同Key总是路由到同一分区”,否则Consumer端无法按Key聚合。我们曾用
UUID.randomUUID().hashCode()做分区,结果同个用户的行为分散在多个分区,实时统计完全不准。记住:分区逻辑必须是纯函数,不依赖外部状态。
3.4 生产环境完整配置清单与参数依据
以下是我们在2000万日志场景中验证过的Producer配置,每一项都有明确依据:
| 参数 | 推荐值 | 为什么选这个值 | 关联风险 |
|---|---|---|---|
bootstrap.servers | kafka-broker-01:9092,kafka-broker-02:9092,kafka-broker-03:9092 | 至少3个Broker地址,避免单点故障。不写全所有Broker,防止DNS解析失败。 | 只写1个地址,该Broker宕机则Producer启动失败 |
key.serializer/value.serializer | com.example.MurmurHashSerializer | 自定义Serializer,支持版本管理,体积小。 | 用StringSerializer,中文乱码;用JsonSerializer,体积大、GC压力高 |
acks | 1 | 平衡可靠性与延迟。配合Broker端log.flush.interval.messages=10000降低丢数据概率。 | acks=all导致P99延迟超标;acks=0丢数据率>0.1% |
retries | 5 | 总重试耗时≈3.1秒,覆盖常见网络抖动(<1秒)和Broker短暂不可用。 | retries=Integer.MAX_VALUE导致内存泄漏 |
retry.backoff.ms | 200 | 比默认100更宽松,避免重试过于密集。 | 太小(如50)导致重试风暴,压垮Broker |
batch.size | 32768(32KB) | 比默认16KB更大,提升网络包利用率。实测2000TPS下批次填充率从65%升到89%。 | 太大(如128KB)导致小流量时延迟升高 |
linger.ms | 10 | 在2000TPS下实测,延迟与吞吐最优平衡点。 | linger.ms=100让P99延迟从95ms升到135ms |
buffer.memory | 33554432(32MB) | 经压测验证,支撑峰值3000TPS无OOM。设64MB后Full GC频率翻倍。 | 太小(如8MB)在流量突增时抛BufferExhaustedException |
compression.type | lz4 | 比snappy压缩率高15%,比gzipCPU消耗低40%。日志类文本压缩效果好。 | none增加网络带宽压力;gzip让CPU使用率飙升 |
max.in.flight.requests.per.connection | 1 | 强制顺序发送,彻底解决乱序问题。虽降低吞吐15%,但保障业务逻辑正确性。 | >1(默认5)在重试时必然导致乱序 |
request.timeout.ms | 60000 | 必须大于linger.ms + 网络RTT(实测平均25ms)+ Broker处理时间(实测平均80ms),留足缓冲。 | 小于实际耗时,大量TimeoutException |
注意:所有配置必须通过
props.put("key", "value")方式传入,禁止在代码里硬编码。我们用Spring Boot的@ConfigurationProperties绑定到POJO,配置变更无需重启应用。
4. Producer核心操作实现实战:从创建到优雅关闭
4.1 创建Producer实例:线程安全与资源复用
Producer不是轻量对象,它是重量级的、线程安全的、持有大量资源(网络连接、内存缓冲、后台线程)。因此,必须全局单例复用,绝不能每次发消息都new KafkaProducer()。
错误示范:
// ❌ 每次都new,创建无数连接和缓冲区,OOM警告! public void sendLog(UserEvent event) { Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer"); props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer"); Producer<String, String> producer = new KafkaProducer<>(props); // 资源泄露! producer.send(new ProducerRecord<>("logs", event.getUid(), event.toJson())); producer.close(); // close太慢,影响性能 }正确做法:Spring Bean管理(推荐)或静态单例。
@Component public class KafkaProducerManager { private final Producer<String, byte[]> producer; public KafkaProducerManager(@Value("${kafka.bootstrap-servers}") String servers) { Properties props = new Properties(); props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, servers); props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, UserEventSerializer.class); props.put(ProducerConfig.ACKS_CONFIG, "1"); props.put(ProducerConfig.RETRIES_CONFIG, 5); props.put(ProducerConfig.BATCH_SIZE_CONFIG, 32768); props.put(ProducerConfig.LINGER_MS_CONFIG, 10); props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 33554432L); props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4"); props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 1); props.put(ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG, 60000); this.producer = new KafkaProducer<>(props); } public void sendAsync(String topic, String key, UserEvent value) { ProducerRecord<String, byte[]> record = new ProducerRecord<>(topic, key, value); producer.send(record, new DeliveryCallback()); // 回调处理成功/失败 } @PreDestroy public void close() { if (producer != null) { // 优雅关闭:等待所有in-flight请求完成,清空缓冲区 producer.close(Duration.ofSeconds(30)); } } }关键细节:
@PreDestroy方法里调用close(Duration)而非close()。后者是阻塞式关闭,可能卡住;前者指定超时时间,30秒内尽力完成,超时则强制释放资源。我们实测,30秒足够处理完所有积压批次。
4.2 发送消息:同步、异步、事务的适用场景
Producer提供三种发送模式,选错一种,整个链路就废:
异步发送(send + Callback):最常用,适合绝大多数场景。
如上文代码所示,send()立即返回,Callback在后台线程执行。注意Callback里不能阻塞,且要处理nullmetadata(发送失败时metadata为null)。同步发送(send().get()):仅用于调试或极低流量初始化。
try { RecordMetadata metadata = producer.send(record).get(10, TimeUnit.SECONDS); log.info("Sync sent to {}-{}-{}", metadata.topic(), metadata.partition(), metadata.offset()); } catch (ExecutionException | TimeoutException e) { log.error("Sync send failed", e); }get()会阻塞当前线程,绝对禁止在业务线程中调用。它唯一的用途是:应用启动时,发一条测试消息验证Producer连通性,失败则直接抛异常退出。事务发送(beginTransaction/commitTransaction/abortTransaction):用于“精确一次”语义(EOS)。
它要求Broker端开启transaction.state.log.min.isr=2,Producer端配置enable.idempotence=true和transactional.id="my-app-01"。事务能保证:一组消息要么全部成功,要么全部失败,且Consumer端不会重复消费。但代价是:事务ID绑定到特定Producer实例,该实例崩溃后,其他实例无法继续该事务;且事务有额外开销,吞吐比普通发送低20%。我们只在“支付指令”这种强一致性场景用,日志采集不用。
实操心得:永远优先用异步发送。我们曾为图省事,在订单创建后同步调用
send().get()发消息,结果高峰期订单接口P99延迟从120ms飙到850ms,因为get()在等Broker ACK。改成异步后,订单接口回归正常,消息发送失败由独立告警系统捕获。
4.3 优雅关闭:为什么close()必须放在finally里?
Producer的close()方法不只是释放连接,它还要做三件事:
- 停止
Sender后台线程; - 清空
RecordAccumulator中所有未发送的批次; - 等待所有
InFlightRequests收到ACK或超时。
如果忘记close(),应用退出时JVM会回收资源,但可能有消息卡在缓冲区没发出,造成“静默丢数据”。更糟的是,如果Producer是单例,被多个模块共享,某个模块忘了close,整个应用的Producer就处于半残废状态。
所以,任何可能抛异常的发送逻辑,都必须用try-finally包裹:
public void sendWithGuarantee(String topic, String key, UserEvent value) { ProducerRecord<String, byte[]> record = new ProducerRecord<>(topic, key, value); Future<RecordMetadata> future = null; try { future = producer.send(record); // 等待发送结果(仅在必须确认时用,非必需) RecordMetadata metadata = future.get(5, TimeUnit.SECONDS); log.debug("Sent successfully: {}", metadata); } catch (Exception e) { log.error("Send failed for record: {}", record, e); // 失败时,future可能还挂着,但Producer会自动重试或丢弃 } finally { // 这里不close producer!producer是单例,全局管理 // 但如果是局部变量producer,这里必须close() } }对于局部变量Producer(如单元测试),finally里必须close():
@Test public void testProducer() { Producer<String, String> producer = null; try { producer = new KafkaProducer<>(props); producer.send(new ProducerRecord<>("test", "key", "value")).get(); } catch (Exception e) { fail(e.getMessage()); } finally { if (producer != null) { producer.close(); // 必须!否则测试进程不退出 } } }注意:
close()本身也可能抛异常(如网络异常),所以生产代码中应捕获Exception,但不要让它中断主流程。我们统一用try-catch吞掉,因为close失败通常意味着资源已损坏,再处理也没意义。
4.4 监控与告警:必须关注的5个核心指标
没有监控的Producer就像蒙眼开车。我们在线上部署了Prometheus + Grafana,重点盯以下5个指标:
| 指标名 | 来源 | 健康阈值 | 异常含义 | 排查步骤 |
|---|---|---|---|---|
record-send-rate | kafka.producer:type=producer-metrics,client-id=([-.w]+) | >0 | 归零表示Producer完全停止发消息 | 检查应用日志是否有OutOfMemoryError;检查bootstrap.servers是否连通 |
request-latency-avg | kafka.producer:type=producer-metrics,client-id=([-.w]+) | <100ms | >200ms说明网络或Broker有问题 | pingBroker IP;telnet端口;检查Broker CPU/磁盘IO |
buffer-available-bytes | kafka.producer:type=producer-metrics,client-id=([-.w]+) | >10MB | <1MB说明buffer.memory快耗尽 | 检查retries是否过大导致积压;检查batch.size是否太小 |
outgoing-byte-rate | kafka.producer:type=producer-metrics,client-id=([-.w]+) | 匹配业务TPS预期 | 突降50%以上 | 检查业务代码是否异常;检查Producer是否被GC停顿 |
record-error-rate | kafka.producer:type=producer-metrics,client-id=([-.w]+) | =0 | >0.001%需立即告警 | 查看Callback日志中的具体Exception类型 |
我们设置了企业微信告警:当record-error-rate > 0.001%持续5分钟,或buffer-available-bytes < 1MB持续2分钟,立即推送告警。第一次告警,运维查网络;第二次,开发查日志;第三次,直接电话会议。
实操心得:不要只看
record-error-rate。我们曾遇到record-error-rate=0,但outgoing-byte-rate从10MB/s掉到1MB/s,查日志发现全是TimeoutException。原因是request.timeout.ms设太小(2000ms),而Broker负载高时处理时间达2500ms。把request.timeout.ms调到60000后,流量恢复正常。所以指标要关联看。
5. 常见问题排查与独家避坑指南
5.1 “消息发不出去”问题速查表
这是最高频问题,现象是:代码没报错,但Consumer收不到任何消息。按以下顺序快速定位:
| 现象 | 可能原因 | 快速验证命令 | 解决方案 |
|---|---|---|---|
producer.send()后Callback从未触发 | Producer未启动成功 | jstack <pid>查看是否有Sender线程 | 检查bootstrap.servers地址是否可达;检查防火墙是否放行9092端口 |
Callback触发,但exception != null,日志显示TimeoutException | 请求超时 | kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic your-topic查看分区Leader是否正常 | 增大request.timeout.ms;检查Broker负载;确认acks配置与min.insync.replicas匹配 |
Callback触发,exception=null,但Consumer收不到 | 消息发到了错误Topic/Partition | kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic your-topic --from-beginning --max-messages 10 | 检查Producer代码中Topic名是否拼写错误;检查Key是否为null导致分区不均 |
record-error-rate飙升,日志大量SerializationException | 序列化失败 | 在Callback里打印exception.toString() | 检查Serializer的serialize()方法是否对null做了处理;确认Value对象是否实现了Serializable |
buffer-available-bytes持续下降至0,send()开始抛BufferExhaustedException | 内存缓冲区耗尽 | jstat -gc <pid>查看GC情况 | 减小batch.size;增大buffer.memory;检查retries是否过大导致重试积压 |
独家技巧:在Callback里加一行
log.debug("Send result: key={}, valueSize={}", record.key(), record.value().length);。我们曾靠这行日志发现:前端传来的JSON字符串里混入了不可见的Unicode字符(U+200B),导致序列化后字节数暴增,batch.size瞬间被撑爆。加了日志后,一眼看出valueSize异常(>1MB),立刻定位到数据源问题。
5.2 “消息乱序”问题的根源与根治方案
乱序是Producer最隐蔽的坑。现象:Consumer按分区顺序消费,但同Key的消息(如用户UID)到达顺序错乱,导致实时统计错误。
根本原因只有一个:max.in.flight.requests.per.connection > 1且发生重试。
默认是5,意味着Producer可以同时向Broker发5个请求。假设请求1(含UID=123)先发,但网络慢;请求2(含UID=123)后发,却先到Broker。Broker按接收顺序写Log,Consumer自然按此顺序读,UID=123的消息就乱了。
唯一根治方案:max.in.flight.requests.per.connection=1。
这强制Producer“发完一个,再发下一个”,彻底杜绝乱序。虽然吞吐会降15%(我们实测),但换来的是100%顺序保证。别信“用enable.idempotence=true就能解决”,幂等性只保证“不重复”,不保证“不乱序”。
注意:
enable.idempotence=true(默认false)必须配合`retries>