Kafka 死信队列(DLQ)重投
0.注意事项
- DLQ消息重投的时候(必须确保headers的重投次数是最新的)以后,等待重投完成,前端必须重新刷新界面(防止重试次数被覆盖)(后端的校验怎么做?)
- 后端没办法做校验,不知道前端勾选的哪些数据,如果勾选的数据的时间差异很大,如果数据集比较大的时候,后端再查一次,会很慢;
- 或者可以重投的时候,将key和重投次数存放到redis,如果出现重复数据,不允许投递;设置过期时间24h,24h后界面一般都会要求重新登录
1. 功能概述
死信队列模块提供两个核心 HTTP API:
| 端点 | 方法 | 功能描述 |
|---|---|---|
POST /api/dlq/preview |
分页预览 | 根据时间范围从 Kafka 拉取死信队列消息,支持多字段模糊筛选,只查看不消费 |
POST /api/dlq/resend |
批量重投 | 将勾选的死信消息重新发送到原业务 topic,自动处理重试计数 |
此外还有一个模拟推送接口 POST /api/dlq/push,用于生成测试数据(将数据库表数据封装为消息推入 DLQ),迁移时可选择保留或移除。
2. 设计思路
2.1 整体架构
┌──────────┐ ┌──────────────┐ ┌─────────────────┐
│ 前端页面 │────→│ DlqController │────→│ DlqServiceImpl │
└──────────┘ └──────────────┘ └────────┬────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
┌──────▼──────┐ ┌─────────▼────────┐ ┌───────▼────────┐
│KafkaConfig │ │KafkaProducerCache│ │ Kafka Cluster │
│ServiceImpl │ │ (ConcurrentMap) │ │ (DLQ Topic + │
│(读DB配置) │ │ (复用Producer) │ │ Business Topic)│
└──────┬──────┘ └──────────────────┘ └────────────────┘
│
┌──────▼──────┐
│ParamConfig │
│Mapper (DB) │
└─────────────┘
2.2 核心设计决策
预览接口(preview)
- 不消费消息:使用随机
group.id(dlq-preview-{UUID}),关闭自动提交(enable.auto.commit=false),确保每次预览都从 earliest 开始拉取,不影响消费者组的 offset 进度。 - 按时间范围定位:通过
consumer.offsetsForTimes()按起始时间 seek 到各分区对应 offset,poll 到结束时间即停止。 - 内存过滤与分页:将时间范围内全部消息拉到内存(上限 1000 条),在内存中进行模糊匹配筛选、按 messageKey 去重(保留最新一条),最后做内存分页。
- 安全上限:单次最多拉取 1000 条(
MAX_TOTAL_RECORDS = 1000),防止无限拉取导致 OOM。每次 poll 最多 500 条,poll 超时 5 秒。
重投接口(resend)
- 原 topic 推导:从消息 value JSON 中提取
table字段,拼接为<KAFKA_TOPIC>.<table>格式。 - 同步投递:使用
producer.send().get()同步等待结果,任意一条失败立即抛BizException终止。 - 重试计数:检查原始 headers 中
__connector.errors.retry.count,存在则 +1,不存在则设为 1。 - Producer 复用:通过
KafkaProducerCache按 tenantId 缓存 Producer 实例(ConcurrentHashMap),避免每次创建。
配置读取
- Kafka 全部配置(bootstrap servers、topic 名称)不在 yml/properties 文件中写死,而是从数据库
param_config表动态读取,支持多租户(按tenantId + paramKey唯一标识一条配置)。 - 三个配置键:
BOOTSTRAP(Kafka 地址)、DLQ_TOPIC(死信主题名)、KAFKA_TOPIC(原业务主题前缀)。
2.3 数据流
preview 请求
│
▼
获取 DLQ topic ──→ 创建临时 Consumer ──→ offsetsForTimes 定位
│
▼
poll 消息(≤ 1000条)──→ 内存过滤去重 ──→ 内存分页 ──→ PageInfo<DlqMessageVO>
resend 请求
│
▼
遍历消息列表 ──→ 解析 value.table ──→ 获取原 topic
│
▼
构建 retry headers ──→ 同步投递 ──→ 全部成功/任一失败抛异常
3. 涉及文件清单
迁移时需要将以下文件完整复制到目标系统(调整包路径):
必须迁移的核心文件(12 个)
| 序号 | 文件 | 说明 |
|---|---|---|
| 1 | controller/DlqController.java |
REST 控制器(preview + resend + push) |
| 2 | model/dto/DlqQueryDTO.java |
预览查询请求 DTO |
| 3 | model/dto/DlqResendDTO.java |
重投请求 DTO(含内部类 MessageItem) |
| 4 | model/dto/DlqPushDTO.java |
推送请求 DTO(模拟数据用,可选) |
| 5 | model/vo/DlqMessageVO.java |
消息视图对象 |
| 6 | service/DlqService.java |
业务接口 |
| 7 | service/impl/DlqServiceImpl.java |
业务实现(核心,约 620 行) |
| 8 | service/KafkaConfigService.java |
Kafka 配置读取接口 |
| 9 | service/impl/KafkaConfigServiceImpl.java |
Kafka 配置读取实现 |
| 10 | cache/KafkaProducerCache.java |
Producer 缓存组件 |
| 11 | mapper/ParamConfigMapper.java |
配置表 Mapper 接口 |
| 12 | resources/mapper/ParamConfigMapper.xml |
MyBatis XML 映射 |
目标系统已有时可跳过
| 序号 | 文件 | 说明 |
|---|---|---|
| 13 | exception/BizException.java |
业务异常类 |
| 14 | common/Result.java |
统一响应体 |
| 15 | model/constants/ResultCode.java |
响应状态码常量 |
| 16 | model/pojo/ParamConfigDO.java |
参数配置表实体 |
可选参考文件
| 序号 | 文件 | 说明 |
|---|---|---|
| 17 | service/impl/DlqConsumerRunner.java |
DLQ 消费后台监听器(模拟消费失败→重投 DLQ 的场景) |
4. Maven 依赖
以下依赖是 DLQ 功能必需的,确认目标系统 pom.xml 中已包含:
<!-- Spring Boot 父工程(版本 ≥ 3.0 以支持 Jakarta) -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
</parent>
<!-- 核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Kafka 客户端 -->
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
</dependency>
<!-- MyBatis(如果目标系统不用 MyBatis 则需适配) -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
<!-- 分页插件 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>4.0.0</version>
</dependency>
<!-- 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Swagger 注解(接口文档用,可移除) -->
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations-jakarta</artifactId>
<version>2.2.49</version>
</dependency>
注意:如果目标系统不使用 MyBatis,需要将
KafkaConfigServiceImpl中的ParamConfigMapper替换为目标系统的配置读取方式(如 JPA Repository、直接 JDBC 等)。
5. 数据库表结构
param_config 表
DLQ 功能依赖此表存储 Kafka 动态配置。迁移前需在目标数据库中创建(如果还没有):
CREATE TABLE IF NOT EXISTS param_config (
id BIGSERIAL PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
param_key VARCHAR(128) NOT NULL,
param_value VARCHAR(512) NOT NULL,
creator_id BIGINT,
create_time TIMESTAMP DEFAULT NOW(),
updater_id BIGINT,
update_time TIMESTAMP DEFAULT NOW(),
deleted SMALLINT DEFAULT 0
);
必须插入的配置数据
-- 以下示例值请替换为实际环境的配置
-- Kafka 地址
INSERT INTO param_config (tenant_id, param_key, param_value)
VALUES ('<tenantId>', 'BOOTSTRAP', 'kafka-broker:9092');
-- 死信队列 topic 名称
INSERT INTO param_config (tenant_id, param_key, param_value)
VALUES ('<tenantId>', 'DLQ_TOPIC', 'dead-letter-queue');
-- 原业务 topic 前缀(重投时拼接 .<tableName>)
INSERT INTO param_config (tenant_id, param_key, param_value)
VALUES ('<tenantId>', 'KAFKA_TOPIC', 'manage_dev');
每个租户需要独立的三条配置。若系统无多租户概念,可固定 tenantId(如
"1"或"default")。
6. 完整源码
以下源码包含完整注释,可以直接复制到目标项目中。需将包路径
com.liang.study替换为目标系统的包路径。
6.1 控制器 —— DlqController
package com.liang.study.controller;
import com.github.pagehelper.PageInfo;
import com.liang.study.common.Result;
import com.liang.study.model.dto.DlqPushDTO;
import com.liang.study.model.dto.DlqQueryDTO;
import com.liang.study.model.dto.DlqResendDTO;
import com.liang.study.model.vo.DlqMessageVO;
import com.liang.study.service.DlqService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 死信队列控制器
*
* @author liang
* @since 2026-06-28
*/
@RestController
@RequestMapping("/api/dlq")
@RequiredArgsConstructor
public class DlqController {
private final DlqService dlqService;
/**
* 模拟推送消息到死信队列
*
* @param dto 推送请求
* @return 成功响应
*/
@PostMapping("/push")
@Operation(summary = "模拟推送消息到死信队列")
public Result<Void> push(@Valid @RequestBody DlqPushDTO dto) {
dlqService.pushToDlq(dto);
return Result.success();
}
/**
* 分页预览死信队列消息(不消费,不提交 offset)
*
* @param dto 查询请求
* @return 分页结果
*/
@PostMapping("/preview")
@Operation(summary = "分页预览死信队列消息")
public Result<PageInfo<DlqMessageVO>> preview(@Valid @RequestBody DlqQueryDTO dto) {
PageInfo<DlqMessageVO> pageInfo = dlqService.preview(dto);
return Result.success(pageInfo);
}
/**
* 批量重投选中的消息到原业务 topic
*
* @param dto 重投请求(包含消息列表)
* @return 成功响应
*/
@PostMapping("/resend")
@Operation(summary = "批量重投选中的消息到原 topic")
public Result<Void> resend(@Valid @RequestBody DlqResendDTO dto) {
dlqService.resend(dto);
return Result.success();
}
}
6.2 数据传输对象(DTO)
DlqQueryDTO —— 预览查询请求
package com.liang.study.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 死信队列预览查询请求
*
* @author liang
* @since 2026-06-28
*/
@Data
@Schema(description = "死信队列预览查询请求")
public class DlqQueryDTO {
/** 租户ID */
@NotBlank(message = "租户ID不能为空")
@Schema(description = "租户ID", example = "tenant-001")
private String tenantId;
/** 查询开始时间 */
@NotNull(message = "开始时间不能为空")
@Schema(description = "开始时间", example = "2026-06-28T00:00:00")
private LocalDateTime startTime;
/** 查询结束时间 */
@NotNull(message = "结束时间不能为空")
@Schema(description = "结束时间", example = "2026-06-28T23:59:59")
private LocalDateTime endTime;
/** 连接器名称(模糊匹配,可选) */
@Schema(description = "连接器名称(模糊匹配,可选)", example = "testConnector1")
private String connectorName;
/** 表名(模糊匹配,可选) */
@Schema(description = "表名(模糊匹配,可选)", example = "user")
private String tableName;
/** IP 地址(模糊匹配,可选) */
@Schema(description = "IP(模糊匹配,可选)", example = "127.0.0.1")
private String ip;
/** 错误消息(模糊匹配,可选) */
@Schema(description = "错误消息(模糊匹配,可选)", example = "Failed")
private String errorMsg;
/** 页码,默认 1 */
@Schema(description = "页码", example = "1")
private Integer pageNum = 1;
/** 每页条数,默认 20 */
@Schema(description = "每页条数", example = "20")
private Integer pageSize = 20;
}
DlqResendDTO —— 批量重投请求
package com.liang.study.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 批量重投消息到原 topic 请求
*
* @author liang
* @since 2026-06-28
*/
@Data
@Schema(description = "批量重投消息到原 topic 请求")
public class DlqResendDTO {
/** 租户ID */
@NotBlank(message = "租户ID不能为空")
@Schema(description = "租户ID", example = "tenant-001")
private String tenantId;
/** 待重投的消息列表(至少一条) */
@NotEmpty(message = "消息列表不能为空")
@Valid
@Schema(description = "待重投的消息列表")
private List<MessageItem> messages;
/**
* 单条待重投的消息
*/
@Data
@Schema(description = "单条待重投的消息")
public static class MessageItem {
/** 死信队列消息的 key(JSON 字符串) */
@NotBlank(message = "消息Key不能为空")
@Schema(description = "死信队列消息的 key(JSON 字符串)")
private String key;
/** 死信队列消息的 value(JSON 字符串) */
@NotBlank(message = "消息Value不能为空")
@Schema(description = "死信队列消息的 value(JSON 字符串)")
private String value;
/** 死信队列消息的 headers(key-value 对) */
@Schema(description = "死信队列消息的 headers(key-value 对)")
private Map<String, String> headers;
}
}
DlqPushDTO —— 模拟推送请求(可选)
package com.liang.study.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 模拟推送消息到死信队列请求
*
* @author liang
* @since 2026-06-28
*/
@Data
@Schema(description = "模拟推送消息到死信队列请求")
public class DlqPushDTO {
/** 租户ID */
@NotBlank(message = "租户ID不能为空")
@Schema(description = "租户ID", example = "tenant-001")
private String tenantId;
/** 数据库表名(支持 schema.table 格式) */
@NotBlank(message = "表名不能为空")
@Schema(description = "数据库表名", example = "manage_dev.user")
private String tableName;
}
6.3 视图对象(VO)
DlqMessageVO —— 消息视图
package com.liang.study.model.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 死信队列消息视图
*
* @author liang
* @since 2026-06-28
*/
@Data
@Schema(description = "死信队列消息视图")
public class DlqMessageVO {
/** 消息时间戳 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS", timezone = "GMT+8")
@Schema(description = "消息时间戳")
private LocalDateTime timestamp;
/** 连接器名称(从 headers.__connector.errors.connector.name 提取) */
@Schema(description = "连接器名称(从 headers 提取)")
private String connectorName;
/** IP 地址(从 headers.__connector.errors.ip 提取) */
@Schema(description = "IP(从 headers 提取)")
private String ip;
/** 表名(从 value.table 提取) */
@Schema(description = "表名(从 value.table 提取)")
private String tableName;
/** 错误消息(从 headers.__connector.errors.exception.message 提取) */
@Schema(description = "错误消息(从 headers 提取)")
private String errorMsg;
/** 重试次数(从 headers.__connector.errors.retry.count 提取) */
@Schema(description = "重试次数(从 headers 提取)")
private Integer retryCount;
/** 消息 key(JSON 字符串) */
@Schema(description = "消息 key(JSON 字符串)")
private String messageKey;
/** 消息 value(JSON 字符串) */
@Schema(description = "消息 value(JSON 字符串)")
private String messageValue;
/** 消息 headers 键值对 */
@Schema(description = "消息 headers")
private Map<String, String> headers;
}
6.4 服务接口
DlqService
package com.liang.study.service;
import com.github.pagehelper.PageInfo;
import com.liang.study.model.dto.DlqPushDTO;
import com.liang.study.model.dto.DlqQueryDTO;
import com.liang.study.model.dto.DlqResendDTO;
import com.liang.study.model.vo.DlqMessageVO;
/**
* 死信队列业务接口
*
* @author liang
* @since 2026-06-28
*/
public interface DlqService {
/**
* 根据表名查询数据库表数据并封装为消息推送到死信队列
* <p>
* 业务步骤:获取主键列 → 查询全表数据 → 逐行构造消息 → 推送到 DLQ topic。
*
* @param dto 推送请求(包含 tenantId 和 tableName)
* @throws com.liang.study.exception.BizException 表无主键、表名格式非法、配置缺失时
*/
void pushToDlq(DlqPushDTO dto);
/**
* 按时间范围从 Kafka 拉取死信队列消息并分页返回(不消费,不提交 offset)
* <p>
* 后端一次性拉取时间范围内全部消息到内存中过滤和分页。
*
* @param dto 查询请求(包含时间范围、可选筛选条件、分页参数)
* @return 分页后的死信消息列表
*/
PageInfo<DlqMessageVO> preview(DlqQueryDTO dto);
/**
* 将选中的一条或多条死信消息重投到原业务 topic
* <p>
* 每条消息的原 topic 名称 = KAFKA_TOPIC 配置值 + "." + value.table。
* 重投时 headers 中的 __connector.errors.retry.count 新设或自增。
* 任意一条消息投递失败即终止并抛异常。
*
* @param dto 重投请求(包含 tenantId 和消息列表)
* @throws com.liang.study.exception.BizException 任一消息投递失败时
*/
void resend(DlqResendDTO dto);
}
6.5 服务实现(核心)
DlqServiceImpl
package com.liang.study.service.impl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.pagehelper.PageInfo;
import com.liang.study.cache.KafkaProducerCache;
import com.liang.study.exception.BizException;
import com.liang.study.model.dto.DlqPushDTO;
import com.liang.study.model.dto.DlqQueryDTO;
import com.liang.study.model.dto.DlqResendDTO;
import com.liang.study.model.vo.DlqMessageVO;
import com.liang.study.service.DlqService;
import com.liang.study.service.KafkaConfigService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerConfig;
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.OffsetAndTimestamp;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.header.Header;
import org.apache.kafka.common.header.internals.RecordHeader;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
/**
* 死信队列业务实现
*
* @author liang
* @since 2026-06-28
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DlqServiceImpl implements DlqService {
// ==================== 常量 ====================
/**
* headers 键:connector 名称
*/
static final String HEADER_CONNECTOR_NAME = "__connector.errors.connector.name";
/**
* headers 键:异常消息
*/
static final String HEADER_EXCEPTION_MESSAGE = "__connector.errors.exception.message";
/**
* headers 键:IP 地址
*/
static final String HEADER_IP = "__connector.errors.ip";
/**
* headers 键:重试次数
*/
static final String HEADER_RETRY_COUNT = "__connector.errors.retry.count";
/**
* 每次 poll 最大拉取消息条数
*/
private static final int MAX_POLL_RECORDS = 500;
/**
* poll 超时时间(毫秒)
*/
private static final long POLL_TIMEOUT_MS = 5000;
/**
* 预览接口单次最多拉取消息总条数,超过该值自动停止拉取
*/
private static final int MAX_TOTAL_RECORDS = 1000;
/**
* 模拟的 connector 名称候选值
*/
private static final String[] CONNECTOR_NAMES = {"testConnector1", "testConnector2"};
/**
* 模拟的错误消息候选值
*/
private static final String[] ERROR_MESSAGES = {
"Failed to flush batch to Kafka",
"Failed to deliver message to broker",
"Failed to produce record: topic not found",
"Failed to commit offset: coordinator not available",
"Failed to deserialize record value",
"Failed to connect to Kafka broker"
};
/**
* 模拟的 IP 候选值
*/
private static final String[] IPS = {"127.0.0.1", "192.168.2.3"};
private final KafkaConfigService kafkaConfigService;
private final KafkaProducerCache producerCache;
private final DataSource dataSource;
private final ObjectMapper objectMapper;
// ==================== 公开方法 ====================
@Override
public void pushToDlq(DlqPushDTO dto) {
String tenantId = dto.getTenantId();
String tableName = dto.getTableName();
List<String> pkColumns = getPrimaryKeyColumns(tableName);
if (pkColumns.isEmpty()) {
throw new BizException("表 " + tableName + " 没有主键索引");
}
List<Map<String, Object>> rows = queryTableData(tableName);
String dlqTopic = kafkaConfigService.getDlqTopic(tenantId);
KafkaProducer<String, String> producer = producerCache.getOrCreate(tenantId);
for (Map<String, Object> row : rows) {
String key = buildMessageKey(row, pkColumns);
String value = buildMessageValue(row, tableName);
List<Header> headers = buildRandomHeaders();
ProducerRecord<String, String> record = new ProducerRecord<>(
dlqTopic, null, System.currentTimeMillis(), key, value, headers);
producer.send(record, (metadata, exception) -> {
if (exception != null) {
log.error("推送 DLQ 失败: tenantId={}, key={}", tenantId, key, exception);
}
});
}
producer.flush();
log.info("模拟推送 DLQ 完成: tenantId={}, table={}, 条数={}",
tenantId, tableName, rows.size());
}
@Override
public PageInfo<DlqMessageVO> preview(DlqQueryDTO dto) {
String tenantId = dto.getTenantId();
String dlqTopic = kafkaConfigService.getDlqTopic(tenantId);
Properties consumerProps = buildConsumerProps(tenantId);
List<DlqMessageVO> allMessages = new ArrayList<>();
long startEpoch = toEpochMilli(dto.getStartTime());
long endEpoch = toEpochMilli(dto.getEndTime());
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps)) {
List<TopicPartition> partitions = fetchPartitions(consumer, dlqTopic);
if (partitions.isEmpty()) {
return buildEmptyPage(dto.getPageNum(), dto.getPageSize());
}
consumer.assign(partitions);
seekToStartTime(consumer, partitions, startEpoch);
pollAndCollect(consumer, dto, endEpoch, allMessages);
}
List<DlqMessageVO> deduped = dedupByMessageKey(allMessages);
return paginateInMemory(deduped, dto.getPageNum(), dto.getPageSize());
}
@Override
public void resend(DlqResendDTO dto) {
String tenantId = dto.getTenantId();
KafkaProducer<String, String> producer = producerCache.getOrCreate(tenantId);
int successCount = 0;
for (DlqResendDTO.MessageItem item : dto.getMessages()) {
String tableName = parseTableFromValue(item.getValue());
String originalTopic = kafkaConfigService.getOriginalTopic(tenantId, tableName);
sendSingle(producer, originalTopic, item);
successCount++;
}
log.info("批量重投完成: tenantId={}, 成功={}", tenantId, successCount);
}
/**
* 发送单条消息到指定 topic,失败时抛 BizException
*
* @param producer Kafka Producer 实例
* @param originalTopic 目标 topic
* @param item 待重投的消息
* @throws BizException 投递失败时
*/
private void sendSingle(KafkaProducer<String, String> producer,
String originalTopic, DlqResendDTO.MessageItem item) {
Map<String, String> headers = buildRetryHeaders(item.getHeaders());
List<Header> kafkaHeaders = toKafkaHeaders(headers);
try {
ProducerRecord<String, String> record = new ProducerRecord<>(
originalTopic, null, System.currentTimeMillis(),
item.getKey(), item.getValue(), kafkaHeaders);
producer.send(record).get();
} catch (Exception e) {
log.error("重投失败: topic={}, key={}", originalTopic, item.getKey(), e);
throw new BizException("重投消息失败: " + e.getMessage());
}
}
// ==================== 私有方法:推送相关 ====================
/**
* 通过 JDBC DatabaseMetaData 获取表的主键列名
*
* @param tableName 表名(支持 schema.table 格式)
* @return 主键列名列表
*/
private List<String> getPrimaryKeyColumns(String tableName) {
List<String> pkColumns = new ArrayList<>();
String schema = null;
String tbl = tableName;
if (tableName.contains(".")) {
String[] parts = tableName.split("\\.");
schema = parts[0];
tbl = parts[1];
}
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData metaData = conn.getMetaData();
try (ResultSet rs = metaData.getPrimaryKeys(null, schema, tbl)) {
while (rs.next()) {
pkColumns.add(rs.getString("COLUMN_NAME"));
}
}
} catch (Exception e) {
log.error("获取主键失败: tableName={}", tableName, e);
throw new BizException("获取表主键信息失败: " + e.getMessage());
}
return pkColumns;
}
/**
* 查询表全部数据(做表名格式校验防 SQL 注入)
*
* @param tableName 表名
* @return 行数据列表,每行为列名→值的 Map
*/
private List<Map<String, Object>> queryTableData(String tableName) {
if (!tableName.matches("^[a-zA-Z0-9_.]+$")) {
throw new BizException("表名格式不合法: " + tableName);
}
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
return jdbcTemplate.queryForList("SELECT * FROM " + tableName);
}
/**
* 构建消息 key,格式:{"主键列1":"值1", "主键列2":"值2"}
*
* @param row 数据行(列名 → 值)
* @param pkColumns 主键列名列表
* @return 消息 key 的 JSON 字符串
* @throws BizException JSON 序列化失败时
*/
private String buildMessageKey(Map<String, Object> row, List<String> pkColumns) {
Map<String, Object> keyMap = new LinkedHashMap<>();
for (String pkCol : pkColumns) {
keyMap.put(pkCol, row.get(pkCol));
}
try {
return objectMapper.writeValueAsString(keyMap);
} catch (Exception e) {
throw new BizException("构建消息 key 失败: " + e.getMessage());
}
}
/**
* 构建消息 value,格式:{"data":{...}, "opType":"U", "table":"短表名"}
*
* @param row 数据行(列名 → 值)
* @param tableName 完整表名(可能包含 schema 前缀)
* @return 消息 value 的 JSON 字符串
* @throws BizException JSON 序列化失败时
*/
private String buildMessageValue(Map<String, Object> row, String tableName) {
String shortName = tableName.contains(".")
? tableName.substring(tableName.lastIndexOf('.') + 1) : tableName;
Map<String, Object> valueMap = new LinkedHashMap<>();
valueMap.put("data", row);
valueMap.put("opType", "U");
valueMap.put("table", shortName);
try {
return objectMapper.writeValueAsString(valueMap);
} catch (Exception e) {
throw new BizException("构建消息 value 失败: " + e.getMessage());
}
}
/**
* 构建模拟的随机 headers(从预定义候选值中随机选取 connectorName、errorMessage、IP)
*
* @return Kafka Header 列表
*/
private List<Header> buildRandomHeaders() {
ThreadLocalRandom random = ThreadLocalRandom.current();
return Arrays.asList(
new RecordHeader(HEADER_CONNECTOR_NAME,
CONNECTOR_NAMES[random.nextInt(CONNECTOR_NAMES.length)]
.getBytes(StandardCharsets.UTF_8)),
new RecordHeader(HEADER_EXCEPTION_MESSAGE,
ERROR_MESSAGES[random.nextInt(ERROR_MESSAGES.length)]
.getBytes(StandardCharsets.UTF_8)),
new RecordHeader(HEADER_IP,
IPS[random.nextInt(IPS.length)].getBytes(StandardCharsets.UTF_8))
);
}
// ==================== 私有方法:预览相关 ====================
/**
* 构建 Kafka Consumer 配置(不自动提交 offset,每次使用随机 group 确保只拉取不消费)
*
* @param tenantId 租户ID
* @return Kafka Consumer 配置属性
*/
private Properties buildConsumerProps(String tenantId) {
String bootstrapServers = kafkaConfigService.getKafkaProperties(tenantId)
.getProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG);
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class.getName());
// 每次预览使用随机 group,不提交 offset,确保只拉取不消费
props.put(ConsumerConfig.GROUP_ID_CONFIG, "dlq-preview-" + UUID.randomUUID());
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, MAX_POLL_RECORDS);
return props;
}
/**
* 获取 topic 的所有分区
*
* @param consumer Kafka Consumer 实例
* @param topic topic 名称
* @return 分区列表
*/
private List<TopicPartition> fetchPartitions(KafkaConsumer<String, String> consumer,
String topic) {
return consumer.partitionsFor(topic).stream()
.map(p -> new TopicPartition(p.topic(), p.partition()))
.collect(Collectors.toList());
}
/**
* 按时间戳 seek 到各分区的起始 offset(使用 {@link KafkaConsumer#offsetsForTimes})
* <p>
* 若时间戳超出分区所有消息范围(例如未来时间),则 seek 到分区末尾,确保不返回任何消息。
*
* @param consumer Kafka Consumer 实例
* @param partitions 目标分区列表
* @param startEpoch 起始时间的 epoch 毫秒值
*/
private void seekToStartTime(KafkaConsumer<String, String> consumer,
List<TopicPartition> partitions, long startEpoch) {
Map<TopicPartition, Long> timestamps = new HashMap<>();
for (TopicPartition tp : partitions) {
timestamps.put(tp, startEpoch);
}
Map<TopicPartition, OffsetAndTimestamp> offsets = consumer.offsetsForTimes(timestamps);
List<TopicPartition> seekToEndPartitions = new ArrayList<>();
for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : offsets.entrySet()) {
if (entry.getValue() != null) {
consumer.seek(entry.getKey(), entry.getValue().offset());
} else {
// 时间戳超出消息范围,标记为需要 seek 到末尾
seekToEndPartitions.add(entry.getKey());
}
}
if (!seekToEndPartitions.isEmpty()) {
consumer.seekToEnd(seekToEndPartitions);
}
}
/**
* 循环 poll 消息,直到超过结束时间、无新消息或达到 {@link #MAX_TOTAL_RECORDS} 上限
*
* @param consumer Kafka Consumer 实例
* @param dto 查询请求(用于筛选条件)
* @param endEpoch 结束时间的 epoch 毫秒值
* @param collector 收集器(结果写入此列表)
*/
private void pollAndCollect(KafkaConsumer<String, String> consumer,
DlqQueryDTO dto, long endEpoch,
List<DlqMessageVO> collector) {
boolean hasMore = true;
while (hasMore && collector.size() < MAX_TOTAL_RECORDS) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofMillis(POLL_TIMEOUT_MS));
if (records.isEmpty()) {
break;
}
for (ConsumerRecord<String, String> record : records) {
if (record.timestamp() > endEpoch) {
hasMore = false;
break;
}
DlqMessageVO vo = parseRecord(record);
if (matchesFilter(vo, dto)) {
collector.add(vo);
if (collector.size() >= MAX_TOTAL_RECORDS) {
break;
}
}
}
}
if (collector.size() >= MAX_TOTAL_RECORDS) {
log.info("预览消息已达拉取上限: max={}, 超出部分未拉取", MAX_TOTAL_RECORDS);
}
}
/**
* 将一条 Kafka 消息解析为 DlqMessageVO
* <p>
* 提取 headers 中的 connectorName、ip、errorMsg,以及 value 中的 table。
*
* @param record Kafka 消息记录
* @return 死信消息视图对象
*/
private DlqMessageVO parseRecord(ConsumerRecord<String, String> record) {
DlqMessageVO vo = new DlqMessageVO();
vo.setTimestamp(LocalDateTime.ofInstant(
Instant.ofEpochMilli(record.timestamp()), ZoneId.systemDefault()));
vo.setMessageKey(record.key());
vo.setMessageValue(record.value());
Map<String, String> headersMap = new LinkedHashMap<>();
for (Header header : record.headers()) {
String headerKey = header.key();
String headerValue = new String(header.value(), StandardCharsets.UTF_8);
headersMap.put(headerKey, headerValue);
if (HEADER_CONNECTOR_NAME.equals(headerKey)) {
vo.setConnectorName(headerValue);
} else if (HEADER_IP.equals(headerKey)) {
vo.setIp(headerValue);
} else if (HEADER_EXCEPTION_MESSAGE.equals(headerKey)) {
vo.setErrorMsg(headerValue);
} else if (HEADER_RETRY_COUNT.equals(headerKey)) {
try {
vo.setRetryCount(Integer.parseInt(headerValue));
} catch (NumberFormatException e) {
log.warn("解析 retry.count 失败: value={}", headerValue);
}
}
}
vo.setHeaders(headersMap);
// 从 value JSON 中提取 table 字段
try {
Map<String, Object> valueMap = objectMapper.readValue(record.value(),
new TypeReference<>() {
});
vo.setTableName((String) valueMap.get("table"));
} catch (Exception e) {
log.warn("解析 value.table 失败: key={}", record.key(), e);
}
return vo;
}
/**
* 判断消息是否匹配所有筛选条件(模糊匹配,空条件不过滤)
*
* @param vo 消息视图
* @param dto 查询请求(包含各项筛选条件)
* @return true=匹配通过, false=被过滤
*/
private boolean matchesFilter(DlqMessageVO vo, DlqQueryDTO dto) {
return matches(vo.getConnectorName(), dto.getConnectorName())
&& matches(vo.getTableName(), dto.getTableName())
&& matches(vo.getIp(), dto.getIp())
&& matches(vo.getErrorMsg(), dto.getErrorMsg());
}
/**
* 单字段模糊匹配:筛选值为 null 或空则通过,否则检查字段值包含筛选值
*
* @param fieldValue 消息中的实际值
* @param filterValue 用户输入的筛选值
* @return true=匹配通过, false=不匹配
*/
private boolean matches(String fieldValue, String filterValue) {
if (filterValue == null || filterValue.isEmpty()) {
return true;
}
return fieldValue != null && fieldValue.contains(filterValue);
}
/**
* 按 messageKey 去重,相同 key 仅保留时间戳最新的一条
*
* @param messages 原始消息列表
* @return 去重后的消息列表
*/
private List<DlqMessageVO> dedupByMessageKey(List<DlqMessageVO> messages) {
Map<String, DlqMessageVO> latestByKey = new LinkedHashMap<>();
for (DlqMessageVO vo : messages) {
String key = vo.getMessageKey();
if (key == null) {
continue;
}
DlqMessageVO existing = latestByKey.get(key);
if (existing == null || vo.getTimestamp().isAfter(existing.getTimestamp())) {
latestByKey.put(key, vo);
}
}
return new ArrayList<>(latestByKey.values());
}
/**
* 内存分页:从全量数据中截取对应页的数据
*
* @param allMessages 全量消息列表
* @param pageNum 页码(从 1 开始)
* @param pageSize 每页条数
* @return 分页结果
*/
private PageInfo<DlqMessageVO> paginateInMemory(List<DlqMessageVO> allMessages,
int pageNum, int pageSize) {
int total = allMessages.size();
int from = (pageNum - 1) * pageSize;
if (from >= total) {
return buildEmptyPage(pageNum, pageSize);
}
int to = Math.min(from + pageSize, total);
PageInfo<DlqMessageVO> pageInfo = new PageInfo<>(allMessages.subList(from, to));
pageInfo.setTotal(total);
pageInfo.setPageNum(pageNum);
pageInfo.setPageSize(pageSize);
pageInfo.setPages((total + pageSize - 1) / pageSize);
return pageInfo;
}
/**
* 构建空分页结果(total=0,list 为空)
*
* @param pageNum 页码
* @param pageSize 每页条数
* @return 空分页对象
*/
private PageInfo<DlqMessageVO> buildEmptyPage(int pageNum, int pageSize) {
PageInfo<DlqMessageVO> pageInfo = new PageInfo<>(Collections.emptyList());
pageInfo.setTotal(0);
pageInfo.setPageNum(pageNum);
pageInfo.setPageSize(pageSize);
pageInfo.setPages(0);
return pageInfo;
}
// ==================== 私有方法:重投相关 ====================
/**
* 从消息 value JSON 中提取 table 字段
*
* @param value 消息 value(JSON 字符串)
* @return table 字段值
* @throws BizException table 字段缺失或 JSON 格式错误时
*/
private String parseTableFromValue(String value) {
try {
Map<String, Object> map = objectMapper.readValue(value,
new TypeReference<>() {
});
Object table = map.get("table");
if (table == null) {
throw new BizException("消息 value 中缺少 table 字段");
}
return (String) table;
} catch (BizException e) {
throw e;
} catch (Exception e) {
throw new BizException("消息 value 格式错误: " + e.getMessage());
}
}
/**
* 构建重投 headers:retry.count 首次设置为 1,否则自增 1
*
* @param originalHeaders 原始 headers(可能为 null)
* @return 处理后的 headers(新 Map,不会修改原始对象)
*/
private Map<String, String> buildRetryHeaders(Map<String, String> originalHeaders) {
Map<String, String> headers = (originalHeaders != null)
? new HashMap<>(originalHeaders) : new HashMap<>();
String retryStr = headers.get(HEADER_RETRY_COUNT);
if (retryStr != null) {
headers.put(HEADER_RETRY_COUNT, String.valueOf(Integer.parseInt(retryStr) + 1));
} else {
headers.put(HEADER_RETRY_COUNT, "1");
}
return headers;
}
/**
* Map header 转 Kafka Header 列表
*
* @param headers 消息 headers 键值对
* @return Kafka Header 列表
*/
private List<Header> toKafkaHeaders(Map<String, String> headers) {
return headers.entrySet().stream()
.map(e -> new RecordHeader(e.getKey(),
e.getValue().getBytes(StandardCharsets.UTF_8)))
.collect(Collectors.toList());
}
// ==================== 私有工具方法 ====================
/**
* LocalDateTime 转 epoch 毫秒(使用系统默认时区)
*
* @param dateTime 本地日期时间
* @return epoch 毫秒值
*/
private long toEpochMilli(LocalDateTime dateTime) {
return dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
}
6.6 Kafka 配置服务
KafkaConfigService 接口
package com.liang.study.service;
import java.util.Properties;
/**
* Kafka 配置读取服务
*
* @author liang
* @since 2026-06-28
*/
public interface KafkaConfigService {
/**
* 获取 Kafka 连接配置属性(包含 bootstrap.servers 及默认的序列化器)
*
* @param tenantId 租户ID
* @return Kafka 生产者配置 Properties
* @throws com.liang.study.exception.BizException 指定租户的 BOOTSTRAP 配置不存在时
*/
Properties getKafkaProperties(String tenantId);
/**
* 获取死信队列 topic 名称
*
* @param tenantId 租户ID
* @return DLQ topic 名称
* @throws com.liang.study.exception.BizException 指定租户的 DLQ_TOPIC 配置不存在时
*/
String getDlqTopic(String tenantId);
/**
* 获取原业务 topic 名称,格式为 KAFKA_TOPIC.tableName
*
* @param tenantId 租户ID
* @param tableName 表名
* @return 完整 topic 名称
* @throws com.liang.study.exception.BizException 指定租户的 KAFKA_TOPIC 配置不存在时
*/
String getOriginalTopic(String tenantId, String tableName);
}
KafkaConfigServiceImpl 实现
package com.liang.study.service.impl;
import com.liang.study.exception.BizException;
import com.liang.study.mapper.ParamConfigMapper;
import com.liang.study.model.pojo.ParamConfigDO;
import com.liang.study.service.KafkaConfigService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Properties;
/**
* Kafka 配置读取服务实现
* <p>
* 从 param_config 表中按 tenantId + paramKey 读取配置,
* 支持 BOOTSTRAP、DLQ_TOPIC、KAFKA_TOPIC 三个配置键。
*
* @author liang
* @since 2026-06-28
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KafkaConfigServiceImpl implements KafkaConfigService {
/**
* 参数配置表中 Kafka 地址对应的 paramKey
*/
private static final String BOOTSTRAP_KEY = "BOOTSTRAP";
/**
* 参数配置表中死信队列 topic 对应的 paramKey
*/
private static final String DLQ_TOPIC_KEY = "DLQ_TOPIC";
/**
* 参数配置表中原业务 topic 前缀对应的 paramKey
*/
private static final String KAFKA_TOPIC_KEY = "KAFKA_TOPIC";
private final ParamConfigMapper paramConfigMapper;
@Override
public Properties getKafkaProperties(String tenantId) {
String bootstrapServers = getConfigValue(tenantId, BOOTSTRAP_KEY);
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
StringSerializer.class.getName());
return props;
}
@Override
public String getDlqTopic(String tenantId) {
return getConfigValue(tenantId, DLQ_TOPIC_KEY);
}
@Override
public String getOriginalTopic(String tenantId, String tableName) {
String baseTopic = getConfigValue(tenantId, KAFKA_TOPIC_KEY);
return baseTopic + "." + tableName;
}
/**
* 从参数配置表中按 tenantId + paramKey 查询配置值
*
* @param tenantId 租户ID
* @param paramKey 参数键
* @return 参数值
* @throws BizException 配置不存在时
*/
private String getConfigValue(String tenantId, String paramKey) {
List<ParamConfigDO> list = paramConfigMapper.page(tenantId, paramKey);
if (list.isEmpty()) {
throw new BizException("Kafka 配置不存在: tenantId="
+ tenantId + ", paramKey=" + paramKey);
}
return list.getFirst().getParamValue();
}
}
6.7 Kafka Producer 缓存
package com.liang.study.cache;
import com.liang.study.service.KafkaConfigService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
/**
* Kafka Producer 缓存
* <p>
* 按 tenantId 缓存 KafkaProducer 实例,避免每次请求重新创建。
* KafkaProducer 线程安全,可被多个请求并发复用。
* 应用关闭时自动释放所有 Producer 连接。
*
* @author liang
* @since 2026-06-28
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class KafkaProducerCache implements DisposableBean {
/**
* Producer 缓存,key=tenantId, value=KafkaProducer
*/
private final Map<String, KafkaProducer<String, String>> cache = new ConcurrentHashMap<>();
private final KafkaConfigService kafkaConfigService;
/**
* 获取或创建指定租户的 KafkaProducer(复用已缓存的实例)
*
* @param tenantId 租户ID
* @return KafkaProducer 实例
*/
public KafkaProducer<String, String> getOrCreate(String tenantId) {
return cache.computeIfAbsent(tenantId, id -> {
log.info("创建 KafkaProducer: tenantId={}", id);
Properties props = kafkaConfigService.getKafkaProperties(id);
return new KafkaProducer<>(props);
});
}
@Override
public void destroy() {
cache.forEach((tenantId, producer) -> {
log.info("关闭 KafkaProducer: tenantId={}", tenantId);
producer.close();
});
cache.clear();
}
}
6.8 通用依赖组件
以下三个类是 DLQ 模块依赖的基础组件,如果目标系统已有类似的统一响应体和异常处理,可直接复用。
BizException —— 业务异常
package com.liang.study.exception;
import com.liang.study.model.constants.ResultCode;
import lombok.Getter;
/**
* 自定义业务异常类
* <p>
* 禁止直接抛出 RuntimeException,业务异常统一使用此类
*
* @author liang
* @since 2026-06-26
*/
@Getter
public class BizException extends RuntimeException {
/** 异常状态码 */
private final int code;
/**
* 构造业务异常
*
* @param code 状态码
* @param msg 异常消息
*/
public BizException(int code, String msg) {
super(msg);
this.code = code;
}
/**
* 构造业务异常(默认 code=400)
*
* @param msg 异常消息
*/
public BizException(String msg) {
super(msg);
this.code = ResultCode.FAIL;
}
}
Result —— 统一响应体
package com.liang.study.common;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.liang.study.model.constants.ResultCode;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 通用统一响应体
*
* @param <T> 响应数据类型
* @author liang
* @since 2026-06-26
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Result<T> {
/** 状态码:200-成功,400-业务异常,500-系统错误 */
private int code;
/** 响应数据 */
private T data;
/** 提示消息 */
private String msg;
/** 响应时间戳 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS", timezone = "GMT+8")
private LocalDateTime timestamp;
/** 追踪ID,仅非成功时返回,用于日志追踪和问题排查 */
private String traceId;
private Result() {
this.timestamp = LocalDateTime.now();
}
/**
* 成功返回(无数据)
*/
public static <T> Result<T> success() {
Result<T> result = new Result<>();
result.setCode(ResultCode.SUCCESS);
result.setMsg("操作成功");
return result;
}
/**
* 成功返回(带数据)
*/
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(ResultCode.SUCCESS);
result.setMsg("操作成功");
result.setData(data);
return result;
}
/**
* 业务异常返回
*/
public static <T> Result<T> fail(String msg) {
Result<T> result = new Result<>();
result.setCode(ResultCode.FAIL);
result.setMsg(msg);
return result;
}
/**
* 系统错误返回
*/
public static <T> Result<T> error(String msg) {
Result<T> result = new Result<>();
result.setCode(ResultCode.ERROR);
result.setMsg(msg);
return result;
}
}
ResultCode —— 状态码常量
package com.liang.study.model.constants;
/**
* 统一响应状态码常量
*
* @author liang
* @since 2026-06-26
*/
public final class ResultCode {
private ResultCode() {
// 工具类,禁止实例化
}
/** 成功 */
public static final int SUCCESS = 200;
/** 业务异常(参数校验失败、数据不存在等) */
public static final int FAIL = 400;
/** 系统错误(未知异常、服务不可用等) */
public static final int ERROR = 500;
}
ParamConfigMapper(MyBatis)
package com.liang.study.mapper;
import com.liang.study.model.pojo.ParamConfigDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 参数配置表 Mapper
*
* @author liang
* @since 2026-06-26
*/
@Mapper
public interface ParamConfigMapper {
/**
* 分页查询(配合 PageHelper 使用)
*
* @param tenantId 租户ID
* @param paramKey 参数键(模糊搜索,可选)
* @return 参数配置列表
*/
List<ParamConfigDO> page(@Param("tenantId") String tenantId, @Param("paramKey") String paramKey);
}
ParamConfigMapper.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.liang.study.mapper.ParamConfigMapper">
<!-- 分页查询(配合 PageHelper 使用) -->
<select id="page" resultType="com.liang.study.model.pojo.ParamConfigDO">
SELECT id, tenant_id, param_key, param_value, creator_id,
create_time, updater_id, update_time
FROM param_config
WHERE tenant_id = #{tenantId} AND deleted = 0
<if test="paramKey != null and paramKey != ''">
AND param_key LIKE CONCAT('%', #{paramKey}, '%')
</if>
ORDER BY id DESC
</select>
</mapper>
ParamConfigDO —— 配置表实体
package com.liang.study.model.pojo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 参数配置表数据库实体
*
* @author liang
* @since 2026-06-26
*/
@Data
public class ParamConfigDO {
/** 主键ID */
private Long id;
/** 租户ID */
private String tenantId;
/** 参数键 */
private String paramKey;
/** 参数值 */
private String paramValue;
/** 创建人ID */
private Long creatorId;
/** 创建时间 */
private LocalDateTime createTime;
/** 更新人ID */
private Long updaterId;
/** 更新时间 */
private LocalDateTime updateTime;
/** 是否逻辑删除(0-未删除 1-已删除) */
private Integer deleted;
}
7. 关键设计说明
7.1 消息格式
DLQ 中的消息为标准 Kafka 消息,格式如下:
Key (JSON 字符串)
{
"主键列1": "值1",
"主键列2": "值2"
}
Value (JSON 字符串)
{
"data": {
"列名1": "值1",
"列名2": "值2"
},
"opType": "U",
"table": "表名"
}
Headers
| Key | 说明 | 示例 |
|---|---|---|
__connector.errors.connector.name |
连接器名称 | testConnector1 |
__connector.errors.exception.message |
错误消息 | Failed to flush batch to Kafka |
__connector.errors.ip |
IP 地址 | 127.0.0.1 |
__connector.errors.retry.count |
重试次数 | 1 |
7.2 重投时原 topic 命名规则
原 topic = <KAFKA_TOPIC 配置值> + "." + <消息 value 中的 table 字段>
例如:KAFKA_TOPIC 配置为 manage_dev,消息 value.table 为 user,则重投目标 topic 为 manage_dev.user。
7.3 预览接口的核心实现原理
1. 从数据库获取 DLQ topic 名称
2. 创建临时 KafkaConsumer(随机 group.id,不自动提交)
3. 获取所有分区元数据
4. 通过 offsetsForTimes() 按起始时间 seek 到对应 offset
5. 循环 poll,超过结束时间戳或达到 1000 条上限停止
6. 在内存中:模糊匹配筛选 → 按 messageKey 去重(保留最新) → 分页截取
7. 返回 PageInfo
7.4 多租户支持
所有接口通过 tenantId 区分租户,配置按 tenantId + paramKey 从 param_config 表读取,Producer 按 tenantId 缓存。单租户系统可将 tenantId 固定为一个常量值(如 "default")。
8. 迁移注意事项
8.1 包路径修改
所有源码中的 com.liang.study 需要全局替换为目标系统的包路径。涉及的文件:
- Java 源文件中的
package声明和import语句 ParamConfigMapper.xml中的namespace和resultType
8.2 依赖适配
| 场景 | 处理方式 |
|---|---|
| 目标系统不用 MyBatis | 重写 KafkaConfigServiceImpl.getConfigValue(),改用 JPA / JDBC 查询 param_config 表 |
| 目标系统不用 Lombok | 将所有 @Data、@Slf4j、@RequiredArgsConstructor 替换为手写的 getter/setter、Logger、构造函数 |
| 目标系统不用 PageHelper | 可保留 PageInfo 作为纯数据结构使用(已在 paginateInMemory 中手动设置字段),或者替换为目标系统的分页类 |
| 目标系统不用 Swagger | 删除所有 @Schema 和 @Operation 注解,不影响功能 |
| 目标系统是 Spring Boot 2.x | 将 jakarta.validation.* 替换为 javax.validation.* |
8.3 配置数据准备
迁移前必须在目标数据库的 param_config 表中插入每个租户的三条配置数据(参见第 5 节)。否则启动后所有接口都会因配置缺失而抛出 BizException。
8.4 时区注意事项
DlqMessageVO.timestamp使用@JsonFormat(timezone = "GMT+8")序列化,DlqServiceImpl.toEpochMilli()使用ZoneId.systemDefault()转换。如果目标系统部署在不同时区,需要统一调整。- 前端传入的
startTime和endTime应使用 ISO-8601 格式(如2026-06-28T00:00:00),Spring 会自动反序列化为LocalDateTime。
8.5 Bean 注入要求
以下 Bean 必须能被 Spring 容器管理:
| Bean | 类型 | 来源 |
|---|---|---|
objectMapper |
com.fasterxml.jackson.databind.ObjectMapper |
Spring Boot 自动配置(已有) |
dataSource |
javax.sql.DataSource |
Spring Boot 自动配置(已有) |
paramConfigMapper |
MyBatis Mapper | 需扫描到 @Mapper |
kafkaConfigService |
KafkaConfigService |
需实现并注册 |
producerCache |
KafkaProducerCache |
迁移时一并复制 |
dlqService |
DlqService |
迁移时一并复制 |
8.6 预览接口的性能考量
MAX_TOTAL_RECORDS = 1000:可根据目标系统的内存和消息量调整。DLQ 消息量若非常大,可考虑降低此值。MAX_POLL_RECORDS = 500:每次 poll 的最大条数,受 topic 分区数影响。过多分区可能导致单次 poll 返回大量消息。POLL_TIMEOUT_MS = 5000:如果网络延迟较高,可适当增加 poll 超时时间。- 去重操作使用
LinkedHashMap保留插入顺序,时间复杂度 O(n)。如果预期消息量很大(> 10000),可考虑优化策略。
8.7 重投接口的幂等性
重投接口不保证幂等性——每次调用都会将消息重新发送到原 topic。如果业务需要幂等,建议在目标系统添加去重逻辑(例如记录已重投的 messageKey)。
8.8 错误处理
- 重投失败:任一条消息投递失败即抛
BizException终止,前面已成功的消息不会回滚(Kafka 消息一旦发送无法撤销)。 - 异常处理:需在目标系统配置
@RestControllerAdvice全局异常处理器,确保BizException被正确捕获并返回Result.fail(msg)。 - Consumer 关闭:preview 使用 try-with-resources 确保 Consumer 正确关闭,无需额外处理。
8.9 模拟推送接口的取舍
push 接口和 pushToDlq() 方法依赖 DataSource + JdbcTemplate 直接查询数据库表。如果目标系统不需要模拟测试功能,建议:
- 删除
DlqController.push()方法 - 删除
DlqService.pushToDlq()方法 - 删除
DlqServiceImpl中pushToDlq()及其私有辅助方法(约 100 行) - 删除
DlqPushDTO
8.10 安全注意事项
queryTableData()方法对tableName做了正则校验^[a-zA-Z0-9_.]+$,防止 SQL 注入。迁移后如修改此方法,需保留此校验逻辑。- DLQ 预览接口可能暴露消息敏感内容,建议添加权限控制。
9. 目标系统需要提前准备的配置数据
迁移后需在目标数据库 param_config 表中插入如下数据(按实际环境修改值):
-- 示例:租户ID = "1"
-- 1. Kafka 连接地址
INSERT INTO param_config (tenant_id, param_key, param_value, creator_id)
VALUES ('1', 'BOOTSTRAP', '192.168.1.100:9092', 0);
-- 2. 死信队列 topic 名称
INSERT INTO param_config (tenant_id, param_key, param_value, creator_id)
VALUES ('1', 'DLQ_TOPIC', 'dead-letter-queue', 0);
-- 3. 原业务 topic 前缀
INSERT INTO param_config (tenant_id, param_key, param_value, creator_id)
VALUES ('1', 'KAFKA_TOPIC', 'manage_dev', 0);
文档版本:v1.0
生成时间:2026-06-28
适用代码:study-backend master 分支 commit3f8c7c6
浙公网安备 33010602011771号