kafka ACK 应答策略与数据丢失/重复的权衡 (ACKs & Data Durability)

【今日主题】:ACK 应答策略与数据丢失/重复的权衡 (ACKs & Data Durability)

【业务场景痛点】

场景:大型电商的秒杀系统。

  • QPS:瞬时峰值 50万+ QPS 写入 Kafka。
  • 需求:用户的下单消息必须不丢失(否则用户付了钱,订单没生成,客服会被打爆),同时可以容忍少量重复(后续通过幂等性或业务层去重解决)。
  • 故障:在秒杀高峰期,某台 Broker 因硬件故障突然宕机。
  • 问题:如果 Producer 配置不当,刚刚写入到宕机 Broker 的数据就会丢失。如果配置过于保守,Producer 的吞吐量会急剧下降,导致请求超时,整个秒杀系统瘫痪。如何在“不丢数据”和“高性能”之间取得平衡?

【架构与原理解析】

1. 数据流向与状态机(画图式讲解)

让我们模拟一条消息 M1 的旅程:

  • 步骤 1:Producer 发送

    • Producer 发送 M1 给 Leader(假设是 Broker-1)。
    • Broker-1 将 M1 写入其 OS Page Cache(立即返回,速度极快),并标记其 LEO (Log End Offset)1001(假设下一条消息的偏移量)。
  • 步骤 2:Follower 同步 (Replication)

    • Follower (Broker-2 和 Broker-3) 主动从 Leader 拉取(Fetch)消息。
    • Broker-2 拉到 M1,写入自己的 Page Cache,更新自己的 LEO 为 1001
    • Broker-3 也拉到 M1,写入自己的 Page Cache,更新自己的 LEO 为 M1
  • 步骤 3:HW (High Watermark) 的推进

    • HW 是一个标记,代表已提交的消息边界(Consumer 可见的边界)。它取值为 所有 ISR (In-Sync Replicas) 中最小的 LEO
    • 初始状态:Leader (B-1) LEO=1001, Follower B-2 LEO=1001, Follower B-3 LEO=1001。HW = 1000。
    • 当 B-2 和 B-3 都成功拉取并写入 M1 后,Leader 会检测到它们的 LEO 都达到了 1001。
    • Leader 更新 HW:将 HW 从 1000 提升到 1001。
    • 此时,M1 才真正“提交”,对 Consumer 可见。

2. 为什么默认配置在生产环境是“不可用”的?

  • 默认配置acks=1 (只写给 Leader),min.insync.replicas=1
  • 风险:如果 Leader 写入成功(数据在 Page Cache)后立刻宕机,且 Follower 尚未同步数据,那么这条数据就永久丢失了。虽然 Leader 选举后 Follower 可能成为新 Leader,但旧 Leader 的未同步数据就像从未存在过。
  • 结论:默认配置牺牲了可靠性,换取了最高吞吐。这在测试环境可以,但在生产环境,尤其是金融、订单场景,是绝对不可接受的。

【生产级配置与代码】

1. 核心配置 (producer.properties)

要实现“高可靠、高可用”,必须调整以下参数:

# 1. 应答策略:必须配置为 all (-1)。等待所有 ISR 副本确认。
acks=all

# 2. ISR 最小副本数:至少要有多少个副本写入成功,Leader 才会认为写入成功。
#    结合 acks=all 使用。如果集群有3节点,建议设置为 2。这样即使1个节点宕机,仍可写入。
min.insync.replicas=2

# 3. 幂等性 Producer:解决网络重试导致的重复问题。必须与 acks=all 配合使用。
enable.idempotence=true

# 4. 重试次数:网络抖动时自动重试。
retries=2147483647 (INT_MAX,无限重试,直到成功)

# 5. 重试间隔:避免重试风暴压垮集群。
retry.backoff.ms=100

# 6. 乱序保证:单分区内,消息严格有序。与幂等性配合实现 Exactly-Once Semantics (EOS)。
max.in.flight.requests.per.connection=1 (高可靠场景),5 (高吞吐场景)

2. 代码演示 (Java)

以下代码演示了如何发送消息,并处理回调(成功/失败),以及在网络异常时的健壮性处理。

import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
import java.util.concurrent.Future;

public class ReliableProducer {

    public static void main(String[] args) {
        // 1. 配置 Producer
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-broker1:9092,kafka-broker2:9092");
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

        // 生产级可靠性配置
        props.put(ProducerConfig.ACKS_CONFIG, "all");
        props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
        props.put(ProducerConfig.MIN_INSYNC_REPLICAS_CONFIG, 2);
        props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
        props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 1); // 严格有序

        // 2. 创建 Producer
        KafkaProducer<String, String> producer = new KafkaProducer<>(props);

        // 3. 发送消息(带回调和重试逻辑)
        String topic = "order_events";
        String key = "order_12345";
        String value = "{\"user_id\": 1001, \"amount\": 99.00}";

        ProducerRecord<String, String> record = new ProducerRecord<>(topic, key, value);

        // 使用 send() 的回调机制来处理结果
        producer.send(record, new Callback() {
            @Override
            public void onCompletion(RecordMetadata metadata, Exception exception) {
                if (exception != null) {
                    // 场景:网络异常、集群不可用、配置错误(如 min.insync.replicas 不满足)
                    // 处理:记录日志,触发告警,甚至可以考虑将消息暂存到本地磁盘/Redis 进行异步重试
                    System.err.printf("发送失败!Topic: %s, Partition: %s, Offset: %s, 异常: %s%n",
                            metadata.topic(), metadata.partition(), metadata.offset(), exception.getMessage());
                    exception.printStackTrace();
                } else {
                    // 场景:成功写入 ISR 副本
                    // 处理:记录日志,业务逻辑可认为消息已提交
                    System.out.printf("发送成功!Topic: %s, Partition: %s, Offset: %s%n",
                            metadata.topic(), metadata.partition(), metadata.offset());
                }
            }
        });

        // 4. 确保所有消息都发送完毕
        producer.flush();
        producer.close();
    }
}

【老司机的血泪教训】

  1. 误区一:盲目追求高吞吐,设置 acks=1min.insync.replicas=1

    • 事故:某次机房电力波动,导致一台 Broker 宕机。由于 acks=1,Producer 认为成功写入的数据,实际上在宕机 Broker 上丢失。导致数千笔订单消失,引发严重客诉。
    • 教训可靠性是底线。对于核心业务,必须 acks=allmin.insync.replicas >= 2。吞吐量的损失可以通过增加分区数和 Broker 节点来横向扩展弥补。
  2. 误区二:忽略 max.in.flight.requests.per.connection 对顺序性的影响

    • 事故:为了提升吞吐,将 max.in.flight.requests.per.connection 设置为 5,并开启了 enable.idempotence=true。在发生网络重试时,虽然保证了不丢不重,但消息的全局顺序无法保证(A消息先发送但因重试晚于B消息写入)。
    • 教训:如果你的业务严格依赖消息顺序(如账户的流水),必须将 max.in.flight.requests.per.connection 设置为 1。这是用性能换取绝对的顺序性。

【今日运维命令/作业】

作业:在你的测试集群上,创建一个 Topic,并模拟 Broker 宕机,观察 Producer 在不同 acks 配置下的行为。

命令:查看消费者组的 Lag(消息堆积),这是衡量系统健康度的核心指标。

# 命令格式
kafka-consumer-groups.sh --bootstrap-server <kafka_broker_url:9092> --describe --group <your_consumer_group_id>

# 示例
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group order-consumer-group

输出解读

  • LAG 列:如果持续为 0,说明消费实时。
  • CURRENT-OFFSETLOG-END-OFFSET:如果两者持续拉大,说明消费速度跟不上生产速度,需要扩容 Consumer 或优化处理逻辑。

总结:Kafka 的可靠性不是由单一参数决定的,而是 Producer、Broker、Consumer 协同的结果。今天请务必动手修改 producer.properties,并用 Java 代码发送消息,观察在关闭一个 Broker 节点后系统的反应。只有亲手制造过故障,才能在生产环境中避免故障。

posted @ 2026-01-04 14:14  孤独的执行者  阅读(5)  评论(0)    收藏  举报