Kafka topic 与 microservice 多实例如何避免重复消费
结论
让所有 Service B 实例使用同一个固定的 Kafka group.id,并订阅同一个 topic。
在同一个 consumer group 中,一个 partition 同一时刻只会分配给一个 consumer。因此,每条消息只会由一个 Service B 实例消费,不会因为部署了 10 个实例就被同时处理 10 次。
如果实例数多于 partition 数,多出的 consumer 分不到 partition,只会保持空闲,但这些实例仍可正常处理 HTTP 请求。
consumer group 解决的是多实例之间的竞争消费问题。故障重试、offset 提交失败或 rebalance 仍可能造成重复处理,所以支付等业务操作必须保持幂等。
Kafka 如何分配消息
Kafka 以 partition 为单位分配消费任务,而不是逐条分配消息。
假设 Orders topic 有 4 个 partitions,Payment Service 有 3 个实例,并且都使用相同的 group.id:
Orders topic
├── partition-0 → Payment Service 1
├── partition-1 → Payment Service 2
├── partition-2 → Payment Service 3
└── partition-3 → Payment Service 1
一个实例可以负责多个 partitions,但同一个 consumer group 内的一个 partition 不会同时分给多个实例。
如果有 6 个实例,却只有 4 个 partitions,分配结果可能是:
partition-0 → Payment Service 1
partition-1 → Payment Service 2
partition-2 → Payment Service 3
partition-3 → Payment Service 4
Payment Service 5 → 无 partition
Payment Service 6 → 无 partition
后两个实例的 Kafka consumer 会处于空闲状态,收不到消息。不过,它们仍然可以承担 HTTP 流量。当其他 consumer 下线并触发 rebalance 时,空闲 consumer 可能接管释放出来的 partition。
对于单个 topic,consumer group 的有效并行度上限通常是:
min(consumer 数量, partition 数量)
正确设计 consumer group
承担相同业务职责的所有实例都应使用同一个 group.id:
group.id=payment-service
不要为每个实例生成不同的 group,例如:
group.id=payment-service-${HOSTNAME}
每个实例使用不同的 group,就意味着它们属于不同的消费组。Kafka 会分别为每个 group 保存 offset,并把每条消息发送给每个 group,结果就变成了广播:
payment-service-1 收到订单 1001
payment-service-2 收到订单 1001
payment-service-3 收到订单 1001
只有不同业务确实需要各自处理同一事件时,才应该使用不同的 group,例如:
payment-service
inventory-service
notification-service
支付、扣减库存和发送通知都需要处理订单事件,因此这三个服务应使用不同的 group.id。Payment Service 的所有副本则应共用 payment-service 这个 group。
Spring Kafka 配置示例
所有 Payment Service 实例使用相同的配置:
spring:
kafka:
bootstrap-servers: kafka-1:9092,kafka-2:9092
consumer:
group-id: payment-service
enable-auto-commit: false
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
listener:
ack-mode: record
消费者代码可以这样写:
@Component
public class OrderConsumer {
private final PaymentService paymentService;
public OrderConsumer(PaymentService paymentService) {
this.paymentService = paymentService;
}
@KafkaListener(
topics = "orders",
groupId = "payment-service"
)
public void consume(OrderCreatedEvent event) {
paymentService.charge(
event.orderId(),
event.userId(),
event.amount()
);
}
}
无论部署多少个应用副本,只要它们连接到同一个 Kafka 集群,订阅相同的 topic,并使用相同的 group.id,Kafka 就会把 partitions 分配给这些实例。
为什么仍然需要幂等
同一个 consumer group 无法保证业务处理严格只执行一次。
例如:
- consumer 收到订单消息;
- Payment Service 成功完成扣款;
- 服务在提交 offset 前崩溃;
- partition 被重新分配;
- 新 consumer 从上一次已提交的 offset 继续消费;
- 同一条订单消息再次被处理。
这就是典型的 at least once 场景。重复通常由故障恢复、超时或重试引起,与实例数量无关。
支付接口应使用稳定的业务幂等键,例如 orderId:
public PaymentResult charge(
String orderId,
String userId,
BigDecimal amount) {
Payment existingPayment = paymentRepository.findByOrderId(orderId);
if (existingPayment != null) {
return existingPayment.toResult();
}
PaymentResult result = paymentGateway.charge(
userId,
amount,
orderId
);
paymentRepository.save(
new Payment(orderId, result.transactionId(), result.status())
);
return result;
}
数据库层还要增加唯一约束,防止两个并发请求同时通过先查询、后写入的检查:
ALTER TABLE payments
ADD CONSTRAINT uk_payments_order_id UNIQUE (order_id);
如果支付网关支持 idempotency key,还应把 orderId 或专门生成的 paymentRequestId 作为幂等键传给网关。只在本地数据库中去重,无法彻底解决外部扣款成功但本地状态写入失败的问题。
调用外部支付服务时,还要结合业务状态机、支付结果查询、重试和对账机制来处理不确定状态。
offset 应在什么时候提交
通常应在业务处理成功后提交 offset:
读取消息
↓
检查幂等状态
↓
执行或确认支付
↓
保存支付结果
↓
提交 Kafka offset
如果先提交 offset,再执行支付,服务在两步之间崩溃后,Kafka 会认为消息已经消费,但订单可能还没有完成支付。
如果先完成支付,再提交 offset,服务崩溃后可能再次收到同一条消息,不过幂等机制可以避免重复扣款。这是 at least once 场景下通常采用的取舍。
Kafka offset、业务数据库事务和第三方支付调用一般不在同一个原子事务中。仅仅开启 Kafka transaction,并不能让外部支付接口自动获得端到端的 exactly once 语义。
partition 数量如何规划
如果希望 N 个 consumer 实例都能参与消费,topic 至少需要 N 个 partitions:
partition 数量 >= 期望的最大消费并行度
例如:
Orders partitions = 12
Payment Service replicas = 8
这时最多有 8 个 consumer 并行工作,剩余 4 个 partitions 会由部分实例额外负责。
如果扩容到 16 个实例:
Orders partitions = 12
Payment Service replicas = 16
最多只有 12 个实例能分到 partition,其余 4 个实例不会消费 Kafka 消息。
partition 数量不需要机械地等于 HTTP 实例数,因为两类负载的扩缩容依据不同:
- HTTP 实例数取决于请求量、响应延迟和连接数。
- Kafka 消费并行度取决于消息吞吐量、单条消息的处理时间和积压情况。
- partition 数量决定 consumer group 的最大并行度。
- 一个实例内部也可能运行多个 consumer 线程,此时应按 consumer 总数计算,不能只看进程数或 Pod 数量。
如果 HTTP 流量很大,而 Kafka 消费量很小,可以允许部分实例的 Kafka consumer 空闲。也可以把 HTTP API 和 Kafka consumer 拆成两个独立的 Deployment,共用同一套业务代码或业务模块,并分别扩缩容:
payment-api
replicas: 10
只处理 HTTP
payment-consumer
replicas: 4
只消费 Kafka
这样可以根据各自的负载设置副本数,也能减少 HTTP 扩容对 consumer group 的影响,避免频繁触发 rebalance。
消息 key 与顺序
生产订单事件时,建议使用 orderId 作为 Kafka message key:
kafkaTemplate.send(
"orders",
order.getId(),
new OrderCreatedEvent(
order.getId(),
order.getUserId(),
order.getAmount()
)
);
Kafka 通常会根据 key 选择 partition。相同 orderId 的事件会进入同一个 partition,因此可以保持该订单相关事件在 partition 内的顺序,例如:
OrderCreated
PaymentRequested
OrderCancelled
使用 key 不能防止重复消费,但可以避免同一订单的相关事件被不同 consumer 并发处理或乱序处理。
还需要注意 rebalance
实例上线、下线、失联,或者订阅关系变化时,consumer group 可能触发 rebalance。Kafka 会在此期间重新分配 partitions,消费可能出现短暂停顿。
因此应避免:
- 单条消息的处理时间长期超过 consumer 的存活和轮询相关限制;
- 在消费线程中执行没有超时控制的外部调用;
- HTTP 自动扩缩容频繁创建或销毁 Kafka consumers;
- 使用随机生成或随实例变化的
group.id; - 忽略失败重试和死信处理。
对于处理时间较长的支付任务,应设置合理的调用超时、有限重试和死信 topic,并监控 consumer lag。具体的 consumer 参数要结合 Kafka 客户端版本、单条消息的最长处理时间和批量拉取配置来确定,不能套用一组固定数值。
推荐方案
Order Service 和 Payment Service 可以采用以下设计:
- Order Service 以
orderId为 key,向orderstopic 发送事件。 - 所有 Payment Service consumer 使用固定的
group.id=payment-service。 - 根据目标消费并行度和后续扩容需求设置
orders的 partition 数量。 - consumer 完成业务处理后再提交 offset。
- 使用
orderId或paymentRequestId实现业务幂等。 - 在数据库中为幂等键建立唯一约束。
- 调用支付网关时,使用网关提供的 idempotency key。
- 为失败消息配置有限重试、死信处理和人工对账机制。
- 如果 HTTP 与 Kafka 的扩缩容需求差异明显,将 API 和 Kafka consumer 分开部署。
相同业务的多个实例应使用同一个 consumer group,由 Kafka 在 partition 层面分配任务。业务层则通过幂等机制处理故障重试可能带来的少量重复。
备注:内容仅供参考。
