🔄 如何保证消息的幂等消费:消费多次也不怕!

38 阅读18分钟

📖 开场:电梯按钮的智慧

想象你在电梯里 🛗:

场景1:普通按钮

1次按"10楼":电梯去10楼 ✅
第2次按"10楼":电梯又去一次10楼 ❌ (重复了)
第3次按"10楼":电梯再去一次10楼 ❌❌ (更重复了)

结果:你按了3次,电梯去了3次,浪费时间!😱

场景2:幂等按钮(现实中的电梯)

1次按"10楼":电梯去10楼 ✅
第2次按"10楼":电梯知道已经按过了,不重复去 ✅
第3次按"10楼":还是不重复去 ✅

结果:你按了3次,电梯只去1次,完美!👍

这就是幂等性(Idempotence)!

定义同一个操作执行多次,结果和执行一次是一样的


🤔 为什么需要消息幂等消费?

消息重复的场景

场景1:生产者重复发送 📤

Producer发送消息 → 网络抖动 → 没收到ACK
→ Producer重试 → 再发一次 → 消息重复了!💀

Broker:收到2条相同的消息

示例

订单消息:
第1次发送:订单ID=1001,金额=100元 ✅
第2次发送:订单ID=1001,金额=100元 ✅ (重复)

如果不做幂等处理:
→ 创建了2个订单!💀
→ 扣了2次钱!💀💀

场景2:Broker存储重复 💾

Broker收到消息 → 存储成功 → 准备返回ACK → 网络故障
→ Producer没收到ACK → 重试发送 → Broker又存储一次!💀

场景3:消费者重复消费 📥

Consumer消费消息 → 处理成功 → 准备提交offset → 程序宕机 💀
→ Consumer重启 → 从上次的offset重新消费 → 重复消费!💀

或者:
Consumer消费消息 → 处理成功 → 提交offset失败(网络故障)
→ 下次还是从旧offset开始 → 重复消费!💀

示例

积分消息:用户ID=1001,增加10积分

第1次消费:积分+10 ✅ (积分=110)
第2次消费:积分+10 ❌ (积分=120,错了!)

应该是:
第1次消费:积分+10 ✅ (积分=110)
第2次消费:发现已经处理过,不再+10 ✅ (积分=110,正确!)

场景4:Rebalance导致重复 🔄

Consumer-1正在消费分区0 → 消费到一半
→ 触发Rebalance → 分区0分配给Consumer-2
→ Consumer-2从上次提交的offset开始消费
→ 中间那部分消息被重复消费了!💀

重复消费的危害 💀

场景1:重复扣款 💰

消息:扣减用户余额100元
重复消费3次 → 扣了300元!😱

场景2:重复发货 📦

消息:订单已支付,通知发货
重复消费 → 发了多次货!😱

场景3:数据错误 📊

消息:统计UV+1
重复消费10次 → UV多了10!😱

场景4:重复通知 📧

消息:发送支付成功通知
重复消费 → 用户收到10条短信!😱

所以,幂等消费非常重要!


🛡️ 幂等消费的实现方案

方案1:唯一ID + 数据库去重表 🔐⭐⭐⭐⭐⭐

核心思想:给每条消息一个唯一ID,消费前先查表,看是否已经处理过

流程

1. 消息带上唯一ID(消息ID或业务ID)
2. 消费者收到消息
3. 查询去重表:这个ID处理过吗?
   ├─ 已处理 → 直接返回成功,不再处理 ✅
   └─ 未处理 → 继续处理
4. 处理业务逻辑
5. 插入去重表:标记这个ID已处理
6. 提交数据库事务

去重表设计

CREATE TABLE message_idempotent (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    message_id VARCHAR(64) NOT NULL UNIQUE COMMENT '消息唯一ID',
    consumer_group VARCHAR(100) NOT NULL COMMENT '消费者组',
    topic VARCHAR(100) NOT NULL COMMENT 'Topic',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=已处理',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '处理时间',
    
    UNIQUE KEY uk_message_id (message_id, consumer_group)
) COMMENT='消息幂等表';

-- 创建索引
CREATE INDEX idx_create_time ON message_idempotent(create_time);

字段说明

  • message_id:消息唯一ID(可以是消息的msgId,也可以是业务ID)
  • consumer_group:消费者组(同一条消息,不同消费者组可以重复消费)
  • status:处理状态
  • create_time:处理时间(用于定期清理)

代码实现

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Slf4j
public class IdempotentMessageConsumer {
    
    @Autowired
    private IdempotentService idempotentService;
    
    @Autowired
    private OrderService orderService;
    
    /**
     * 幂等消费消息
     */
    @Transactional
    public void consumeMessage(ConsumerRecord<String, String> record) {
        // 1. 获取消息ID(可以是msgId,也可以从消息体解析业务ID)
        String messageId = extractMessageId(record);
        String consumerGroup = "order-consumer-group";
        
        log.info("开始消费消息, messageId={}", messageId);
        
        // 2. ⭐ 检查是否已经处理过(查询去重表)
        if (idempotentService.isProcessed(messageId, consumerGroup)) {
            log.info("✅ 消息已处理过,跳过, messageId={}", messageId);
            return;  // 已处理,直接返回
        }
        
        try {
            // 3. 处理业务逻辑
            Order order = parseOrder(record.value());
            orderService.createOrder(order);
            
            // 4. ⭐ 标记消息已处理(插入去重表)
            idempotentService.markProcessed(messageId, consumerGroup, record.topic());
            
            log.info("✅ 消息处理成功, messageId={}", messageId);
            
        } catch (Exception e) {
            log.error("❌ 消息处理失败, messageId={}", messageId, e);
            throw e;  // 抛异常,触发重试
        }
    }
    
    /**
     * 提取消息ID
     */
    private String extractMessageId(ConsumerRecord<String, String> record) {
        // 方法1:使用Kafka的offset作为唯一ID
        // return record.topic() + "-" + record.partition() + "-" + record.offset();
        
        // 方法2:从消息体解析业务ID
        JSONObject json = JSON.parseObject(record.value());
        return json.getString("orderId");  // 订单ID作为唯一ID
    }
    
    /**
     * 解析订单
     */
    private Order parseOrder(String json) {
        return JSON.parseObject(json, Order.class);
    }
}

幂等服务实现

@Service
@Slf4j
public class IdempotentService {
    
    @Autowired
    private MessageIdempotentMapper idempotentMapper;
    
    /**
     * 检查消息是否已处理
     */
    public boolean isProcessed(String messageId, String consumerGroup) {
        MessageIdempotent record = idempotentMapper.selectByMessageId(messageId, consumerGroup);
        return record != null;
    }
    
    /**
     * 标记消息已处理
     */
    public void markProcessed(String messageId, String consumerGroup, String topic) {
        MessageIdempotent record = new MessageIdempotent();
        record.setMessageId(messageId);
        record.setConsumerGroup(consumerGroup);
        record.setTopic(topic);
        record.setStatus(1);  // 1=已处理
        record.setCreateTime(new Date());
        
        try {
            idempotentMapper.insert(record);
        } catch (DuplicateKeyException e) {
            // ⭐ 唯一索引冲突,说明已经插入过了(并发场景)
            log.warn("消息已被其他线程处理, messageId={}", messageId);
        }
    }
    
    /**
     * 清理过期记录(定时任务,每天清理7天前的数据)
     */
    @Scheduled(cron = "0 0 2 * * ?")  // 每天凌晨2点执行
    public void cleanExpiredRecords() {
        Date expireTime = DateUtils.addDays(new Date(), -7);  // 7天前
        int count = idempotentMapper.deleteByCreateTimeBefore(expireTime);
        log.info("清理过期幂等记录, count={}", count);
    }
}

Mapper实现

@Mapper
public interface MessageIdempotentMapper {
    
    /**
     * 查询记录
     */
    @Select("SELECT * FROM message_idempotent " +
            "WHERE message_id = #{messageId} AND consumer_group = #{consumerGroup}")
    MessageIdempotent selectByMessageId(@Param("messageId") String messageId,
                                       @Param("consumerGroup") String consumerGroup);
    
    /**
     * 插入记录
     */
    @Insert("INSERT INTO message_idempotent(message_id, consumer_group, topic, status, create_time) " +
            "VALUES(#{messageId}, #{consumerGroup}, #{topic}, #{status}, #{createTime})")
    void insert(MessageIdempotent record);
    
    /**
     * 删除过期记录
     */
    @Delete("DELETE FROM message_idempotent WHERE create_time < #{expireTime}")
    int deleteByCreateTimeBefore(@Param("expireTime") Date expireTime);
}

优缺点

优点

  • ✅ 实现简单
  • ✅ 可靠性高(数据库保证)
  • ✅ 支持并发(唯一索引)
  • ✅ **最常用的方案!**⭐⭐⭐⭐⭐

缺点

  • ❌ 每次消费都要查询数据库(性能开销)
  • ❌ 去重表数据会越来越多(需要定期清理)

优化

  • 加本地缓存(Caffeine/Guava Cache)
  • 加Redis缓存
  • 定期清理去重表

方案2:Redis去重 ⚡⭐⭐⭐⭐

核心思想:使用Redis的SET结构存储已处理的消息ID

流程

1. 消费者收到消息,获取唯一ID
2. 尝试写入Redis:SETNX message:{id} 1 EX 86400
   ├─ 写入成功 → 之前没处理过,继续处理
   └─ 写入失败 → 已经处理过,直接返回
3. 处理业务逻辑
4. 业务处理成功

代码实现

@Service
@Slf4j
public class RedisIdempotentService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private OrderService orderService;
    
    private static final String IDEMPOTENT_KEY_PREFIX = "message:idempotent:";
    private static final long EXPIRE_TIME = 86400;  // 24小时过期
    
    /**
     * 幂等消费消息
     */
    public void consumeMessage(ConsumerRecord<String, String> record) {
        String messageId = extractMessageId(record);
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        
        log.info("开始消费消息, messageId={}", messageId);
        
        // ⭐ 尝试写入Redis(SETNX:只在键不存在时设置)
        Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(key, "1", EXPIRE_TIME, TimeUnit.SECONDS);
        
        if (Boolean.FALSE.equals(success)) {
            log.info("✅ 消息已处理过,跳过, messageId={}", messageId);
            return;  // 已处理
        }
        
        try {
            // 处理业务逻辑
            Order order = parseOrder(record.value());
            orderService.createOrder(order);
            
            log.info("✅ 消息处理成功, messageId={}", messageId);
            
        } catch (Exception e) {
            log.error("❌ 消息处理失败, messageId={}", messageId, e);
            
            // ⭐ 处理失败,删除Redis键(允许重试)
            redisTemplate.delete(key);
            
            throw e;
        }
    }
}

优缺点

优点

  • ✅ 性能极高(Redis内存操作)
  • ✅ 自动过期(不需要手动清理)
  • ✅ 实现简单

缺点

  • ❌ Redis故障会导致重复消费(可靠性不如数据库)
  • ❌ 分布式场景下,Redis单点问题

适用场景

  • 对性能要求高的场景
  • 可以接受Redis故障时的重复消费

方案3:业务层面的幂等设计 🎯⭐⭐⭐⭐⭐

核心思想:设计天然幂等的业务逻辑

3.1 使用数据库唯一索引

场景:订单创建

CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    order_id VARCHAR(64) NOT NULL UNIQUE COMMENT '订单ID(业务唯一ID)',
    user_id BIGINT NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    status TINYINT NOT NULL,
    create_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_order_id (order_id)  # ⭐ 唯一索引
);

代码

@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 创建订单(幂等)
     */
    public void createOrder(Order order) {
        try {
            // 直接插入,如果订单ID重复,唯一索引会报错
            orderMapper.insert(order);
            log.info("订单创建成功, orderId={}", order.getOrderId());
            
        } catch (DuplicateKeyException e) {
            // ⭐ 唯一索引冲突,说明订单已存在(重复消费)
            log.warn("订单已存在,跳过创建, orderId={}", order.getOrderId());
            // 不抛异常,认为成功(幂等)
        }
    }
}

优点

  • ✅ 非常简单
  • ✅ 数据库保证
  • ✅ 无需额外的去重表

3.2 使用状态机

场景:订单状态流转

订单状态:
待支付(0) → 已支付(1) → 已发货(2) → 已完成(3)

幂等设计:只允许按顺序流转,不允许倒退

表设计

CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    order_id VARCHAR(64) NOT NULL UNIQUE,
    status TINYINT NOT NULL COMMENT '状态:0=待支付,1=已支付,2=已发货,3=已完成',
    version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
    update_time DATETIME NOT NULL
);

代码

@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 更新订单状态(幂等)
     */
    public boolean updateOrderStatus(String orderId, int fromStatus, int toStatus) {
        // ⭐ 使用乐观锁 + 状态检查
        int rows = orderMapper.updateStatus(orderId, fromStatus, toStatus);
        
        if (rows > 0) {
            log.info("订单状态更新成功, orderId={}, {}→{}", orderId, fromStatus, toStatus);
            return true;
        } else {
            log.warn("订单状态更新失败(已经是目标状态或状态不匹配), orderId={}", orderId);
            
            // ⭐ 检查当前状态
            Order order = orderMapper.selectByOrderId(orderId);
            if (order.getStatus() == toStatus) {
                log.info("订单已经是目标状态,认为成功(幂等), orderId={}", orderId);
                return true;  // 幂等
            } else {
                log.error("订单状态异常, orderId={}, currentStatus={}", orderId, order.getStatus());
                return false;
            }
        }
    }
}

Mapper

@Mapper
public interface OrderMapper {
    
    /**
     * 更新订单状态(带状态检查)
     */
    @Update("UPDATE orders SET status = #{toStatus}, version = version + 1, update_time = NOW() " +
            "WHERE order_id = #{orderId} AND status = #{fromStatus}")
    int updateStatus(@Param("orderId") String orderId,
                    @Param("fromStatus") int fromStatus,
                    @Param("toStatus") int toStatus);
    
    /**
     * 查询订单
     */
    @Select("SELECT * FROM orders WHERE order_id = #{orderId}")
    Order selectByOrderId(@Param("orderId") String orderId);
}

处理逻辑

消费消息:订单已支付(待支付 → 已支付)

第1次消费:
UPDATE orders SET status = 1 WHERE order_id = '1001' AND status = 0
影响行数:1 ✅ 更新成功

第2次消费(重复):
UPDATE orders SET status = 1 WHERE order_id = '1001' AND status = 0
影响行数:0 ❌ 没有更新(因为status已经是1了)
→ 查询当前状态:status=1(已经是目标状态)
→ 认为成功(幂等)✅

优点

  • ✅ 业务语义清晰
  • ✅ 天然幂等
  • ✅ 无需额外去重表

3.3 使用乐观锁

场景:库存扣减

表设计

CREATE TABLE inventory (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    product_id BIGINT NOT NULL,
    stock INT NOT NULL COMMENT '库存',
    version INT NOT NULL DEFAULT 0 COMMENT '版本号',
    update_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_product_id (product_id)
);

代码

@Service
public class InventoryService {
    
    @Autowired
    private InventoryMapper inventoryMapper;
    
    /**
     * 扣减库存(幂等)
     */
    public boolean deductStock(Long productId, int quantity, String orderId) {
        // 先查询当前库存和版本号
        Inventory inventory = inventoryMapper.selectByProductId(productId);
        
        if (inventory.getStock() < quantity) {
            log.error("库存不足, productId={}, stock={}, need={}", 
                     productId, inventory.getStock(), quantity);
            return false;
        }
        
        // ⭐ 使用乐观锁扣减库存
        int rows = inventoryMapper.deductStock(
            productId,
            quantity,
            inventory.getVersion()  // 当前版本号
        );
        
        if (rows > 0) {
            log.info("库存扣减成功, productId={}, quantity={}", productId, quantity);
            
            // ⭐ 记录扣减日志(防止重复扣减)
            saveDeductLog(productId, quantity, orderId);
            return true;
        } else {
            log.warn("库存扣减失败(版本号不匹配,可能被其他线程修改), productId={}", productId);
            return false;  // 需要重试
        }
    }
    
    /**
     * 保存扣减日志(用于幂等检查)
     */
    private void saveDeductLog(Long productId, int quantity, String orderId) {
        InventoryLog log = new InventoryLog();
        log.setProductId(productId);
        log.setQuantity(quantity);
        log.setOrderId(orderId);  // ⭐ 订单ID作为唯一ID
        log.setCreateTime(new Date());
        
        try {
            inventoryLogMapper.insert(log);
        } catch (DuplicateKeyException e) {
            // 日志表有唯一索引(orderId),重复插入说明已经扣减过了
            log.warn("扣减日志已存在, orderId={}", orderId);
        }
    }
}

Mapper

@Mapper
public interface InventoryMapper {
    
    /**
     * 扣减库存(乐观锁)
     */
    @Update("UPDATE inventory SET stock = stock - #{quantity}, " +
            "version = version + 1, update_time = NOW() " +
            "WHERE product_id = #{productId} AND version = #{version} AND stock >= #{quantity}")
    int deductStock(@Param("productId") Long productId,
                   @Param("quantity") int quantity,
                   @Param("version") int version);
    
    /**
     * 查询库存
     */
    @Select("SELECT * FROM inventory WHERE product_id = #{productId}")
    Inventory selectByProductId(@Param("productId") Long productId);
}

扣减日志表

CREATE TABLE inventory_log (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    product_id BIGINT NOT NULL,
    order_id VARCHAR(64) NOT NULL COMMENT '订单ID',
    quantity INT NOT NULL,
    create_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_order_id (order_id)  # ⭐ 唯一索引,防止重复扣减
);

3.4 使用SELECT FOR UPDATE(悲观锁)

场景:账户余额扣减(不能有并发问题)

@Service
public class AccountService {
    
    @Autowired
    private AccountMapper accountMapper;
    
    /**
     * 扣减余额(幂等)
     */
    @Transactional
    public boolean deductBalance(Long userId, BigDecimal amount, String tradeNo) {
        // ⭐ 先检查是否已经处理过(查询交易记录表)
        if (isTradeProcessed(tradeNo)) {
            log.info("交易已处理过,跳过, tradeNo={}", tradeNo);
            return true;  // 幂等
        }
        
        // ⭐ 使用SELECT FOR UPDATE锁定账户(悲观锁)
        Account account = accountMapper.selectForUpdate(userId);
        
        if (account.getBalance().compareTo(amount) < 0) {
            log.error("余额不足, userId={}, balance={}, need={}", 
                     userId, account.getBalance(), amount);
            return false;
        }
        
        // 扣减余额
        accountMapper.deductBalance(userId, amount);
        
        // ⭐ 记录交易流水(防止重复扣减)
        saveTradeRecord(userId, amount, tradeNo);
        
        log.info("余额扣减成功, userId={}, amount={}", userId, amount);
        return true;
    }
    
    /**
     * 检查交易是否已处理
     */
    private boolean isTradeProcessed(String tradeNo) {
        TradeRecord record = tradeRecordMapper.selectByTradeNo(tradeNo);
        return record != null;
    }
    
    /**
     * 保存交易记录
     */
    private void saveTradeRecord(Long userId, BigDecimal amount, String tradeNo) {
        TradeRecord record = new TradeRecord();
        record.setUserId(userId);
        record.setAmount(amount);
        record.setTradeNo(tradeNo);  // ⭐ 交易号作为唯一ID
        record.setCreateTime(new Date());
        tradeRecordMapper.insert(record);
    }
}

Mapper

@Mapper
public interface AccountMapper {
    
    /**
     * 锁定账户(悲观锁)
     */
    @Select("SELECT * FROM account WHERE user_id = #{userId} FOR UPDATE")
    Account selectForUpdate(@Param("userId") Long userId);
    
    /**
     * 扣减余额
     */
    @Update("UPDATE account SET balance = balance - #{amount}, update_time = NOW() " +
            "WHERE user_id = #{userId}")
    void deductBalance(@Param("userId") Long userId, @Param("amount") BigDecimal amount);
}

方案4:分布式锁 🔐⭐⭐⭐

核心思想:使用分布式锁保证同一条消息只被处理一次

流程

1. 消费者收到消息,获取唯一ID
2. 尝试获取分布式锁:LOCK(message:{id})
   ├─ 获取成功 → 继续处理
   └─ 获取失败 → 其他消费者正在处理,等待或放弃
3. 处理业务逻辑
4. 释放锁

使用Redisson实现

@Service
@Slf4j
public class DistributedLockIdempotentService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private OrderService orderService;
    
    private static final String LOCK_KEY_PREFIX = "message:lock:";
    
    /**
     * 幂等消费消息(使用分布式锁)
     */
    public void consumeMessage(ConsumerRecord<String, String> record) {
        String messageId = extractMessageId(record);
        String lockKey = LOCK_KEY_PREFIX + messageId;
        
        // ⭐ 获取分布式锁
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 尝试获取锁(等待10秒,锁定30秒)
            boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
            
            if (!locked) {
                log.warn("获取锁失败,其他消费者正在处理, messageId={}", messageId);
                return;
            }
            
            log.info("获取锁成功,开始处理消息, messageId={}", messageId);
            
            // ⭐ 再次检查是否已处理(双重检查)
            if (isProcessed(messageId)) {
                log.info("消息已处理过,跳过, messageId={}", messageId);
                return;
            }
            
            // 处理业务逻辑
            Order order = parseOrder(record.value());
            orderService.createOrder(order);
            
            // 标记已处理
            markProcessed(messageId);
            
            log.info("消息处理成功, messageId={}", messageId);
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("获取锁被中断, messageId={}", messageId, e);
        } catch (Exception e) {
            log.error("消息处理失败, messageId={}", messageId, e);
            throw e;
        } finally {
            // ⭐ 释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
                log.info("释放锁成功, messageId={}", messageId);
            }
        }
    }
}

优缺点

优点

  • ✅ 强一致性保证
  • ✅ 支持分布式环境

缺点

  • ❌ 性能开销大(需要等待锁)
  • ❌ Redis故障影响可用性
  • ❌ 锁超时问题(业务处理时间超过锁时间)

适用场景

  • 并发度不高的场景
  • 对一致性要求极高的场景

📊 方案对比总结

方案性能可靠性复杂度适用场景推荐度
数据库去重表😐 中等🛡️🛡️🛡️ 极高😊 简单通用场景⭐⭐⭐⭐⭐
Redis去重⚡⚡⚡ 极高🛡️🛡️ 较高😊 简单高性能场景⭐⭐⭐⭐
业务幂等设计⚡⚡ 高🛡️🛡️🛡️ 极高😐 中等业务场景⭐⭐⭐⭐⭐
分布式锁🐌 低🛡️🛡️ 较高😰 复杂低并发场景⭐⭐⭐

🎯 最佳实践

组合方案⭐⭐⭐⭐⭐

推荐:数据库去重表 + Redis缓存 + 业务幂等

@Service
@Slf4j
public class BestPracticeIdempotentService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private IdempotentService dbIdempotentService;
    
    @Autowired
    private OrderService orderService;
    
    private static final String REDIS_KEY_PREFIX = "message:idempotent:";
    private static final long REDIS_EXPIRE = 3600;  // 1小时
    
    /**
     * 三层防护的幂等消费
     */
    @Transactional
    public void consumeMessage(ConsumerRecord<String, String> record) {
        String messageId = extractMessageId(record);
        
        log.info("开始消费消息, messageId={}", messageId);
        
        // ⭐ 第1层防护:Redis快速去重(性能优先)
        String redisKey = REDIS_KEY_PREFIX + messageId;
        Boolean exists = redisTemplate.hasKey(redisKey);
        if (Boolean.TRUE.equals(exists)) {
            log.info("✅ Redis检测到已处理,跳过, messageId={}", messageId);
            return;
        }
        
        // ⭐ 第2层防护:数据库去重(可靠性保证)
        if (dbIdempotentService.isProcessed(messageId, "order-group")) {
            log.info("✅ 数据库检测到已处理,跳过, messageId={}", messageId);
            // 补齐Redis缓存
            redisTemplate.opsForValue().set(redisKey, "1", REDIS_EXPIRE, TimeUnit.SECONDS);
            return;
        }
        
        try {
            // 处理业务逻辑
            Order order = parseOrder(record.value());
            
            // ⭐ 第3层防护:业务层幂等(唯一索引)
            orderService.createOrderIdempotent(order);  // 内部用唯一索引保证
            
            // 标记已处理(数据库)
            dbIdempotentService.markProcessed(messageId, "order-group", record.topic());
            
            // 标记已处理(Redis)
            redisTemplate.opsForValue().set(redisKey, "1", REDIS_EXPIRE, TimeUnit.SECONDS);
            
            log.info("✅ 消息处理成功, messageId={}", messageId);
            
        } catch (Exception e) {
            log.error("❌ 消息处理失败, messageId={}", messageId, e);
            throw e;
        }
    }
}

三层防护

1. Redis:快速去重,性能最高 ⚡
2. 数据库去重表:可靠性保证 🛡️
3. 业务唯一索引:最后一道防线 🔐

消息ID的选择策略

方案1:使用Kafka的offset作为ID

String messageId = record.topic() + "-" + record.partition() + "-" + record.offset();
// 示例:orders-0-12345

优点

  • ✅ 绝对唯一
  • ✅ 自动生成

缺点

  • ❌ 不同消费者组会重复处理(需要加上groupId)

方案2:使用业务ID作为消息ID

JSONObject json = JSON.parseObject(record.value());
String messageId = json.getString("orderId");  // 订单ID

优点

  • ✅ 业务语义清晰
  • ✅ 跨系统可用

缺点

  • ❌ 需要在消息体中包含唯一ID

方案3:生成UUID

// 生产者发送消息时生成UUID
Message msg = new Message();
msg.setKey(UUID.randomUUID().toString());  // 消息Key作为唯一ID

优点

  • ✅ 绝对唯一
  • ✅ 不依赖业务

缺点

  • ❌ 需要在生产者端生成

清理策略

定期清理去重表

@Scheduled(cron = "0 0 2 * * ?")  // 每天凌晨2点
public void cleanExpiredRecords() {
    // 清理7天前的数据
    Date expireTime = DateUtils.addDays(new Date(), -7);
    int count = idempotentMapper.deleteByCreateTimeBefore(expireTime);
    log.info("清理过期幂等记录, count={}", count);
}

Redis自动过期

redisTemplate.opsForValue().set(key, "1", 86400, TimeUnit.SECONDS);  // 24小时过期

🎓 面试题速答

Q1: 什么是消息幂等消费?

A: 幂等性 = 同一条消息消费多次,结果和消费一次是一样的

为什么需要?

  • 网络抖动 → 消息重复发送
  • 程序宕机 → 重复消费
  • Rebalance → 重复消费

不做幂等的后果

  • 重复扣款、重复发货、数据错误

Q2: 如何实现消息幂等消费?

A: 四种方案!

  1. 数据库去重表(推荐)⭐⭐⭐⭐⭐

    • 消息ID + 唯一索引
    • 可靠性最高
  2. Redis去重⭐⭐⭐⭐

    • SETNX + 过期时间
    • 性能最高
  3. 业务幂等设计⭐⭐⭐⭐⭐

    • 唯一索引、状态机、乐观锁
    • 最优雅
  4. 分布式锁⭐⭐⭐

    • Redisson锁
    • 性能较低

Q3: 如何选择消息ID?

A: 三种选择!

  1. Kafka offset:topic-partition-offset
  2. 业务ID:订单ID、交易号等
  3. UUID:生产者生成唯一ID

推荐:使用业务ID,语义清晰


Q4: 去重表数据会越来越多怎么办?

A: 定期清理!

@Scheduled(cron = "0 0 2 * * ?")
public void cleanExpiredRecords() {
    // 清理7天前的数据
    Date expireTime = DateUtils.addDays(new Date(), -7);
    idempotentMapper.deleteByCreateTimeBefore(expireTime);
}

Redis自动过期

redisTemplate.opsForValue().set(key, "1", 86400, TimeUnit.SECONDS);

Q5: 业务幂等设计有哪些方法?

A: 四种方法!

  1. 唯一索引:订单ID、交易号等加唯一索引
  2. 状态机:订单状态流转,只能往前走
  3. 乐观锁:version字段,更新时检查版本号
  4. 悲观锁:SELECT FOR UPDATE锁定记录

推荐:唯一索引 + 状态机


Q6: 数据库去重和Redis去重如何选择?

A:

数据库去重

  • 优点:可靠性高,断电不丢
  • 缺点:性能一般
  • 适合:金融、交易等重要场景

Redis去重

  • 优点:性能极高
  • 缺点:Redis故障可能重复消费
  • 适合:日志、监控等一般场景

最佳方案组合使用!⭐⭐⭐⭐⭐

  • Redis做第一层快速去重
  • 数据库做第二层可靠保证
  • 业务层做第三层防线

🎬 总结:一张图看懂幂等消费

                消息幂等消费全景图

┌──────────────────────────────────────────────────┐
│               Producer发送消息                   │
│                                                  │
│  生成唯一ID: orderId / UUID / offset             │
└─────────────────┬────────────────────────────────┘
                  │
                  ↓
┌──────────────────────────────────────────────────┐
│                  Broker                          │
│                                                  │
│  存储消息(可能重复)                             │
└─────────────────┬────────────────────────────────┘
                  │
                  ↓
┌──────────────────────────────────────────────────┐
│               Consumer消费消息                   │
│                                                  │
│  ┌────────────────────────────────────────┐     │
│  │  🛡️ 第1层防护:Redis快速去重         │     │
│  │  SETNX message:{id} 1 EX 86400         │     │
│  │  ├─ 成功:继续处理                     │     │
│  │  └─ 失败:已处理,跳过                 │     │
│  └────────────────────────────────────────┘     │
│                  ↓                               │
│  ┌────────────────────────────────────────┐     │
│  │  🛡️ 第2层防护:数据库去重表           │     │
│  │  SELECT * FROM idempotent WHERE id=?   │     │
│  │  ├─ 存在:已处理,跳过                 │     │
│  │  └─ 不存在:继续处理                   │     │
│  └────────────────────────────────────────┘     │
│                  ↓                               │
│  ┌────────────────────────────────────────┐     │
│  │  💼 处理业务逻辑                       │     │
│  │  orderService.createOrder(order)       │     │
│  └────────────────────────────────────────┘     │
│                  ↓                               │
│  ┌────────────────────────────────────────┐     │
│  │  🛡️ 第3层防护:业务层幂等             │     │
│  │  - 唯一索引(orderId)                 │     │
│  │  - 状态机(status检查)                │     │
│  │  - 乐观锁(version检查)               │     │
│  └────────────────────────────────────────┘     │
│                  ↓                               │
│  ┌────────────────────────────────────────┐     │
│  │  ✅ 标记已处理                         │     │
│  │  - 插入去重表                          │     │
│  │  - 写入Redis                           │     │
│  └────────────────────────────────────────┘     │
└──────────────────────────────────────────────────┘

         三层防护,层层保障,消费多次也不怕!🛡️

🎉 恭喜你!

你已经完全掌握了消息幂等消费的所有方案!🎊

核心要点

  1. 幂等性:同一操作执行多次,结果和执行一次一样
  2. 去重方案:数据库去重表(最常用)、Redis去重(最快)、业务幂等(最优雅)
  3. 组合方案:三层防护(Redis + 数据库 + 业务层)最佳

下次面试,这样回答

"消息幂等消费是指同一条消息消费多次,结果和消费一次是一样的。

我们项目中使用三层防护:第一层用Redis做快速去重,性能最高;第二层用数据库去重表,保证可靠性;第三层在业务层使用唯一索引和状态机,作为最后一道防线。

消息ID我们使用业务ID(订单ID、交易号),语义清晰。去重表定期清理7天前的数据,避免数据量过大。

对于库存扣减等场景,我们还额外使用乐观锁保证并发安全。"

面试官:👍 "非常全面!你对幂等性理解很深刻!"


🎈 表情包时间 🎈

       学完消息幂等消费后:

              之前:
         😱 "消息重复消费了!数据乱了!"
         
              现在:
         😎 "三层防护,随便重复,稳得一批!"
         
              重复消息:
         💀 "我来了!我又来了!我还来!"
              
              你:
         🛡️ "来多少次都一样!"

本文完 🎬

记得点赞👍 收藏⭐ 分享🔗

上一篇: 186-RocketMQ的刷盘机制和主从同步.md
下一篇: 188-消息队列的顺序性保证方案.md


作者注:写完这篇,我按电梯按钮的姿势都变优雅了!🛗
如果这篇文章对你有帮助,请给我一个Star⭐!

版权声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。