📖 开场:电梯按钮的智慧
想象你在电梯里 🛗:
场景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: 四种方案!
-
数据库去重表(推荐)⭐⭐⭐⭐⭐
- 消息ID + 唯一索引
- 可靠性最高
-
Redis去重⭐⭐⭐⭐
- SETNX + 过期时间
- 性能最高
-
业务幂等设计⭐⭐⭐⭐⭐
- 唯一索引、状态机、乐观锁
- 最优雅
-
分布式锁⭐⭐⭐
- Redisson锁
- 性能较低
Q3: 如何选择消息ID?
A: 三种选择!
- Kafka offset:topic-partition-offset
- 业务ID:订单ID、交易号等
- 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: 四种方法!
- 唯一索引:订单ID、交易号等加唯一索引
- 状态机:订单状态流转,只能往前走
- 乐观锁:version字段,更新时检查版本号
- 悲观锁: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 │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
三层防护,层层保障,消费多次也不怕!🛡️
🎉 恭喜你!
你已经完全掌握了消息幂等消费的所有方案!🎊
核心要点:
- 幂等性:同一操作执行多次,结果和执行一次一样
- 去重方案:数据库去重表(最常用)、Redis去重(最快)、业务幂等(最优雅)
- 组合方案:三层防护(Redis + 数据库 + 业务层)最佳
下次面试,这样回答:
"消息幂等消费是指同一条消息消费多次,结果和消费一次是一样的。
我们项目中使用三层防护:第一层用Redis做快速去重,性能最高;第二层用数据库去重表,保证可靠性;第三层在业务层使用唯一索引和状态机,作为最后一道防线。
消息ID我们使用业务ID(订单ID、交易号),语义清晰。去重表定期清理7天前的数据,避免数据量过大。
对于库存扣减等场景,我们还额外使用乐观锁保证并发安全。"
面试官:👍 "非常全面!你对幂等性理解很深刻!"
🎈 表情包时间 🎈
学完消息幂等消费后:
之前:
😱 "消息重复消费了!数据乱了!"
现在:
😎 "三层防护,随便重复,稳得一批!"
重复消息:
💀 "我来了!我又来了!我还来!"
你:
🛡️ "来多少次都一样!"
本文完 🎬
记得点赞👍 收藏⭐ 分享🔗
上一篇: 186-RocketMQ的刷盘机制和主从同步.md
下一篇: 188-消息队列的顺序性保证方案.md
作者注:写完这篇,我按电梯按钮的姿势都变优雅了!🛗
如果这篇文章对你有帮助,请给我一个Star⭐!版权声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。