Kafka本身只保证单个分区内的消息是有序的

Kafka本身只保证单个分区内的消息是有序的

作者:淘书创始人

摘要

Kafka本身只保证单个分区内的消息是有序的


在 Kafka 集群环境下,当多个相同的服务实例同时消费消息时,确保消息落地到数据库时能保持原有的顺序性。这是分布式消费中非常常见且关键的需求。

问题分析与核心思路

要保证落库顺序性,核心是让需要保证顺序的消息始终由同一个消费实例处理,并在消费端和数据库端做好配套的顺序控制。具体拆解为以下关键步骤:

1. Kafka 层面:保证分区内消息的有序性

Kafka 本身只保证单个分区内的消息是有序的 ,跨分区无法保证。因此第一步是将需要保证顺序的消息发送到同一个分区

  • •发送消息时,指定相同的 key(比如业务ID:订单ID、用户ID),Kafka 会根据 key 的哈希值将消息路由到固定分区。

  • •示例:所有属于同一个订单的消息,都用订单ID作为 key 发送,确保进入同一个分区。

2. 消费端:单分区单线程消费

即使消息进入同一个分区,如果消费端用多线程消费该分区,依然会打乱顺序。因此:

  • •每个消费实例的每个分区仅用单线程消费(Kafka Consumer 默认就是单线程消费分区,只需避免手动开启多线程消费单个分区)。

  • •多个服务实例会消费不同的分区(由 Kafka 消费者组自动分配),但单个分区始终只被一个实例消费,保证分区内顺序。

3. 数据库层面:防止并发写入打乱顺序

即使消费端顺序消费,若数据库写入时存在并发(比如消费线程异步落库),仍可能导致顺序错乱。需通过以下方式控制:

  • 单线程落库

    :消费到消息后,不异步写入,而是同步按顺序落库(最简单直接)。

  • 数据库锁/事务

    :若必须异步,可针对业务ID加行锁(如 SELECT ... FOR UPDATE),确保同一业务ID的写入串行执行。

  • 版本号/时间戳校验

    :在数据库表中增加版本号(version)或消息时间戳字段,落库前校验当前版本是否为预期值,避免旧消息覆盖新消息。

完整实现示例

以下是基于 Java + Spring Kafka 的完整示例(最常用的技术栈),涵盖消息生产、消费、落库的全流程顺序控制:

1. 生产者:指定业务Key发送消息
go 复制代码
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import java.util.Properties;

public class OrderProducer {
    private static final String TOPIC_NAME = "order_topic";

    public static void main(String[] args) {
        Properties props = new Properties();
        props.put("bootstrap.servers", "kafka1:9092,kafka2:9092,kafka3:9092");
        props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

        KafkaProducer<String, String> producer = new KafkaProducer<>(props);

        // 模拟发送同一个订单的3条消息,指定订单ID为key,确保进入同一分区
        String orderId = "ORDER_001";
        producer.send(new ProducerRecord<>(TOPIC_NAME, orderId, "订单创建:ORDER_001"));
        producer.send(new ProducerRecord<>(TOPIC_NAME, orderId, "订单支付:ORDER_001"));
        producer.send(new ProducerRecord<>(TOPIC_NAME, orderId, "订单完成:ORDER_001"));

        producer.close();
    }
}
2. 消费者:单线程消费 + 同步落库
go 复制代码
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;

public class OrderConsumer {
    private static final String TOPIC_NAME = "order_topic";
    private final JdbcTemplate jdbcTemplate;

    // 初始化数据库连接
    public OrderConsumer(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    public void startConsume() {
        Properties props = new Properties();
        props.put("bootstrap.servers", "kafka1:9092,kafka2:9092,kafka3:9092");
        props.put("group.id", "order_consumer_group"); // 消费者组
        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        // 关键:禁止自动提交offset,确保消息落库成功后再提交,避免重复消费
        props.put("enable.auto.commit", "false");
        // 关键:每次poll的最大消息数,避免单次拉取过多导致顺序错乱
        props.put("max.poll.records", "100");

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Collections.singletonList(TOPIC_NAME));

        while (true) {
            // 拉取消息(单线程)
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
            for (var record : records) {
                String orderId = record.key();
                String message = record.value();
                long offset = record.offset(); // 记录offset,用于提交

                try {
                    // 核心:同步落库,确保顺序
                    insertToDb(orderId, message);
                    // 落库成功后,手动提交offset(按分区提交,保证顺序)
                    consumer.commitSync();
                } catch (Exception e) {
                    // 落库失败,不提交offset,下次重新消费
                    System.err.println("消息落库失败,orderId=" + orderId + ",错误:" + e.getMessage());
                    // 可选:重试机制,避免直接丢弃
                    retryInsert(orderId, message, 3);
                }
            }
        }
    }

    // 同步落库方法(单线程执行)
    private void insertToDb(String orderId, String message) {
        String sql = "INSERT INTO order_message (order_id, content, create_time) VALUES (?, ?, NOW())";
        // 若需更强的顺序保证,可加行锁:SELECT * FROM order_message WHERE order_id=? FOR UPDATE
        jdbcTemplate.update(sql, orderId, message);
    }

    // 重试落库
    private void retryInsert(String orderId, String message, int retryCount) {
        int count = 0;
        while (count < retryCount) {
            try {
                insertToDb(orderId, message);
                return;
            } catch (Exception e) {
                count++;
                try {
                    Thread.sleep(1000 * count); // 指数退避重试
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
            }
        }
        // 重试失败,可记录到死信队列
        System.err.println("重试落库失败,orderId=" + orderId);
    }

    public static void main(String[] args) {
        // 初始化数据源(实际项目中用Spring配置)
        DataSource dataSource = getDataSource();
        OrderConsumer consumer = new OrderConsumer(dataSource);
        consumer.startConsume();
    }

    private static DataSource getDataSource() {
        // 配置数据库连接(示例:MySQL)
        org.apache.tomcat.dbcp.dbcp2.BasicDataSource ds = new org.apache.tomcat.dbcp.dbcp2.BasicDataSource();
        ds.setUrl("jdbc:mysql://db:3306/test?useUnicode=true&characterEncoding=utf8");
        ds.setUsername("root");
        ds.setPassword("123456");
        return ds;
    }
}
3. 数据库表设计(增加版本号强化顺序)
go 复制代码
CREATE TABLE `order_message` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `order_id` varchar(64) NOT NULL COMMENT '订单ID(业务Key)',
  `content` varchar(255) NOT NULL COMMENT '消息内容',
  `version` int NOT NULL DEFAULT 0 COMMENT '版本号,用于顺序校验',
  `create_time` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_version` (`order_id`, `version`) COMMENT '确保同一订单的版本号唯一'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

落库时可基于版本号递增,确保顺序:

go 复制代码
// 改进后的insertToDb方法,基于版本号保证顺序
private void insertToDb(String orderId, String message) {
    // 1. 获取当前订单的最大版本号
    Integer maxVersion = jdbcTemplate.queryForObject(
        "SELECT IFNULL(MAX(version), 0) FROM order_message WHERE order_id=?",
        new Object[]{orderId},
        Integer.class
    );
    int newVersion = maxVersion + 1;
    // 2. 插入新消息(版本号+1)
    String sql = "INSERT INTO order_message (order_id, content, version, create_time) VALUES (?, ?, ?, NOW())";
    jdbcTemplate.update(sql, orderId, message, newVersion);
}

关键补充说明

  1. 消费者组配置

    :多个服务实例必须加入同一个消费者组,Kafka 会自动将分区均匀分配给组内实例,确保单个分区仅被一个实例消费。

  2. 避免重复消费

    :关闭自动提交 offset,仅在落库成功后手动提交,防止消息落库失败但 offset 已提交导致的丢失,或重复提交导致的重复消费。

  3. 性能与顺序的平衡

    :单分区单线程消费会限制吞吐量,若需高性能,可按业务维度拆分多个分区(比如按订单ID尾号分10个分区),每个分区独立保证顺序,整体提升并发。

  4. 异常处理

    :落库失败时需重试,重试失败则写入死信队列,避免阻塞消费;死信队列可单独处理,人工介入修复后重新发送。

总结

要保证 Kafka 多实例消费时落库的顺序性,核心要点如下:

  1. Kafka 层面

    :通过相同的业务 Key 将需顺序的消息路由到同一个分区(利用 Kafka 分区内有序的特性)。

  2. 消费层面

    :单个分区仅由一个消费实例的单线程消费,关闭自动提交 offset,落库成功后再手动提交。

  3. 数据库层面

    :同步落库(或加行锁/版本号),确保同一业务ID的消息串行写入,避免并发打乱顺序。

这三个环节缺一不可,既利用了 Kafka 的分区特性保证消费顺序,又通过数据库的控制确保落库顺序,是分布式场景下保证顺序性的标准方案。


原文链接: https://1024bat.cn/article/78

来源: 淘书1024bat

相关推荐
2501_941982052 小时前
Java 分布式环境下的 Access_Token 一致性方案:如何避免多节点冲突?
java·开发语言·分布式
what丶k3 小时前
为何Kafka成为消息队列首选?深度对比RabbitMQ与RocketMQ
kafka·java-rocketmq·java-rabbitmq
菜宾3 小时前
java-分布式面试题(事务+锁+消息队列+zookeeper+dubbo+nginx+es)
java·开发语言·分布式
麦兜*3 小时前
Spring Boot 3.x 深度实战:从零构建企业级分布式微服务架构全景解析
spring boot·分布式·架构
资深web全栈开发3 小时前
分布式锁的陷阱:Redlock 真的安全吗?
分布式·安全·wpf
德彪稳坐倒骑驴4 小时前
Spark入门知识
大数据·分布式·spark
rustfs4 小时前
如何将 Minio DirectPV 配置为 RustFS 存储后端?
分布式·docker·云原生·rust
橘橙黄又青4 小时前
RabbitMQ篇
分布式·rabbitmq
少云清4 小时前
【性能测试】4_Locust _locust分布式
分布式·性能测试·locust