大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
生产者
这一课你将能做到
- 用 Java
KafkaProducer向coderoadmap.order-events发送带orderIdkey 的 JSON,并在回调里打印 partition/offset - 对照表格说清
acks=0/1/all各自「可能丢什么、换来什么延迟」,并能据此为订单事件选型 - 说明「先 MySQL 事务提交再 send」的原因,并指出无 key 导致乱序的错法
为什么要学
Consumer、Spring Kafka、投递语义课都建立在「消息已经可靠进了分区日志」这个前提上。Producer 侧三件套:序列化、分区键、acks/重试。配错会出现两种事故:以为发出去了其实丢了(下单成功通知永远不来)、同订单事件乱序(Shipped 比 Paid 先被消费)。
本课的代码独立成类即可运行,与你的 Spring Boot 主工程(MySQL + Redis)互不干扰;心智上请记住:Producer 只负责把事件可靠写入日志,业务成败仍由数据库事务决定。
人话:Producer = 往日记本某一页(分区)追加一行,并选择「等多久才敢说这句写稳了」。
先建立图像
业务线程
│ 1. MySQL 提交订单(真相)
│ 2. kafkaProducer.send(record)
▼
key/value 序列化 → 按 key 哈希选 partition → 进 RecordAccumulator 批量缓冲
│
├─ acks=0 : 不等 Broker 确认(最快,可能丢)
├─ acks=1 : Leader 分区写入即确认(折中)
└─ acks=all : ISR 内所有副本确认(最稳,稍慢)
│
▼ 回调 (metadata | exception)
| acks | 谁确认才算成功 | 丢消息风险 | 典型场景 |
|---|---|---|---|
0 | 没人,发出即算 | 网络/Broker 一挂就丢 | 可丢的指标、埋点 |
1 | Leader 写入 | Leader 确认后立刻宕机且未同步给 Follower 时丢 | 一般业务事件 |
all(-1) | ISR 全部副本 | 配合 min.insync.replicas≥2 基本不丢 | 订单、支付相关事件 |
跟着做
步骤 1:确认 Broker 与 Topic
按上一课保证 localhost:9092 可用,Topic coderoadmap.order-events 3 分区。核对命令:
docker exec kafka-lab /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic coderoadmap.order-events
步骤 2:写最小可运行 Producer
Maven 依赖(Boot 项目加 spring-kafka 即含客户端;纯 Java 项目用 org.apache.kafka:kafka-clients:3.9.0):
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.9.0</version>
</dependency>
完整代码:
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
public class OrderEventProducerDemo {
public static void main(String[] args) {
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// 订单类事件:等 ISR 全部确认
props.put(ProducerConfig.ACKS_CONFIG, "all");
// 幂等生产者:Broker 端按 (PID, 分区, 序号) 去重,网络重试不重复追加
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
// 批量与延迟微调:攒 16KB 或 10ms 再发,吞吐更高
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384);
props.put(ProducerConfig.LINGER_MS_CONFIG, 10);
try (KafkaProducer<String, String> producer = new KafkaProducer<>(props)) {
String orderId = "1001"; // key:决定分区,保同订单顺序
String json = "{\"event\":\"OrderCreated\",\"orderId\":\"1001\"}";
ProducerRecord<String, String> record =
new ProducerRecord<>("coderoadmap.order-events", orderId, json);
producer.send(record, (metadata, exception) -> {
if (exception != null) {
// 失败必须当一等公民处理:记日志/告警/补偿,不能假装成功
System.err.println("发送失败: " + exception.getMessage());
} else {
System.out.printf("成功 partition=%d offset=%d%n",
metadata.partition(), metadata.offset());
}
});
producer.flush(); // 进程退出前把缓冲刷出
}
}
}
运行后应看到 partition=x offset=n;再用 console consumer --from-beginning 核对 JSON 已在 Topic 里。
步骤 3:对比 acks 的行为
把 ACKS_CONFIG 分别改成 1 与 all,各发 10 条,观察回调全部成功、主观延迟差异不大(本地实验)。然后停掉 Kafka(docker compose stop kafka)再 send:回调里应出现异常。笔记写结论:业务层必须处理回调异常,fire-and-forget 不存在。
步骤 4:验证 key 与分区
连续发同一 orderId 的 Created、Paid 两条,看回调 partition 是否相同;再发几条 key=null 的,观察 partition 轮换。应看到:稳定 key → 同分区;无 key → 分散,失去订单级顺序。
步骤 5:接到「事务后发送」心智
对照 Spring Boot 事务课:
@Transactional
public void createOrder(CreateOrderCmd cmd) {
orderRepo.save(Order.from(cmd)); // 先落库,真相在 MySQL
// 事务提交成功后再发;事务内先发、回滚后会下游处理"幽灵订单"
// 更稳的方案(Outbox)见"投递语义"课
}
写一句进笔记:先 send 再 DB 回滚 = 下游处理一笔不存在的订单。
步骤 6(可选):KafkaTemplate 一眼
Spring 项目里同样的配置收敛到 yml,发送一行:
kafkaTemplate.send("coderoadmap.order-events", orderId, json);
细节留给「Spring Kafka」课展开。
讲清楚要点
- 因为Broker 只存字节,所以必须配 key/value Serializer;实验用
StringSerializer,POJO 走 JSON(消息格式课)。 - 因为默认分区器对 key 哈希(murmur2),所以同实体用同一 key 才能分区内有序;key 一变,顺序承诺作废。
- 因为
acks=all等 ISR 全部确认,所以Leader 宕机也不会丢已确认消息,代价是延迟略高;订单/支付类事件值得。 - 因为
send是异步的(写进本地缓冲就返回 Future),所以失败只能靠回调或get()感知;进程退出前要flush()/close()。 - 因为网络重试可能造成 Broker 重复追加,所以开
enable.idempotence让 Broker 按(PID, partition, seq)去重——它只管生产侧,不管消费侧重复。 - 因为跨分区没有全局顺序,所以不要用「整个 Topic 单分区」硬保全局序扛吞吐;按实体 key + 分区序才是标准解。
你可能会懵的地方
-
「回调成功 = 下游已处理?」 回调成功只代表写入日志。下游消费、业务处理、幂等是另外三层,互不包含。
-
「acks=all 是不是 exactly-once?」 不是。
acks=all+ 幂等生产者只解决「可靠写入且 Broker 不重复追加」;端到端 exactly-once 还涉及消费提交与业务幂等(投递语义课)。别在简历上一句话画等号。 -
「同步
get()是不是更安全?」 关键路径短超时get(1, SECONDS)可接受;高吞吐场景批量异步 + 监控错误率,别让业务线程堵在 Future 上拖垮 Tomcat 线程池。 -
「key 选 slug 还是 lessonId?」 选生命周期内稳定且需要保序的实体 id。slug 可能被改,单独做顺序键会翻车;
lessonId/orderId稳定。 -
「retries 拉满会不会无限重试?」 配合
delivery.timeout.ms(默认 120s)封顶:单条消息从发送到确认的总时限,超时回调报错。重试交给客户端,时限你来定。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
所有事件 key=null 却要求同订单顺序 | 消息轮询散落多分区,消费乱序 | key=orderId 稳定路由 |
acks=0 发支付结果还当成功 | 进程/网络抖动即丢,无法追责 | 关键事件 acks=all + 回调处理 |
| DB 未提交就 send,回滚后下游照常消费 | 下游处理「幽灵订单」 | 事务提交后 send,或上 Outbox |
| 忽略回调异常,只打一行 log | 「偶尔丢消息」无从排查 | 失败进重试队列/死信并告警 |
| 用单分区 Topic 换全局有序 | 吞吐锁死在 1,积压风险高 | 业务实体 key + 接受分区级顺序 |
卡点急救
- 发送报
TimeoutException: Expiring 1 record(s):Broker 连不上或缓冲积压超时。先确认容器 Up、localhost:9092可达,再查 advertised.listeners。 - 回调一次都不触发:主线程先退出、缓冲没刷出去。
flush()或让 producer 生命周期覆盖整个发送过程(try-with-resources)。 - 同订单消息还是乱序:查三点——key 是否真的相同、中间是否有
partitioner.class自定义、消费端是否多线程乱处理(按 key 再分发到单线程队列)。
面试常问
-
Kafka 如何保证消息不丢(生产者角度)? 三层配置:
acks=all等 ISR 全部确认;retries+delivery.timeout.ms保证网络抖动自动重试;enable.idempotence=true保证重试不重复写入。再加服务端min.insync.replicas>=2与unclean.leader.election.enable=false,避免「ISR 只剩 1 个也放行」和「落后副本当 Leader」。 -
ISR 是什么?为什么 acks=all 要依赖它? ISR(In-Sync Replicas):与 Leader 保持同步的副本集合(含 Leader 自己)。Follower 落后超过
replica.lag.time.max.ms会被踢出。acks=all的含义是「ISR 内所有副本都写入才算成功」——所以 ISR 缩到 1 时 all 退化成 1;用min.insync.replicas兜底,低于下限直接拒绝写入,宁可不可用也不丢数据。 -
怎么保证同一订单的消息有序? Producer 用
orderId作 key(哈希固定分区)+ 分区内追加有序 + 消费端同 key 串行处理(或多线程内按 key 哈希到固定线程)。补充代价:大订单量的 key 会造成分区热点,可评估组合 key。
自测三问
acks=0/1/all各用一句话说明「谁确认才返回」;订单事件你选哪个?为什么?- 故意停掉 Kafka 再 send,回调应出现什么?业务层至少做哪两件事?
- 课程 slug 变更事件:key 选 slug 还是稳定
lessonId?各写一句利弊。
小结一句话
生产者负责可靠写入与合理分区:稳定 key 保序、acks/重试/幂等保不丢,回调异常必须处理;业务真相仍在 MySQL。
延伸
- Kafka 官方文档 — Producer Configs
- 下一课「消费者组」:另一端如何拉取、提交位点、参与 rebalance。