目录
- [基于 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 <= 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 < NOW()
AND retry_count < 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 < NOW()
AND retry_count >= max_retry
</update>
<delete id="deleteConfirmedBefore">
DELETE FROM message_outbox
WHERE status = 'CONFIRMED' AND updated_at < #{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';
两个关键:
retry_count必须清零,否则投递器一看又置回 DEAD;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 固定,日志记录操作人。不追求绝对不重复,而是"重复了也没关系" + "最终一定能送达" + "死信有人管"。