Kafka:基于 Outbox 的可靠消息投递完整方案

目录

  • [基于 Outbox 的 Kafka 可靠消息投递完整方案](#基于 Outbox 的 Kafka 可靠消息投递完整方案)
  • 一、总体结构
  • 二、生产者端:防丢失
    • [2.1 建表 SQL](#2.1 建表 SQL)
    • [2.2 实体类](#2.2 实体类)
    • [2.3 Mapper 接口](#2.3 Mapper 接口)
    • [2.4 Mapper XML](#2.4 Mapper XML)
    • [2.5 业务侧:业务 + outbox 同事务](#2.5 业务侧:业务 + outbox 同事务)
    • [2.6 投递器:抢占 + 异步发送 + 回调](#2.6 投递器:抢占 + 异步发送 + 回调)
    • [2.7 回收器](#2.7 回收器)
    • [2.8 死信监控](#2.8 死信监控)
    • [2.9 人工重投](#2.9 人工重投)
    • [2.10 Kafka 生产者配置](#2.10 Kafka 生产者配置)
  • [三、消费者端:防丢失 + 去重](#三、消费者端:防丢失 + 去重)
    • [3.1 建表 SQL](#3.1 建表 SQL)
    • [3.2 消费者](#3.2 消费者)
    • [3.3 Kafka 消费者配置](#3.3 Kafka 消费者配置)
  • 四、消费者端死信处理
    • [4.1 死信怎么产生](#4.1 死信怎么产生)
    • [4.2 死信监控:只查 topic 消息数](#4.2 死信监控:只查 topic 消息数)
    • [4.3 死信管理接口](#4.3 死信管理接口)
    • [4.4 处理流程](#4.4 处理流程)
    • [4.5 命令行查看死信(备用)](#4.5 命令行查看死信(备用))
    • [4.6 为什么搬运要用 Kafka 事务](#4.6 为什么搬运要用 Kafka 事务)
    • [4.7 搬运方案的边界](#4.7 搬运方案的边界)
    • [4.8 实操细节](#4.8 实操细节)
  • 五、关键节点解释
    • [5.1 生产者端](#5.1 生产者端)
    • [5.2 消费者端](#5.2 消费者端)
    • [5.3 消费者死信](#5.3 消费者死信)
  • 六、关键参数对齐
  • 七、核心设计原则
  • 八、一句话

基于 Outbox 的 Kafka 可靠消息投递完整方案

核心:生产者用 outbox 保证消息不丢,消费者用幂等表保证不重复,消费者死信全量搬运回原 topic。固定租约,不搞心跳,死信不落库。


一、总体结构

复制代码
生产者(防丢失)                          消费者(防丢失 + 去重)
─────────────────                        ─────────────────────
业务 + outbox 同事务                      幂等表 + 业务同事务
    │                                         │
    ▼                                         ▼
投递器抢占(SENDING + 固定租约60s)        @KafkaListener 消费
    │                                         │
    ▼                                         ├─ 成功/重复 → 提交位移
KafkaTemplate 异步发送                       │
    │                                         └─ 业务失败 → 发 dead-topic
    ├─ 回调成功 → CONFIRMED                          + 提交位移
    └─ 回调失败 → PENDING(重试)                       │
    │                                                   ▼
回收器扫过期 SENDING → PENDING / DEAD            dead-topic 监控告警
    │                                                   │
    ▼                                                   ▼
死信监控告警 → 人工 SQL 重投                   人工处理:
                                                - peek 查看内容
                                                - retry-all 全量搬运
                                                - Kafka 事务保证不丢

两端靠 messageId 关联。不追求绝对不重复,而是 at-least-once + 消费端幂等 = exactly-once 效果


二、生产者端:防丢失

2.1 建表 SQL

sql 复制代码
-- ============================================================
-- Outbox 消息表
-- 用途:业务与消息同事务落库,投递器异步投递到 Kafka,保证消息不丢
-- ============================================================
CREATE TABLE `message_outbox` (
  `id`              BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
  `message_id`      VARCHAR(64)  NOT NULL COMMENT '全局唯一消息ID,UUID,用于回调关联和消费端幂等',
  `topic`           VARCHAR(128) NOT NULL COMMENT 'Kafka 目标 topic',
  `msg_key`         VARCHAR(128) NOT NULL COMMENT 'Kafka 消息 key,决定分区。相同 key 的消息进同一分区,保证分区内有序',
  `payload`         TEXT         NOT NULL COMMENT '消息体,JSON字符串',
  `headers`         TEXT         NULL     COMMENT '自定义消息头,JSON字符串,可为空',
  `status`          VARCHAR(20)  NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待投递/SENDING投递中/CONFIRMED已确认/DEAD死信',
  `worker_id`       VARCHAR(64)  NULL     COMMENT '抢占该消息的worker实例ID,用于所有权校验',
  `lease_expire_at` DATETIME     NULL     COMMENT '租约到期时间,SENDING状态下超过此时间视为宕机,可回收',
  `retry_count`     INT          NOT NULL DEFAULT 0 COMMENT '已重试次数',
  `max_retry`       INT          NOT NULL DEFAULT 5 COMMENT '最大重试次数',
  `next_retry_at`   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下次可投递时间,用于指数退避',
  `last_error`      VARCHAR(500) NULL     COMMENT '最近一次失败原因',
  `created_at`      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at`      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_message_id` (`message_id`),
  KEY `idx_status_next` (`status`, `next_retry_at`),
  KEY `idx_status_lease` (`status`, `lease_expire_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Outbox消息表,保证业务与消息的最终一致投递';

2.2 实体类

java 复制代码
package com.example.producer.entity;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * Outbox 消息实体
 * <p>
 * 状态机:
 * PENDING --抢占--> SENDING --回调成功--> CONFIRMED
 *   ^                  |
 *   |                  +-- 回调失败 / 发送异常 --> PENDING(重试)
 *   |                  |
 *   +--回收(租约过期)--+
 *                      |
 *                      +-- retry >= max --> DEAD
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MessageOutbox implements Serializable {

    private static final long serialVersionUID = 1L;

    /** 主键 */
    private Long id;

    /** 全局唯一消息ID,用于回调关联和消费端幂等 */
    private String messageId;

    /** Kafka 目标 topic */
    private String topic;

    /** Kafka 消息 key,决定分区。相同 key 的消息进同一分区,保证分区内有序 */
    private String msgKey;

    /** 消息体,JSON 字符串 */
    private String payload;

    /** 自定义消息头,JSON 字符串,可为空 */
    private String headers;

    /** 状态:PENDING / SENDING / CONFIRMED / DEAD */
    private String status;

    /** 抢占该消息的 worker 实例 ID,用于所有权校验 */
    @JsonIgnore
    private String workerId;

    /** 租约到期时间,SENDING 状态下超过此时间视为宕机,可回收 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime leaseExpireAt;

    /** 已重试次数 */
    private Integer retryCount;

    /** 最大重试次数 */
    private Integer maxRetry;

    /** 下次可投递时间,用于指数退避 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime nextRetryAt;

    /** 最近一次失败原因 */
    private String lastError;

    /** 创建时间 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;

    /** 更新时间 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updatedAt;
}

2.3 Mapper 接口

java 复制代码
package com.example.producer.mapper;

import com.example.producer.entity.MessageOutbox;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.time.LocalDateTime;
import java.util.List;

/**
 * Outbox 消息 Mapper
 */
@Mapper
public interface MessageOutboxMapper {

    /** 插入一条待投递消息 */
    int insert(MessageOutbox outbox);

    /** 按主键查询 */
    MessageOutbox selectById(@Param("id") Long id);

    /** 抢占候选:捞一批待投递的消息 id,FOR UPDATE SKIP LOCKED */
    List<Long> selectPendingIds(@Param("limit") int limit);

    /** 批量标记为 SENDING,并写 worker_id 和租约 */
    int markSending(@Param("ids") List<Long> ids,
                    @Param("workerId") String workerId,
                    @Param("leaseSeconds") int leaseSeconds);

    /** Kafka 回调成功,标记为 CONFIRMED */
    int markConfirmed(@Param("id") Long id,
                      @Param("workerId") String workerId);

    /** 投递失败,回 PENDING 并设置下次重试时间 */
    int markRetry(@Param("id") Long id,
                  @Param("workerId") String workerId,
                  @Param("retryCount") int retryCount,
                  @Param("delayMs") long delayMs,
                  @Param("error") String error);

    /** 重试用尽,标记为 DEAD */
    int markDead(@Param("id") Long id,
                 @Param("workerId") String workerId,
                 @Param("error") String error);

    /** 回收:SENDING 租约过期且还有重试机会,回 PENDING */
    int recycleToPending();

    /** 回收:SENDING 租约过期且重试用尽,置 DEAD */
    int recycleToDead();

    /** 清理历史 CONFIRMED 记录 */
    int deleteConfirmedBefore(@Param("before") LocalDateTime before);

    /** 按状态统计数量 */
    int countByStatus(@Param("status") String status);

    /** 查询指定状态的记录 */
    List<MessageOutbox> selectByStatus(@Param("status") String status,
                                       @Param("limit") int limit);

    /** 把 DEAD 重置为 PENDING,retry_count 清零 */
    int resetToPending(@Param("id") Long id);

    /** 批量重置 */
    int resetToPendingBatch(@Param("ids") List<Long> ids);
}

2.4 Mapper XML

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.producer.mapper.MessageOutboxMapper">

  <insert id="insert" parameterType="com.example.producer.entity.MessageOutbox"
          useGeneratedKeys="true" keyProperty="id">
    INSERT INTO message_outbox
      (message_id, topic, msg_key, payload, headers,
       status, retry_count, max_retry, next_retry_at, created_at, updated_at)
    VALUES
      (#{messageId}, #{topic}, #{msgKey}, #{payload}, #{headers},
       #{status}, #{retryCount}, #{maxRetry}, #{nextRetryAt}, NOW(), NOW())
  </insert>

  <select id="selectById" resultType="com.example.producer.entity.MessageOutbox">
    SELECT * FROM message_outbox WHERE id = #{id}
  </select>

  <!-- 抢占候选:跳过已被其他事务锁定的行,避免多实例阻塞 -->
  <select id="selectPendingIds" resultType="java.lang.Long">
    SELECT id FROM message_outbox
    WHERE status = 'PENDING' AND next_retry_at &lt;= NOW()
    ORDER BY id
    LIMIT #{limit}
    FOR UPDATE SKIP LOCKED
  </select>

  <update id="markSending">
    UPDATE message_outbox
    SET status = 'SENDING',
        worker_id = #{workerId},
        lease_expire_at = DATE_ADD(NOW(), INTERVAL #{leaseSeconds} SECOND),
        updated_at = NOW()
    WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
      #{id}
    </foreach>
  </update>

  <!-- 带 worker_id 条件,防止过期 worker 覆盖 -->
  <update id="markConfirmed">
    UPDATE message_outbox
    SET status = 'CONFIRMED', updated_at = NOW()
    WHERE id = #{id} AND worker_id = #{workerId} AND status = 'SENDING'
  </update>

  <update id="markRetry">
    UPDATE message_outbox
    SET status = 'PENDING',
        worker_id = NULL,
        retry_count = #{retryCount},
        next_retry_at = DATE_ADD(NOW(), INTERVAL #{delayMs} MICROSECOND),
        last_error = #{error},
        updated_at = NOW()
    WHERE id = #{id} AND worker_id = #{workerId} AND status = 'SENDING'
  </update>

  <update id="markDead">
    UPDATE message_outbox
    SET status = 'DEAD', worker_id = NULL,
        last_error = #{error}, updated_at = NOW()
    WHERE id = #{id} AND worker_id = #{workerId} AND status = 'SENDING'
  </update>

  <update id="recycleToPending">
    UPDATE message_outbox
    SET status = 'PENDING', worker_id = NULL,
        retry_count = retry_count + 1,
        next_retry_at = NOW(),
        last_error = 'lease expired',
        updated_at = NOW()
    WHERE status = 'SENDING'
      AND lease_expire_at &lt; NOW()
      AND retry_count &lt; max_retry
  </update>

  <update id="recycleToDead">
    UPDATE message_outbox
    SET status = 'DEAD', worker_id = NULL,
        last_error = 'lease expired and max retry reached',
        updated_at = NOW()
    WHERE status = 'SENDING'
      AND lease_expire_at &lt; NOW()
      AND retry_count &gt;= max_retry
  </update>

  <delete id="deleteConfirmedBefore">
    DELETE FROM message_outbox
    WHERE status = 'CONFIRMED' AND updated_at &lt; #{before}
    LIMIT 1000
  </delete>

  <select id="countByStatus" resultType="int">
    SELECT COUNT(*) FROM message_outbox WHERE status = #{status}
  </select>

  <select id="selectByStatus" resultType="com.example.producer.entity.MessageOutbox">
    SELECT * FROM message_outbox
    WHERE status = #{status}
    ORDER BY id
    LIMIT #{limit}
  </select>

  <!-- 人工重投:DEAD -> PENDING,retry_count 清零 -->
  <update id="resetToPending">
    UPDATE message_outbox
    SET status = 'PENDING',
        worker_id = NULL,
        retry_count = 0,
        next_retry_at = NOW(),
        last_error = CONCAT('manual retry at ', NOW()),
        updated_at = NOW()
    WHERE id = #{id} AND status = 'DEAD'
  </update>

  <update id="resetToPendingBatch">
    UPDATE message_outbox
    SET status = 'PENDING',
        worker_id = NULL,
        retry_count = 0,
        next_retry_at = NOW(),
        last_error = CONCAT('manual retry at ', NOW()),
        updated_at = NOW()
    WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
      #{id}
    </foreach>
      AND status = 'DEAD'
  </update>

</mapper>

2.5 业务侧:业务 + outbox 同事务

java 复制代码
package com.example.producer.service;

import com.example.producer.entity.MessageOutbox;
import com.example.producer.mapper.MessageOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.UUID;

/**
 * 业务服务:业务写库 + 写 outbox,必须在同一事务。
 */
@Service
public class OrderService {

    @Autowired private OrderMapper orderMapper;
    @Autowired private MessageOutboxMapper outboxMapper;
    @Autowired private ObjectMapper objectMapper;

    /**
     * 创建订单:业务 + 消息同事务落库。
     * 事务提交后,由投递器异步把消息发到 Kafka。
     */
    @Transactional(rollbackFor = Exception.class)
    public void createOrder(Order order) throws Exception {
        // 1. 业务写库
        orderMapper.insert(order);

        // 2. 同事务写 outbox
        MessageOutbox outbox = MessageOutbox.builder()
            .messageId(UUID.randomUUID().toString())
            .topic("order-topic")
            .msgKey(String.valueOf(order.getId()))
            .payload(objectMapper.writeValueAsString(order))
            .status("PENDING")
            .retryCount(0)
            .maxRetry(5)
            .nextRetryAt(LocalDateTime.now())
            .build();
        outboxMapper.insert(outbox);
    }
}

2.6 投递器:抢占 + 异步发送 + 回调

java 复制代码
package com.example.producer.core;

import com.example.producer.entity.MessageOutbox;
import com.example.producer.mapper.MessageOutboxMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;

/**
 * Outbox 投递器
 * <p>
 * 定时扫描 message_outbox,抢占 PENDING 记录,异步发送到 Kafka,
 * 通过回调决定 CONFIRMED 还是重试。
 * <p>
 * 关键点:
 * 1. 抢占事务短,只查候选 + 改状态,不做网络 IO;
 * 2. 发送异步,不阻塞投递器;
 * 3. 回调里改状态,带 worker_id 条件;
 * 4. 固定租约 60 秒,覆盖 Kafka 客户端最长重试时间(delivery.timeout.ms)。
 */
@Component
public class OutboxDispatcher {

    private static final Logger log = LoggerFactory.getLogger(OutboxDispatcher.class);

    /** 当前实例唯一标识,启动时生成一次 */
    private static final String WORKER_ID = UUID.randomUUID().toString();

    /** 固定租约时长(秒),必须 >= Kafka 客户端最长重试时间 × 2 */
    private static final int LEASE_SECONDS = 60;

    /** 每批抢占条数 */
    private static final int BATCH_SIZE = 20;

    /** 退避起步时间(毫秒) */
    private static final long BASE_DELAY_MS = 10_000L;

    /** 退避封顶时间(毫秒) */
    private static final long CAP_DELAY_MS = 10 * 60_000L;

    @Autowired private MessageOutboxMapper outboxMapper;
    @Autowired private KafkaTemplate<String, String> kafkaTemplate;

    /** 每 2 秒扫一次 */
    @Scheduled(fixedDelay = 2000)
    public void dispatch() {
        List<Long> ids = grabBatch();
        if (ids.isEmpty()) return;
        for (Long id : ids) {
            MessageOutbox outbox = outboxMapper.selectById(id);
            if (outbox == null || !"SENDING".equals(outbox.getStatus())) continue;
            sendAsync(outbox);
        }
    }

    /**
     * 短事务:查询候选 + 标记 SENDING + 设租约。
     * 只做查询和状态更新,绝不做网络 IO,避免长事务。
     */
    @Transactional(rollbackFor = Exception.class)
    public List<Long> grabBatch() {
        List<Long> ids = outboxMapper.selectPendingIds(BATCH_SIZE);
        if (ids.isEmpty()) return ids;
        outboxMapper.markSending(ids, WORKER_ID, LEASE_SECONDS);
        return ids;
    }

    /**
     * 异步发送到 Kafka,回调改状态。
     * 不阻塞投递器,Kafka 客户端内部负责重试。
     */
    private void sendAsync(MessageOutbox outbox) {
        try {
            kafkaTemplate.send(outbox.getTopic(), outbox.getMsgKey(), outbox.getPayload())
                .whenComplete((result, ex) -> {
                    if (ex == null) {
                        outboxMapper.markConfirmed(outbox.getId(), WORKER_ID);
                    } else {
                        log.error("kafka send failed, id={}, msgId={}",
                                outbox.getId(), outbox.getMessageId(), ex);
                        onFailure(outbox.getId(), ex.getMessage());
                    }
                });
        } catch (Exception e) {
            log.error("kafka send error, id={}", outbox.getId(), e);
            onFailure(outbox.getId(), e.getMessage());
        }
    }

    /**
     * 失败处理:计算退避,决定重试或死信。
     */
    private void onFailure(Long id, String error) {
        MessageOutbox outbox = outboxMapper.selectById(id);
        if (outbox == null || !"SENDING".equals(outbox.getStatus())) return;

        int retry = outbox.getRetryCount() + 1;
        if (retry >= outbox.getMaxRetry()) {
            outboxMapper.markDead(id, WORKER_ID, error);
        } else {
            long delayMs = calcDelayMs(retry);
            outboxMapper.markRetry(id, WORKER_ID, retry, delayMs, error);
        }
    }

    /**
     * 指数退避 + 随机抖动。
     */
    private long calcDelayMs(int retry) {
        long exp = BASE_DELAY_MS * (1L << (retry - 1));
        long delay = Math.min(exp, CAP_DELAY_MS);
        long jitter = ThreadLocalRandom.current().nextLong(delay / 5 + 1);
        return delay + jitter;
    }
}

2.7 回收器

java 复制代码
package com.example.producer.core;

import com.example.producer.mapper.MessageOutboxMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

/**
 * Outbox 回收器
 * <p>
 * 1. 定期回收 SENDING 超时的记录,回 PENDING 或置 DEAD;
 * 2. 定期清理历史 CONFIRMED 记录。
 */
@Component
public class OutboxRecycler {

    private static final Logger log = LoggerFactory.getLogger(OutboxRecycler.class);

    @Autowired private MessageOutboxMapper outboxMapper;

    /** 每 30 秒扫一次,租约过期的 SENDING 改回 PENDING 或置 DEAD */
    @Scheduled(fixedDelay = 30_000)
    public void recycle() {
        int toPending = outboxMapper.recycleToPending();
        int toDead = outboxMapper.recycleToDead();
        if (toPending > 0 || toDead > 0) {
            log.warn("recycle done, toPending={}, toDead={}", toPending, toDead);
        }
    }

    /** 每天凌晨 3 点清理 7 天前的 CONFIRMED */
    @Scheduled(cron = "0 0 3 * * ?")
    public void cleanup() {
        int deleted = outboxMapper.deleteConfirmedBefore(LocalDateTime.now().minusDays(7));
        log.info("cleanup confirmed, deleted={}", deleted);
    }
}

2.8 死信监控

java 复制代码
package com.example.producer.core;

import com.example.producer.mapper.MessageOutboxMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * Outbox 死信监控
 * <p>
 * 每 5 分钟检查一次 DEAD 数量,只在数量变化时告警,避免重复骚扰。
 */
@Component
public class OutboxDeadMonitor {

    private static final Logger log = LoggerFactory.getLogger(OutboxDeadMonitor.class);

    @Autowired private MessageOutboxMapper outboxMapper;

    private final AtomicInteger lastDeadCount = new AtomicInteger(0);

    /** 每 5 分钟检查一次,数量变化才告警 */
    @Scheduled(fixedDelay = 5 * 60_000)
    public void monitor() {
        int deadCount = outboxMapper.countByStatus("DEAD");
        if (deadCount > 0 && deadCount != lastDeadCount.get()) {
            log.error("[ALERT] outbox DEAD count changed: {} -> {}, need manual handle",
                    lastDeadCount.get(), deadCount);
            lastDeadCount.set(deadCount);
        } else if (deadCount == 0) {
            lastDeadCount.set(0);
        }
    }
}

2.9 人工重投

sql 复制代码
UPDATE message_outbox
SET status = 'PENDING',
    worker_id = NULL,
    retry_count = 0,
    next_retry_at = NOW(),
    last_error = CONCAT('manual retry at ', NOW()),
    updated_at = NOW()
WHERE id = ? AND status = 'DEAD';

两个关键:

  1. retry_count 必须清零,否则投递器一看又置回 DEAD;
  2. WHERE status='DEAD' 必须带上,防止误重置 CONFIRMED。

2.10 Kafka 生产者配置

yaml 复制代码
spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
      buffer-memory: 67108864
      acks: all
      retries: 2147483647
      properties:
        enable.idempotence: true
        max.in.flight.requests.per.connection: 5
        delivery.timeout.ms: 30000
        request.timeout.ms: 10000
        max.block.ms: 10000

Broker 端配置(server.properties):

properties 复制代码
replication.factor=3
min.insync.replicas=2
unclean.leader.election.enable=false

三、消费者端:防丢失 + 去重

3.1 建表 SQL

sql 复制代码
-- ============================================================
-- 消费端幂等表
-- 用途:消费端按 message_id 去重,保证同一消息只被业务处理一次
-- ============================================================
CREATE TABLE `consumed_message` (
  `message_id`  VARCHAR(64) NOT NULL COMMENT '已消费的消息ID,主键',
  `consumed_at` DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间',
  PRIMARY KEY (`message_id`),
  KEY `idx_consumed_at` (`consumed_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消费端幂等表,记录已处理的消息ID';

3.2 消费者

java 复制代码
package com.example.consumer;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

import java.util.concurrent.TimeUnit;

/**
 * Kafka 消费者
 * <p>
 * 核心流程:
 * 1. 幂等记录 + 业务逻辑,同一数据库事务;
 * 2. 事务提交成功 -> 提交位移;
 * 3. 重复消息 -> 回滚 + 提交位移;
 * 4. 业务失败 -> 先发死信(同步等成功),再提交位移。
 * <p>
 * 关键点:
 * - 位移提交放在事务外,避免提交成功但事务回滚导致消息丢失;
 * - 发死信必须成功才提交位移,否则消息会永久丢失。
 */
@Component
public class OrderConsumer {

    private static final Logger log = LoggerFactory.getLogger(OrderConsumer.class);

    @Autowired private JdbcTemplate jdbcTemplate;
    @Autowired private PlatformTransactionManager transactionManager;
    @Autowired private KafkaTemplate<String, String> kafkaTemplate;

    private static final String DEAD_TOPIC = "order-topic.DLT";

    @KafkaListener(topics = "order-topic", groupId = "order-group")
    public void onMessage(String payload,
                          @Header("kafka_messageKey") String key,
                          @Header(name = "messageId", required = false) String messageId,
                          Acknowledgment ack) {

        TransactionTemplate tx = new TransactionTemplate(transactionManager);
        boolean success = false;
        boolean duplicate = false;

        try {
            Boolean result = tx.execute(status -> {
                // 1. 幂等记录:插入成功说明第一次处理
                try {
                    jdbcTemplate.update(
                        "INSERT INTO consumed_message(message_id) VALUES (?)", messageId);
                } catch (DuplicateKeyException e) {
                    status.setRollbackOnly();
                    return false;
                }
                // 2. 业务处理,和幂等记录同事务
                doBusiness(payload);
                return true;
            });
            if (Boolean.FALSE.equals(result)) duplicate = true;
            else success = true;
        } catch (Exception e) {
            log.error("consume failed, messageId={}", messageId, e);
        }

        try {
            if (success || duplicate) {
                // 成功 或 重复,提交位移
                ack.acknowledge();
            } else {
                // 业务失败:先发死信(同步等成功),再提交位移
                sendToDeadTopicSync(payload, messageId, key);
                ack.acknowledge();
                log.error("message moved to dead topic, messageId={}", messageId);
            }
        } catch (Exception e) {
            // 发死信失败或位移提交失败,不 ack,消息重投
            log.error("dead topic send or ack failed, messageId={}", messageId, e);
        }
    }

    private void doBusiness(String payload) {
        // 业务处理
    }

    /**
     * 同步发送到死信 topic,等成功后才返回。
     * 发不成功就抛异常,让调用方不提交位移,消息重投。
     */
    private void sendToDeadTopicSync(String payload, String messageId, String key) throws Exception {
        kafkaTemplate.send(DEAD_TOPIC, key, payload).get(10, TimeUnit.SECONDS);
    }
}

3.3 Kafka 消费者配置

yaml 复制代码
spring:
  kafka:
    consumer:
      group-id: order-group
      auto-offset-reset: earliest
      enable-auto-commit: false
      max-poll-records: 10
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      properties:
        session.timeout.ms: 30000
        max.poll.interval.ms: 300000
        fetch.min.bytes: 1
        fetch.max.wait.ms: 500
    listener:
      ack-mode: manual
      concurrency: 3

四、消费者端死信处理

4.1 死信怎么产生

消费者业务失败:

复制代码
业务失败
  → 同步发到 dead-topic(order-topic.DLT),等成功
  → 再提交原 topic 位移

为什么发独立 topic,而不是不提交位移:

  • 不提交位移 = requeue,业务失败会无限循环,打爆服务;
  • 发独立 topic,原 topic 往前走,死信单独处理。

关键:必须先发死信成功,再提交位移。 如果先提交位移、发死信失败,消息就永久丢了。

代价: 发死信成功但提交位移前宕机,消息会重投,又发一次死信 → dead-topic 可能有重复,靠消费端幂等兜底。

4.2 死信监控:只查 topic 消息数

不落库,直接查 Kafka topic 的消息数,数量变化才告警。

java 复制代码
package com.example.consumer;

import org.apache.kafka.clients.admin.AdminClient;
import org.apache.kafka.clients.admin.ListOffsetsResult;
import org.apache.kafka.clients.admin.OffsetSpec;
import org.apache.kafka.common.TopicPartition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaAdmin;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

/**
 * 死信 topic 监控
 * <p>
 * 每 5 分钟检查一次 dead-topic 的总消息数,只在数量变化时告警。
 * 不消费,不落库,只监控。
 */
@Component
public class DeadLetterMonitor {

    private static final Logger log = LoggerFactory.getLogger(DeadLetterMonitor.class);

    @Autowired private KafkaAdmin kafkaAdmin;

    private static final String DEAD_TOPIC = "order-topic.DLT";

    private final AtomicInteger lastCount = new AtomicInteger(0);

    /** 每 5 分钟检查一次,频率不要太高 */
    @Scheduled(fixedDelay = 5 * 60_000)
    public void monitor() {
        try (AdminClient admin = AdminClient.create(kafkaAdmin.getConfigurationProperties())) {
            // 查 dead-topic 每个分区的 log end offset
            Map<TopicPartition, OffsetSpec> request = admin
                .describeTopics(Collections.singletonList(DEAD_TOPIC))
                .allTopicNames().get()
                .get(DEAD_TOPIC).partitions().stream()
                .collect(Collectors.toMap(
                    p -> new TopicPartition(DEAD_TOPIC, p.partition()),
                    p -> OffsetSpec.latest()));

            ListOffsetsResult result = admin.listOffsets(request);
            long total = result.all().get().values().stream()
                .mapToLong(o -> o.offset())
                .sum();

            int count = (int) total;
            if (count > 0 && count != lastCount.get()) {
                log.error("[ALERT] dead topic count changed: {} -> {}, need manual handle",
                        lastCount.get(), count);
                lastCount.set(count);
            } else if (count == 0) {
                lastCount.set(0);
            }
        } catch (Exception e) {
            log.error("monitor dead topic failed", e);
        }
    }
}

4.3 死信管理接口

提供 peek 查看内容、retry-all 全量搬运、count 查剩余数量三个接口。

java 复制代码
package com.example.consumer.admin;

import org.apache.kafka.clients.admin.AdminClient;
import org.apache.kafka.clients.admin.ListOffsetsResult;
import org.apache.kafka.clients.admin.OffsetSpec;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.TopicPartition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.kafka.core.KafkaAdmin;
import org.springframework.web.bind.annotation.*;

import java.time.Duration;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * 死信管理接口
 * <p>
 * 提供三个能力:
 * 1. peek:查看 dead-topic 消息内容,不消费不提交位移;
 * 2. count:查看 dead-topic 剩余消息数;
 * 3. retry-all:全量搬运回原 topic,Kafka 事务保证不丢。
 * <p>
 * retry-all 用 Redis 分布式锁防止并发重复搬运。
 */
@RestController
@RequestMapping("/admin/dead-message")
public class DeadMessageAdminController {

    private static final Logger log = LoggerFactory.getLogger(DeadMessageAdminController.class);

    private static final String BOOTSTRAP_SERVERS = "localhost:9092";
    private static final String DEAD_TOPIC = "order-topic.DLT";
    private static final String ORDER_TOPIC = "order-topic";
    private static final String RETRY_GROUP = "dlq-retry-group";
    private static final String LOCK_KEY = "dlq:retry:lock";

    @Autowired private KafkaAdmin kafkaAdmin;
    @Autowired private StringRedisTemplate redisTemplate;

    /** 实例 ID,启动时生成一次,用于事务 ID 和日志 */
    private static final String INSTANCE_ID =
            System.getenv().getOrDefault("HOSTNAME", "local") + "-" + UUID.randomUUID();

    /**
     * 查看 dead-topic 消息内容。
     * 用临时消费者组,读完不提交位移,不影响 retry-all 的进度。
     */
    @GetMapping("/peek")
    public List<String> peek(@RequestParam(defaultValue = "10") int limit) {
        Properties props = new Properties();
        props.put("bootstrap.servers", BOOTSTRAP_SERVERS);
        // 临时消费者组,每次不同,保证从 earliest 开始读
        props.put("group.id", "dlq-peek-" + UUID.randomUUID());
        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("auto.offset.reset", "earliest");
        props.put("enable.auto.commit", "false");

        List<String> result = new ArrayList<>();
        try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
            consumer.subscribe(Collections.singletonList(DEAD_TOPIC));
            long deadline = System.currentTimeMillis() + 5000;
            while (result.size() < limit && System.currentTimeMillis() < deadline) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
                for (ConsumerRecord<String, String> record : records) {
                    result.add("key=" + record.key() + ", value=" + record.value());
                    if (result.size() >= limit) break;
                }
            }
        }
        return result;
    }

    /**
     * 查看 dead-topic 剩余未搬运消息数。
     */
    @GetMapping("/count")
    public long count() {
        try (AdminClient admin = AdminClient.create(kafkaAdmin.getConfigurationProperties())) {
            // dead-topic 的 LEO
            Map<TopicPartition, OffsetSpec> leoRequest = admin
                .describeTopics(Collections.singletonList(DEAD_TOPIC))
                .allTopicNames().get()
                .get(DEAD_TOPIC).partitions().stream()
                .collect(Collectors.toMap(
                    p -> new TopicPartition(DEAD_TOPIC, p.partition()),
                    p -> OffsetSpec.latest()));
            long leo = admin.listOffsets(leoRequest).all().get()
                .values().stream().mapToLong(o -> o.offset()).sum();

            // 消费者组已提交的位移
            Map<TopicPartition, OffsetAndMetadata> committed = admin
                .listConsumerGroupOffsets(RETRY_GROUP)
                .partitionsToOffsetAndMetadata().get();
            long committedSum = committed.values().stream()
                .mapToLong(OffsetAndMetadata::offset).sum();

            return Math.max(0, leo - committedSum);
        } catch (Exception e) {
            log.error("count failed", e);
            return -1;
        }
    }

    /**
     * 全量搬运:把 dead-topic 里所有未搬运的消息搬到原 topic。
     * <p>
     * 关键点:
     * 1. Redis 分布式锁,防止并发重复搬;
     * 2. Kafka 事务保证"位移提交 + 消息发送"原子;
     * 3. 事务 ID 用固定值(按实例),跨调用复用,Coordinator 能正确 fencing;
     * 4. 返回搬运数量和剩余数量,运维知道要不要再调。
     */
    @PostMapping("/retry-all")
    public String retryAll(@RequestParam(defaultValue = "admin") String operator) {
        // 1. Redis 分布式锁,防止并发
        Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(LOCK_KEY, operator, 60, TimeUnit.SECONDS);
        if (Boolean.FALSE.equals(locked)) {
            return "another retry is running, try later";
        }

        try {
            return doRetryAll(operator);
        } finally {
            redisTemplate.delete(LOCK_KEY);
        }
    }

    private String doRetryAll(String operator) {
        // ========== 消费者配置 ==========
        Properties consumerProps = new Properties();
        consumerProps.put("bootstrap.servers", BOOTSTRAP_SERVERS);
        consumerProps.put("group.id", RETRY_GROUP);
        consumerProps.put("key.deserializer",
                "org.apache.kafka.common.serialization.StringDeserializer");
        consumerProps.put("value.deserializer",
                "org.apache.kafka.common.serialization.StringDeserializer");
        // 第一次调用从最早位置开始;后续从上次提交的位移继续
        consumerProps.put("auto.offset.reset", "earliest");
        // 关闭自动提交,位移由事务提交
        consumerProps.put("enable.auto.commit", "false");

        // ========== 生产者配置(事务) ==========
        Properties producerProps = new Properties();
        producerProps.put("bootstrap.servers", BOOTSTRAP_SERVERS);
        producerProps.put("key.serializer",
                "org.apache.kafka.common.serialization.StringSerializer");
        producerProps.put("value.serializer",
                "org.apache.kafka.common.serialization.StringSerializer");
        // 事务 ID 按实例固定,跨调用复用,Coordinator 能正确 fencing
        producerProps.put("transactional.id", "dlq-retry-" + INSTANCE_ID);
        producerProps.put("enable.idempotence", "true");
        producerProps.put("transaction.timeout.ms", "60000");

        KafkaProducer<String, String> producer = new KafkaProducer<>(producerProps);
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps);

        // 初始化事务,向 Coordinator 申请 PID 和 Epoch
        producer.initTransactions();

        int count = 0;
        try {
            consumer.subscribe(Collections.singletonList(DEAD_TOPIC));
            // 最多跑 30 秒,避免接口长时间阻塞
            long deadline = System.currentTimeMillis() + 30_000;

            while (System.currentTimeMillis() < deadline) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
                if (records.isEmpty()) break;

                producer.beginTransaction();
                try {
                    // 1. 把消息发到原 topic
                    for (ConsumerRecord<String, String> record : records) {
                        producer.send(new ProducerRecord<>(
                                ORDER_TOPIC, record.key(), record.value()));
                    }

                    // 2. 把 dead-topic 的位移也放进事务
                    Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
                    for (TopicPartition partition : records.partitions()) {
                        List<ConsumerRecord<String, String>> list = records.records(partition);
                        long lastOffset = list.get(list.size() - 1).offset();
                        offsets.put(partition, new OffsetAndMetadata(lastOffset + 1));
                    }
                    producer.sendOffsetsToTransaction(offsets, RETRY_GROUP);

                    // 3. 提交事务:消息发送 + 位移提交,原子成功
                    producer.commitTransaction();
                    count += records.count();
                } catch (Exception e) {
                    // 回滚:消息没发出去,位移没提交,下次重新拉
                    producer.abortTransaction();
                    log.error("transaction aborted", e);
                    throw e;
                }
            }

            long remaining = count();
            log.warn("retry-all done, operator={}, moved={}, remaining={}",
                    operator, count, remaining);
            return "moved " + count + " messages, remaining " + remaining;
        } catch (Exception e) {
            log.error("retry-all failed", e);
            return "failed: " + e.getMessage();
        } finally {
            consumer.close();
            producer.close();
        }
    }
}

4.4 处理流程

复制代码
1. 收到告警:dead-topic 消息数变化
2. 调 GET /admin/dead-message/peek 查看死信内容
3. 调 GET /admin/dead-message/count 看剩余数量
4. 分析失败原因,修复问题(代码/数据/下游)
5. 调 POST /admin/dead-message/retry-all?operator=xxx
   → Redis 锁防并发
   → Kafka 事务全量搬运
   → 返回搬运数量 + 剩余数量
   → 如果 remaining > 0,再调一次
6. 观察消费成功

4.5 命令行查看死信(备用)

bash 复制代码
# 查看死信消息
kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic order-topic.DLT \
  --from-beginning \
  --max-messages 10

4.6 为什么搬运要用 Kafka 事务

不加事务的问题:

复制代码
consumer.poll() → 拿到消息
  → producer.send() 发送
  → consumer.commitSync() 提交位移
  • send 成功、commitSync 前宕机 → 位移没提交,下次重新拉,消息重复发;
  • send 失败、commitSync 成功 → 位移已提交,消息没发出去 → 消息丢失

加事务后:

复制代码
producer.beginTransaction()
  → producer.send()
  → producer.sendOffsetsToTransaction()
  → producer.commitTransaction()
  • 消息发送 + 位移提交,原子成功;
  • 要么都成功,要么都失败回滚;
  • 不会丢消息。

搬运场景对丢消息零容忍,事务的额外开销可以忽略,所以加事务。

4.7 搬运方案的边界

优点:

  • 不需要 dead_message 表、死信消费者;
  • 和 RabbitMQ Shovel 思路一致;
  • 代码量少;
  • Kafka 事务保证不丢消息。

缺点:

  • 不能按条重投,只能全量搬;
  • 不能追踪每条死信的处理状态;
  • 不能记录处理人和备注。

适合:

  • 死信量小,失败原因单一;
  • 修复后全量重投,不需要选择性处理;
  • 不需要审计。

4.8 实操细节

1. Redis 锁防并发

两个运维同时点 retry-all,会重复搬。用 Redis 锁保证同一时刻只有一个搬运在跑。

2. 返回剩余数量

运维要知道还剩多少,需不需要再调一次。retry-all 返回 moved + remaining

3. 事务 ID 用固定值

transactional.id = "dlq-retry-" + INSTANCE_ID,同一个实例多次调用共享事务 ID,Coordinator 能正确 fencing 旧实例。

4. peek 接口方便看内容

运维不用登服务器敲 kafka-console-consumer.sh,直接调接口看。

5. 日志记录操作人和数量

java 复制代码
log.warn("retry-all done, operator={}, moved={}, remaining={}", operator, count, remaining);

出问题能追溯。


五、关键节点解释

5.1 生产者端

业务 + outbox 同事务:先写业务、提交,再发消息,中间宕机会丢消息。放进同一事务,要么都成功,要么都回滚。

FOR UPDATE SKIP LOCKED :多实例部署,SKIP LOCKED 让每个实例拿不同的行,不阻塞、不重复。

短事务抢占 + 事务外发送:发送慢会占住数据库连接、行锁、undo log。抢占只做"查 + 改状态",毫秒级提交,发送在事务外。

异步发送 + 回调改状态:投递器发出消息立刻返回,Kafka 客户端内部重试。回调改状态。

固定租约 60 秒 :必须 >= Kafka 客户端最长重试时间(delivery.timeout.ms=30s)。否则回收后重投,旧客户端还在重试,产生大量重复。

worker_id 条件更新:防止过期 worker 覆盖新 worker 的状态。

指数退避 + 抖动:给下游恢复时间,避免同时重试冲垮下游。

死信监控降频 + 去重:5 分钟一次,数量变化才报。

重投时 retry_count 清零:否则投递器一看又置回 DEAD。

5.2 消费者端

幂等记录 + 业务同事务:先插幂等记录再执行业务,中间宕机会导致"幂等记录在、业务没做"。同事务保证要么都在,要么都不在。

位移提交放在事务外:事务提交成功后 ack;事务回滚不 ack。避免"ack 成功但事务回滚"。

重复消息也提交位移:幂等记录已存在,业务结果已落库,直接 ack,否则死循环。

业务失败发死信:不 requeue(会无限循环),发独立 topic,原 topic 位移提交,死信单独处理。

5.3 消费者死信

发死信 + 提交位移必须保证"先发后提交":发死信失败就不提交位移,消息重投。否则"发死信失败 + 提交位移"= 丢消息。

发死信成功但提交位移前宕机 :位移没提交,消息重投,又发一次死信 → dead-topic 重复,靠 messageId 去重。

死信不落库:只监控 topic 消息数,人工用 peek 接口查看,全量搬运回原 topic。

全量搬运用 Kafka 事务:保证"消费位移提交 + 消息发送"原子,不会丢消息。

搬运进度由独立消费者组管理:第一次从 earliest 开始,后续从上次位移继续。

搬运接口加锁 + 返回剩余 + 固定事务 ID + peek + 日志:五个实操细节,缺一不可。


六、关键参数对齐

参数 说明
Kafka delivery.timeout.ms 30 秒 客户端单次发送最长 30 秒
Outbox 租约 60 秒 = 客户端超时 × 2
回收扫描间隔 30 秒 和租约对齐
投递器扫描间隔 2 秒 正常投递延迟上限
BATCH_SIZE 20 一次抢占条数
max_retry 5 投递器重试上限
退避 10s 起步,10min 封顶,20% 抖动 避免同时重试
死信监控间隔 5 分钟 降频,数量变化才报
搬运接口超时 30 秒 单次最多跑 30 秒
Redis 锁超时 60 秒 防止死锁

核心对齐:租约 >= Kafka 客户端最长重试时间


七、核心设计原则

原则 体现
不追求绝对不重复 投递器 at-least-once,重复靠消费端幂等
不追求绝对不丢 正常靠 acks=all,极端靠 outbox + 固定租约回收
事务边界清晰 业务 + outbox 同事务;幂等 + 业务同事务;位移提交在事务外
状态机收敛 PENDING/SENDING/CONFIRMED/DEAD,回收兜底
死信不落库 只监控 topic 消息数,全量搬运
搬运用 Kafka 事务 保证消费位移提交 + 消息发送原子
监控不骚扰 降频 + 数量变化才报
参数对齐 租约 >= Kafka 客户端超时,回收间隔 <= 租约
实操细节 加锁、返回剩余、固定事务 ID、peek、日志

八、一句话

生产者业务+outbox同事务 → 投递器抢占(SENDING + 60秒租约)→ KafkaTemplate 异步发送 → 回调改状态 → 回收器兜底 → 死信监控 → SQL 重投,保证消息不丢。

消费者幂等表+业务同事务 → 成功/重复提交位移 / 失败发 dead-topic + 提交位移,保证不重复处理、失败不丢。

消费者死信 :不落库,只监控 topic 消息数,人工通过 peek 查看内容,retry-all 全量搬运回原 topic,用 Kafka 事务保证不丢消息。搬运接口加 Redis 锁防并发,返回剩余数量,事务 ID 固定,日志记录操作人。

不追求绝对不重复,而是"重复了也没关系" + "最终一定能送达" + "死信有人管"。

相关推荐
szephyr1 天前
消息队列入门:RabbitMQ 和 Kafka 到底怎么选,什么时候不该用
后端·架构·kafka·消息队列·rabbitmq
A.说学逗唱的Coke2 天前
【大模型专题】别再用 HTTP 直连 Agent 了:用 Kafka 承载 A2A 协议,从 PoC 走到生产
人工智能·kafka·a2a
用户531397318173 天前
[Kafka源码揭秘] 消息写入全链路:网络IO与请求入队
kafka·消息队列
swordbob3 天前
一个 Partition就是一个年级,一个消费者组占用1年级1班,另一个消费者组占用1年级2班,座位号(offset)可以相同,但班级号不同?
kafka
香吧香3 天前
Kafka 三节点集群:只订阅一个 broker 会丢消息吗?能用 VIP订阅 吗?
kafka
雾隐隐o3 天前
Kafka 生产者消息丢失:ACK、重试与可靠发送
kafka
heimeiyingwang3 天前
【中台·技术篇】消息队列中台:Kafka/RocketMQ 统一管理与多租户隔离
kafka·rocketmq·中台
程序员天天困4 天前
Kafka 接入 AI 的三条路线:MCP 提案、会话记忆与实时上下文
大数据·后端·kafka
hey you~5 天前
云客服多渠道统一接入,消息队列技术实现方案
kafka·消息队列·rocketmq·系统集成·云客服·多渠道接入·接口对接