垃圾收集器与GC日志:仓储高峰期如何看懂停顿

GC 日志是 JVM 调优和线上排查的第一手证据。接口变慢时,不能只看业务日志里的耗时,还要判断是否发生了 Stop The World、Full GC、老年代空间不足、晋升失败或大对象分配。供应链系统在大促、仓库波次下发、订单集中履约时对象创建速度很快,GC 日志能帮助我们把问题从“感觉慢”变成可量化的事实。

整体流程图

GC 日志分析流程

常见垃圾收集器

不同 JDK 版本和业务场景会使用不同收集器:

  1. Serial GC:单线程收集,适合小内存客户端程序,不适合高并发服务。
  2. Parallel GC:吞吐优先,适合批处理任务,例如夜间库存重算、历史订单归档。
  3. CMS:低停顿老年代收集器,JDK 9 后被标记废弃,JDK 14 移除。
  4. G1:面向服务端低停顿场景,JDK 9 以后成为默认收集器,适合大多数订单、库存、仓储服务。
  5. ZGC:低停顿收集器,适合大堆和低延迟服务,具体使用要结合 JDK 版本和生产验证。

如果没有特殊原因,现代 Spring Boot 服务通常优先使用 G1。它把堆划分为多个 Region,通过预测停顿时间选择回收集合,目标是在可控停顿下获得稳定吞吐。

仓储高峰期 Demo

假设 WMS 在晚上 8 点下发波次任务,订单服务要把 30 万个待出库订单按仓库、承运商、优先级分组:

1
2
3
4
5
6
7
8
9
10
11
public class WaveDispatchService {
public List<WaveGroup> buildWave(List<OrderLine> lines) {
Map<String, List<OrderLine>> grouped = lines.stream()
.collect(Collectors.groupingBy(line ->
line.getWarehouseCode() + ":" + line.getCarrierCode()));

return grouped.entrySet().stream()
.map(entry -> new WaveGroup(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
}
}

这段代码业务上清晰,但在高峰期会创建大量临时对象:分组 key、Map 节点、List、Stream 中间对象、WaveGroup。如果堆空间偏小或对象晋升过快,GC 停顿会明显增加。

如何打开 GC 日志

JDK 8 常用参数:

1
2
3
4
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-XX:+PrintTenuringDistribution \
-Xloggc:/data/logs/order-service/gc.log

JDK 9 及以后推荐使用统一日志:

1
-Xlog:gc*,safepoint:file=/data/logs/order-service/gc.log:time,uptime,level,tags:filecount=10,filesize=100M

线上服务建议默认开启 GC 日志。日志文件滚动要配置好,否则长时间运行可能撑爆磁盘。

GC 日志重点看什么

分析 GC 日志时,不要只看有没有 GC,要看四类指标:

  1. 频率:Young GC、Mixed GC、Full GC 多久发生一次。
  2. 停顿时间:每次暂停多少毫秒,P95/P99 是否影响接口 SLA。
  3. 回收效果:GC 前后堆、老年代、元空间占用下降多少。
  4. 触发原因:Allocation Failure、Metadata GC Threshold、Humongous Allocation、System.gc() 等。

一段简化后的日志可能类似:

1
2
3
[2023-06-18T20:01:12.345+0800][info][gc] GC(42) Pause Young (Normal) 512M->180M(1024M) 35.7ms
[2023-06-18T20:03:44.210+0800][info][gc] GC(43) Pause Young (Concurrent Start) 760M->420M(1024M) 68.4ms
[2023-06-18T20:03:44.281+0800][info][gc] GC(44) Concurrent Mark Cycle

这说明 JVM 正在进行新生代回收,并启动并发标记。如果后面频繁出现 Full GC,且每次回收后老年代下降不明显,就要怀疑长生命周期对象过多或内存泄漏。

常见判断结论

GC 日志可以帮助形成明确结论:

  1. Young GC 频繁但停顿短:对象创建速度快,可能需要优化批处理对象分配,或者适当增大堆。
  2. Full GC 频繁且回收效果差:老年代长期占满,优先查缓存、静态集合、批量任务和大对象。
  3. 元空间触发 GC:检查动态代理、脚本引擎、热部署和类加载器泄漏。
  4. 大对象触发回收:检查一次性大数组、大 JSON、大 Excel 导出。
  5. 明确出现 System.gc():检查代码、第三方库或运维脚本是否主动触发 Full GC。

优化仓储波次的代码思路

对于前面的分组逻辑,可以从业务和代码两侧降压:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void dispatchWaveByPage(String waveNo) {
long lastId = 0L;
while (true) {
List<OrderLine> page = orderLineRepository.queryPage(waveNo, lastId, 2000);
if (page.isEmpty()) {
break;
}

Map<RouteKey, List<OrderLine>> grouped = groupByRoute(page);
waveRepository.saveGroups(grouped);
lastId = page.get(page.size() - 1).getId();

grouped.clear();
page.clear();
}
}

核心不是手动调用 GC,而是控制对象峰值:分页查询、分批落库、避免超大集合、减少无意义字符串拼接。GC 调优优先解决对象生命周期和分配速率,再调整 JVM 参数。

GC 日志的价值在于把性能问题证据化。对于供应链系统,订单高峰、仓储波次、库存同步都可能制造对象洪峰,只有把日志、业务峰值和代码路径结合起来看,才能得出可靠结论。

GC对象判定与回收算法:订单批处理对象如何被回收

GC 的第一步不是回收,而是判断哪些对象还活着。只有理解对象存活判定、引用类型和基础回收算法,才能解释为什么某些对象明明业务上已经不用了,却仍然无法被回收,也才能写出对批处理和高并发服务更友好的代码。

整体流程图

GC 对象判定与回收算法

对象如何被判定为存活

主流 JVM 使用可达性分析判断对象是否存活。它从一组 GC Roots 出发,沿着引用链向下搜索,能被搜索到的对象就是存活对象,搜索不到的对象就是可回收对象。

常见 GC Roots 包括:

  1. 虚拟机栈中局部变量引用的对象。
  2. 方法区中类静态属性引用的对象。
  3. 方法区中常量引用的对象。
  4. Native 方法引用的对象。
  5. 活跃线程对象、类加载器、同步锁持有对象等。

供应链服务中,一个订单导入任务创建了很多临时对象。如果这些对象只存在于方法局部变量里,任务结束后大概率可以被回收。但如果它们被放入静态集合、长生命周期缓存、线程本地变量中,就可能继续可达,导致内存无法释放。

订单批处理 Demo

下面是一个简化的订单导入任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class OrderImportJob {
private final OrderRepository orderRepository;

public void importOrders(List<OrderRow> rows) {
List<OrderCommand> commands = new ArrayList<>(rows.size());

for (OrderRow row : rows) {
OrderCommand command = new OrderCommand(
row.getOrderNo(),
row.getSkuCode(),
row.getQuantity(),
row.getWarehouseCode()
);
commands.add(command);
}

orderRepository.batchCreate(commands);
}
}

commands 是局部变量。importOrders() 执行结束后,如果 batchCreate() 没有把这些对象保存到全局结构里,这批 OrderCommand 对象就会失去可达路径,后续 GC 可以回收。

下面这种写法就有风险:

1
2
3
4
5
6
7
8
9
10
public class BadOrderImportJob {
private static final List<OrderCommand> HISTORY = new ArrayList<>();

public void importOrders(List<OrderRow> rows) {
for (OrderRow row : rows) {
OrderCommand command = convert(row);
HISTORY.add(command);
}
}
}

HISTORY 是静态字段,属于 GC Roots 可达链的一部分。只要进程不退出,列表里的历史订单命令就一直可达。业务上即使导入完成,内存也不会自动释放。这类问题在导入、导出、报表、对账任务里很常见。

引用类型

Java 引用可以分为四类:

  1. 强引用:最常见,例如 new OrderCommand() 赋值给变量。只要强引用可达,GC 不会回收。
  2. 软引用:内存不足时可能被回收,可用于对内存敏感的缓存,但现代服务更推荐使用成熟缓存组件并设置容量。
  3. 弱引用:下一次 GC 发生时,只要没有强引用就会被回收,常见于 WeakHashMap
  4. 虚引用:不能通过引用拿到对象,主要用于跟踪对象回收状态。

供应链系统里不要简单依赖软引用实现核心缓存。库存、价格、路由规则这类数据更适合用 Caffeine、Redis、本地缓存加版本号等方式,并明确容量、过期和刷新策略。

基础回收算法

常见 GC 算法有三类:

  1. 标记-清除:先标记可回收对象,再清除它们。问题是会产生内存碎片。
  2. 标记-复制:把存活对象复制到另一块区域,再清理原区域。适合存活对象少的新生代。
  3. 标记-整理:标记后把存活对象向一端移动,减少碎片。适合老年代。

JVM 分代回收的基本依据是弱分代假说:大多数对象朝生夕死,少数对象会长期存活。供应链接口里的请求 DTO、计算中间对象、临时报表行通常很快死亡,适合在新生代快速回收;缓存、连接池、规则表、单例服务通常会长期存活,最终进入老年代。

让 GC 更轻松的编码方式

GC 可以自动回收内存,但不能替开发者修正错误的引用关系。实践中要注意:

  1. 批处理按批次提交并释放引用,不要把全量数据放进一个大集合。
  2. 静态集合只放真正需要全局共享的数据,并设置上限。
  3. ThreadLocal 使用后及时 remove(),尤其在线程池场景。
  4. 缓存必须有容量和过期策略。
  5. 大对象和大数组要谨慎,避免频繁进入老年代。

一个更稳妥的导入方式是分片处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void importOrders(Stream<OrderRow> rowStream) {
List<OrderCommand> batch = new ArrayList<>(1000);
rowStream.forEach(row -> {
batch.add(convert(row));
if (batch.size() == 1000) {
orderRepository.batchCreate(batch);
batch.clear();
}
});
if (!batch.isEmpty()) {
orderRepository.batchCreate(batch);
batch.clear();
}
}

这段代码把峰值对象数量限制在一个批次内,既降低堆内存压力,也减少 GC 扫描和复制的成本。对于订单导入、库存同步、仓储流水归档,这类批处理方式通常比一次性全量加载更稳定。

Java内存结构:库存服务里的堆栈元空间

Java 内存结构是理解 JVM 问题的基础。线上服务出现 OOM、线程数过高、接口偶发慢、GC 频繁时,如果分不清堆、栈、元空间和直接内存,就很难判断问题属于对象太多、线程太多、类太多,还是 Netty 这类框架使用的堆外内存太多。

整体流程图

Java 内存结构流程

JVM 运行时数据区

JVM 运行时数据区可以从线程共享和线程私有两个角度理解:

  1. 程序计数器:线程私有,记录当前线程执行到哪条字节码指令。
  2. Java 虚拟机栈:线程私有,每次方法调用都会创建栈帧,保存局部变量表、操作数栈、返回地址等。
  3. 本地方法栈:线程私有,为 Native 方法服务。
  4. Java 堆:线程共享,绝大多数对象实例和数组都在这里分配,也是 GC 管理的重点区域。
  5. 方法区:线程共享,在 HotSpot 里 JDK 8 之后主要由元空间承载,保存类元数据、常量、方法信息等。
  6. 直接内存:不属于 JVM 运行时数据区规范的一部分,但大量框架会使用,例如 NIO、Netty、文件传输。

供应链服务里,订单对象、库存快照、分页结果、DTO 通常进入堆;每个请求线程都有自己的虚拟机栈;大量动态代理类、反射元数据会占用元空间;网关、消息队列客户端、RPC 框架可能使用直接内存。

库存查询 Demo

下面用一个库存查询例子说明堆和栈的关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class InventoryQueryService {
private final InventoryRepository repository;

public InventoryQueryService(InventoryRepository repository) {
this.repository = repository;
}

public InventoryView query(String skuCode, String warehouseCode) {
InventoryRecord record = repository.find(skuCode, warehouseCode);
int available = record.getOnHand() - record.getLocked();
return new InventoryView(skuCode, warehouseCode, available);
}
}

当线程执行 query() 时:

  1. skuCodewarehouseCoderecordavailable 这些局部变量引用或基本类型值保存在当前线程的栈帧里。
  2. InventoryRecordInventoryView 对象通常分配在堆上。
  3. InventoryQueryService.class、方法元数据、常量池等类信息在元空间里。
  4. 请求结束后,栈帧弹出,局部变量消失;如果返回对象不再被其他地方引用,后续 GC 可以回收它。

栈上的变量生命周期通常很短,堆上的对象生命周期由引用关系决定。理解这一点,才能解释为什么一个局部变量引用的大对象在方法结束后可以被回收,而放入静态集合或缓存后就可能长期占用内存。

对象在堆里的结构

HotSpot 中一个普通对象通常由三部分组成:

  1. 对象头:保存 Mark Word、类型指针等信息。
  2. 实例数据:业务字段,例如 skuCodewarehouseCodeavailableQty
  3. 对齐填充:为了满足内存对齐要求。

以库存对象为例:

1
2
3
4
5
6
7
8
9
10
11
public class InventoryView {
private String skuCode;
private String warehouseCode;
private int availableQty;

public InventoryView(String skuCode, String warehouseCode, int availableQty) {
this.skuCode = skuCode;
this.warehouseCode = warehouseCode;
this.availableQty = availableQty;
}
}

如果一次批量查询返回 100 万条库存视图,不只是 100 万个 InventoryView 对象占内存,里面引用的 String、字符数组、集合容器也会占内存。线上估算内存时不能只看字段数量,还要看对象图。

常见内存异常

不同内存区域对应不同异常:

1
2
3
4
5
6
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: GC overhead limit exceeded
java.lang.OutOfMemoryError: Metaspace
java.lang.OutOfMemoryError: Direct buffer memory
java.lang.StackOverflowError
java.lang.OutOfMemoryError: unable to create native thread

供应链项目里的典型触发原因:

  1. Java heap space:一次导出全量订单、库存快照缓存无限增长、消息堆积后一次性反序列化。
  2. Metaspace:动态生成类过多,或者热部署环境 ClassLoader 泄漏。
  3. Direct buffer memory:Netty、NIO、大文件传输使用堆外内存过多。
  4. StackOverflowError:递归解析 BOM 物料树没有终止条件。
  5. unable to create native thread:线程池无界增长,或者容器线程数限制太低。

业务实现建议

供应链系统经常面对大批量数据,内存结构设计要服务于吞吐和稳定性:

  1. 分页处理订单、库存、出入库流水,不要一次性加载全量数据。
  2. 缓存要设置容量、过期时间和淘汰策略,避免静态 Map 无限增长。
  3. 大文件导入导出使用流式处理,避免整个文件读入堆内存。
  4. 线程池大小要受控,因为每个线程都会消耗栈内存和操作系统资源。
  5. 使用 Netty 或 NIO 时,要同时监控堆内存和直接内存。

Java 内存结构的学习目标,是能把业务对象、线程、类元数据、IO 缓冲区分别映射到对应内存区域,并据此判断问题的根因和优化方向。

JVM架构与类加载:订单服务从源码到运行

JVM 是 Java 程序的运行时基础。开发供应链系统时,我们通常关注订单、库存、仓储、物流这些业务模块,但每一次接口调用最终都会落到 JVM 的类加载、字节码执行、内存管理和垃圾回收上。掌握 JVM 架构的价值不是背概念,而是能解释线上现象:为什么服务启动慢、为什么类冲突、为什么热部署失败、为什么一次配置改动导致初始化异常。

整体流程图

JVM 类加载与执行流程

需要掌握的核心技能点

JVM 架构至少要掌握下面这些内容:

  1. JDK、JRE、JVM 的关系:JDK 提供编译、诊断和运行工具,JRE 提供运行环境,JVM 负责执行字节码。
  2. .java.class 的流程:源码经过 javac 编译成字节码,JVM 再解释执行或通过 JIT 编译成本地机器码。
  3. 类加载机制:加载、验证、准备、解析、初始化。
  4. 类加载器体系:Bootstrap ClassLoader、Platform ClassLoader、Application ClassLoader、自定义 ClassLoader。
  5. 双亲委派模型:优先让父加载器加载类,避免核心类被篡改,也减少重复加载。
  6. 执行引擎:解释器、JIT 编译器、热点代码探测、方法内联、逃逸分析。
  7. 本地方法接口:Java 代码通过 JNI 调用操作系统或本地库能力。

供应链业务场景

假设有一个订单履约服务 OrderFulfillmentService,它负责把电商订单转成仓库出库单,并根据仓库能力选择履约策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface FulfillmentPolicy {
String chooseWarehouse(String skuCode, String province);
}

public class DefaultFulfillmentPolicy implements FulfillmentPolicy {
static {
System.out.println("load warehouse routing rules");
}

@Override
public String chooseWarehouse(String skuCode, String province) {
if ("GD".equals(province)) {
return "SOUTH_WAREHOUSE";
}
return "CENTRAL_WAREHOUSE";
}
}

当业务代码第一次主动使用 DefaultFulfillmentPolicy 时,JVM 才会触发类初始化:

1
2
3
4
5
6
7
8
public class OrderFulfillmentService {
private final FulfillmentPolicy policy = new DefaultFulfillmentPolicy();

public String createOutboundOrder(String orderNo, String skuCode, String province) {
String warehouseCode = policy.chooseWarehouse(skuCode, province);
return orderNo + " -> " + warehouseCode;
}
}

这段代码背后发生了几件事:

  1. Application ClassLoader 找到 DefaultFulfillmentPolicy.class
  2. JVM 验证字节码是否合法,避免非法访问栈、越界跳转等问题。
  3. JVM 为静态字段分配默认值,这一步叫准备。
  4. JVM 把符号引用解析成直接引用,例如方法、字段、类的真实内存入口。
  5. 执行 <clinit>,也就是静态代码块和静态变量赋值。

所以,供应链项目里如果把数据库连接、远程配置、缓存预热写进静态代码块,服务启动或首次访问时就可能出现类初始化失败。更合理的方式是把这些动作放到 Spring Bean 生命周期里,并做好失败重试和降级。

类加载冲突的典型问题

供应链系统经常集成 WMS、TMS、ERP、OMS 等外部系统,依赖包很容易变复杂。例如一个老的 WMS SDK 依赖 jackson 2.9,订单服务本身依赖 jackson 2.15,如果版本冲突,可能出现:

1
java.lang.NoSuchMethodError: com.fasterxml.jackson.databind.ObjectMapper.readerForUpdating

这不是编译期问题,而是运行期加载到的类版本和编译期预期不一致。排查时要关注:

1
2
3
mvn dependency:tree
javap -classpath target/classes com.example.OrderFulfillmentService
java -verbose:class -jar order-service.jar

-verbose:class 可以看到类从哪个 jar 加载。定位到冲突后,常见处理方式包括统一依赖版本、排除传递依赖、隔离插件 ClassLoader,或者把老 SDK 包装成独立适配服务。

双亲委派为什么重要

双亲委派的核心是:一个类加载器收到加载请求时,先委托父加载器尝试加载,父加载器加载不到时自己再加载。它的直接收益有两个:

  1. 安全:业务代码不能随便伪造 java.lang.String 这类核心类。
  2. 稳定:同一个基础类优先由上层加载器加载,减少重复定义带来的类型不一致。

但有些场景会打破或绕开双亲委派,例如 JDBC SPI、应用服务器隔离、插件化系统。供应链中如果要让不同仓库客户使用不同的计费插件,可以自定义 ClassLoader 隔离插件依赖,但要明确边界:插件可以依赖公共接口,不应该反向依赖主应用内部实现。

实战建议

JVM 架构和类加载要能落到排查能力上:

  1. 看到 ClassNotFoundException,优先判断运行时 classpath 是否缺包。
  2. 看到 NoClassDefFoundError,除了缺包,还要判断类初始化是否失败过。
  3. 看到 NoSuchMethodErrorNoSuchFieldError,优先怀疑依赖版本冲突。
  4. 看到启动阶段变慢,检查静态初始化、Spring 扫描范围、反射和代理生成。
  5. 设计插件系统时,先定义稳定接口,再考虑 ClassLoader 隔离。

JVM 不是脱离业务的底层知识。对于订单履约、库存同步、仓储调度这类高并发服务,类加载决定了服务启动和依赖边界,执行引擎决定了热点路径性能,诊断工具决定了线上问题能否快速收敛。

kafka组件里面生产者和消费者的原理

开场个人观察

Kafka 这种组件,刚开始学的时候很容易记成几个名词:Producer、Consumer、Topic、Partition、Broker、Offset。真正用到项目里以后才会发现,Kafka 的难点不在于“会不会发消息”,而在于吞吐量、顺序性、可靠性和消费进度之间的取舍。

在供应链、订单、库存、ERP 同步这类系统里,Kafka 很常见。比如订单创建后通知库存系统预占库存,采购单状态变化后通知财务系统生成应付记录,仓库出库后通知报表系统刷新数据。这些业务都有一个共同点:消息量可能很大,但业务又不能随便丢。

所以理解 Kafka,不能只看 API,要看完整链路:生产者怎么把消息写进去,Broker 怎么存,消费者怎么拉取,offset 怎么提交,失败时怎么恢复。

Kafka生产者消费者工作流程

核心观点

Kafka 的核心设计可以概括成三句话。

第一,生产者不是一条一条傻发,而是会把消息按 topic 和 partition 组织起来,经过序列化、分区选择、批量缓存后再发送给 Broker。

第二,Broker 不是把消息存在一个普通队列里,而是把消息追加到分区日志中。分区是 Kafka 并行能力的基础,日志追加是它高吞吐的基础。

第三,消费者不是等 Broker 推消息,而是主动 poll 拉取。消费者属于某个 consumer group,同一个 group 里一个分区同一时刻通常只会分给一个消费者处理,处理进度通过 offset 记录。

Kafka 的吞吐量来自批量、顺序写、分区并行和零拷贝等机制;可靠性来自副本、ack、幂等、事务和 offset 提交策略。项目里真正要做的,是根据业务重要性选择合适参数,而不是一味追求“最快”。

实践方法

先看生产者。生产者发送一条消息时,大致会经历这些步骤:

  1. 业务代码构造消息,比如订单号、业务类型、变更时间。
  2. 序列化,把对象转成字节。
  3. 选择分区,如果指定 key,通常会根据 key hash 到固定 partition。
  4. 放入本地缓冲区,按 batch 组织。
  5. Sender 线程把 batch 发给对应 Broker。
  6. Broker 追加日志并根据 ack 策略返回结果。

如果要增大生产吞吐量,常见方向有几个。

batch.size 可以调大,让更多消息合并成一个批次。批量越充分,网络请求次数越少。

linger.ms 可以适当增加,让生产者多等几毫秒凑批次。它会牺牲一点延迟,换更高吞吐。

compression.type 可以使用 lz4snappyzstd,减少网络传输和磁盘占用。消息体较大时效果明显。

分区数要足够。一个 topic 如果只有一个 partition,再多消费者也无法在同一个 consumer group 内并行消费这个 topic。

生产者可以设置 acks=all、开启幂等 enable.idempotence=true,并合理配置重试。这样可以在 Broker 短暂失败时自动恢复,同时避免重试造成重复写入。

再看消费者。消费者是通过 poll 拉取消息,处理后提交 offset。这里最关键的是 offset 提交时机。

如果先提交 offset 再处理业务,消费者宕机后这批消息可能永远不会再处理,容易丢消息。

如果先处理业务再提交 offset,宕机后可能重复消费,但至少消息不会丢。大多数核心业务更接受“重复但可幂等”,而不是“直接丢”。

所以在订单、库存这类场景里,我更倾向于手动提交 offset:

1
2
3
4
poll 消息
执行业务处理
写入业务库或幂等表
处理成功后 commit offset

为了防止重复消费,业务侧要做幂等。比如用消息唯一 ID 建一张消费记录表,或者让订单状态流转本身具备幂等判断:已经处理过的状态不再重复扣减库存。

踩坑提醒

第一个坑,是只调大分区数,不看消费者处理能力。分区数增加能提高并行度,但也会带来更多文件句柄、更多 leader 选举和更复杂的再均衡。分区不是越多越好。

第二个坑,是为了吞吐把 acks 调成 0。这样生产者发出去就不管了,速度很快,但 Broker 是否收到并不确定。日志、埋点可以这么考虑,订单状态、库存变更就不应该这么随意。

第三个坑,是自动提交 offset。自动提交很方便,但它提交的是消费进度,不是业务成功。只要业务处理和 offset 提交之间没有绑定,就要接受消息丢失或重复的风险。

第四个坑,是忽略 rebalance。消费者数量变化、心跳超时、poll 时间过长,都可能触发再均衡。处理单条消息耗时很长时,要注意 max.poll.interval.ms 和批量大小,避免消费者被踢出 group。

第五个坑,是把 Kafka 当数据库。Kafka 适合做日志流和消息流,不适合承担复杂查询。业务状态仍然要落到数据库、缓存或搜索系统中。

总结

Kafka 的生产者负责高效、可靠地把消息写入分区日志;消费者负责按分区拉取消息、处理业务并提交 offset。吞吐量靠批量、压缩、分区和顺序写;可靠性靠副本、ack、幂等、事务和手动提交。

在真实项目里,我会按业务重要性分层:日志类消息可以优先吞吐,核心业务消息优先可靠;允许重复,但不能无声丢失。只要这条原则清楚,Kafka 参数就不会乱调。