Kafka技术文档:从入门到精通
开篇
为什么你需要学习 Kafka?(先说好处)
在开始之前,我想先回答一个最实际的问题:学 Kafka 对你的职业发展有什么帮助?
1. 面试必问,薪资溢价明显
- 大厂(阿里、字节、美团)的 Java 后端面试,消息队列是必考项
- 精通 Kafka 的候选人,平均薪资比只懂 RabbitMQ 的高 15-20%
- 因为 Kafka 是大数据生态的"基础设施",掌握它意味着你能处理海量数据场景
2. 解决真实的生产痛点
想象一下你负责的电商系统:
- 双11大促 :每秒 10 万订单涌入,数据库扛不住 → 用 Kafka 削峰填谷
- 用户行为分析 :需要实时统计点击流、推荐商品 → 用 Kafka 收集日志
- 微服务解耦 :订单服务要通知库存、物流、积分等 5 个下游 → 用 Kafka 异步解耦
3. 性能怪兽:单机百万级吞吐量
这是最让人兴奋的部分------Kafka 的性能数据:
| 指标 | Kafka | 传统方案(MySQL队列/RabbitMQ) |
|---|---|---|
| 单机吞吐量 | 百万级/秒 | 万级/秒 |
| 延迟 | 5-10ms | 1-5ms (RabbitMQ) / 数秒(MySQL) |
| 数据可靠性 | 可配置(0/1/-1) | 高 |
| 横向扩展能力 | 线性增长 | 受限于单机性能 |
核心优势总结:
- 顺序写磁盘 + 零拷贝技术 → 吞吐量接近硬件极限
- 分区并行机制 → 加机器就能提升性能
- 分布式Commit Log设计 → 消息可回溯、可重放
- 成熟的生态系统 → Flink/Spark/Storm 实时计算无缝对接
第一篇:Kafka 核心架构------理解分布式消息系统的设计哲学
数据说话:没有消息队列的系统瓶颈有多严重?
先看一组真实的生产环境监控数据(某日活 100 万用户的电商系统):
| 接口 | 平均耗时 | 峰值 QPS | 耦合下游服务数 | 可用性 |
|---|---|---|---|---|
| 用户注册 | 3.2 秒 | 5000 | 5 个(邮件/优惠券/画像/日志/风控) | 99.2% |
| 订单支付 | 1.8 秒 | 10000 | 4 个(库存/物流/积分/财务) | 99.5% |
| 商品搜索 | 200ms | 50000 | 0 个 | 99.9% |
你发现问题了吗? 注册和支付接口的耗时是搜索接口的 16 倍和 9 倍!而且可用性也低得多。
根因分析:
- 用户注册时,主线程要同步等待 5 个下游服务全部响应才能返回
- 只要其中任何一个下游超时(比如邮件服务挂了),整个注册流程就失败
- 下游服务的故障会反向传播到核心业务链路
如果把系统比作一家餐厅:
- 没有消息队列:厨师(订单服务)做完一道菜后,亲自去端给顾客(库存/物流/积分),效率低下
- 有了消息队列:厨师只管做菜,把菜放到窗口(Kafka),服务员(消费者)各取所需
这就是消息队列的核心价值:解耦、削峰填谷、异步化。
Kafka vs RabbitMQ vs RocketMQ(选型决策表)
同样都是消息队列,什么时候该用哪个?
| 维度 | Kafka | RabbitMQ | RocketMQ |
|---|---|---|---|
| 吞吐量 | 百万级/秒(极高) | 万级/秒(中等) | 十万级/秒(高) |
| 延迟 | ms 级(低) | us 级(极低) | ms 级(低) |
| 可靠性 | 可配置(0/1/-1) | 高(ACK机制) | 极高(事务消息) |
| 适用场景 | 大数据、日志收集、流式计算 | 传统业务解耦、复杂路由 | 金融交易、电商订单 |
| 社区活跃度 | 极高(Confluent 商业支持) | 中等 | 主要在国内 |
| 学习成本 | 中等 | 较低 | 较高 |
选型建议:
- 日志采集、用户行为分析、实时数仓 → Kafka(吞吐量是王道)
- 传统业务解耦(如注册后发邮件) → RabbitMQ(路由灵活)
- 金融交易、电商核心链路 → RocketMQ(事务消息保证强一致性)
Kafka 核心架构图(生产级)
发送消息
消费消息
消费者集群
Consumer Group A
订单处理服务
Consumer 1-3 (负载均衡)
Consumer Group B
数据分析服务
Consumer 4-6 (广播模式)
Kafka 集群
Broker 节点
Broker 1
Leader: Partition 0,2
Follower: Partition 1,3
Broker 2
Leader: Partition 1,3
Follower: Partition 0,2
Broker 3
Follower: 所有 Partition
ZooKeeper
元数据管理
Broker 节点集群
Topic: order-events
Partition: 0,1,2,3
Topic: user-events
Partition: 0,1,2
生产者集群
Producer 1
订单服务
Producer 2
用户服务
Producer 3
日志服务
文字版架构说明:
四大核心组件:
-
Producer(生产者):发送消息到 Kafka 的客户端
- 可以是订单服务、用户服务、日志采集器等
- 负责选择目标 Topic 和 Partition(可指定分区键)
-
Broker(代理节点):Kafka 服务端,存储消息
- 通常部署 3 个或以上节点组成集群
- 每个 Topic 分成多个 Partition 分布在不同 Broker 上
- 采用 Leader-Follower 架构保证高可用
-
Consumer(消费者):从 Kafka 拉取消息的客户端
- 组成 Consumer Group 实现负载均衡
- 同一 Group 内的消费者互斥消费 Partition
- 不同 Group 可以独立消费同一份消息(广播效果)
-
ZooKeeper(元数据中心):管理集群元数据
- 存储 Broker 列表、Topic 配置、Consumer Group 偏移量
- Kafka 3.x 正在逐步移除对 ZooKeeper 的依赖(KRaft 模式)
零拷贝技术详解(Kafka 高性能的核心秘密)
这是 Kafka 最让人惊叹的设计之一,也是面试最高频的考点。
传统 I/O 的问题:4 次内存拷贝
假设你要从磁盘读取一个 1GB 的日志文件,然后通过网络发送给消费者。传统方式需要经历 4 次内存拷贝:
网卡 Socket缓冲区 内核空间 用户缓冲区 应用内存 内核缓冲区 PageCache 磁盘文件 网卡 Socket缓冲区 内核空间 用户缓冲区 应用内存 内核缓冲区 PageCache 磁盘文件 传统I/O流程 4次拷贝 2次CPU参与 第一次CPU介入 数据从内核复制到应用内存 第二次CPU介入 数据又从应用复制回内核 1.DMA拷贝 磁盘到内核PageCache 硬件自动完成 2.CPU拷贝 内核到用户空间 系统调用read 3.CPU拷贝 用户空间到Socket缓冲区 系统调用write 4.DMA拷贝 Socket缓冲区到网卡 硬件自动完成
性能瓶颈分析:
- 2 次 CPU 拷贝(步骤②③):CPU 要把数据从一个内存区域搬到另一个区域
- 上下文切换开销:用户态 ↔ 内核态切换 2 次(read + write)
- Cache 污染:数据经过用户空间,会污染 CPU 的 L1/L2 缓存
实际影响:
- 对于 1GB 文件:CPU 要搬运 2GB 数据(读 1GB + 写 1GB)
- 在高并发场景下,CPU 可能被 I/O 操作占满,无法处理业务逻辑
Kafka 的 Zero-Copy 优化:2 次内存拷贝
Kafka 使用 Linux 的 sendfile 系统调用,完全绕过用户空间:
网卡 内核PageCache 磁盘文件 网卡 内核PageCache 磁盘文件 Kafka Zero-Copy 2次拷贝 0次CPU参与 数据直接停留在 操作系统管理的PageCache 直接从内核空间 传输到网卡 无需CPU介入 1.DMA拷贝 磁盘到内核PageCache 2.DMA拷贝 PageCache到网卡 sendfile系统调用
核心原理:
sendfile(fd, fd, offset, count)是一个特殊的系统调用- 它告诉操作系统:"把文件描述符 A 的数据直接传给文件描述符 B"
- 整个过程在内核空间完成,数据不会到达用户空间
性能对比:零拷贝 vs 传统 I/O
| 维度 | 传统 I/O | Kafka Zero-Copy | 提升幅度 |
|---|---|---|---|
| 内存拷贝次数 | 4 次 | 2 次 | 减少 50% |
| CPU 参与次数 | 2 次 | 0 次 | CPU 零负载 |
| 上下文切换 | 2-4 次 | 1-2 次 | 减少 50% |
| 1GB 文件传输时间 | ~100ms | ~30ms | 快 3 倍 |
| CPU 利用率 | 60-80% 用于 I/O | 接近 0% | 全部用于业务 |
为什么其他消息队列不用 Zero-Copy?
这是一个很好的面试追问。
RabbitMQ 不用的原因:
- RabbitMQ 需要在用户空间对消息进行路由、过滤、转换
- 它的消息模型更复杂(Exchange → Queue 绑定关系)
- 必须读取消息内容才能决定投递到哪个队列
ActiveMQ 不用的原因:
- 基于 JMS 规范设计,强调消息的可靠性和事务性
- 需要维护消息状态(已读/未读/重试次数)
- 无法简单地把文件内容直接发送给网络
Kafka 能用 Zero-Copy 的原因:
- Kafka 的消息模型很简单:写入是追加,读取是顺序
- 消息不需要复杂的路由逻辑(Partition 由 Producer 决定)
- 消息存储就是普通的日志文件,可以直接 sendfile
设计哲学差异:
RabbitMQ:功能丰富但复杂 → 牺牲性能换取灵活性
Kafka:简单粗暴但高效 → 牺牲灵活性换取极致性能
代码层面:如何验证 Zero-Copy 生效?
如果你想在生产环境验证 Kafka 是否使用了 Zero-Copy,可以通过以下方式:
1. 查看 Broker 日志
bash
# 启动时会有类似输出
[2024-01-15 10:23:45,123] INFO Using java.nio.channels.FileChannel.transferTo for zero-copy transfers
2. 监控系统指标
bash
# Linux 下查看 context switch 次数
vmstat 1
# 如果 Zero-Copy 生效,cs(context switches)应该明显降低
# 正常情况:cs < 50000/秒
# 异常情况(未生效):cs > 200000/秒
3. Java 代码验证(简化版)
java
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.channels.FileChannel;
public class ZeroCopyDemo {
public static void main(String[] args) throws Exception {
// 模拟 Kafka 的 Zero-Copy 传输
FileChannel source = new FileInputStream("kafka-log-0.log").getChannel();
FileChannel dest = new FileOutputStream("/dev/stdout").getChannel();
// transferTo 底层调用 sendfile 系统调用
long transferred = source.transferTo(0, source.size(), dest);
System.out.println("零拷贝传输字节数: " + transferred);
source.close();
dest.close();
}
}
关键 API:
FileChannel.transferTo()→ Java 层面的 Zero-Copy 实现- 底层调用 Linux 的
sendfile()系统调用 - 支持 Java 7+ 和 Linux kernel 2.6.33+
面试追问深度解析
Q1:Zero-Copy 只适用于读操作吗?写操作呢?
A:不完全是。
- 读操作(Consumer 拉取消息):完美适用 Zero-Copy(sendfile)
- 写操作(Producer 发送消息) :部分适用
- Producer 写入时,消息先进入 Page Cache(内核空间)
- 从 Page Cache 到网卡也可以用 Zero-Copy
- 但如果开启了压缩/加密,就需要在用户空间处理
Q2:Zero-Copy 有什么缺点或限制?
A:主要有 3 个限制:
- 文件必须支持 mmap 或 sendfile(普通文件可以,设备文件不行)
- 无法对数据进行加工(比如加密、压缩、过滤必须在用户空间完成)
- 依赖操作系统支持(Windows 的实现不如 Linux 完善)
Q3:除了 Zero-Copy,Kafka 还有哪些性能优化?
A:四大优化组合拳:
- 顺序写磁盘(避免随机 I/O 的寻道时间)
- Zero-Copy(减少内存拷贝)
- Page Cache(利用操作系统的缓存机制)
- 批量压缩(减少网络 I/O 和存储空间)
这四个技术叠加,才造就了 Kafka 的百万级吞吐量。
面试追问:
Q:Kafka 为什么这么快?它的架构设计有哪些优化点?
A:
1. 顺序写磁盘(零拷贝技术) 详见上方「零拷贝技术详解」章节
- 传统方式:应用内存 → 内核空间 → 磁盘(4 次内存拷贝)
- Kafka 方式:通过 sendfile 系统调用,直接从磁盘拷贝到网卡(2 次)
- 性能提升:接近网络带宽上限
2. 分区并行
- 一个 Topic 分成多个 Partition,分布在多台机器上
- 生产者和消费者可以并行读写
- 吞吐量随机器数量线性增长
3. 页缓存(Page Cache)
- Kafka 不自己管理缓存,而是依赖操作系统
- 消息写入时先进入 Page Cache,定期刷盘
- 读消息时如果命中 Page Cache,速度接近内存读取
4. 批量压缩
- 支持批量发送(linger.ms + batch.size)
- 支持 GZIP/Snappy/LZ4 压缩算法
- 减少 I/O 和存储开销
第二篇:核心概念精讲------从 Topic 到 Offset
类比学习法:把 Kafka 想象成一个大型图书馆
如果你直接背定义,可能过两天就忘了。但如果你能建立类比,记忆会深刻得多。
| Kafka 概念 | 图书馆类比 | 具体含义 |
|---|---|---|
| Topic(主题) | 图书分类(如"计算机"、"文学") | 消息的逻辑分类,如 order-events、user-events |
| Partition(分区) | 同一类书放在不同书架(书架 1、2、3) | Topic 的物理分片,每个 Partition 是有序的消息队列 |
| Offset(偏移量) | 书的页码(第 1 页、第 100 页) | 消息在 Partition 中的位置编号,单调递增 |
| Consumer Group(消费者组) | 一批读者共享阅读进度 | 同一组内的消费者协调分配 Partition,每个 Partition 只能被一个消费者消费 |
| Replica(副本) | 同一本书复印多份存不同仓库 | Partition 的冗余备份,防止单点故障 |
关键洞察:
- Topic 是逻辑概念(就像"计算机类书籍"这个分类)
- Partition 是物理存储(就像实际的书架 1、书架 2)
- Offset 是位置标记(就像"我读到了第 100 页")
- Consumer Group 是协调机制(就像"我们几个人一起读完这些书")
Topic: order-events
Partition 0
Offset: 0→100→200→...
Partition 1
Offset: 0→50→150→...
Partition 2
Offset: 0→80→180→...
Replica 0-0 (Leader)
Broker 1
Replica 0-1 (Follower)
Broker 2
Replica 0-2 (Follower)
Broker 3
Replica 1-0 (Follower)
Broker 1
Replica 1-1 (Leader)
Broker 2
Replica 1-2 (Follower)
Broker 3
文字版分区与副本说明:
上图展示了一个 Topic 有 3 个 Partition,每个 Partition 有 3 个 Replica(副本因子=3):
- 红色节点 = Leader:负责读写请求
- 蓝色节点 = Follower:只从 Leader 同步数据,不对外提供服务
- 同一 Partition 的副本分散在不同 Broker 上:防止单机故障导致数据丢失
关键设计原则:
- Partition 数量决定并行度:越多 Partition,吞吐量越高(但也意味着更多文件句柄)
- 副本因子决定可靠性:通常设为 3(容忍 2 台机器故障)
- Leader 选举:Leader 挂掉后,Controller 会从 ISR 中选举新 Leader
消费者组(Consumer Group)的工作机制
这是 Kafka 最核心也最容易混淆的概念之一。
场景假设:
- Topic "order-events" 有 3 个 Partition(P0、P1、P2)
- 消费者组 A 有 3 个消费者(C1、C2、C3)
分配结果:
C1 → 消费 P0
C2 → 消费 P1
C3 → 消费 P2
如果消费者组 A 只有 2 个消费者呢?
C1 → 消费 P0
C2 → 消费 P1 + P2 (一个消费者可以消费多个 Partition)
如果消费者组 A 有 4 个消费者呢?
C1 → 消费 P0
C2 → 消费 P1
C3 → 消费 P2
C4 → 空闲(多余的消费者无法分配到 Partition)
面试追问:
Q:消费者组内消费者的数量应该怎么设置?多了会怎样?少了会怎样?
A:
最优配置:消费者数量 = Partition 数量
- 每个消费者独占一个 Partition,最大化并行度
消费者数量 < Partition 数量
- 部分消费者要消费多个 Partition
- 并行度降低,但不会出错
消费者数量 > Partition 数量
- 多余的消费者空闲浪费资源
- 但可以用于"热备",当某个消费者挂掉时自动接管
生产环境建议:
- 初期:消费者数量 <= Partition 数量
- 扩容时:先增加 Partition 数(需要 Rebalance),再增加消费者
第三篇:生产者详解------如何高效地发送消息
代码演进:从"能用"到"好用"的三个阶段
你可能写过这样的代码(阶段一:同步阻塞):
java
// 同步发送(阻塞等待响应)
Future<RecordMetadata> future = kafkaTemplate.send("order-events", orderId, orderJson);
RecordMetadata metadata = future.get(); // 阻塞!
log.info("消息发送成功,Partition: {}, Offset: {}", metadata.partition(), metadata.offset());
问题: 每次发送都要等 Broker 确认,吞吐量上不去。
改进后(阶段二:异步回调):
java
// 异步发送(非阻塞,高性能)
kafkaTemplate.send("order-events", orderId, orderJson)
.addCallback(
success -> log.info("消息发送成功"),
failure -> log.error("消息发送失败", failure)
);
再进一步(阶段三:极致性能):
java
// Fire-and-Forget(不关心结果)
kafkaTemplate.send("order-events", orderId, orderJson);
// 不等待、不回调、不重试(适用于日志采集等允许丢失的场景)
同样一个需求,三种实现方式的 Trade-off:
| 模式 | 可靠性 | 性能 | 适用场景 |
|---|---|---|---|
| Send-and-Forget | 低(可能丢失) | 极高 | 日志采集、指标上报 |
| 异步回调(Async) | 中(可重试) | 高 | 业务消息(推荐) |
| 同步等待(Sync) | 高(强一致) | 低 | 金融交易、资金操作 |
关键配置参数(生产级调优)
yaml
# application.yml(Spring Kafka 生产者配置)
spring:
kafka:
producer:
# ========== 连接配置 ==========
bootstrap-servers: kafka1:9092,kafka2:9092,kafka3:9092
# ========== 序列化器 ==========
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
# ========== 可靠性配置(重要!)==========
acks: all # 0=不等待确认 / 1=Leader确认 / all=所有ISR确认
retries: 3 # 重试次数
retry.backoff.ms: 100 # 重试间隔(指数退避)
enable.idempotence: true # 幂等性(防止重复写入,Kafka 0.11+)
# ========== 性能调优 ==========
batch-size: 16384 # 批量大小(16KB)
linger-ms: 10 # 等待时间(10ms内积累一批消息一起发)
buffer-memory: 33554432 # 缓冲区大小(32MB)
compression-type: lz4 # 压缩算法(lz4 平衡性能和压缩率)
# ========== 自定义分区策略 ==========
partitioner-classname: com.example.kafka.CustomPartitioner
参数解读(面试高频):
acks 参数的三种模式:
| acks 值 | 含义 | 可靠性 | 性能 | 适用场景 |
|---|---|---|---|---|
| 0 | Producer 发送后立即返回,不等 Broker 确认 | 最低(可能丢数据) | 最高 | 日志采集、监控指标 |
| 1 | Leader 写入成功即返回,不等待 Follower 同步 | 中等(Leader 挂了可能丢) | 较高 | 一般业务消息 |
| all (-1) | ISR 中所有副本都写入成功才返回 | 最高(极难丢数据) | 较低 | 金融交易、支付 |
Trade-off 决策:
- 如果你的业务允许丢失少量消息(如用户点击流):用 acks=0 或 1
- 如果你的业务绝对不能丢数据(如订单、支付):用 acks=all + min.insync.replicas=2
生产者如何保证消息不丢失(核心机制深度解析)
这是金融级场景下最核心的可靠性保障,也是面试最高频的考点之一。
签收确认机制(acks = all 或 -1)------ 不丢消息的最核心配置
三种模式对比(带场景化解释):
| acks 值 | 含义 | 可靠性 | 性能 | 适用场景 |
|---|---|---|---|---|
| 0 | Producer 发送后立即返回,不等 Broker 确认 | 最低(可能丢数据) | 最高 | 日志采集、监控指标、用户行为分析 |
| 1 | Leader 写入成功即返回,不等待 Follower 同步 | 中等(Leader 挂了可能丢) | 较高 | 一般业务消息(允许极少量丢失) |
| all (-1) | ISR 中所有副本都写入成功才返回 | 最高(极难丢数据) | 较低 | 金融交易、支付、订单(推荐!) |
acks=all 的工作原理详解:
Producer 发送消息 → Leader (Broker 1) 收到并写入
→ Leader 等待 Follower (Broker 2, 3) 同步完成
→ ISR 集合 [B1, B2, B3] 全部写入成功
→ Broker 返回 ACK 给 Producer
→ Producer 继续发送下一条消息
如果某个 Follower 超时未同步:
→ 该 Follower 被踢出 ISR(ISR shrink)
→ 剩余 ISR 成员全部同步成功即可返回 ACK
为什么金融场景必须用 acks=all?
假设某支付系统的配置是 acks=1:
java
// 场景:用户支付 10000 元
T=0ms Producer 发送 "支付成功" 消息,Leader (B1) 写入成功,立即返回 ACK
T=1ms B1 尝试同步给 Follower (B2, B3)
T=2ms **突然断电!B1 磁盘损坏,数据丢失**
T=3ms B2, B3 还没来得及同步完成
结果:Producer 以为消息发送成功了,但实际上消息已经永久丢失!
用户扣了钱,但下游库存/物流服务没收到通知 → 严重事故!
如果使用 acks=all:
java
// 同样的场景,但配置了 acks=all + min.insync.replicas=2
T=0ms Producer 发送消息,Leader (B1) 写入成功
T=1ms B1 等待 B2 同步完成(min.insync.replicas=2 要求至少 2 个副本)
T=2ms B2 确认同步成功,但 B3 还没同步完
T=3ms **满足 ISR 中至少 2 个副本写入成功,返回 ACK 给 Producer**
T=4ms 即使此时 B1 断电,B2 上已经有完整的数据副本
结果:消息安全!即使 B1 挂了,B2 可以被选举为新 Leader,数据不丢失
重试机制(retries)------ 没回执就一直发
为什么需要重试?
网络环境是不可靠的:
- TCP 包可能丢失(路由器拥塞)
- Broker 可能短暂不可用(GC 停顿/磁盘 I/O 风暴)
- DNS 解析可能超时
重试配置的最佳实践:
yaml
# application.yml(生产者侧)
spring:
kafka:
producer:
# ========== 重试配置(重要!)==========
retries: Integer.MAX_VALUE # 无限重试(或设置一个很大的值,如 2147483647)
retry.backoff.ms: 100 # 初始重试间隔 100ms
# Kafka 会采用指数退避策略:
# 第 1 次重试:等待 100ms
# 第 2 次重试:等待 200ms
# 第 3 次重试:等待 400ms
# ...以此类推,避免雪崩效应
max.in.flight.requests.per.connection: 5 # 每个连接最多 5 个未确认请求
enable.idempotence: true # 幂等性(防止重试导致重复)
指数退避策略的作用:
假设 Broker 因为 GC 停顿导致 10 秒内无法响应:
java
// 错误做法:固定间隔重试
retries: 3
retry.backoff.ms: 100 // 固定 100ms
时间线:
T=0ms 发送失败
T=100ms 重试 1 次(Broker 还在 GC)
T=200ms 重试 2 次(Broker 还在 GC)
T=300ms 重试 3 次(Broker 还在 GC)
T=301ms 放弃!消息丢失
// 正确做法:指数退避重试
retries: Integer.MAX_VALUE
retry.backoff.ms: 100 // 初始 100ms,后续翻倍
时间线:
T=0ms 发送失败
T=100ms 重试 1 次(Broker 还在 GC)
T=300ms 重试 2 次(等待 200ms,Broker 仍在 GC)
T=700ms 重试 3 次(等待 400ms,GC 结束了!)
T=1500ms 重试成功
实战避坑指南:必须使用带有 Callback 的异步发送
错误做法(同步阻塞式):
java
// 危险!这种写法在生产环境会带来性能问题
public void sendOrderEvent(OrderEvent event) {
try {
// 同步阻塞等待 Broker 确认
RecordMetadata metadata = kafkaTemplate.send("order-events", event.getId(), JSON.toJSONString(event)).get();
log.info("消息发送成功: partition={}, offset={}", metadata.partition(), metadata.offset());
} catch (Exception e) {
log.error("消息发送失败", e);
// 问题:这里只是打印日志,没有补偿措施!
}
}
问题分析:
1. .get() 会阻塞当前线程,直到 Broker 返回 ACK 或抛出异常
2. 如果 Broker 响应慢(100ms),则 QPS 上限只有 10/秒(单线程)
3. 异常处理不够完善,没有重试或补偿机制
4. 在高并发场景下,线程池会被耗尽
- 正确做法(异步回调 + 完善的错误处理):**
java
@Component
@RequiredArgsConstructor
@Slf4j
public class ReliableOrderProducer {
private final KafkaTemplate<String, String> kafkaTemplate;
private final OutboxRepository outboxRepository; // 补偿表
/**
* 发送订单事件(异步回调 + 失败补偿)
*/
public void sendOrderEventReliably(OrderEvent event) {
String eventId = event.getId();
String topic = "order-events";
try {
kafkaTemplate.send(topic, eventId, JSON.toJSONString(event))
.addCallback(
// 成功回调
success -> {
RecordMetadata metadata = success.getRecordMetadata();
log.info(" 消息发送成功: eventId={}, partition={}, offset={}",
eventId, metadata.partition(), metadata.offset());
},
// 失败回调(关键!)
failure -> {
log.error(" 消息发送失败: eventId={}, error={}",
eventId, failure.getMessage(), failure);
// ====== 实战避坑:必须做以下处理 ======
// 1. 记录 Error 日志(便于排查)
log.error("消息详情: topic={}, key={}, payload={}",
topic, eventId, JSON.toJSONString(event));
// 2. 落入补偿表(Outbox),后续定时任务重新投递
saveToOutboxForRetry(event);
// 3. 触发告警(通知运维人员关注)
alertService.sendAlert("Kafka 发送失败",
String.format("eventId=%s, error=%s", eventId, failure.getMessage()));
}
);
} catch (Exception e) {
// 连接异常等极端情况
log.error("发送异常: eventId={}", eventId, e);
saveToOutboxForRetry(event); // 兜底:写入补偿表
}
}
/**
* 将失败的消息写入 Outbox 补偿表
*/
private void saveToOutboxForRetry(OrderEvent event) {
try {
OutboxMessage outbox = OutboxMessage.builder()
.aggregateType("Order")
.aggregateId(event.getId())
.payload(JSON.toJSONString(event))
.topic("order-events")
.status(OutboxStatus.PENDING)
.retryCount(0)
.build();
outboxRepository.save(outbox);
log.info("消息已落入补偿表,等待定时任务重新投递: eventId={}", event.getId());
} catch (Exception e) {
log.error("写入补偿表也失败了!需要人工介入: eventId={}", event.getId(), e);
// 最后的兜底:发送邮件/钉钉通知开发人员
emergencyAlert(event, e);
}
}
}
Callback 中的错误处理策略对比:
| 策略 | 实现方式 | 可靠性 | 适用场景 |
|---|---|---|---|
| 仅记录日志 | log.error() |
低(消息可能丢失) | 开发/测试环境 |
| 写入补偿表(推荐) | saveToOutbox() |
高(最终一致性) | 生产环境(金融/电商) |
| 同步重试 | kafkaTemplate.send().get() |
中(阻塞线程) | 对延迟不敏感的场景 |
| 死信队列(DLQ) | kafkaDlq.send() |
高(人工处理) | 需要人工审核的场景 |
完整的生产者可靠性保障方案(端到端)
补偿机制
签收确认机制
重试机制
- 构建消息
- 异步发送
kafkaTemplate.send()
成功
失败
重试次数 < 最大值
超过最大重试
3. 发送到 Broker
4. 等待 ISR 同步
5. 全部同步成功
6. 触发 Callback
Producer
业务代码
Send Buffer
缓冲区
Callback 回调
记录日志
结束流程
重试机制
retries=Integer.MAX_VALUE
指数退避策略
落入补偿表
Outbox Table
Leader (主柜)
写入消息
ISR 集合
Follower 们 (备用柜)
返回 ACK
acks=all 生效
定时任务轮询
每秒一次
重新投递到 Kafka
端到端的可靠性保障总结:
| 层次 | 机制 | 配置项 | 作用 |
|---|---|---|---|
| 第一层 | 缓冲区批量发送 | batch.size + linger.ms |
提升吞吐量 |
| 第二层 | 异步回调 | addCallback() |
非阻塞,感知发送结果 |
| 第三层 | 自动重试 | retries + retry.backoff.ms |
应对网络抖动 |
| 第四层 | 幂等性 | enable.idempotence=true |
防止重复写入 |
| 第五层 | ISR 签收确认 | acks=all |
保证多副本持久化 |
| 第六层 | 补偿表兜底 | Outbox 表 + 定时任务 | 最终一致性保障 |
一句话总结:
生产者的消息不丢失保障是一个六层防御体系:批量发送提升性能 → 异步回调感知结果 → 自动重试应对抖动 → 幂等性防重复 → ISR 多副本持久化 → 补偿表兜底。每一层都是上一层的保险,确保在极端情况下消息也不会丢失。
进阶:分布式事务难题------Outbox 模式如何保证数据一致性
场景引入:当数据库操作和消息发送必须"同生共死"
在第三篇中,我们学习了如何高效地发送消息。但这里有一个致命的问题被刻意回避了:
假设你的订单服务要完成两件事:
- 在 MySQL 中插入一条订单记录(INSERT INTO orders)
- 向 Kafka 发送一个 "订单创建" 事件(通知库存/物流/积分服务)
java
@Transactional
public void createOrder(OrderDTO order) {
// 步骤1:保存订单到数据库
orderRepository.save(order); // 成功!
// 步骤2:发送消息到 Kafka
kafkaTemplate.send("order-events", order.getId(), JSON.toJSONString(order));
// 突然报错!Kafka Broker 连接超时...
}
问题来了:
- 数据库事务回滚了,但消息可能已经发出去了 → 下游收到幽灵订单
- 或者消息没发出去,但订单已经入库了 → 库存不扣减、物流不发货
这就是经典的分布式事务问题 :如何保证数据库操作 和消息发送的一致性?
方案对比:为什么 Outbox 模式是最佳选择
| 方案 | 一致性保证 | 复杂度 | 性能影响 | 适用场景 |
|---|---|---|---|---|
| 本地消息表(Outbox) | 强一致性(最终一致) | 中等 | 低(异步) | 推荐!电商/金融 |
| 2PC/XA 两阶段提交 | 强一致性 | 高 | 高(锁资源) | 传统银行系统 |
| TCC(Try-Confirm-Cancel) | 强一致性 | 很高 | 中等 | 金融核心系统 |
| Saga 模式 | 最终一致性 | 高 | 低 | 长时间业务流程 |
| 最大努力通知 | 弱一致性 | 低 | 无 | 对一致性要求不高 |
为什么选择 Outbox 模式?
- 实现简单(不需要引入 Seata 等中间件)
- 性能好(基于数据库本地事务,无远程锁)
- 可靠性高(消息持久化,支持重试)
- 与 Kafka 天然契合(异步解耦)
Outbox 模式核心原理:把消息当成"数据"来管理
核心思想:不要直接发 Kafka,而是先把消息写入数据库的一张"发件箱"表,然后由定时任务异步投递。
Kafka Broker 定时轮询任务 outbox表 本地消息表 MySQL 数据库 订单服务 Kafka Broker 定时轮询任务 outbox表 本地消息表 MySQL 数据库 订单服务 Outbox 模式工作流程 此时订单和消息 要么都成功 要么都失败 loop [定时轮询 每秒一次] 1. 开启本地事务 2. INSERT INTO orders 订单数据 3. INSERT INTO outbox 消息数据 4. COMMIT 事务 原子操作 5. SELECT status PENDING 6. 发送消息到 Kafka 7. 返回成功确认 8. UPDATE status SENT
关键设计要点:
- 步骤 2 和 3 在同一个数据库事务中 → 保证原子性
- 消息先落库再发送 → 即使 Kafka 挂了,消息也不会丢
- 定时任务轮询 → 异步投递,不影响主业务性能
- 状态机管理 → PENDING → SENT → CONFIRMED(三态模型)
数据库设计:Outbox 表结构
sql
CREATE TABLE `outbox` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
`aggregate_type` VARCHAR(50) NOT NULL COMMENT '聚合根类型(Order/Payment/User)',
`aggregate_id` VARCHAR(64) NOT NULL COMMENT '聚合根ID(订单号/支付流水号)',
`payload` TEXT NOT NULL COMMENT '消息体(JSON格式)',
`topic` VARCHAR(100) NOT NULL COMMENT '目标 Topic',
`status` TINYINT DEFAULT 0 COMMENT '状态:0-PENDING 1-SENT 2-CONFIRMED',
`retry_count` INT DEFAULT 0 COMMENT '重试次数',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`sent_at` DATETIME NULL COMMENT '发送时间',
`version` INT DEFAULT 0 COMMENT '乐观锁版本号',
UNIQUE KEY `uk_aggregate` (`aggregate_type`, `aggregate_id`),
KEY `idx_status_created` (`status`, `created_at`)
) ENGINE=InnoDB COMMENT='Outbox 本地消息表';
字段说明:
aggregate_type + aggregate_id:唯一约束,防止重复插入payload:完整的消息内容(JSON)status:三态模型(待发送/已发送/已确认)retry_count:失败重试计数器(超过阈值告警)version:乐观锁,防止并发重复消费
Spring Boot 完整实现:从零搭建 Outbox 模式
第一步:定义实体类和 Repository
java
@Entity
@Table(name = "outbox")
public class OutboxMessage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "aggregate_type")
private String aggregateType;
@Column(name = "aggregate_id")
private String aggregateId;
@Column(name = "payload", columnDefinition = "TEXT")
private String payload;
@Column(name = "topic")
private String topic;
@Enumerated(EnumType.ORDINAL)
@Column(name = "status")
private OutboxStatus status = OutboxStatus.PENDING;
@Column(name = "retry_count")
private Integer retryCount = 0;
@CreationTimestamp
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "sent_at")
private LocalDateTime sentAt;
@Version
@Column(name = "version")
private Integer version;
}
public enum OutboxStatus {
PENDING, // 待发送
SENT, // 已发送(等待 Kafka 确认)
CONFIRMED // 已确认(Kafka 已成功接收)
}
java
public interface OutboxRepository extends JpaRepository<OutboxMessage, Long> {
// 查询待发送的消息(分页,避免一次查太多)
@Query("SELECT o FROM OutboxMessage o WHERE o.status = 0 ORDER BY o.createdAt ASC")
Page<OutboxMessage> findPendingMessages(Pageable pageable);
// 根据聚合根查询(幂等检查)
Optional<OutboxMessage> findByAggregateTypeAndAggregateId(
String aggregateType, String aggregateId);
}
第二步:业务代码中使用 Outbox
java
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
/**
* 创建订单(使用 Outbox 模式保证一致性)
*/
@Transactional
public OrderDTO createOrder(OrderCreateRequest request) {
// 1. 构建订单对象
Order order = Order.builder()
.orderId(UUID.randomUUID().toString())
.userId(request.getUserId())
.amount(request.getAmount())
.status("CREATED")
.build();
// 2. 保存订单到数据库
orderRepository.save(order);
// 3. 构建事件消息并写入 Outbox 表
OrderCreatedEvent event = OrderCreatedEvent.builder()
.orderId(order.getOrderId())
.userId(order.getUserId())
.amount(order.getAmount())
.createdAt(LocalDateTime.now())
.build();
OutboxMessage outbox = OutboxMessage.builder()
.aggregateType("Order") // 聚合根类型
.aggregateId(order.getOrderId()) // 聚合根ID
.payload(JSON.toJSONString(event)) // 消息体
.topic("order-events") // 目标Topic
.status(OutboxStatus.PENDING) // 待发送
.build();
outboxRepository.save(outbox);
// 4. 事务提交后,消息会被定时任务自动投递到 Kafka
return OrderDTO.fromEntity(order);
}
}
关键点:
@Transactional保证订单和 Outbox 消息在同一个事务中- 如果第 2 步或第 3 步失败,整个事务回滚 → 不会出现"有订单无消息"的情况
- 消息不是立即发送,而是由后台任务异步投递
第三步:消息轮询与投递(核心组件)
java
@Component
@RequiredArgsConstructor
@Slf4j
public class OutboxPoller {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
/**
* 定时任务:每秒轮询一次 Outbox 表
* 使用 @Scheduled 或 XXL-JOB 调度
*/
@Scheduled(fixedDelay = 1000) // 每 1 秒执行一次
public void pollAndSend() {
// 1. 分页查询待发送的消息(每次最多 100 条)
Pageable pageable = PageRequest.of(0, 100);
Page<OutboxMessage> pendingMessages =
outboxRepository.findPendingMessages(pageable);
if (pendingMessages.isEmpty()) {
return; // 没有待处理的消息,直接返回
}
log.info("发现 {} 条待发送消息", pendingMessages.getTotalElements());
// 2. 遍历并发送
for (OutboxMessage message : pendingMessages) {
try {
sendToKafka(message);
} catch (Exception e) {
log.error("消息发送失败: {}", message.getAggregateId(), e);
handleFailure(message);
}
}
}
/**
* 发送消息到 Kafka
*/
private void sendToKafka(OutboxMessage message) {
// 发送消息(同步等待确认,确保可靠性)
SendResult<String, String> result = kafkaTemplate
.send(message.getTopic(), message.getAggregateId(), message.getPayload())
.get(5, TimeUnit.SECONDS); // 超时时间 5 秒
// 更新状态为 SENT
message.setStatus(OutboxStatus.SENT);
message.setSentAt(LocalDateTime.now());
outboxRepository.save(message);
log.info("消息发送成功: topic={}, key={}",
message.getTopic(), message.getAggregateId());
}
/**
* 处理失败情况(指数退避重试)
*/
private void handleFailure(OutboxMessage message) {
int retryCount = message.getRetryCount() + 1;
if (retryCount >= 3) { // 重试超过 3 次
message.setStatus(OutboxStatus.CONFIRMED); // 标记为异常,人工介入
log.error("消息发送失败超过 3 次,需人工处理: {}", message.getAggregateId());
// TODO: 发送告警邮件/钉钉通知运维人员
alertService.sendAlert("Outbox 消息投递失败", message);
} else {
// 更新重试次数,下次继续尝试
message.setRetryCount(retryCount);
}
outboxRepository.save(message);
}
}
第四步:消费者端幂等处理(防止重复消费)
即使使用了 Outbox 模式,网络抖动仍可能导致消息重复。因此消费者端必须做幂等处理:
java
@Service
@RequiredArgsConstructor
public class InventoryConsumer {
private final InventoryRepository inventoryRepository;
private final RedisTemplate<String, String> redisTemplate;
@KafkaListener(topics = "order-events", groupId = "inventory-group")
@Transactional
public void handleOrderCreated(ConsumerRecord<String, String> record) {
OrderCreatedEvent event = JSON.parseObject(record.value(), OrderCreatedEvent.class);
String orderId = event.getOrderId();
// 1. 幂等检查(Redis 去重)
String redisKey = "order:processed:" + orderId;
Boolean isNew = redisTemplate.opsForValue().setIfAbsent(redisKey, "1", 7, TimeUnit.DAYS);
if (!isNew) {
log.warn("重复消息,跳过处理: orderId={}", orderId);
return; // 已经处理过,直接返回
}
// 2. 执行业务逻辑(扣减库存)
Inventory inventory = inventoryRepository.findByProductId(event.getProductId());
if (inventory.getStock() < event.getQuantity()) {
throw new RuntimeException("库存不足");
}
inventory.setStock(inventory.getStock() - event.getQuantity());
inventoryRepository.save(inventory);
log.info("库存扣减成功: productId={}, quantity={}",
event.getProductId(), event.getQuantity());
}
}
幂等策略对比:
| 策略 | 实现复杂度 | 适用场景 |
|---|---|---|
| Redis 去重(推荐) | 低 | 大多数场景(如示例) |
| 数据库唯一索引 | 中 | 需要强一致性 |
| 状态机去重 | 高 | 复杂业务流程 |
进阶实现:基于 Canal CDC 的 Outbox 模式(零侵入方案)
上面我们讲的是"定时任务轮询"方式,这是最经典的 Outbox 实现。但在实际生产中,还有一种更优雅的方案------基于 Canal(CDC)的实时数据捕获。
你的项目是怎么做的?(真实案例解析)
让我以一个真实的社交关注系统为例,展示 Canal + Kafka 的 Outbox 架构:
消费者服务(粉丝表/计数器)
Kafka 集群
Canal Server
MySQL 数据库
业务服务(订单/关注/支付)
实时推送
提取 payload
本地事务
INSERT following + INSERT outbox
following 表
(业务数据)
outbox 表
(本地消息)
Binlog 日志
Canal Client
监听 Binlog 变更
canal-outbox Topic
(CDC 数据流)
CanalOutboxConsumer
消费 + 解析 payload
RelationEventProcessor
执行业务逻辑
Redis 幂等去重
核心流程:
- 业务代码 :在同一个事务中写入
following表 +outbox表 - Canal 监听 :自动捕获
outbox表的 Binlog 变更(INSERT/UPDATE) - 桥接投递 :
CanalKafkaBridge提取payload字段,发送到 Kafka - 消费者处理 :
CanalOutboxConsumer解析消息,调用业务处理器
第一步:Outbox 表设计(你的项目版本)
java
/**
* 本地消息发件箱表
* 注意:这个表结构比定时任务版更简洁,因为不需要 status 和 retry_count 字段
* (Canal 只关心 INSERT,不关心状态管理)
*/
@Data
@TableName("outbox")
@Builder
public class Outbox implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 聚合根类型 (如: ORDER, USER, FOLLOW) */
private String aggregateType;
/** 聚合根ID (业务主键) */
private Long aggregateId;
/** 事件类型 (如: FollowCreated, OrderCreated) */
private String type;
/** 消息体内容 (JSON格式) - 核心字段!*/
private String payload;
/** 创建时间 */
private LocalDateTime createdAt;
}
与定时任务版的区别:
- 没有
status字段(不需要 PENDING/SENT 状态) - 没有
retry_count字段(Canal 保证至少一次投递) - 没有
version字段(不需要乐观锁) - 更简洁,专注于数据存储 而非任务管理
第二步:业务代码写入 Outbox(零感知)
java
@Service
@RequiredArgsConstructor
public class RelationServiceImpl implements IRelationService {
private final FollowingMapper followingMapper;
private final OutboxMapper outboxMapper; // 注入 Outbox Mapper
/**
* 关注用户(使用 Outbox + Canal 模式)
*/
@Transactional
public void follow(Long fromUserId, Long toUserId) {
// 1. 写入关注表(业务数据)
Following following = Following.builder()
.fromUserId(fromUserId)
.toUserId(toUserId)
.status(FollowingStatus.FOLLOWING_SUCCESS.getCode())
.build();
followingMapper.insert(following);
// 2. 写入 Outbox 表(事件消息)- 在同一个事务中!
RelationEvent event = RelationEvent.builder()
.id(following.getId())
.fromUserId(fromUserId)
.toUserId(toUserId)
.type(RelationEventType.FollowCreated.getDesc()) // "FOLLOW_CREATED"
.build();
Outbox outbox = Outbox.builder()
.aggregateType("Follow") // 聚合根类型
.aggregateId(following.getId()) // 聚合根ID
.type("FollowCreated") // 事件类型
.payload(JSON.toJSONString(event)) // 核心字段:JSON 消息体
.createdAt(LocalDateTime.now())
.build();
outboxMapper.insert(outbox);
// 3. 事务提交后,Canal 会自动监听到 outbox 表的变更
// 后续流程完全自动化,业务代码无需关心!
}
}
关键点:
- 业务代码只负责写数据库,不直接调 Kafka API
- Outbox 消息和业务数据在同一个事务中,保证原子性
- 零侵入:不需要引入定时任务框架、不需要配置轮询间隔
第三步:CanalKafkaBridge(核心组件)
这是最关键的组件!它负责:
- 连接 Canal Server,订阅 Binlog 变更
- 过滤出
outbox表的数据 - 提取
payload字段 - 发送到 Kafka
java
@Service
@Slf4j
public class CanalKafkaBridge implements SmartLifecycle {
private final KafkaTemplate<String, String> kafka;
private CanalConnector connector;
private volatile boolean running;
private final ObjectMapper objectMapper;
// 配置项从 application.yml 注入
public CanalKafkaBridge(
KafkaTemplate<String, String> kafka,
ObjectMapper objectMapper,
@Value("${canal.enabled}") boolean enabled,
@Value("${canal.host}") String host,
@Value("${canal.port}") int port,
@Value("${canal.destination}") String destination,
@Value("${canal.filter}") String filter, // 例如:"mydb.outbox"
...
) { ... }
@Override
public void start() {
running = true;
taskExecutor.execute(() -> {
// 1. 连接 Canal Server
connector = CanalConnectors.newSingleConnector(
new InetSocketAddress(host, port),
destination, username, password
);
connector.connect();
connector.subscribe(filter); // 订阅 outbox 表的变更
connector.rollback(); // 回滚位点,防止重复消费
// 2. 主循环:持续拉取 Binlog
while (running) {
Message message = connector.getWithoutAck(batchSize);
if (batchId == -1 || message.getEntries().isEmpty()) {
Thread.sleep(intervalMs); // 无数据时休眠
continue;
}
// 3. 遍历每条 Binlog Entry
for (CanalEntry.Entry entry : message.getEntries()) {
if (entry.getEntryType() != ROWDATA) continue;
RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
// 4. 只处理 INSERT 和 UPDATE
if (eventType != INSERT && eventType != UPDATE) continue;
// 5. 提取 payload 字段(核心!)
ArrayNode dataArray = objectMapper.createArrayNode();
for (RowData rowData : rowChange.getRowDatasList()) {
ObjectNode rowNode = objectMapper.createObjectNode();
for (Column col : rowData.getAfterColumnsList()) {
if ("payload".equalsIgnoreCase(col.getName())) {
rowNode.put("payload", col.getValue()); // 提取 JSON
}
}
dataArray.add(rowNode);
}
// 6. 组装 Kafka 消息并发送
ObjectNode msgNode = objectMapper.createObjectNode();
msgNode.put("table", entry.getHeader().getTableName()); // "outbox"
msgNode.put("type", "INSERT");
msgNode.set("data", dataArray);
String json = objectMapper.writeValueAsString(msgNode);
kafka.send(KafkaTopic.CANAL_OUTBOX, json); // 发送到 Kafka
}
// 7. ACK 确认(告诉 Canal 这批数据处理完了)
connector.ack(batchId);
}
});
}
}
关键设计点:
- Long Polling :
getWithoutAck()是长轮询模式,实时性高 - 批量处理 :一次拉取
batchSize条记录,提升吞吐量 - ACK 机制:只有 Kafka 发送成功后才 ACK,保证不丢数据
- 过滤优化 :只提取
payload字段,减少网络传输量
完整数据流解析(关键!理解各组件的职责)
你可能会有疑问:既然 CanalKafkaBridge 已经把数据发送到 Kafka 了,为什么还需要 OutboxMessageUtil?
答案是:它们处于数据流的不同阶段,职责完全不同!
┌─────────────────────────────────────────────────────────────────────┐
│ 完整数据流(端到端) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 阶段1: 业务写入 │
│ ┌──────────────┐ │
│ │ RelationService│ ──@Transactional──→ INSERT following + outbox │
│ └──────────────┘ │
│ ↓ │
│ 阶段2: Binlog 捕获 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ MySQL │ ──Binlog──→ │ Canal Server │ │
│ │ (outbox表) │ │ (推送变更) │ │
│ └──────────────┘ └──────────────┘ │
│ ↓ ↓ │
│ 阶段3: 桥接投递(生产者侧) │
│ ┌──────────────────────────────────────────────┐ │
│ │ CanalKafkaBridge │ │
│ │ 职责:消费 Canal → 组装消息格式 → 发送到 Kafka │ │
│ │ │ │
│ │ 输入:Canal 的 RowData (二进制) │ │
│ │ 输出:JSON 格式字符串 → Kafka │ │
│ │ │ │
│ │ 发送的格式如下(这是关键!): │ │
│ │ { │ │
│ │ "table": "outbox", │ │
│ │ "type": "INSERT", │ │
│ │ "data": [ │ │
│ │ {"payload": "{...事件JSON...}"}, │ │
│ │ {"payload": "{...事件JSON...}"} │ │
│ │ ] │ │
│ │ } │ │
│ └──────────────────────────────────────────────┘ │
│ ↓ │
│ │ (这个 JSON 字符串被发送到了 Kafka) │
│ ↓ │
│ 阶段4: 消费处理(消费者侧) │
│ ┌──────────────────────────────────────────────┐ │
│ │ CanalOutboxConsumer (Kafka Listener) │ │
│ │ 职责:从 Kafka 拉取消息 → 解析格式 → 处理业务 │ │
│ │ │ │
│ │ 输入:Kafka 消息(就是上面那个 JSON 字符串!) │ │
│ │ 处理: │ │
│ │ 1. 收到的 message = 上面的 JSON 字符串 │ │
│ │ 2. 调用 OutboxMessageUtil.extractRows() 解析 │ │
│ │ 3. 提取出 payload 字段 │ │
│ │ 4. 反序列化为 RelationEvent 对象 │ │
│ │ 5. 调用 processor.process(evt) 执行业务逻辑 │ │
│ └──────────────────────────────────────────────┘ │
│ ↓ │
│ 阶段5: 业务执行 │
│ ┌──────────────┐ │
│ │RelationEventProcessor│ → 更新粉丝表 + Redis缓存 + 计数器 │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
核心要点(一定要理解!):
| 组件 | 所在位置 | 输入 | 输出 | 职责 |
|---|---|---|---|---|
| CanalKafkaBridge | 生产者服务 | Canal 二进制数据 | JSON 字符串 | 组装消息格式并发送 |
| CanalOutboxConsumer | 消费者服务 | JSON 字符串(来自 Kafka) | 业务事件对象 | 接收并解析消息 |
| OutboxMessageUtil | 消费者服务(被 Consumer 调用) | JSON 字符串 | List | 解析工具类 |
一句话总结:
CanalKafkaBridge负责打包(把 Binlog 数据包装成 JSON)CanalOutboxConsumer负责拆包(收到 JSON 后解析出 payload)OutboxMessageUtil就是拆包用的工具(专门解析 CanalKafkaBridge 打包的格式)
第四步:消费者端处理(带幂等)
java
@Service
@RequiredArgsConstructor
public class CanalOutboxConsumer {
private final ObjectMapper objectMapper;
private final RelationEventProcessor processor;
@KafkaListener(topics = KafkaTopic.CANAL_OUTBOX, groupId = "relation-outbox-consumer")
public void onMessage(String message, Acknowledgment ack) {
try {
// 1. 解析 Canal 消息格式
// 这里的 message 就是 CanalKafkaBridge 发送到 Kafka 的那个 JSON 字符串!
// 格式为:{ table: "outbox", type: "INSERT", data: [{ payload: "..." }] }
//
// 问题来了:怎么从这个 JSON 里提取出我们需要的 payload?
// 答案:用 OutboxMessageUtil 工具类!
List<JsonNode> rows = OutboxMessageUtil.extractRows(objectMapper, message);
if (rows.isEmpty()) {
ack.acknowledge();
return;
}
// 2. 遍历每一行数据(每行对应 outbox 表的一条记录)
for (JsonNode row : rows) {
JsonNode payloadNode = row.get("payload");
if (payloadNode == null) continue;
// 3. 反序列化为业务事件对象
// payload 是一个 JSON 字符串,里面包含了 RelationEvent 的所有字段
// 例如:{"id":123, "fromUserId":1, "toUserId":2, "type":"FOLLOW_CREATED"}
RelationEvent evt = objectMapper.readValue(
payloadNode.asText(),
RelationEvent.class
);
// 4. 调用业务处理器(更新粉丝表、缓存、计数器等)
processor.process(evt);
}
// 5. 全部成功,ACK 确认
ack.acknowledge();
} catch (Exception e) {
throw new RuntimeException(e); // 让 Spring Kafka 自动重试
}
}
}
OutboxMessageUtil 工具类(解析 CanalKafkaBridge 发送的消息格式)
java
/**
* OutboxMessageUtil - 消费者端的解析工具类
*
* 为什么需要这个工具类?
* 因为 CanalKafkaBridge 发送到 Kafka 的消息是自定义的 JSON 格式:
* {
* "table": "outbox",
* "type": "INSERT",
* "data": [
* {"payload": "{...}"},
* {"payload": "{...}"}
* ]
* }
*
* CanalOutboxConsumer 收到这个消息后,需要:
* 1. 校验表名是否为 "outbox"
* 2. 校验操作类型是否为 INSERT 或 UPDATE
* 3. 提取 data 数组中的每一行
* 4. 返回给调用者进一步处理
*
* 这些通用的解析逻辑就封装在这个工具类里,避免代码重复。
*/
public class OutboxMessageUtil {
/**
* 从 Canal 消息中提取 outbox 表的变更行
*
* @param objectMapper Jackson JSON 解析器
* @param message 从 Kafka 收到的消息字符串(CanalKafkaBridge 发送的格式)
* @return outbox 表的变更行列表(每行是一个 JsonNode,包含 payload 等字段)
*/
public static List<JsonNode> extractRows(ObjectMapper objectMapper, String message) {
// 第一步:将 JSON 字符串解析为 JsonNode 树结构
JsonNode root = objectMapper.readTree(message);
// 第二步:校验表名 - 必须是 "outbox" 表的变更
// (因为 Canal 可能监听多张表,我们需要过滤)
JsonNode table = root.get("table");
if (table == null || !"outbox".equals(table.asText())) {
return Collections.emptyList(); // 不是 outbox 表,直接返回空
}
// 第三步:校验操作类型 - 只处理 INSERT 和 UPDATE
// (DELETE 操作通常不包含有意义的 payload)
JsonNode type = root.get("type");
if (type == null || (!"INSERT".equals(type.asText()) && !"UPDATE".equals(type.asText()))) {
return Collections.emptyList(); // 不是插入或更新,跳过
}
// 第四步:提取 data 数组
// data 数组包含了所有变更的行数据
// 例如:[{"payload":"{...}"}, {"payload":"{...}"}]
JsonNode data = root.get("data");
if (data == null || !data.isArray()) {
return Collections.emptyList(); // data 字段不存在或不是数组
}
// 第五步:将 data 数组转换为列表返回
List<JsonNode> rows = new ArrayList<>();
data.forEach(rows::add);
return rows;
}
}
调用链路图解:
CanalKafkaBridge.start() 循环:
├── connector.getWithoutAck(batchSize) // 拉取 Canal Binlog
├── 遍历 Entry,提取 payload 字段
├── 组装 JSON: {table, type, data:[{payload}]}
└── kafka.send(KafkaTopic.CANAL_OUTBOX, json) // 发送到 Kafka
↓
↓ (网络传输)
↓
CanalOutboxConsumer.onMessage() 被 Spring Kafka 触发:
├── String message = 从 Kafka 收到的参数 // 就是上面的 json
├── OutboxMessageUtil.extractRows(message) // 调用工具类解析
│ ├── 解析 JSON
│ ├── 校验 table == "outbox"
│ ├── 校验 type in ["INSERT", "UPDATE"]
│ └── 返回 List<JsonNode> rows
├── 遍历 rows,提取每个 row.get("payload")
├── 反序列化为 RelationEvent evt
└── processor.process(evt) // 执行业务逻辑
第五步:业务处理器(Redis 幂等去重)
java
@Service
@RequiredArgsConstructor
public class RelationEventProcessor {
private final StringRedisTemplate redis;
private final FollowerMapper followerMapper;
private final IUserCounterService userCounterService;
public void process(RelationEvent evt) {
// 1. Redis 幂等去重(10分钟过期)
String dedupKey = "dedup:rel:" + evt.type() + ":"
+ evt.fromUserId() + ":" + evt.toUserId() + ":" + evt.id();
Boolean first = redis.opsForValue().setIfAbsent(dedupKey, "1", Duration.ofMinutes(10));
if (first == null || !first) return; // 已处理过,跳过
// 2. 执行业务逻辑(更新粉丝表)
if (evt.type().equals("FOLLOW_CREATED")) {
Follower follower = Follower.builder()
.id(evt.id())
.fromUserId(evt.fromUserId())
.toUserId(evt.toUserId())
.relStatus(FollowingStatus.FOLLOWING_SUCCESS.getCode())
.build();
followerMapper.insertOrUpdate(follower);
// 3. 更新缓存(ZSet 存储关注列表)
long now = System.currentTimeMillis();
redis.opsForZSet().add("uf:flws:" + evt.fromUserId(), String.valueOf(evt.toUserId()), now);
redis.opsForZSet().add("uf:fans:" + evt.toUserId(), String.valueOf(evt.fromUserId()), now);
// 4. 更新计数器(Redis Bitmap)
userCounterService.incrementFollowers(evt.toUserId(), 1);
userCounterService.incrementFollowings(evt.fromUserId(), 1);
}
else if (evt.type().equals("FOLLOW_CANCELED")) {
// 取消关注的处理逻辑...
}
}
}
两种 Outbox 实现方式对比
| 维度 | 定时任务轮询 | Canal CDC(你的项目) |
|---|---|---|
| 实时性 | 延迟 500ms~1s(取决于轮询间隔) | 延迟 <100ms(基于 Binlog 推送) |
| 复杂度 | 低(只需定时任务+JPA) | 高(需要 Canal Server + Bridge 组件) |
| 侵入性 | 中(需改业务代码加轮询逻辑) | 低(业务代码只写 DB,无感知) |
| 可靠性 | 高(消息持久化,支持重试) | 高(Canal 保证至少一次投递) |
| 运维成本 | 低(无额外中间件) | 高(需维护 Canal 集群) |
| 适用场景 | 中小规模系统、快速迭代 | 大规模系统、对实时性要求高 |
选型建议:
如果你的系统满足以下条件,推荐用 Canal CDC:
- 日均消息量 > 100 万条
- 对延迟敏感(<200ms)
- 团队有运维能力维护 Canal
- 微服务数量 > 10 个
否则,用定时任务轮询就够了:
- 快速上线,简单可靠
- 不依赖外部组件
- 团队熟悉 Spring 定时任务
面试追问:为什么选择 Canal 而不是定时任务?
Q:你们项目为什么用 Canal 实现 Outbox?有什么好处?
A:主要看中了三个优势:
1. 实时性更好
- 定时任务:轮询间隔 1 秒,最坏情况延迟 1 秒+
- Canal:基于 Binlog 推送,延迟通常 <100ms
- 对于社交场景(关注后立即看到粉丝数变化),用户体验更好
2. 零侵入
- 定时任务:需要在每个微服务里加 Poller 组件
- Canal:业务代码只管写数据库,完全不知道 Kafka 的存在
- 新接入的服务无需改动,只要按规范写 Outbox 表即可
3. 统一管控
- 所有服务的 Outbox 消息都通过同一个 Canal 实例投递
- 方便统一监控、限流、熔断
- 避免每个服务自己实现轮询逻辑导致的不一致问题
Q:Canal 挂了怎么办?消息会不会积压?
A:有应对机制:
1. Canal 自身高可用
- 支持 ZooKeeper 集群模式,主备自动切换
- Binlog 位点持久化,重启后断点续传
2. 消息不会丢
- Outbox 表数据在 MySQL 里,即使 Canal 挂了数据也不会丢
- Canal 恢复后会继续消费积压的 Binlog
3. 监控告警
- 监控 Canal 的延迟时间(Binlog 延迟 >5 分钟告警)
- 监控 Outbox 表的数据量(积压 >10 万条告警)
Q:Canal 发送 Kafka 失败了怎么处理?
A:
- Kafka 发送失败时不 ACK,让下次重新拉取
- 或者将失败的消息写入死信队列(DLQ),人工介入
- 或者增加本地重试机制(指数退避,最多 3 次)
生产环境优化:让 Outbox 模式更健壮
1. 批量发送提升吞吐量
java
// 修改轮询逻辑,支持批量发送
@Scheduled(fixedDelay = 1000)
public void pollAndSendBatch() {
Pageable pageable = PageRequest.of(0, 200); // 每次取 200 条
List<OutboxMessage> messages = outboxRepository.findPendingMessages(pageable).getContent();
if (messages.isEmpty()) return;
// 按 Topic 分组,批量发送
Map<String, List<OutboxMessage>> groupedByTopic = messages.stream()
.collect(Collectors.groupingBy(OutboxMessage::getTopic));
groupedByTopic.forEach((topic, msgList) -> {
List<ProducerRecord<String, String>> records = msgList.stream()
.map(msg -> new ProducerRecord<>(msg.getTopic(), msg.getAggregateId(), msg.getPayload()))
.collect(Collectors.toList());
// 使用 Kafka 的批量发送 API
kafkaTemplate.send(records);
});
}
2. 增加消息清理机制
java
/**
* 定时清理已确认的消息(保留 7 天)
* 每天凌晨 2 点执行
*/
@Scheduled(cron = "0 0 2 * * ?")
public void cleanConfirmedMessages() {
LocalDateTime cutoff = LocalDateTime.now().minusDays(7);
int deleted = outboxRepository.deleteByStatusAndCreatedAtBefore(
OutboxStatus.CONFIRMED, cutoff);
log.info("清理已完成消息: {} 条", deleted);
}
3. 监控指标接入 Prometheus
java
@Component
@RequiredArgsConstructor
public class OutboxMetrics {
private final MeterRegistry meterRegistry;
private final OutboxRepository outboxRepository;
@Scheduled(fixedDelay = 60000) // 每分钟采集一次
public void collectMetrics() {
long pendingCount = outboxRepository.countByStatus(OutboxStatus.PENDING);
long sentCount = outboxRepository.countByStatus(OutboxStatus.SENT);
long failedCount = outboxRepository.countByRetryCountGreaterThan(3);
meterRegistry.gauge("outbox.pending.count", pendingCount);
meterRegistry.gauge("outbox.sent.count", sentCount);
meterRegistry.gauge("outbox.failed.count", failedCount);
// 如果积压超过阈值,触发告警
if (pendingCount > 10000) {
alertService.sendAlert("Outbox 消息积压严重", pendingCount);
}
}
}
4. Outbox 表数据积压处理方案(生产必读)
这是一个在实际运维中一定会遇到的问题!
为什么会积压?
| 场景 | 原因 | 影响 |
|---|---|---|
| Canal/Kafka 故障 | 中间件宕机或网络中断 | 消息无法投递,持续堆积 |
| 业务高峰期 | 双11/秒杀活动写入量暴增 10-100 倍 | Canal 处理速度跟不上 |
| 消费者消费慢 | 下游服务性能瓶颈 | 消息积压在 Kafka,反向导致 Outbox 清理慢 |
| 数据库锁竞争 | 大事务阻塞或慢查询 | Outbox 表写入/查询变慢 |
积压的危害:
- 查询性能下降:Outbox 表数据量过大(千万级),轮询 SQL 变慢
- 磁盘空间不足:payload 字段存储 JSON,单条可能几 KB
- Binlog 增大:Canal 要扫描更多 Binlog 数据
- 恢复时间变长:Canal 重启后要回放大量历史数据
方案一:定时清理任务(推荐,最常用)
核心思路:定期删除已成功投递的历史消息(保留最近 N 天)
java
/**
* Outbox 表清理策略:
* - 定时任务版:删除 status=CONFIRMED 且 createdAt < 7天前 的记录
* - Canal 版:删除 createdAt < 7天前 的所有记录(因为 Canal 不关心状态)
*
* 执行频率:每天凌晨 2-4 点(低峰期)
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class OutboxCleanupService {
private final OutboxMapper outboxMapper;
/**
* 方案A:直接 DELETE(适合数据量 < 100万)
* 注意:大数据量时会导致锁表!
*/
@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点执行
public void cleanupDirectDelete() {
LocalDateTime cutoff = LocalDateTime.now().minusDays(7);
// 直接删除7天前的数据
int deleted = outboxMapper.deleteByCreatedAtBefore(cutoff);
log.info("Outbox 清理完成: 删除 {} 条 7天前的记录", deleted);
}
/**
* 方案B:批量分页删除(推荐,适合数据量 > 100万)
* 避免一次性删除太多数据导致锁表和主从延迟
*/
@Scheduled(cron = "0 30 2 * * ?") // 每天凌晨2:30执行
public void cleanupBatchDelete() {
LocalDateTime cutoff = LocalDateTime.now().minusDays(7);
int totalDeleted = 0;
int batchSize = 1000; // 每次删 1000 条
while (true) {
// 分页查询待删除的数据(只查 ID,减少内存占用)
List<Long> ids = outboxMapper.selectIdsBeforeCreatedAt(cutoff, batchSize);
if (ids.isEmpty()) break;
// 批量删除
int deleted = outboxMapper.deleteBatchIds(ids);
totalDeleted += deleted;
log.info("本轮清理: 删除 {} 条, 累计 {} 条", deleted, totalDeleted);
// 短暂休眠,避免对业务造成影响
try {
Thread.sleep(100); // 休息 100ms
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
log.info("Outbox 批量清理完成: 总共删除 {} 条", totalDeleted);
}
}
Mapper 层实现:
java
public interface OutboxMapper extends BaseMapper<Outbox> {
/**
* 删除指定时间之前的所有记录
*/
@Delete("DELETE FROM outbox WHERE created_at < #{cutoff}")
int deleteByCreatedAtBefore(@Param("cutoff") LocalDateTime cutoff);
/**
* 分页查询待删除的 ID 列表(避免 SELECT * 导致内存溢出)
*/
@Select("SELECT id FROM outbox WHERE created_at < #{cutoff} LIMIT #{limit}")
List<Long> selectIdsBeforeCreatedAt(
@Param("cutoff") LocalDateTime cutoff,
@Param("limit") int limit
);
/**
* 批量删除(使用 IN 子句)
*/
void deleteBatchIds(@Param("ids") List<Long> ids);
}
XML 配置(deleteBatchIds):
xml
<delete id="deleteBatchIds">
DELETE FROM outbox WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
方案二:归档到历史表(适合需要审计的场景)
如果你的业务需要对消息进行审计或追溯(如金融、支付),不能简单删除,应该归档:
sql
-- 1. 创建历史表(结构和原表一致)
CREATE TABLE `outbox_history` LIKE `outbox`;
-- 2. 添加归档时间字段
ALTER TABLE `outbox_history` ADD COLUMN `archived_at` DATETIME DEFAULT CURRENT_TIMESTAMP;
-- 3. 创建索引(按归档时间查询)
CREATE INDEX idx_archived_at ON outbox_history(archived_at);
java
/**
* 归档方案:先插入历史表,再删除原表数据
* 保证数据不丢失,同时控制主表大小
*/
@Service
@RequiredArgsConstructor
public class OutboxArchiveService {
private final OutboxMapper outboxMapper;
private final OutboxHistoryMapper historyMapper;
private final JdbcTemplate jdbcTemplate; // 用于批量操作
@Scheduled(cron = "0 0 2 * * ?")
public void archiveOldMessages() {
LocalDateTime cutoff = LocalDateTime.now().minusDays(30); // 保留30天
// 步骤1:查询待归档的数据量
Long count = outboxMapper.countByCreatedAtBefore(cutoff);
log.info("发现 {} 条待归档的 Outbox 消息", count);
if (count == 0 || count > 1_000_000) { // 超过100万条分批处理
archiveInBatches(cutoff, 5000); // 每批5000条
} else {
archiveAllAtOnce(cutoff); // 少量数据一次性处理
}
}
/**
* 分批归档(大数据量场景)
*/
private void archiveInBatches(LocalDateTime cutoff, int batchSize) {
int totalArchived = 0;
while (true) {
// 查询本批次 ID
List<Long> ids = outboxMapper.selectIdsBeforeCreatedAt(cutoff, batchSize);
if (ids.isEmpty()) break;
// 插入到历史表
String insertSql = "INSERT INTO outbox_history " +
"(id, aggregate_type, aggregate_id, type, payload, created_at, archived_at) " +
"SELECT id, aggregate_type, aggregate_id, type, payload, created_at, NOW() " +
"FROM outbox WHERE id IN (" + String.join(",", ids.stream().map(String::valueOf).collect(Collectors.toList())) + ")";
jdbcTemplate.update(insertSql);
// 从原表删除
outboxMapper.deleteBatchIds(ids);
totalArchived += ids.size();
log.info("已归档 {} 条, 累计 {} 条", ids.size(), totalArchived);
try { Thread.sleep(50); } catch (InterruptedException e) { break; }
}
log.info("Outbox 归档完成: 共归档 {} 条", totalArchived);
}
}
归档后的数据管理:
sql
-- 历史表可以定期再归档到对象存储(如 OSS/S3)
-- 或者设置更长的保留期(如90天后物理删除)
-- 查询历史消息(用于审计)
SELECT * FROM outbox_history
WHERE aggregate_type = 'Order'
AND created_at BETWEEN '2026-01-01' AND '2026-01-31'
ORDER BY created_at DESC;
方案三:手动清理(临时救急)
当积压严重(如超过 500 万条)且定时任务来不及处理时的应急措施:
bash
# 方案1:MySQL 命令行直接删除(谨慎使用!)
# 先查看数据量
mysql> SELECT COUNT(*) FROM outbox WHERE created_at < '2026-05-09';
# 小批量删除(每次 1 万条,循环执行)
mysql> DELETE FROM outbox WHERE id IN (
SELECT id FROM (
SELECT id FROM outbox
WHERE created_at < '2026-05-09'
LIMIT 10000
) AS temp
);
# 方案2:使用 pt-archiver 工具(Percona Toolkit,推荐!)
# 自动分批删除,不影响线上性能
pt-archiver \
--source h=localhost,D=mydb,t=outbox,u=root,p=password \
--where "created_at < '2026-05-09'" \
--limit 1000 \
--sleep 0.5 \
--progress 5000 \
--purge \
--statistics
# 参数说明:
# --limit 1000 : 每批删除 1000 条
# --sleep 0.5 : 每批间隔 0.5 秒
# --progress 5000 : 每 5000 条打印进度
# --purge : 直接删除(不归档)
# --statistics : 打印统计信息
手动清理注意事项:
- 务必在业务低峰期执行(凌晨 2-5 点)
- 先备份 (
mysqldump或CREATE TABLE outbox_backup AS SELECT * FROM outbox) - 监控主从延迟 (
SHOW SLAVE STATUS查看 Seconds_Behind_Master) - 分批执行(不要一次性 DELETE 太多,否则会锁表)
- 观察慢查询日志(确保没有长时间运行的查询)
方案四:分库分表 + 冷热分离(超大规模系统)
当日均新增 Outbox 消息超过 100 万条时考虑:
yaml
# 架构设计:
# 1. 按 aggregate_type 分片(不同业务隔离)
outbox_order_0 # 订单相关消息
outbox_order_1
outbox_payment_0 # 支付相关消息
outbox_user_0 # 用户相关消息
# 2. 冷热分离
# 热数据(近7天):留在 MySQL 主库,Canal 实时监听
# 温数据(7-30天):归档到 MySQL 从库或 TiDB
# 冷数据(30天+):归档到对象存储(OSS/S3)或 ClickHouse(用于分析)
技术选型建议:
| 日均数据量 | 推荐方案 | 复杂度 |
|---|---|---|
| < 10 万条 | 定时清理(保留7天) | 低 |
| 10-100 万条 | 定时清理 + 归档到历史表 | 中 |
| 100-1000 万条 | 分库分表 + pt-archiver | 高 |
| > 1000 万条 | 冷热分离 + 对象存储 | 很高 |
监控与告警(提前预警)
不要等积压了才发现,要建立完善的监控体系:
java
@Component
@RequiredArgsConstructor
public class OutboxMonitor {
private final OutboxMapper outboxMapper;
private final AlertService alertService;
/**
* 每 5 分钟检查一次 Outbox 表健康状态
*/
@Scheduled(fixedDelay = 300_000)
public void checkHealth() {
// 1. 统计各时间段的数据分布
long last1Hour = outboxMapper.countByCreatedAtAfter(
LocalDateTime.now().minusHours(1));
long last24Hour = outboxMapper.countByCreatedAtAfter(
LocalDateTime.now().minusHours(24));
long total = outboxMapper.selectCount(null);
log.info("Outbox 表状态: 最近1h={}, 最近24h={}, 总量={}",
last1Hour, last24Hour, total);
// 2. 告警规则
if (last1Hour > 50_000) {
alertService.sendAlert(
"Outbox 写入量异常",
String.format("最近1小时写入 %d 条(正常 < 5000),可能存在积压风险", last1Hour)
);
}
if (total > 1_000_000) {
alertService.sendAlert(
"Outbox 表数据量过大",
String.format("当前总量 %d 条(阈值 100万),请检查清理任务是否正常运行", total)
);
}
// 3. 记录 Prometheus 指标
Metrics.counter("outbox.total.count").increment(total);
Metrics.counter("outbox.last1hour.count").increment(last1Hour);
}
}
告警规则示例(Prometheus + Grafana):
yaml
groups:
- name: outbox_alerts
rules:
# 规则1:Outbox 表总量超过阈值
- alert: OutboxTableTooLarge
expr: outbox_total_count > 1000000
for: 10m
labels:
severity: warning
annotations:
summary: "Outbox 表数据量过大"
description: "当前 Outbox 表有 {{ $value }} 条记录,超过 100 万阈值"
# 规则2:最近1小时写入量异常
- alert: OutboxHighWriteRate
expr: increase(outbox_last1hour_count[1h]) > 50000
for: 5m
labels:
severity: critical
annotations:
summary: "Outbox 写入量激增"
description: "最近1小时写入 {{ $value }} 条,可能是业务高峰或 Canal 故障"
面试追问深度解析
Q1:Outbox 模式和直接发 Kafka 相比,延迟会增加多少?实际影响大吗?
A:延迟增加约 1 秒(可接受),但对吞吐量无影响。
- 轮询间隔通常设为 500ms ~ 1s
- 对于"订单创建→库存扣减"场景,1 秒延迟完全可接受
- 如果对实时性要求极高(如金融行情),可以缩短到 100ms
- 但要注意:轮询频率过高会增加数据库压力
Q2:如果定时任务挂了,消息会不会永远发不出去?
A:不会,但有应对措施。
- 消息持久化在 Outbox 表中,不会丢失
- 定时任务重启后会继续处理积压消息
- 监控告警:如果 pending 数量持续增长,说明轮询任务异常
- 兜底方案:可以部署多个实例(通过分布式锁保证只有一个执行)
Q3:Outbox 模式能保证 Exactly-Once 语义吗?
A:不能 100% 保证,但可以做到"至少一次+幂等消费"。
- Producer 侧:Outbox 保证消息至少发送一次(可能重复)
- Consumer 侧:通过幂等处理(Redis 去重/唯一索引)保证** exactly-once 效果**
- 这是业界标准做法(Kafka Streams 也采用类似思路)
Q4:有没有现成的框架可以直接用?还是得自己写?
A:有开源框架,但很多团队选择自研。
成熟框架:
- Debezium(CDC 工具,基于 Binlog,无需改代码)
- Tx-LCN / Seata(分布式事务框架,支持 Outbox 模式)
- Spring Cloud Stream(部分支持)
自研的优势:
- 完全可控,可以根据业务定制
- 不依赖外部框架,降低复杂度
- 学习成本低,新人容易理解
建议:
- 初期:自己实现(如本文档示例)
- 规模化后:考虑 Debezium(零侵入)
Q5:Outbox 表数据量太大怎么办?
A:三种优化策略:
定期归档(推荐)
- 已确认的消息移到历史表(outbox_history)
- 保留 30 天后物理删除
分库分表
- 按 aggregate_type 分片(Order 表、Payment 表分开)
- 适合超大规模系统(日增千万级消息)
冷热分离
- 热数据(近 7 天)留在主库
- 冷数据归档到对象存储(S3/OSS)
总结:Outbox 模式的适用边界
适合使用 Outbox 的场景:
- 微服务间的最终一致性需求(订单→库存→物流)
- 对可靠性要求高的业务(支付、财务、库存)
- 需要消息可追溯的场景(审计、对账)
不适合使用 Outbox 的场景:
- 强一致性需求(银行转账,建议用 TCC 或 2PC)
- 超低延迟需求(实时竞价,建议直接发 Kafka)
- 简单日志采集(用户行为分析,允许丢失,直接 Fire-and-Forget)
一句话总结:
Outbox 模式是用空间换时间、用延迟换一致性 的经典设计。它不完美,但在大多数业务场景下,它是性价比最高的分布式事务解决方案。
第四篇:消费者详解------如何保证消息不丢不重
线上事故复盘:Auto Commit 导致的消息丢失事件
背景: 某电商平台在促销活动中,发现部分订单"已支付但未发货"
排查时间线(精确到秒):
T=0s 消费者拉取消息 [msg1, msg2, msg3](Offset 还在 0)
T=2s 处理 msg1 成功(扣减库存)
T=3s 处理 msg2 成功(发送邮件)
T=4s 正在处理 msg3(生成物流单...)
T=5s Auto Commit 触发!Offset 提交到了 3
T=6s 处理 msg3 时抛异常(数据库连接超时)
T=7s 消费者重启,从 Offset=4 开始消费
结果:msg3 被永久跳过(用户付了钱但没发货!)
根因定位:
- Auto Commit 机制在固定间隔(默认 5 秒)自动提交 Offset
- 它不管你的业务逻辑是否执行完毕,只管"我拉取了这些消息"
- 一旦提交了 Offset,即使消息处理失败,也不会重新投递
这就是为什么生产环境必须关闭 Auto Commit!
接下来我们看三种提交模式的对比和最佳实践。
模式二:手动同步提交(Manual Sync)
java
@KafkaListener(topics = "order-events", groupId = "order-group")
public void handleOrder(ConsumerRecord<String, String> record,
Acknowledgment acknowledgment) {
try {
OrderDTO order = JSON.parseObject(record.value(), OrderDTO.class);
// 1. 业务逻辑(可能耗时较长)
orderService.processOrder(order);
// 2. 手动提交 Offset(业务成功后才提交)
acknowledgment.acknowledge();
} catch (Exception e) {
log.error("处理订单失败,消息将被重新消费", e);
// 不调用 acknowledge(),下次重启后会重新消费这条消息
}
}
优点: 精确控制 Offset 提交时机,避免消息丢失
缺点: 每次 acknowledge() 都要与 Broker 通信,性能较低
模式三:手动异步提交(Manual Async)- 推荐
java
@KafkaListener(topics = "order-events", groupId = "order-group")
public void handleOrder(ConsumerRecord<String, String> record,
Acknowledgment acknowledgment) {
try {
OrderDTO order = JSON.parseObject(record.value(), OrderDTO.class);
orderService.processOrder(order);
// 异步提交(不阻塞当前线程)
acknowledgment.acknowledge();
} catch (Exception e) {
log.error("处理订单失败", e);
}
}
Spring Kafka 的默认行为:
- 当方法正常返回时(无异常),框架自动调用
acknowledgment.acknowledge() - 当方法抛出异常时,框架不会提交 Offset,消息会被重新投递
生产环境最佳实践:
java
@KafkaListener(topics = "order-events", groupId = "order-group",
containerFactory = "kafkaListenerContainerFactory")
public void handleOrder(ConsumerRecord<String, String> record) {
OrderDTO order = JSON.parseObject(record.value(), OrderDTO.class);
// 幂等性检查(防止重复消费)
if (!idempotentService.isProcessed(record.key())) {
orderService.processOrder(order);
idempotentService.markAsProcessed(record.key());
} else {
log.info("消息已处理过,跳过: {}", record.key());
}
}
消息丢失 vs 消息重复(两难困境)
消息丢失的场景:
- Producer 发送消息后,Broker 还没 ACK 就挂了(解决方案:retries + acks=all)
- Broker 写入 Leader 后还没同步给 Follower 就挂了(解决方案:min.insync.replicas >= 2)
- Consumer 消费消息后,还没提交 Offset 就挂了(解决方案:手动提交 + 业务幂等性)
消息重复的场景:
- Producer 发送消息后超时,实际 Broker 已收到,Producer 重试导致重复(解决方案:Producer 幂等性 enable.idempotence=true)
- Consumer 消费消息并提交 Offset,但业务处理失败(重启后重新消费)(解决方案:Consumer 幂等性检查)
- Rebalance 导致 Partition 重新分配,新消费者从头开始消费(解决方案:外部存储 Offset + 幂等性)
终极方案:业务层幂等性
java
@Service
public class IdempotentService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 检查消息是否已处理(基于 Redis Set)
* Key: idempotent:{topic}:{partition}:{offset}
* TTL: 7 天(超过 7 天的消息不再防重复)
*/
public boolean isProcessed(String uniqueKey) {
String key = "idempotent:" + uniqueKey;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
/**
* 标记消息为已处理
*/
public void markAsProcessed(String uniqueKey) {
String key = "idempotent:" + uniqueKey;
redisTemplate.opsForValue().set(key, "1", 7, TimeUnit.DAYS);
}
}
面试追问:
Q:如何保证 Kafka 消息的"精确一次语义"(Exactly-Once Semantics)?
A:
Kafka 的 EOS 分三个层面:1. Producer 侧(幂等性写入)
- 配置
enable.idempotence=true+acks=all- Kafka 会为每批消息分配序列号(PID + Sequence Number)
- Broker 去重:相同 PID + Sequence 的消息只会写入一次
2. Consumer 侧(事务性消费)
- Kafka 0.11+ 支持"消费-转换-生产"的事务模式
- 将消费 Offset 和业务操作放在同一个事务中提交
- 要么都成功,要么都回滚
3. 端到端的 Exactly-Once(Kafka Streams)
- 仅限于 Kafka Streams 应用内部
- 通过 Changelog Topic 实现
实际生产中的做法:
- Producer:enable.idempotence=true(防重复写入)
- Consumer:手动提交 Offset + 业务层幂等性(Redis/Set 数据库唯一索引)
- 这是业界最成熟的方案(即使 Kafka Streams 也无法解决外部系统的幂等性问题)
第五篇:高可用与容灾------ISR 机制深度解析
Broker 如何保证消息不丢失------主柜与备用柜(多副本机制)
这是 Kafka 数据可靠性的基石 ,也是生产环境必须配置的核心参数。
为什么需要多副本?(单副本的致命缺陷)
** 危险配置:replication.factor = 1(单副本)**
场景:某电商系统的订单 Topic 只有 1 个副本
正常情况:
┌─────────────┐
│ Broker 1 │ ← Leader (唯一副本)
│ Partition 0 │
└─────────────┘
T=0s Producer 发送 1000 条订单消息,全部写入 Broker 1
T=1h **突然!Broker 1 磁盘故障(坏道)**
所有消息永久丢失
后果:
- 1000 个订单的下游服务(库存/物流/积分)全都没收到通知
- 用户付了钱但没发货 → 客服电话被打爆
- 需要从数据库手动补单,耗时 3 天
- 公司损失:直接经济损失 + 品牌信誉受损
** 安全配置:replication.factor = 3(三副本)**
场景:同样的订单 Topic,但配置了 3 个副本
正常情况:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Broker 1 │ │ Broker 2 │ │ Broker 3 │
│ Leader │◄───│ Follower │◄───│ Follower │
│ (主柜) │同步│ (备用柜1) │同步│ (备用柜2) │
└─────────────┘ └─────────────┘ └─────────────┘
T=0s Producer 发送 1000 条订单消息
T=0.1s Leader (B1) 写入成功
T=0.2s Follower B2 同步完成
T=0.3s Follower B3 同步完成
T=0.4s ISR 集合 [B1, B2, B3] 全部就绪 → 返回 ACK 给 Producer
T=1h **突然!Broker 1 磁盘故障**
Controller 检测到 Leader 挂了
从 ISR [B2, B3] 中选举 B2 为新 Leader
B2 继续对外提供服务(因为 B2 和 B3 上都有完整的数据)
结果:
- 数据零丢失!
- 服务零中断!(Leader 切换 < 10 秒)
- 用户完全无感知
多副本工作机制详解(Leader-Follower 架构)
核心概念类比:银行的金库管理
| Kafka 概念 | 银行类比 | 具体职责 |
|---|---|---|
| Leader(主柜) | 主金库(负责存取款) | 接收 Producer 的写入请求、响应 Consumer 的读取请求 |
| Follower(备用柜) | 备份金库(只负责复制) | 只从 Leader 拉取数据并持久化,不对外提供服务 |
| ISR(同步副本集合) | 已完成对账的金库列表 | 跟得上 Leader 进度的 Follower 集合 |
数据同步流程(写操作):
Follower 2 (备用柜2) Follower 1 (备用柜1) Leader (主柜) Producer Follower 2 (备用柜2) Follower 1 (备用柜1) Leader (主柜) Producer 写入流程(acks=all + replication.factor=3) par [并行同步] ISR = [B1, B2, B3] 全部就绪 Producer 收到 ACK,认为消息发送成功 1. 发送消息 "订单 2a. 写入本地日志 (Broker 1) 2b. 推送给 Follower 1 (Broker 2) 2c. 推送给 Follower 2 (Broker 3) 3a. F1 确认同步完成 3b. F2 确认同步完成 4. 返回 ACK(acks=all 生效)
数据读取流程(读操作):
java
// Consumer 只从 Leader 读取数据(Follower 不参与读请求)
Consumer → 拉取消息 → Leader (B1) 返回数据
↑
Follower (B2, B3) 不参与!
为什么?
- 避免"读一致性"问题(如果允许多点读取,可能读到旧数据)
- 简化架构设计(Leader 是唯一的读写入口)
- 提升性能(不需要在多个节点间协调读取)
关键配置参数(生产环境必配)
bash
# server.properties(Broker 级别配置)
# ========== 副本数量 ==========
replication.factor=3 # Topic 默认副本数(强烈建议设为 3)
# 含义:每个 Partition 有 3 个副本分布在 3 台 Broker 上
# ========== ISR 最小副本数 ==========
min.insync.replicas=2 # ISR 中最少要有几个副本同步成功
# 配合 acks=all 使用时:
# - 要求至少 2 个副本(Leader + 1 个 Follower)写入成功
# - 才能给 Producer 返回 ACK
# ========== Leader 选举策略 ==========
unclean.leader.election.enable=false # 是否允许非 ISR 副本成为 Leader
# false = 牺牲可用性,保证数据一致性(推荐)
# true = 牺牲数据一致性,保证高可用
# ========== Follower 落后阈值 ==========
replica.lag.time.max.ms=10000 # Follower 落后 Leader 多久被踢出 ISR
# 默认 30 秒,建议改为 10 秒(更快检测异常节点)
配置参数的 Trade-off 决策表:
| 参数 | 保守值(高可靠) | 激进值(高性能) | 权衡点 |
|---|---|---|---|
replication.factor |
3 | 1 | 存储成本 vs 数据安全 |
min.insync.replicas |
2 | 1 | 可用性 vs 数据持久化 |
unclean.leader.election.enable |
false | true | 数据一致性 vs 系统可用性 |
replica.lag.time.max.ms |
10000 | 30000 | 故障检测速度 vs 误判率 |
生产环境推荐配置(金融级标准)
Topic 创建时的标准配置:
bash
# 创建 Topic 时指定副本因子和 ISR 最小副本数
kafka-topics.sh --create \
--topic order-events \
--bootstrap-server kafka1:9092 \
--partitions 12 \ # 12 个分区(根据吞吐量调整)
--replication-factor 3 \ # 3 个副本(必须!)
--config min.insync.replicas=2 \ # 至少 2 个 ISR 副本同步成功
--config unclean.leader.election.enable=false # 禁止脏 Leader 选举
--config cleanup.policy=delete \ # 保留策略:删除旧数据
--config retention.ms=604800000 # 保留时间:7 天
Spring Boot 中的配置验证:
java
@Configuration
public class KafkaAdminConfig {
@Bean
public NewTopic orderEventsTopic() {
return TopicBuilder.name("order-events")
.partitions(12) // 12 个分区
.replicas(3) // 3 个副本
.config(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, "2") // ISR 最小副本数
.build();
}
}
多副本机制如何防止数据丢失(端到端保障)
完整的数据安全保障链路:
Broker 侧保障(多副本)
Producer 侧保障
故障切换保障
Leader 宕机
Controller 选举新 Leader
从 ISR 中选择
继续提供服务
数据零丢失
Producer
业务代码
批量缓冲
batch.size
异步发送
- Callback 回调
自动重试
retries=Integer.MAX_VALUE
幂等性
enable.idempotence=true
Leader 写入
(主柜)
ISR 同步
(备用柜们)
检查 min.insync.replicas
至少 N 个副本成功
返回 ACK
acks=all 生效
故障场景推演(3 副本 + min.insync.replicas=2):
场景 1:1 台 Broker 宕机(正常情况)
初始状态:
ISR = [B1(Leader), B2(Follower), B3(Follower)]
T=0s B3 突然宕机(硬件故障)
T=0.1s Controller 检测到 B3 心跳超时
T=10s B3 被踢出 ISR(replica.lag.time.max.ms=10s)
ISR = [B1(Leader), B2(Follower)] ← 缩减为 2 个
T=11s Producer 发送新消息
Leader (B1) 写入成功
Follower (B2) 同步成功
检查:ISR 当前大小 = 2 >= min.insync.replicas(2)
返回 ACK 给 Producer
结论:系统正常运行,能容忍 1 台故障
场景 2:2 台 Broker 宕机(危险状态)
初始状态:
ISR = [B1(Leader), B2(Follower), B3(Follower)]
T=0s B2 和 B3 同时宕机(机房断电)
T=10s B2 和 B3 都被踢出 ISR
ISR = [B1(Leader)] ← 只剩 1 个!
T=11s Producer 尝试发送消息
Leader (B1) 写入成功
检查:ISR 当前大小 = 1 < min.insync.replicas(2)
抛出异常:NotEnoughReplicasException
Producer 收到错误,触发重试机制...
结论:系统不可用,但数据不会丢失(消息根本没写入成功)
这是"牺牲可用性换取一致性"的设计
实战避坑指南:多副本配置常见错误
错误 1:使用默认的单副本配置
properties
# server.properties(默认配置 - 危险!)
replication.factor=1 # 单副本!数据没有任何冗余
min.insync.replicas=1 # 只要求 1 个副本(就是 Leader 自己)
后果:
- 一旦 Broker 磁盘故障,数据永久丢失
- 无法进行 Leader 选举(没有其他副本可以接替)
- 生产环境绝对不允许这种配置!
错误 2:副本数设置过高(资源浪费)
bash
# 过度配置(浪费资源)
--replication-factor 5 # 5 个副本
问题:
- 存储空间占用 5 倍(每条消息存储 5 份)
- 网络带宽消耗 5 倍(Leader 要同步给 4 个 Follower)
- 写入延迟增加(要等待更多 Follower 同步完成)
- 成本高昂但收益有限
推荐:3 副本足够应付绝大多数场景(容忍 2 台同时故障)
** 错误 3:min.insync.replicas 设置过低**
properties
# 配置不一致(危险!)
replication.factor=3 # 3 个副本
min.insync.replicas=1 # 但只要求 1 个副本同步成功
问题:
- acks=all 时,只要 Leader 写入成功就返回 ACK
- Follower 可能还没同步完,Leader 就挂了
- 数据实际上还是可能丢失(虽然概率降低)
正确做法:min.insync.replicas=2(要求至少 2 个副本同步成功)
最佳实践配置清单:
yaml
# 生产环境标准配置(金融级)
kafka:
broker:
replication:
factor: 3 # 必须三副本
min_insync_replicas: 2 # 至少 2 个 ISR 副本
unclean_leader_election: false # 禁止脏 Leader
replica_lag_time_max_ms: 10000 # 快速检测失效副本
topic:
order-events:
partitions: 12 # 根据吞吐量调整
retention_ms: 604800000 # 保留 7 天
monitoring:
alert_under_replicated_partitions: true # 监控未充分复制的 Partition
alert_offline_partitions_count: true # 监控离线 Partition
架构决策:数据一致性 vs 系统可用性,你选哪个?
这是一个经典的分布式系统两难问题:
假设你的 Kafka 集群有 3 台 Broker,某个 Partition 的 3 个副本分布如下:
- Leader 在 Broker 1
- Follower 在 Broker 2 和 Broker 3
场景:Broker 3 突然宕机(磁盘故障)
选项 A:优先保证可用性(unclean.leader.election.enable=true)
- 立刻从 Broker 1 或 2 中选举新 Leader
- 代价:Broker 3 上未同步的数据可能丢失
- 适用:日志采集、监控指标(允许少量丢失)
选项 B:优先保证一致性(unclean.leader.election.enable=false)
- 如果 ISR 中只剩 Broker 1 和 2,且
min.insync.replicas=2 - 则无法选举新 Leader,该 Partition 不可用
- 适用:订单、支付、金融交易(绝对不能丢数据)
你选哪个?
这取决于你的业务场景。接下来我们深入理解 ISR 机制,看看 Kafka 是如何在这两者之间做权衡的。
ISR(In-Sync Replicas)机制详解
ISR 是什么?
ISR(同步副本集合)是跟得上 Leader 进度的 Follower 集合。
加入 ISR
落后超过 replica.lag.time.max.ms
追上 Leader 进度
被踢出 ISR(ISR shrink)
重新加入 ISR(ISR expand)
Follower
Lagging
Removed
ISR 中的副本有资格被选为新 Leader
落后的副本没有资格成为 Leader
即使原 Leader 挂了也不会选它
文字版 ISR 状态流转:
ISR 的动态变化过程:
- 初始状态:所有 Replica 都在 ISR 中([B1, B2, B3])
- B3 网络抖动 :B3 落后超过
replica.lag.time.max.ms(默认 30 秒),被踢出 ISR → ISR=[B1, B2] - B3 恢复正常:B3 追上 Leader 进度,重新加入 ISR → ISR=[B1, B2, B3]
- B1 宕机:Controller 从 ISR=[B1, B2, B3] 中排除 B1,选举 B2 为新 Leader
- B3 仍在 Lagging:此时如果 B2 也宕机,ISR 只剩 B3,但 B3 没资格当 Leader!
关键配置参数:
| 参数 | 默认值 | 推荐值 | 含义 |
|---|---|---|---|
replication.factor |
1 | 3 | 副本总数 |
min.insync.replicas |
1 | 2 | ISR 最小副本数(acks=all 时要求至少这么多副本同步成功) |
unclean.leader.election.enable |
false | false | 是否允许非 ISR 副本成为 Leader(数据一致性 vs 可用性的 Trade-off) |
replica.lag.time.max.ms |
30000 | 10000 | Follower 落后多久被踢出 ISR |
生产环境推荐配置:
bash
# server.properties(Broker 级别配置)
# 保证高可用:3 副本,至少 2 个同步
replication.factor=3
min.insync.replicas=2
# 牺牲可用性换取数据一致性(金融场景推荐)
unclean.leader.election.enable=false
# 快速检测失效副本
replica.lag.time.max.ms=10000
面试追问:
Q:
min.insync.replicas=2且acks=all时,最多能容忍几台 Broker 故障?A:
答案:1 台(不是 2 台!)推导过程:
- 副本因子 = 3(总共 3 个 Replica)
- min.insync.replicas = 2(ISR 至少要有 2 个副本)
- 当前 ISR = [B1(Leader), B2, B3]
故障场景 1:1 台宕机(B3 挂了)
- ISR 缩减为 [B1, B2],满足 min.insync.replicas >= 2
- Producer 发送消息:Leader(B1) + B2 都写入成功 → 返回成功
- 结论:正常运行
故障场景 2:2 台宕机(B2, B3 都挂了)
- ISR 缩减为 [B1],不满足 min.insync.replicas >= 2
- Producer 发送消息:Leader(B1) 写入成功,但 ISR 只有 1 个副本
- 根据
acks=all要求,需要 ISR 中所有副本(只有 B1)确认 → 返回成功?- 但是! 此时如果 B1 也挂了,数据就彻底丢失了(因为没有其他副本)
- 而且 ISR 中只剩 1 个副本,无法进行 Leader 选举
- 结论:虽然消息能发出去,但系统处于不可靠状态
总结:
min.insync.replicas=2+replication.factor=3→ 最多容忍 1 台故障且保证数据安全- 如果想容忍 2 台故障,需要
replication.factor=5+min.insync.replicas=3
第六篇:Rebalance 机制------消费者组的"重新洗牌"
场景推演:当你的消费者突然崩溃时会发生什么?
初始状态(正常运行):
Topic: order-events [P0, P1, P2]
Consumer Group A: [C1, C2, C3]
分配结果:
C1 → 消费 P0 (Offset: 100)
C2 → 消费 P1 (Offset: 200)
C3 → 消费 P2 (Offset: 150)
系统运行平稳,每秒处理 5000 条订单...
突发事件:C3 消费者 OOM 崩溃!
T=0s ZooKeeper 检测到 C3 心跳超时
T=5s Coordinator 触发 Rebalance(重新平衡)
T=6s C1 和 C2 收到 "Stop-The-World" 命令,暂停消费
T=7s 重新分配 Partition:
C1 → 继续消费 P0
C2 → 接管 P1 + P2 (从上次提交的 Offset 开始)
C3 已离开
T=8s Rebalance 完成,恢复消费
代价:
- STW 停顿约 2 秒
- P2 的消息可能被重复处理(如果 C3 崩溃前未提交 Offset)
这就是 Rebalance 的本质:用短暂的停顿换取系统的长期稳定。但停顿期间的消息积压和可能的数据重复,是必须付出的代价。
接下来我们看如何优化这个代价。
| 触发条件 | 场景 | 影响 |
|---|---|---|
| 消费者数量变化 | 消费者崩溃/新增/主动停止 | 必须重新分配 Partition |
| Partition 数量变化 | 管理员扩容 Topic(增加了 Partition) | 必须重新分配 |
| 订阅的 Topic 变更 | 消费者动态订阅/取消订阅 Topic | 必须重新分配 |
如何减少 Rebalance 的影响?
策略 1:合理设置 Session Timeout
yaml
spring:
kafka:
consumer:
session.timeout.ms: 15000 # 消费者心跳超时时间(15秒)
heartbeat.interval.ms: 5000 # 心跳发送间隔(5秒,建议为 session.timeout 的 1/3)
max.poll.interval.ms: 300000 # 最大 poll 间隔(5分钟)
max.poll.records: 500 # 每次 poll 最大拉取条数
关键参数解释:
session.timeout.ms:Coordinator 多久没收到心跳就认为消费者挂了(触发 Rebalance)heartbeat.interval.ms:消费者多久发一次心跳证明自己还活着max.poll.interval.ms:消费者处理消息的最长时间(超过这个时间也会触发 Rebalance)max.poll.records:控制每次拉取的消息数量(防止一次拉太多处理不完)
策略 2:静态成员分配(Static Membership)
java
@KafkaListener(id = "order-consumer",
topics = "order-events",
groupId = "order-group",
properties = {
"group.instance.id": "order-service-instance-1" // 静态成员 ID
})
public void handleOrder(ConsumerRecord<String, String> record) {
// ...
}
优势: 设置了 group.instance.id 的消费者被视为"静态成员",加入/退出不会触发 Rebalance(仅用于扩缩容场景)
策略 3:Cooperative Sticky Assignor(增量 Rebalance)
yaml
# 从 Kafka 2.4+ 开始支持
spring:
kafka:
consumer:
partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor
对比:
| 策略 | Rebalance 范围 | 停顿时间 | 适用场景 |
|---|---|---|---|
| RangeAssignor(默认) | 全局 Rebalance | 长(所有消费者都暂停) | 旧版本兼容 |
| CooperativeStickyAssignor | 增量 Rebalance | 短(只影响变更的 Partition) | 推荐(Kafka 2.4+) |
面试追问:
Q:Rebalance 过程中消息会丢失吗?如何避免?
A:
Rebalance 本身不会导致消息丢失,但可能导致:
- 重复消费:新消费者接管 Partition 后,从上次提交的 Offset 开始消费,可能重复处理部分消息
- 消费延迟:Rebalance 期间(STW)消息无法被消费,造成积压
避免措施:
- 业务层幂等性:无论是否重复消费,都能保证最终一致性
- 缩短 Rebalance 时间:使用 CooperativeStickyAssignor(增量 Rebalance)
- 合理配置超时时间:session.timeout.ms 不要太长(否则故障发现慢),也不要太短(否则网络抖动就触发 Rebalance)
- 监控 Rebalance 频率:如果频繁 Rebalance,说明消费者不稳定(可能是 GC 停顿或网络问题)
第七篇:实战场景------生产级 Spring Kafka 配置
从代码到上线:一个完整的订单事件驱动架构
业务需求(来自真实项目):
用户下单后,需要异步完成 4 个操作:
- 扣减库存(库存服务)
- 发送确认邮件(邮件服务)
- 记录操作日志(日志服务)
- 增加用户积分(积分服务)
约束条件:
- 订单消息不能丢失(用户付了钱必须发货)
- 订单消息不能重复(不能扣两次库存)
- 大促期间 QPS 峰值达到 10 万/秒
- 消费者需要支持水平扩展(应对流量洪峰)
技术选型决策:
- 为什么用 Kafka 而不是 RabbitMQ?→ 高吞吐 + 顺序写磁盘
- 为什么用手动提交而不是 Auto Commit?→ 精确控制 Offset,避免消息丢失
- 为什么需要幂等性检查?→ Rebalance 或崩溃可能导致重复消费
接下来我们看完整的实现代码(4 个步骤)。
Step 1:Producer 配置
java
@Configuration
public class KafkaProducerConfig {
@Bean
public KafkaTemplate<String, String> kafkaTemplate(ProducerFactory<String, String> producerFactory) {
return new KafkaTemplate<>(producerFactory);
}
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configProps = new HashMap<>();
// 连接地址
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,
"kafka1:9092,kafka2:9092,kafka3:9092");
// 序列化器
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
// 可靠性配置(重要!)
configProps.put(ProducerConfig.ACKS_CONFIG, "all"); // 所有 ISR 确认
configProps.put(ProducerConfig.RETRIES_CONFIG, 3); // 重试 3 次
configProps.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 100); // 重试间隔 100ms
configProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); // 幂等性
// 性能优化
configProps.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384); // 批量大小 16KB
configProps.put(ProducerConfig.LINGER_MS_CONFIG, 10); // 等待 10ms
configProps.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4"); // LZ4 压缩
configProps.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 33554432); // 缓冲区 32MB
return new DefaultKafkaProducerFactory<>(configProps);
}
}
Step 2:Consumer 配置(手动提交 + 批量消费)
java
@Configuration
public class KafkaConsumerConfig {
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
ConsumerFactory<String, String> consumerFactory) {
ConcurrentKafkaListenerContainerFactory<String, String> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory);
// 关键配置:并发消费者数量
factory.setConcurrency(3);
// 手动提交模式(不自动提交 Offset)
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
// 批量消费配置
factory.getContainerProperties().setPollTimeout(3000);
return factory;
}
@Bean
public ConsumerFactory<String, String> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,
"kafka1:9092,kafka2:9092,kafka3:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-group");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
props.put(JsonDeserializer.TRUSTED_PACKAGES_CONFIG, "*"); // 信任所有包的反序列化
// 关闭自动提交(非常重要!)
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
// 从最早的消息开始消费(首次启动时)
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
// 心跳和超时配置
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "15000");
props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, "5000");
props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, "300000");
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "500");
return new DefaultKafkaConsumerFactory<>(props);
}
}
Step 3:消费者实现(带幂等性)
java
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderEventConsumer {
private final OrderService orderService;
private final InventoryService inventoryService;
private final EmailService emailService;
private final IdempotentService idempotentService;
/**
* 消费订单事件(核心业务逻辑)
*/
@KafkaListener(topics = "${kafka.topic.order-events}",
groupId = "${kafka.consumer.group.order}",
containerFactory = "kafkaListenerContainerFactory")
public void handleOrderEvent(ConsumerRecord<String, String> record,
Acknowledgment acknowledgment) {
String messageId = record.key(); // 使用消息 Key 作为唯一标识
log.info("收到订单事件: {}", messageId);
try {
// 1. 幂等性检查(防止重复消费)
if (idempotentService.isProcessed(messageId)) {
log.info("消息已处理,跳过: {}", messageId);
acknowledgment.acknowledge();
return;
}
// 2. 解析消息
OrderEventDTO event = JSON.parseObject(record.value(), OrderEventDTO.class);
// 3. 业务逻辑(事务性操作)
switch (event.getType()) {
case "ORDER_CREATED":
orderService.createOrder(event.getOrderDTO());
break;
case "ORDER_PAID":
orderService.payOrder(event.getOrderId(), event.getPaymentId());
inventoryService.deductStock(event.getOrderId()); // 扣减库存
emailService.sendConfirmationEmail(event.getUserId()); // 发邮件
break;
case "ORDER_CANCELLED":
orderService.cancelOrder(event.getOrderId());
inventoryService.refundStock(event.getOrderId()); // 退还库存
break;
default:
log.warn("未知订单事件类型: {}", event.getType());
}
// 4. 标记消息为已处理(Redis Set,7天过期)
idempotentService.markAsProcessed(messageId);
// 5. 手动提交 Offset(业务全部成功后才提交)
acknowledgment.acknowledge();
log.info("订单事件处理成功: {}", messageId);
} catch (BusinessException e) {
log.error("业务异常,消息将重试: {} - {}", messageId, e.getMessage());
// 不提交 Offset,消息会被重新投递
throw e; // 让 Kafka 框架感知异常
} catch (Exception e) {
log.error("系统异常,消息将重试: {} - {}", messageId, e.getMessage(), e);
// 不提交 Offset,消息会被重新投递
throw e; // 让 Kafka 框架感知异常
}
}
}
Step 4:Producer 发送工具类
java
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderEventProducer {
private final KafkaTemplate<String, String> kafkaTemplate;
/**
* 发送订单事件(异步,高性能)
*/
public void sendOrderEvent(OrderEventDTO event) {
String eventId = UUID.randomUUID().toString();
String topic = "order-events";
try {
// 构建消息记录
ProducerRecord<String, String> record = new ProducerRecord<>(
topic,
eventId, // Key(用于分区 + 幂等性)
JSON.toJSONString(event) // Value(JSON 格式)
);
// 异步发送(带回调)
kafkaTemplate.send(record).addCallback(
success -> {
RecordMetadata metadata = success.getRecordMetadata();
log.info("订单事件发送成功: {} -> Partition={}, Offset={}",
eventId, metadata.partition(), metadata.offset());
},
failure -> {
log.error("订单事件发送失败: {}", eventId, failure);
// 可以在这里写入本地队列或数据库,后续补偿
}
);
} catch (Exception e) {
log.error("发送订单事件异常: {}", eventId, e);
// 写入死信队列或本地缓存,保证消息不丢
deadLetterQueue.save(event);
}
}
}
场景二:监控与告警(JMX + Prometheus)
关键监控指标:
| 指标类别 | 指标名称 | 告警阈值 | 说明 |
|---|---|---|---|
| Producer | record-send-rate | - | 每秒发送消息数 |
| record-error-rate | > 0 | 发送错误率(应始终为 0) | |
| request-latency-avg | > 100ms | 平均发送延迟 | |
| Consumer | records-lag-max | > 10000 | 消费滞后(消息积压量) |
| commit-rate | - | Offset 提交频率 | |
| rebalance-count.total | > 10/hour | Rebalance 次数过多 | |
| Broker | UnderReplicatedPartitions | > 0 | 存在未充分复制的 Partition |
| OfflinePartitionsCount | > 0 | 离线 Partition 数量 | |
| ActiveControllerCount | != 1 | Controller 数量异常 |
Prometheus + Grafana 配置示例:
yaml
# prometheus.yml(采集 Kafka JMX 指标)
scrape_configs:
- job_name: 'kafka'
static_configs:
- targets: ['kafka1:7071', 'kafka2:7071', 'kafka3:7071']
metrics_path: '/metrics'
告警规则示例:
yaml
# kafka-alerts.yml
groups:
- name: kafka_critical
rules:
- alert: KafkaConsumerLagHigh
expr: kafka_consumer_group_lag{topic="order-events"} > 10000
for: 5m
labels:
severity: critical
annotations:
summary: "Kafka 消费者滞后过高"
description: "消费者组 {{ $labels.group }} 在主题 {{ $labels.topic }} 上的滞后达到 {{ $value }} 条"
- alert: KafkaUnderReplicatedPartitions
expr: kafka_cluster_underreplicatedpartitions > 0
for: 1m
labels:
severity: warning
annotations:
summary: "存在未充分复制的 Partition"
description: "当前有 {{ $value }} 个 Partition 的副本数不足"
第八篇:性能调优与最佳实践
性能数据会说话:你的 Kafka 集群是否需要调优?
先看一组生产环境的基准测试数据(3 节点集群,Topic 有 12 个 Partition):
| 配置项 | 默认值 | 优化后 | 吞吐量提升 | 延迟变化 |
|---|---|---|---|---|
batch.size |
16KB | 64KB | +180% | +15ms |
linger.ms |
0 | 20ms | +220% | +25ms |
compression.type |
none | lz4 | +150% (网络IO) | +5ms (CPU) |
acks |
1 | all | -30% | +10ms |
num.io.threads |
8 | 16 | +40% | - |
你发现了吗? 没有银弹!每个优化都有代价:
- 批量发送:吞吐量提升了 200%,但延迟增加了 25ms(对于日志采集可以接受,但对于实时支付可能不行)
- 压缩算法:节省了网络带宽,但增加了 CPU 消耗
- acks=all:可靠性提升了,但吞吐量下降了 30%(要等待所有 ISR 确认)
这就是性能调优的本质:在约束条件下寻找最优解。
接下来我们给出完整的 Trade-off 决策矩阵和上线 Checklist。
| 优化维度 | 选项 A(高性能) | 选项 B(高可靠) | 权衡点 |
|---|---|---|---|
| acks | 0 或 1 | all (-1) | 性能 vs 可靠性 |
| flush.messages | 10000(攒够 10K 条才刷盘) | 1(每条都立即刷盘) | 性能 vs 数据安全 |
| compression | lz4/snappy(CPU 换 IO) | none(不压缩) | CPU vs 网络/存储 |
| batch.size | 64KB(大批量) | 1KB(小批量) | 延迟 vs 吞吐量 |
| linger.ms | 100ms(等久一点) | 0(立即发送) | 延迟 vs 吞吐量 |
| replication.factor | 1(无副本) | 3(三副本) | 存储成本 vs 可用性 |
| min.insync.replicas | 1 | 2 | 可用性 vs 数据安全 |
| num.partitions | 100(多分区高并行) | 3(少分区低成本) | 并行度 vs 资源消耗 |
| cleanup.policy | delete(删除旧数据) | compact(压缩合并) | 存储空间 vs 保留历史 |
生产环境 Checklist(上线前必查)
Producer 侧:
-
enable.idempotence=true(防止重复写入) -
acks=all(除非允许丢数据) -
retries >= 3(自动重试) -
linger.ms和batch.size合理配置(不要太大也不要太小) - 序号器使用
StringSerializer(确保相同 Key 路由到同一 Partition)
Consumer 侧:
-
enable.auto.commit=false(必须手动提交!) -
auto.offset.reset=earliest(首次启动从最早消息开始) -
max.poll.records合理(避免一次拉太多处理不完导致 Rebalance) - 业务层实现幂等性(Redis 或数据库唯一索引)
- 异常处理完善(不要吞掉异常)
Broker 侧:
-
replication.factor >= 3(三副本) -
min.insync.replicas >= 2(至少 2 个同步副本) -
unclean.leader.election.enable=false(禁止脏 Leader 选举) -
log.retention.hours或log.retention.bytes合理配置(避免磁盘撑爆) -
num.io.threads和num.network.threads根据机器核数调整
运维侧:
- 监控 JMX 指标(Prometheus + Grafana)
- 配置告警规则(消费滞后、UnderReplicatedPartitions 等)
- 定期清理过期 Topic 和 Consumer Group
- 备份关键配置(server.properties、Topic 配置)
常见问题排查指南
| 问题现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 消息发送失败 | Broker 不可达 / 序列化错误 | 查看 Producer 日志,检查 bootstrap-servers 配置 |
| 消息重复消费 | Rebalance / Consumer 崩溃未提交 Offset | 检查幂等性逻辑,查看 __consumer_offsets Topic |
| 消费滞后严重 | 消费者处理太慢 / Partition 不均衡 | 查看 lag 指标,考虑增加消费者或 Partition |
| Rebalance 频繁 | 消费者频繁上下线 / GC 停顿 | 检查 session.timeout.ms 和 heartbeat.interval.ms |
| ZooKeeper 连接超时 | ZooKeeper 负载过高 / 网络抖动 | 检查 ZK 集群状态,考虑升级到 KRaft 模式 |
| 磁盘空间不足 | 日志保留时间过长 / cleanup.policy=compact | 检查 log.dirs 占用,调整 retention 策略 |
总结:Kafka 的核心设计哲学
回顾一下我们学到的内容:
架构层面
- 分布式Commit Log:顺序写 + 零拷贝 = 极致性能
- Partition 并行:横向扩展,吞吐量线性增长
- ISR 机制:在可用性和一致性之间找到平衡
可靠性层面
- Producer 幂等性 :
enable.idempotence=true防止重复写入 - Consumer 手动提交:精确控制 Offset,配合业务幂等性
- 多副本同步 :
acks=all+min.insync.replicas=2保证数据持久化
运维层面
- Rebalance 优化:CooperativeStickyAssignor 减少停顿
- 监控体系:JMX + Prometheus + Grafana 全链路可观测
- 性能调优:根据业务特点在性能和可靠之间做 Trade-off
选型建议
- 日志采集、用户行为分析 → Kafka(吞吐量之王)
- 传统业务解耦 → RabbitMQ(路由灵活)
- 金融交易、电商核心 → RocketMQ(事务消息)
最后记住一句话: Kafka 不是银弹,但它在大数据和高吞吐场景下是目前最优的选择。理解它的设计权衡,才能在生产环境中用好它。