45-消息可靠性保证详解

2 阅读15分钟

消息可靠性保证详解

一、知识概述

消息队列在分布式系统中扮演着重要角色,但消息的可靠性问题一直是开发者的痛点。消息可能丢失、重复、乱序,这些问题在金融、电商等场景下可能导致严重的业务问题。

本文将深入分析消息可靠性问题的根源,并介绍幂等设计、消息去重、消息补偿等解决方案,帮助构建高可靠的消息系统。

二、消息可靠性问题分析

2.1 消息丢失场景

消息流转全链路:

Producer → Network → Broker → Storage → Broker → Network → Consumer
    ↓          ↓          ↓          ↓          ↓          ↓          ↓
   [1]       [2]        [3]        [4]        [5]        [6]        [7]

消息丢失风险点:

[1] Producer 发送失败
    - 网络超时
    - Broker 不可用
    - 序列化异常

[2] 网络传输丢失
    - 网络分区
    - 连接断开

[3] Broker 接收后未持久化
    - Broker 宕机
    - 内存消息丢失

[4] 存储失败
    - 磁盘满
    - IO 异常

[5] Broker 发送失败
    - Consumer 不在线
    - 网络问题

[6] 网络传输丢失
    - 网络抖动

[7] Consumer 处理失败
    - 业务异常
    - 系统崩溃

2.2 消息重复场景

消息重复原因:

1. 生产者重试
   Producer                     Broker
      |                           |
      |--- Send Message --------->|
      |                           |
      |<-- ACK (网络超时)          |  ← ACK 丢失
      |                           |
      |--- Retry Send ----------->|  ← 重试发送
      |                           |
   结果:消息被存储两次

2. 消费者重复消费
   Broker                      Consumer
      |                           |
      |--- Push Message --------->|
      |                           |
      |    Process Message        |
      |                           |
      |<-- ACK (网络超时)          |  ← ACK 丢失
      |                           |
      |--- Push Again ----------->|  ← 重新投递
      |                           |
   结果:消息被消费两次

3. Rebalance 期间
   - 消费者组 Rebalance
   - 未提交的 offset 丢失
   - 消息被重新消费

2.3 消息乱序场景

消息顺序问题:

生产顺序:Msg1  Msg2  Msg3

场景1:多队列并发
Queue 0: Msg1  Msg3
Queue 1: Msg2

消费顺序可能是:Msg1  Msg2  Msg3  Msg2  Msg1  Msg3

场景2:消费者并发处理
Consumer Thread 1: Msg1 (处理慢)
Consumer Thread 2: Msg2 (处理快)

实际处理顺序:Msg2  Msg1

场景3:重试导致乱序
Msg1 消费失败  进入重试队列
Msg2 消费成功
Msg3 消费成功
Msg1 重试成功

实际顺序:Msg2  Msg3  Msg1

三、幂等设计

3.1 幂等概念

幂等(Idempotent)是指:同一操作执行多次与执行一次的效果相同。

/**
 * 幂等性分析
 */

// 幂等操作
int x = 1;           // 赋值是幂等的
x = Math.max(x, 2);  // max 是幂等的
set.add("a");        // Set 添加是幂等的

// 非幂等操作
x++;                 // 自增不是幂等的
list.add("a");       // List 添加不是幂等的
account += 100;      // 累加不是幂等的

3.2 幂等设计方案

方案一:唯一 ID + 数据库唯一索引
/**
 * 基于数据库唯一索引的幂等设计
 */
@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 创建订单(幂等)
     */
    @Transactional
    public void createOrder(Order order) {
        try {
            // 插入订单,orderId 有唯一索引
            orderMapper.insert(order);
            
        } catch (DuplicateKeyException e) {
            // 唯一键冲突,说明订单已存在
            log.info("订单已存在,跳过: {}", order.getOrderId());
            // 幂等返回,不报错
        }
    }
}

/**
 * 订单表设计
 */
CREATE TABLE `t_order` (
  `order_id` varchar(64) NOT NULL COMMENT '订单ID(消息Key)',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `amount` decimal(10,2) NOT NULL COMMENT '订单金额',
  `status` int NOT NULL COMMENT '订单状态',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  PRIMARY KEY (`order_id`),  -- 主键保证幂等
  KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
方案二:去重表
/**
 * 基于去重表的幂等设计
 */
@Service
public class IdempotentService {
    
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    private static final String INSERT_DEDUP_SQL = 
        "INSERT INTO t_msg_dedup (msg_id, create_time) VALUES (?, NOW())";
    
    /**
     * 检查并标记消息已处理
     * @return true=首次处理,false=重复消息
     */
    public boolean checkAndMarkProcessed(String msgId) {
        try {
            // 尝试插入去重表
            jdbcTemplate.update(INSERT_DEDUP_SQL, msgId);
            return true;  // 插入成功,首次处理
            
        } catch (DuplicateKeyException e) {
            return false; // 唯一键冲突,重复消息
        }
    }
}

/**
 * 消息去重表设计
 */
CREATE TABLE `t_msg_dedup` (
  `msg_id` varchar(64) NOT NULL COMMENT '消息唯一ID',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  PRIMARY KEY (`msg_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

/**
 * 使用示例
 */
@Service
class OrderMessageConsumer {
    
    @Autowired
    private IdempotentService idempotentService;
    
    @Autowired
    private OrderService orderService;
    
    public void handleOrderMessage(MessageExt msg) {
        String msgId = msg.getKeys(); // 使用业务唯一标识
        
        // 幂等检查
        if (!idempotentService.checkAndMarkProcessed(msgId)) {
            log.info("重复消息,跳过: {}", msgId);
            return;
        }
        
        // 处理业务
        Order order = parseOrder(msg);
        orderService.process(order);
    }
    
    private Order parseOrder(MessageExt msg) {
        return JSON.parseObject(new String(msg.getBody()), Order.class);
    }
}
方案三:Token 机制
/**
 * 基于 Token 的幂等设计
 */
@Service
public class TokenService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 生成 Token
     */
    public String createToken(String businessType) {
        String token = UUID.randomUUID().toString();
        String key = "token:" + businessType + ":" + token;
        
        // Token 有效期 30 分钟
        redisTemplate.opsForValue().set(key, "1", 30, TimeUnit.MINUTES);
        
        return token;
    }
    
    /**
     * 校验并删除 Token(原子操作)
     * @return true=Token 有效,false=Token 无效或已使用
     */
    public boolean checkAndDeleteToken(String businessType, String token) {
        String key = "token:" + businessType + ":" + token;
        
        // Lua 脚本保证原子性
        String luaScript = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "    return redis.call('del', KEYS[1]) " +
            "else " +
            "    return 0 " +
            "end";
        
        RedisScript<Long> script = RedisScript.of(luaScript, Long.class);
        Long result = redisTemplate.execute(script, 
            Collections.singletonList(key), "1");
        
        return result != null && result == 1;
    }
}

/**
 * Controller 层使用 Token
 */
@RestController
@RequestMapping("/order")
public class OrderController {
    
    @Autowired
    private TokenService tokenService;
    
    @Autowired
    private OrderService orderService;
    
    /**
     * 获取 Token
     */
    @GetMapping("/token")
    public String getToken() {
        return tokenService.createToken("order");
    }
    
    /**
     * 创建订单(幂等)
     */
    @PostMapping("/create")
    public Result createOrder(@RequestHeader("Idempotent-Token") String token,
                               @RequestBody OrderRequest request) {
        
        // 校验 Token
        if (!tokenService.checkAndDeleteToken("order", token)) {
            return Result.fail("重复请求或 Token 无效");
        }
        
        // 处理业务
        orderService.createOrder(request);
        
        return Result.success();
    }
}
方案四:状态机幂等
/**
 * 基于状态机的幂等设计
 */
@Service
public class OrderStateMachine {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 订单状态
     */
    public enum OrderStatus {
        CREATED(1, "已创建"),
        PAID(2, "已支付"),
        DELIVERED(3, "已发货"),
        COMPLETED(4, "已完成"),
        CANCELLED(5, "已取消");
        
        private final int code;
        private final String desc;
        
        OrderStatus(int code, String desc) {
            this.code = code;
            this.desc = desc;
        }
        
        public int getCode() { return code; }
    }
    
    /**
     * 支付成功(幂等)
     */
    @Transactional
    public boolean paySuccess(String orderId) {
        Order order = orderMapper.selectById(orderId);
        
        if (order == null) {
            throw new BusinessException("订单不存在");
        }
        
        // 状态检查:只有 CREATED 状态才能支付
        if (order.getStatus() != OrderStatus.CREATED.getCode()) {
            // 幂等:已支付或其他状态,直接返回成功
            log.info("订单状态不是已创建,跳过支付: orderId={}, status={}", 
                orderId, order.getStatus());
            return true;
        }
        
        // 状态转换:CREATED → PAID
        int rows = orderMapper.updateStatus(
            orderId, 
            OrderStatus.CREATED.getCode(),  // 期望原状态
            OrderStatus.PAID.getCode()      // 新状态
        );
        
        if (rows == 0) {
            // 状态已变更(并发导致),幂等返回
            log.info("订单状态已变更,跳过支付: orderId={}", orderId);
            return true;
        }
        
        // 执行支付后逻辑
        afterPaySuccess(order);
        
        return true;
    }
    
    private void afterPaySuccess(Order order) {
        // 发送发货消息、通知用户等
    }
}

/**
 * Mapper 层:乐观锁更新
 */
@Mapper
public interface OrderMapper {
    
    /**
     * 更新订单状态(乐观锁)
     */
    @Update("UPDATE t_order SET status = #{newStatus}, " +
            "update_time = NOW() " +
            "WHERE order_id = #{orderId} AND status = #{oldStatus}")
    int updateStatus(@Param("orderId") String orderId,
                     @Param("oldStatus") int oldStatus,
                     @Param("newStatus") int newStatus);
    
    @Select("SELECT * FROM t_order WHERE order_id = #{orderId}")
    Order selectById(@Param("orderId") String orderId);
}

3.3 幂等方案对比

方案优点缺点适用场景
唯一索引简单、可靠依赖数据库有数据库表的业务
去重表灵活、业务无关需要额外表通用场景
Token性能好、无存储需要两次请求前端防重复提交
状态机业务语义清晰需要明确状态定义状态流转类业务

四、消息去重

4.1 去重原理

消息去重核心:保证消息处理的唯一性

关键点:
1. 消息必须有唯一标识(MessageId / Key)
2. 去重容器要支持快速查询
3. 去重记录要有过期策略

4.2 Redis 去重

/**
 * 基于 Redis 的消息去重
 */
@Service
@Slf4j
public class RedisMessageDedup {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String DEDUP_KEY_PREFIX = "msg:dedup:";
    private static final long EXPIRE_DAYS = 7; // 去重记录保留 7 天
    
    /**
     * 检查消息是否已处理
     */
    public boolean isDuplicate(String msgId) {
        String key = DEDUP_KEY_PREFIX + msgId;
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }
    
    /**
     * 标记消息已处理
     */
    public void markProcessed(String msgId) {
        String key = DEDUP_KEY_PREFIX + msgId;
        redisTemplate.opsForValue().set(key, "1", EXPIRE_DAYS, TimeUnit.DAYS);
    }
    
    /**
     * 检查并标记(原子操作)
     * @return true=首次处理,false=重复消息
     */
    public boolean checkAndMark(String msgId) {
        String key = DEDUP_KEY_PREFIX + msgId;
        
        // setIfAbsent = SETNX
        Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(key, "1", EXPIRE_DAYS, TimeUnit.DAYS);
        
        return Boolean.TRUE.equals(success);
    }
    
    /**
     * 批量去重
     */
    public List<String> filterDuplicate(List<String> msgIds) {
        List<String> uniqueMsgIds = new ArrayList<>();
        
        for (String msgId : msgIds) {
            if (checkAndMark(msgId)) {
                uniqueMsgIds.add(msgId);
            }
        }
        
        return uniqueMsgIds;
    }
}

/**
 * 使用示例
 */
@Service
class OrderConsumer {
    
    @Autowired
    private RedisMessageDedup dedupService;
    
    @Autowired
    private OrderService orderService;
    
    public void handleOrderMessage(MessageExt msg) {
        String msgId = msg.getKeys(); // 业务唯一标识
        
        // 去重检查
        if (!dedupService.checkAndMark(msgId)) {
            log.info("重复消息,跳过: {}", msgId);
            return;
        }
        
        // 处理业务
        try {
            Order order = parseOrder(msg);
            orderService.process(order);
        } catch (Exception e) {
            // 处理失败,删除去重标记,允许重试
            dedupService.remove(msgId);
            throw e;
        }
    }
    
    private Order parseOrder(MessageExt msg) {
        return JSON.parseObject(new String(msg.getBody()), Order.class);
    }
}

// RedisMessageDedup 补充方法
class RedisMessageDedup {
    public void remove(String msgId) {
        String key = DEDUP_KEY_PREFIX + msgId;
        redisTemplate.delete(key);
    }
}

4.3 Bloom Filter 去重

/**
 * 基于 Bloom Filter 的消息去重(海量数据场景)
 */
@Service
@Slf4j
public class BloomFilterDedup {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String BLOOM_KEY = "msg:bloom:filter";
    
    /**
     * 添加到 Bloom Filter
     */
    public void addToBloomFilter(String msgId) {
        // 使用 Redis 的 SETBIT 实现简化版 Bloom Filter
        long[] positions = hash(msgId);
        for (long position : positions) {
            redisTemplate.opsForValue().setBit(BLOOM_KEY, position, true);
        }
    }
    
    /**
     * 检查是否可能存在
     * @return true=可能存在,false=一定不存在
     */
    public boolean mightContain(String msgId) {
        long[] positions = hash(msgId);
        for (long position : positions) {
            Boolean bit = redisTemplate.opsForValue().getBit(BLOOM_KEY, position);
            if (!Boolean.TRUE.equals(bit)) {
                return false; // 一定不存在
            }
        }
        return true; // 可能存在
    }
    
    /**
     * 多重哈希
     */
    private long[] hash(String msgId) {
        // 简化实现:使用 3 个哈希函数
        long[] positions = new long[3];
        int hash1 = msgId.hashCode();
        int hash2 = msgId.hashCode() * 31;
        int hash3 = msgId.hashCode() * 37;
        
        // 映射到 2^32 位空间
        positions[0] = Math.abs(hash1) % Integer.MAX_VALUE;
        positions[1] = Math.abs(hash2) % Integer.MAX_VALUE;
        positions[2] = Math.abs(hash3) % Integer.MAX_VALUE;
        
        return positions;
    }
    
    /**
     * Bloom Filter + 去重表组合方案
     */
    public boolean checkAndMark(String msgId) {
        // 第一层:Bloom Filter 快速过滤
        if (!mightContain(msgId)) {
            // 一定不存在,添加并返回首次处理
            addToBloomFilter(msgId);
            return true;
        }
        
        // 第二层:精确去重表检查
        // 可能存在,需要精确查询去重表
        // ...
        
        return false;
    }
}

/**
 * Guava Bloom Filter 示例(本地内存版)
 */
class LocalBloomFilterDedup {
    
    private final BloomFilter<String> bloomFilter;
    
    public LocalBloomFilterDedup() {
        // 预计元素数量 100 万,误判率 0.01%
        this.bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(Charset.defaultCharset()),
            1_000_000,
            0.0001
        );
    }
    
    public boolean checkAndMark(String msgId) {
        if (bloomFilter.mightContain(msgId)) {
            return false; // 可能重复
        }
        bloomFilter.put(msgId);
        return true;
    }
}

4.4 数据库去重

/**
 * 基于数据库的消息去重(可靠性最高)
 */
@Service
public class DatabaseMessageDedup {
    
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    private static final String INSERT_SQL = 
        "INSERT INTO t_message_log " +
        "(msg_id, topic, tag, key, status, retry_count, create_time) " +
        "VALUES (?, ?, ?, ?, 'PROCESSING', 0, NOW()) " +
        "ON DUPLICATE KEY UPDATE retry_count = retry_count";
    
    private static final String UPDATE_SUCCESS_SQL = 
        "UPDATE t_message_log SET status = 'SUCCESS', update_time = NOW() " +
        "WHERE msg_id = ?";
    
    /**
     * 开始处理消息
     * @return true=可以处理,false=重复消息
     */
    public boolean startProcess(String msgId, String topic, String tag, String key) {
        try {
            int rows = jdbcTemplate.update(INSERT_SQL, msgId, topic, tag, key);
            return rows > 0;
        } catch (Exception e) {
            log.warn("消息已处理过: {}", msgId);
            return false;
        }
    }
    
    /**
     * 标记处理成功
     */
    public void markSuccess(String msgId) {
        jdbcTemplate.update(UPDATE_SUCCESS_SQL, msgId);
    }
    
    /**
     * 标记处理失败
     */
    public void markFailed(String msgId, String error) {
        jdbcTemplate.update(
            "UPDATE t_message_log SET status = 'FAILED', error = ?, " +
            "retry_count = retry_count + 1, update_time = NOW() WHERE msg_id = ?",
            error, msgId
        );
    }
}

/**
 * 消息处理日志表
 */
CREATE TABLE `t_message_log` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `msg_id` varchar(64) NOT NULL COMMENT '消息ID',
  `topic` varchar(128) NOT NULL COMMENT 'Topic',
  `tag` varchar(128) DEFAULT NULL COMMENT 'Tag',
  `key` varchar(128) DEFAULT NULL COMMENT '业务Key',
  `status` varchar(32) NOT NULL COMMENT '状态:PROCESSING/SUCCESS/FAILED',
  `retry_count` int NOT NULL DEFAULT '0' COMMENT '重试次数',
  `error` text COMMENT '错误信息',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_msg_id` (`msg_id`),
  KEY `idx_status` (`status`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

五、消息补偿机制

5.1 补偿原理

消息补偿场景:

1. 消息发送失败
   Producer → [失败] → Broker
   解决方案:
   - 本地消息表记录发送状态
   - 定时任务扫描重发

2. 消息消费失败
   Broker → [失败] → Consumer
   解决方案:
   - 重试队列
   - 死信队列
   - 人工处理

3. 消息状态不确定
   Producer → [超时] → Broker (可能成功,可能失败)
   解决方案:
   - 事务消息
   - 消息状态查询

5.2 本地消息表方案

/**
 * 本地消息表方案
 * 
 * 核心思想:将业务操作和消息发送放在同一事务中
 */
@Service
@Slf4j
public class LocalMessageService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Autowired
    private MessageRecordMapper messageRecordMapper;
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    /**
     * 创建订单并发送消息(事务性)
     */
    @Transactional
    public void createOrderWithMessage(Order order) {
        // 1. 插入订单
        orderMapper.insert(order);
        
        // 2. 插入消息记录(同一事务)
        MessageRecord record = new MessageRecord();
        record.setMessageId(UUID.randomUUID().toString());
        record.setTopic("ORDER_TOPIC");
        record.setTag("TagA");
        record.setMessageBody(JSON.toJSONString(order));
        record.setStatus("PENDING"); // 待发送
        record.setCreateTime(new Date());
        messageRecordMapper.insert(record);
        
        // 事务提交后,定时任务会扫描并发送消息
    }
    
    /**
     * 定时任务:扫描待发送消息
     */
    @Scheduled(fixedDelay = 60000) // 每分钟执行
    public void sendPendingMessages() {
        // 查询待发送消息
        List<MessageRecord> records = messageRecordMapper
            .selectByStatus("PENDING", 100);
        
        for (MessageRecord record : records) {
            try {
                // 发送消息
                SendResult result = rocketMQTemplate.syncSend(
                    record.getTopic() + ":" + record.getTag(),
                    record.getMessageBody()
                );
                
                // 更新状态为已发送
                messageRecordMapper.updateStatus(
                    record.getMessageId(), "SENT", null);
                
                log.info("消息发送成功: {}", record.getMessageId());
                
            } catch (Exception e) {
                log.error("消息发送失败: {}", record.getMessageId(), e);
                
                // 更新重试次数
                int retryCount = record.getRetryCount() + 1;
                if (retryCount >= 5) {
                    // 超过重试次数,标记为失败
                    messageRecordMapper.updateStatus(
                        record.getMessageId(), "FAILED", e.getMessage());
                } else {
                    messageRecordMapper.updateRetryCount(
                        record.getMessageId(), retryCount);
                }
            }
        }
    }
}

/**
 * 消息记录表
 */
@Data
class MessageRecord {
    private Long id;
    private String messageId;
    private String topic;
    private String tag;
    private String messageBody;
    private String status;     // PENDING/SENT/FAILED
    private Integer retryCount;
    private String errorMessage;
    private Date createTime;
    private Date updateTime;
}

/**
 * Mapper
 */
@Mapper
interface MessageRecordMapper {
    
    @Insert("INSERT INTO t_message_record " +
            "(message_id, topic, tag, message_body, status, retry_count, create_time) " +
            "VALUES (#{messageId}, #{topic}, #{tag}, #{messageBody}, #{status}, 0, NOW())")
    void insert(MessageRecord record);
    
    @Select("SELECT * FROM t_message_record WHERE status = #{status} " +
            "ORDER BY create_time LIMIT #{limit}")
    List<MessageRecord> selectByStatus(@Param("status") String status,
                                        @Param("limit") int limit);
    
    @Update("UPDATE t_message_record SET status = #{status}, " +
            "error_message = #{error}, update_time = NOW() " +
            "WHERE message_id = #{messageId}")
    void updateStatus(@Param("messageId") String messageId,
                      @Param("status") String status,
                      @Param("error") String error);
    
    @Update("UPDATE t_message_record SET retry_count = #{retryCount}, " +
            "update_time = NOW() WHERE message_id = #{messageId}")
    void updateRetryCount(@Param("messageId") String messageId,
                          @Param("retryCount") int retryCount);
}

CREATE TABLE `t_message_record` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `message_id` varchar(64) NOT NULL,
  `topic` varchar(128) NOT NULL,
  `tag` varchar(64) DEFAULT NULL,
  `message_body` text NOT NULL,
  `status` varchar(32) NOT NULL COMMENT 'PENDING/SENT/FAILED',
  `retry_count` int NOT NULL DEFAULT '0',
  `error_message` text,
  `create_time` datetime NOT NULL,
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_message_id` (`message_id`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

5.3 死信队列处理

/**
 * 死信队列处理
 */
@Service
@Slf4j
public class DeadLetterQueueHandler {
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    @Autowired
    private MessageFailLogMapper failLogMapper;
    
    /**
     * 消费死信队列消息
     */
    @RocketMQMessageListener(
        topic = "%DLQ%ORDER_CONSUMER_GROUP",  // 死信队列 Topic
        consumerGroup = "DLQ_HANDLER_GROUP"
    )
    public class DlqListener implements RocketMQListener<MessageExt> {
        
        @Override
        public void onMessage(MessageExt message) {
            log.warn("收到死信消息: {}", new String(message.getBody()));
            
            // 记录失败日志
            MessageFailLog failLog = new MessageFailLog();
            failLog.setMessageId(message.getKeys());
            failLog.setTopic(message.getTopic());
            failLog.setTag(message.getTags());
            failLog.setMessageBody(new String(message.getBody()));
            failLog.setReason("Dead letter message");
            failLog.setStatus("PENDING");
            failLog.setCreateTime(new Date());
            
            failLogMapper.insert(failLog);
            
            // 可以选择:
            // 1. 人工处理
            // 2. 自动重试
            // 3. 发送告警
            sendAlert(failLog);
        }
    }
    
    /**
     * 重新处理死信消息
     */
    public void retryDeadLetterMessage(Long failLogId) {
        MessageFailLog failLog = failLogMapper.selectById(failLogId);
        
        if (failLog == null) {
            throw new BusinessException("记录不存在");
        }
        
        try {
            // 重新发送到原 Topic
            rocketMQTemplate.syncSend(
                failLog.getTopic() + ":" + failLog.getTag(),
                failLog.getMessageBody()
            );
            
            // 更新状态
            failLogMapper.updateStatus(failLogId, "RETRIED");
            
            log.info("死信消息重新发送成功: {}", failLogId);
            
        } catch (Exception e) {
            log.error("死信消息重新发送失败: {}", failLogId, e);
            throw new BusinessException("重试失败");
        }
    }
    
    private void sendAlert(MessageFailLog failLog) {
        // 发送告警(邮件、短信、钉钉等)
        log.error("死信告警: {}", failLog);
    }
}

/**
 * 消息失败日志
 */
@Data
class MessageFailLog {
    private Long id;
    private String messageId;
    private String topic;
    private String tag;
    private String messageBody;
    private String reason;
    private String status;  // PENDING/RETRIED/RESOLVED
    private Date createTime;
}

5.4 消息对账机制

/**
 * 消息对账机制
 */
@Service
@Slf4j
public class MessageReconciliation {
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Autowired
    private MessageRecordMapper messageRecordMapper;
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    /**
     * 定时对账:检查订单状态与消息发送状态
     */
    @Scheduled(cron = "0 0 * * * ?") // 每小时执行
    public void reconcile() {
        log.info("开始消息对账...");
        
        // 1. 查找已支付但未发送消息的订单
        List<Order> unpaidOrders = orderMapper.selectPaidButNoMessage();
        
        for (Order order : unpaidOrders) {
            // 补发消息
            compensateMessage(order);
        }
        
        // 2. 查找消息已发送但业务未处理的记录
        List<MessageRecord> sentRecords = messageRecordMapper
            .selectByStatus("SENT", 1000);
        
        for (MessageRecord record : sentRecords) {
            // 检查业务是否已处理
            if (!isBusinessProcessed(record)) {
                // 重新发送或标记异常
                handleUnprocessedMessage(record);
            }
        }
        
        log.info("消息对账完成");
    }
    
    private void compensateMessage(Order order) {
        log.info("补发消息: orderId={}", order.getOrderId());
        
        // 发送消息
        rocketMQTemplate.syncSend("ORDER_TOPIC:TagA", 
            JSON.toJSONString(order));
        
        // 更新订单消息状态
        orderMapper.updateMessageSent(order.getOrderId());
    }
    
    private boolean isBusinessProcessed(MessageRecord record) {
        // 检查业务是否已处理
        // 例如:检查库存是否已扣减
        return true;
    }
    
    private void handleUnprocessedMessage(MessageRecord record) {
        // 处理未处理的消息
        // 可以重新发送或标记异常
        log.warn("消息未处理: {}", record.getMessageId());
    }
}

六、完整示例

6.1 可靠消息消费者

/**
 * 可靠消息消费者完整示例
 */
@Service
@Slf4j
@RocketMQMessageListener(
    topic = "ORDER_TOPIC",
    consumerGroup = "ORDER_CONSUMER_GROUP",
    consumeMode = ConsumeMode.CONCURRENTLY
)
public class ReliableOrderConsumer implements RocketMQListener<MessageExt> {
    
    @Autowired
    private RedisMessageDedup dedupService;
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private MessageFailLogMapper failLogMapper;
    
    @Override
    public void onMessage(MessageExt message) {
        String msgId = message.getKeys();
        String msgBody = new String(message.getBody());
        
        log.info("收到消息: msgId={}, topic={}, tag={}", 
            msgId, message.getTopic(), message.getTags());
        
        // 1. 幂等检查
        if (!dedupService.checkAndMark(msgId)) {
            log.info("重复消息,跳过: {}", msgId);
            return;
        }
        
        try {
            // 2. 解析消息
            Order order = JSON.parseObject(msgBody, Order.class);
            
            // 3. 业务处理(带状态机幂等)
            orderService.processOrder(order);
            
            // 4. 处理成功(去重记录已在 Redis 中)
            log.info("消息处理成功: {}", msgId);
            
        } catch (RetryableException e) {
            // 可重试异常:删除去重标记,触发重试
            log.warn("消息处理失败,等待重试: msgId={}, error={}", msgId, e.getMessage());
            dedupService.remove(msgId);
            throw e;
            
        } catch (NonRetryableException e) {
            // 不可重试异常:记录失败日志,不重试
            log.error("消息处理失败(不重试): msgId={}", msgId, e);
            
            // 记录失败日志
            MessageFailLog failLog = new MessageFailLog();
            failLog.setMessageId(msgId);
            failLog.setTopic(message.getTopic());
            failLog.setTag(message.getTags());
            failLog.setMessageBody(msgBody);
            failLog.setReason(e.getMessage());
            failLog.setStatus("FAILED");
            failLog.setCreateTime(new Date());
            failLogMapper.insert(failLog);
            
            // 不抛异常,确认消息消费成功
        }
    }
}

/**
 * 订单服务
 */
@Service
class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 处理订单(幂等)
     */
    @Transactional
    public void processOrder(Order order) {
        // 1. 查询订单
        Order existingOrder = orderMapper.selectById(order.getOrderId());
        
        // 2. 状态检查(幂等)
        if (existingOrder != null && existingOrder.getStatus() == 1) {
            // 订单已处理
            log.info("订单已处理,跳过: {}", order.getOrderId());
            return;
        }
        
        // 3. 创建订单
        if (existingOrder == null) {
            try {
                orderMapper.insert(order);
            } catch (DuplicateKeyException e) {
                // 并发插入,幂等返回
                log.info("订单已存在,跳过: {}", order.getOrderId());
                return;
            }
        }
        
        // 4. 执行业务逻辑(扣库存、通知等)
        // ...
    }
}

七、总结

消息可靠性保证策略

问题解决方案
消息丢失生产者 ACK、Broker 持久化、消费者确认
消息重复幂等设计、去重机制
消息乱序顺序消息、单队列消费
消息积压增加消费者、批量处理
消息失败重试机制、死信队列、补偿机制

最佳实践

  1. 生产者:使用事务消息或本地消息表保证发送可靠性
  2. 消费者:实现幂等处理,区分可重试和不可重试异常
  3. 监控:监控消息积压、消费延迟、失败率
  4. 补偿:定期对账,发现并修复不一致

六、思考与练习

思考题

  1. 基础题:消息幂等与消息去重有什么区别?为什么说幂等是业务层面的概念,而去重是技术层面的概念?

  2. 进阶题:分析四种幂等方案(唯一索引、去重表、Token、状态机)的优缺点,在什么场景下应该选择哪种方案?

  3. 实战题:设计一个完整的消息可靠性保障体系,涵盖生产者发送、Broker存储、消费者处理三个环节,并考虑监控告警与故障恢复机制。

编程练习

练习:实现一个完整的可靠消息消费者框架,包含:(1) Redis去重服务;(2) 数据库去重表作为二级保障;(3) 幂等业务处理(状态机);(4) 死信队列处理;(5) 消息处理日志记录与对账功能。

章节关联

  • 前置章节:RocketMQ核心原理与实战(事务消息)
  • 后续章节:无(消息队列系列完结)
  • 扩展阅读:《数据密集型应用系统设计》第七章、分布式事务模式

📝 系列结语

本系列文章涵盖了分布式系统的核心理论与实践:从CAP/BASE理论基础,到分布式ID、锁、事务的实现方案,再到三大消息队列(RabbitMQ、Kafka、RocketMQ)的深度解析,最后以消息可靠性保障收尾。希望读者能够将这些知识融会贯通,在实际项目中设计出高可用、高可靠的分布式系统。


本章完


参考资料: