PAYATHON 2026

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 无法保证业务处理严格只执行一次。

例如:

  1. consumer 收到订单消息;
  2. Payment Service 成功完成扣款;
  3. 服务在提交 offset 前崩溃;
  4. partition 被重新分配;
  5. 新 consumer 从上一次已提交的 offset 继续消费;
  6. 同一条订单消息再次被处理。

这就是典型的 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 可以采用以下设计:

  1. Order Service 以 orderId 为 key,向 orders topic 发送事件。
  2. 所有 Payment Service consumer 使用固定的 group.id=payment-service。
  3. 根据目标消费并行度和后续扩容需求设置 orders 的 partition 数量。
  4. consumer 完成业务处理后再提交 offset。
  5. 使用 orderId 或 paymentRequestId 实现业务幂等。
  6. 在数据库中为幂等键建立唯一约束。
  7. 调用支付网关时,使用网关提供的 idempotency key。
  8. 为失败消息配置有限重试、死信处理和人工对账机制。
  9. 如果 HTTP 与 Kafka 的扩缩容需求差异明显,将 API 和 Kafka consumer 分开部署。

相同业务的多个实例应使用同一个 consumer group,由 Kafka 在 partition 层面分配任务。业务层则通过幂等机制处理故障重试可能带来的少量重复。

备注:内容仅供参考。