副标题:顺序性、可靠性、幂等性,一个都不能少!🎯
🎬 开场:消息队列的三大噩梦
场景1:顺序错乱 📝:
markdown
用户操作:
1. 创建订单
2. 支付订单
3. 发货
消息到达顺序:
1. 发货 ❌
2. 创建订单
3. 支付订单
结果:商品还没下单就发货了!😱
场景2:消息丢失 💔:
erlang
发送了100条消息
MQ只收到98条
2条消息人间蒸发了...
结果:钱扣了,但订单没创建!😭
场景3:重复消费 🔄:
diff
同一条消息被消费了3次
结果:
- 同一笔钱扣了3次
- 同一个商品发了3件
- 用户收到3条短信
用户:我只买了一件啊!😤
这就是我们今天要解决的三大问题!
📊 问题概览
问题 | 后果 | 解决难度 |
---|---|---|
顺序性 | 业务逻辑错乱 | ⭐⭐⭐⭐ |
可靠性 | 消息丢失 | ⭐⭐⭐⭐⭐ |
幂等性 | 重复处理 | ⭐⭐⭐ |
1️⃣ 顺序性:让消息按序到达
为什么会乱序?
生活比喻 🚗:
你去肯德基点餐:
队列1:汉堡 → 薯条 → 可乐
队列2:汉堡 → 薯条 → 可乐
队列3:汉堡 → 薯条 → 可乐
三个窗口并行处理
取餐顺序可能是:
薯条 → 汉堡 → 可乐 ❌ 乱了!
MQ中的乱序原因:
原因1:多分区
消息发送到不同分区
并行消费导致乱序
原因2:多Consumer
多个消费者并发处理
处理速度不同导致乱序
原因3:重试机制
失败重试可能导致后发先至
解决方案1:单分区顺序
Kafka实现:
java
/**
* 确保同一个订单的所有消息发到同一个分区
*/
@Service
public class OrderProducer {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
public void sendOrderMessage(Long orderId, String message) {
// 使用orderId作为key,确保相同orderId的消息发到同一个分区
kafkaTemplate.send(
"order-topic",
orderId.toString(), // key决定分区
message
);
}
}
原理:
ini
Kafka分区策略:
hash(key) % partition_count = 分区号
示例:
订单123的所有消息:
- 创建订单 → hash(123) % 3 = 0 → 分区0
- 支付订单 → hash(123) % 3 = 0 → 分区0
- 发货 → hash(123) % 3 = 0 → 分区0
所有消息都在分区0,顺序保证!✅
消费端:
java
@Service
public class OrderConsumer {
/**
* 单线程消费,保证顺序
*/
@KafkaListener(
topics = "order-topic",
concurrency = "1" // 重点:单线程消费
)
public void consume(ConsumerRecord<String, String> record) {
String orderId = record.key();
String message = record.value();
// 顺序处理
processOrder(orderId, message);
}
}
问题:
erlang
优点:
✅ 顺序100%保证
缺点:
❌ 性能低(单线程)
❌ 单点故障(一个分区挂了影响大)
解决方案2:局部有序 + 并发消费
java
/**
* 使用内存队列保证局部有序
*/
@Service
public class OrderConsumerWithQueue {
// 为每个订单维护一个队列
private ConcurrentHashMap<String, BlockingQueue<OrderMessage>> orderQueues
= new ConcurrentHashMap<>();
// 线程池
private ExecutorService executor = Executors.newFixedThreadPool(10);
@KafkaListener(topics = "order-topic", concurrency = "3")
public void consume(ConsumerRecord<String, String> record) {
String orderId = record.key();
OrderMessage message = parse(record.value());
// 获取订单对应的队列
BlockingQueue<OrderMessage> queue = orderQueues.computeIfAbsent(
orderId,
k -> {
LinkedBlockingQueue<OrderMessage> q = new LinkedBlockingQueue<>();
// 启动一个线程处理这个订单的消息
executor.submit(() -> processQueue(orderId, q));
return q;
}
);
// 将消息放入队列
queue.offer(message);
}
private void processQueue(String orderId, BlockingQueue<OrderMessage> queue) {
while (true) {
try {
// 从队列取消息,顺序处理
OrderMessage message = queue.take();
processOrderMessage(orderId, message);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}
原理图:
Kafka分区0 → 订单123队列 → 线程1处理
Kafka分区1 → 订单456队列 → 线程2处理
Kafka分区2 → 订单789队列 → 线程3处理
同一订单的消息在同一个队列
不同订单的消息并行处理
既保证了顺序,又提高了并发!✅
解决方案3:RocketMQ顺序消息
java
/**
* RocketMQ天然支持顺序消息
*/
@Service
public class RocketMQOrderProducer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void sendOrderMessage(Long orderId, String message) {
// 使用orderId作为shardingKey
rocketMQTemplate.syncSendOrderly(
"order-topic",
message,
orderId.toString() // shardingKey
);
}
}
@Service
@RocketMQMessageListener(
topic = "order-topic",
consumerGroup = "order-consumer-group",
consumeMode = ConsumeMode.ORDERLY // 顺序消费
)
public class RocketMQOrderConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String message) {
// RocketMQ保证同一个shardingKey的消息顺序消费
processOrder(message);
}
}
全局顺序 vs 局部顺序
类型 | 实现方式 | 性能 | 适用场景 |
---|---|---|---|
全局顺序 | 单分区+单消费者 | 极低 | 日志收集 |
局部顺序 | 多分区+Key路由 | 高 | 订单处理 ⭐ |
2️⃣ 可靠性:消息一条都不能丢!
消息在哪里可能丢失?
发送端 → MQ服务器 → 消费端
↓ ↓ ↓
丢失点1 丢失点2 丢失点3
丢失点1:生产者发送失败
java
// ❌ 错误做法:不管发送结果
kafkaTemplate.send("topic", "message");
// 如果网络抖动,消息可能丢失!
// ✅ 正确做法:同步发送 + 重试
@Service
public class ReliableProducer {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
public void sendMessage(String topic, String message) {
// 方式1:同步发送
try {
SendResult<String, String> result = kafkaTemplate.send(
topic,
message
).get(3, TimeUnit.SECONDS); // 等待确认
log.info("发送成功: offset={}", result.getRecordMetadata().offset());
} catch (Exception e) {
log.error("发送失败,进行重试", e);
// 重试3次
for (int i = 0; i < 3; i++) {
try {
kafkaTemplate.send(topic, message).get(3, TimeUnit.SECONDS);
log.info("重试成功");
return;
} catch (Exception retryEx) {
log.error("第{}次重试失败", i + 1);
}
}
// 重试失败,记录到数据库
saveFailedMessage(topic, message);
}
}
/**
* 方式2:异步发送 + 回调
*/
public void sendMessageAsync(String topic, String message) {
kafkaTemplate.send(topic, message).addCallback(
result -> {
log.info("发送成功: offset={}",
result.getRecordMetadata().offset());
},
ex -> {
log.error("发送失败", ex);
// 重试或记录失败消息
retryOrSave(topic, message);
}
);
}
}
配置Kafka生产者确认机制:
yaml
spring:
kafka:
producer:
acks: all # 等待所有副本确认
retries: 3 # 自动重试3次
batch-size: 16384 # 批量大小
buffer-memory: 33554432 # 缓冲区大小
acks参数说明:
ini
acks=0: 不等待确认(可能丢失)❌
acks=1: 等待Leader确认(Leader挂了可能丢失)⚠️
acks=all: 等待所有副本确认(最可靠)✅
丢失点2:MQ服务器宕机
持久化配置:
java
// Kafka配置
@Configuration
public class KafkaConfig {
@Bean
public KafkaAdmin admin() {
Map<String, Object> configs = new HashMap<>();
configs.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
return new KafkaAdmin(configs);
}
@Bean
public NewTopic orderTopic() {
return TopicBuilder.name("order-topic")
.partitions(3)
.replicas(3) // 3个副本
.config(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, "2") // 最少2个副本确认
.build();
}
}
配置说明:
ini
replicas=3: 数据有3份副本
min.insync.replicas=2: 至少2份副本确认才算成功
即使1台服务器挂了,数据也不会丢!✅
RocketMQ刷盘策略:
java
// 同步刷盘(安全)
flushDiskType=SYNC_FLUSH
// 异步刷盘(性能)
flushDiskType=ASYNC_FLUSH
生产环境建议:
- 重要消息:SYNC_FLUSH ✅
- 普通消息:ASYNC_FLUSH,配合主从同步
丢失点3:消费者处理失败
java
@Service
public class ReliableConsumer {
@Autowired
private OrderService orderService;
/**
* ❌ 错误做法:先提交offset,再处理
*/
@KafkaListener(topics = "order-topic")
public void consumeWrong(ConsumerRecord<String, String> record) {
// Kafka自动提交了offset
// 处理消息
orderService.process(record.value());
// 如果这里抛异常,消息丢失了!
}
/**
* ✅ 正确做法:先处理,再手动提交
*/
@KafkaListener(
topics = "order-topic",
containerFactory = "manualKafkaListenerContainerFactory"
)
public void consumeRight(ConsumerRecord<String, String> record,
Acknowledgment ack) {
try {
// 先处理消息
orderService.process(record.value());
// 处理成功,手动提交offset
ack.acknowledge();
} catch (Exception e) {
log.error("处理失败,消息将重新消费", e);
// 不提交offset,消息会重新消费
}
}
}
/**
* 配置手动提交
*/
@Configuration
public class KafkaConsumerConfig {
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String>
manualKafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, String> factory
= new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
factory.getContainerProperties()
.setAckMode(AckMode.MANUAL); // 手动提交
return factory;
}
}
端到端可靠性保证
ini
┌──────────────────────────────────────────────┐
│ 端到端可靠性保证 │
├──────────────────────────────────────────────┤
│ │
│ ① 生产者 │
│ └─ acks=all + 重试 + 失败记录 │
│ │
│ ② MQ服务器 │
│ └─ 多副本 + 持久化 + 主从同步 │
│ │
│ ③ 消费者 │
│ └─ 先处理后提交 + 失败重试 │
│ │
└──────────────────────────────────────────────┘
三个环节都做好,消息才真正可靠!
3️⃣ 幂等性:处理一次就够了!
为什么会重复消费?
sql
原因1:网络抖动
消费者处理完了,但ack确认丢失了
MQ以为没处理,再次投递
原因2:消费者宕机重启
offset还没提交,重启后从上次的offset继续消费
原因3:Rebalance
消费者组重新分配分区,可能重复消费
解决方案1:唯一ID去重
java
@Service
public class IdempotentConsumer {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@KafkaListener(topics = "order-topic")
public void consume(ConsumerRecord<String, String> record) {
// 1. 提取消息唯一ID
String messageId = extractMessageId(record.value());
String redisKey = "msg:processed:" + messageId;
// 2. 检查是否已处理
Boolean isProcessed = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "1", 7, TimeUnit.DAYS);
if (isProcessed != null && isProcessed) {
// 首次处理
try {
processMessage(record.value());
log.info("消息处理成功: {}", messageId);
} catch (Exception e) {
// 处理失败,删除标记,允许重试
redisTemplate.delete(redisKey);
throw e;
}
} else {
// 重复消息,跳过
log.warn("重复消息,跳过: {}", messageId);
}
}
}
解决方案2:数据库唯一索引
sql
-- 创建消息处理记录表
CREATE TABLE message_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
message_id VARCHAR(64) UNIQUE NOT NULL, -- 消息唯一ID
topic VARCHAR(100),
content TEXT,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_message_id (message_id)
);
java
@Service
public class DatabaseIdempotentConsumer {
@Autowired
private MessageRecordMapper messageRecordMapper;
@Autowired
private OrderService orderService;
@Transactional
@KafkaListener(topics = "order-topic")
public void consume(String message) {
String messageId = extractMessageId(message);
try {
// 1. 插入消息记录(利用唯一索引)
MessageRecord record = new MessageRecord();
record.setMessageId(messageId);
record.setTopic("order-topic");
record.setContent(message);
messageRecordMapper.insert(record);
// 2. 处理业务
orderService.process(message);
log.info("消息处理成功: {}", messageId);
} catch (DuplicateKeyException e) {
// 唯一索引冲突,说明消息已处理
log.warn("重复消息,跳过: {}", messageId);
}
}
}
解决方案3:业务幂等性设计
java
/**
* 订单创建的幂等性设计
*/
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Transactional
public Order createOrder(CreateOrderRequest request) {
// 1. 检查订单是否已存在(用外部流水号作为唯一标识)
Order existingOrder = orderMapper.selectByOutTradeNo(
request.getOutTradeNo()
);
if (existingOrder != null) {
log.warn("订单已存在: {}", request.getOutTradeNo());
return existingOrder; // 返回已有订单
}
// 2. 创建订单
Order order = new Order();
order.setOutTradeNo(request.getOutTradeNo()); // 外部流水号(唯一)
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
order.setStatus(OrderStatus.CREATED);
orderMapper.insert(order);
return order;
}
}
数据库设计:
sql
CREATE TABLE `order` (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
out_trade_no VARCHAR(64) UNIQUE NOT NULL, -- 外部流水号,唯一索引
user_id BIGINT NOT NULL,
amount DECIMAL(10, 2),
status VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_out_trade_no (out_trade_no) -- 唯一索引保证幂等
);
解决方案4:状态机保证幂等
java
/**
* 使用状态机保证操作幂等
*/
@Service
public class OrderStateMachine {
@Autowired
private OrderMapper orderMapper;
/**
* 支付订单(幂等)
*/
@Transactional
public boolean payOrder(Long orderId) {
// 使用乐观锁更新状态
int updated = orderMapper.updateStatus(
orderId,
OrderStatus.PAID, // 新状态
OrderStatus.CREATED // 当前状态必须是CREATED
);
if (updated > 0) {
log.info("订单支付成功: {}", orderId);
return true;
} else {
// 更新失败,可能已经支付过了
Order order = orderMapper.selectById(orderId);
if (order.getStatus() == OrderStatus.PAID) {
log.warn("订单已支付: {}", orderId);
return true; // 已支付,返回成功
} else {
log.error("订单状态异常: {}", order.getStatus());
return false;
}
}
}
}
SQL:
sql
-- 状态机更新
UPDATE `order`
SET status = #{newStatus},
updated_at = NOW()
WHERE id = #{orderId}
AND status = #{currentStatus} -- 只有当前状态符合才更新
幂等性方案对比
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
Redis去重 | 性能高 | 依赖Redis | 高并发场景 ⭐ |
数据库唯一索引 | 可靠 | 性能稍低 | 金融交易 ⭐⭐ |
业务幂等设计 | 自然 | 需要设计 | 所有场景 ⭐⭐⭐ |
状态机 | 严谨 | 复杂 | 流程类业务 ⭐⭐ |
🎯 综合案例:订单系统
完整的可靠消息处理
java
/**
* 订单消息生产者(保证可靠性)
*/
@Service
public class OrderMessageProducer {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Autowired
private MessageLogMapper messageLogMapper;
@Transactional
public void sendOrderMessage(Order order) {
// 1. 先记录消息日志(与业务在同一事务)
MessageLog log = new MessageLog();
log.setMessageId(UUID.randomUUID().toString());
log.setTopic("order-topic");
log.setContent(JSON.toJSONString(order));
log.setStatus(MessageStatus.PENDING);
messageLogMapper.insert(log);
// 2. 异步发送消息
CompletableFuture.runAsync(() -> {
try {
kafkaTemplate.send(
"order-topic",
order.getId().toString(), // key
log.getContent()
).get(3, TimeUnit.SECONDS);
// 3. 发送成功,更新状态
log.setStatus(MessageStatus.SENT);
messageLogMapper.updateById(log);
} catch (Exception e) {
log.setStatus(MessageStatus.FAILED);
log.setErrorMsg(e.getMessage());
messageLogMapper.updateById(log);
}
});
}
/**
* 定时任务:重试失败的消息
*/
@Scheduled(fixedDelay = 60000) // 每分钟执行
public void retryFailedMessages() {
List<MessageLog> failedMessages = messageLogMapper.selectFailed();
for (MessageLog log : failedMessages) {
try {
kafkaTemplate.send(log.getTopic(), log.getContent())
.get(3, TimeUnit.SECONDS);
log.setStatus(MessageStatus.SENT);
messageLogMapper.updateById(log);
} catch (Exception e) {
log.setRetryCount(log.getRetryCount() + 1);
if (log.getRetryCount() >= 3) {
// 重试3次失败,标记为死信
log.setStatus(MessageStatus.DEAD);
}
messageLogMapper.updateById(log);
}
}
}
}
/**
* 订单消息消费者(保证顺序性和幂等性)
*/
@Service
public class OrderMessageConsumer {
@Autowired
private OrderService orderService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 为每个订单维护一个队列(保证顺序)
private ConcurrentHashMap<String, BlockingQueue<OrderMessage>> orderQueues
= new ConcurrentHashMap<>();
private ExecutorService executor = Executors.newFixedThreadPool(10);
@KafkaListener(
topics = "order-topic",
concurrency = "3"
)
public void consume(ConsumerRecord<String, String> record,
Acknowledgment ack) {
String orderId = record.key();
String content = record.value();
OrderMessage message = JSON.parseObject(content, OrderMessage.class);
// 获取订单对应的队列
BlockingQueue<OrderMessage> queue = orderQueues.computeIfAbsent(
orderId,
k -> {
LinkedBlockingQueue<OrderMessage> q = new LinkedBlockingQueue<>();
executor.submit(() -> processQueue(orderId, q));
return q;
}
);
// 放入队列
queue.offer(message);
// 手动提交offset
ack.acknowledge();
}
private void processQueue(String orderId, BlockingQueue<OrderMessage> queue) {
while (true) {
try {
OrderMessage message = queue.take();
// 幂等性检查
String messageId = message.getMessageId();
String redisKey = "msg:processed:" + messageId;
Boolean isFirst = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "1", 7, TimeUnit.DAYS);
if (isFirst != null && isFirst) {
// 首次处理
try {
orderService.processOrderMessage(message);
log.info("订单消息处理成功: orderId={}, messageId={}",
orderId, messageId);
} catch (Exception e) {
// 处理失败,删除标记
redisTemplate.delete(redisKey);
log.error("订单消息处理失败: {}", messageId, e);
}
} else {
log.warn("重复消息,跳过: {}", messageId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}
💡 最佳实践总结
1. 顺序性保证
✅ DO:
- 使用Key路由,保证同一业务的消息到同一分区
- 消费端使用内存队列+单线程处理
- 区分全局顺序和局部顺序需求
❌ DON'T:
- 不要在需要顺序的场景使用多线程消费
- 不要在不需要顺序的场景牺牲性能
2. 可靠性保证
✅ DO:
- 生产端:acks=all + 重试 + 失败记录
- MQ端:多副本 + 持久化
- 消费端:先处理后提交 + 失败重试
❌ DON'T:
- 不要使用fire-and-forget发送
- 不要自动提交offset
- 不要忽略失败的消息
3. 幂等性保证
✅ DO:
- 使用全局唯一ID
- 利用数据库唯一索引
- 业务设计天然幂等
- 使用状态机
❌ DON'T:
- 不要假设消息只会消费一次
- 不要在消费逻辑中产生副作用
🎯 面试高频问题
Q1:如何保证消息不丢失?
A:三个环节都要保证:
- 生产者:
java
// acks=all + 同步发送
SendResult result = producer.send(message).get();
- Broker:
ini
replicas=3
min.insync.replicas=2
- 消费者:
java
// 先处理后提交
process(message);
ack.acknowledge();
Q2:如何保证消息顺序?
A:
Kafka:
markdown
1. 相同key发到同一分区
2. 单线程消费
RocketMQ:
markdown
1. MessageQueue Selector
2. 顺序消费模式
Q3:如何保证幂等性?
A:四种方案:
- 唯一ID + Redis:性能最好
- 数据库唯一索引:最可靠
- 业务幂等设计:最优雅
- 状态机:最严谨
🎉 总结
核心要点 ✨
-
顺序性:
- Key路由 + 单分区
- 内存队列 + 单线程处理
-
可靠性:
- 生产端重试
- MQ多副本
- 消费端先处理后提交
-
幂等性:
- 唯一ID去重
- 数据库唯一索引
- 业务幂等设计
记忆口诀 📝
vbnet
消息队列三大难,
顺序可靠加幂等。
顺序保证用分区,
相同Key同一区。
单线程来消费它,
队列管理顺序佳。
可靠保证三环节,
发送确认加重试。
多副本来持久化,
先处理后提交它。
幂等设计有四法,
Redis去重数据库。
业务设计天然好,
状态机来最严谨!
📚 参考资料
最后送你一句话:
"消息队列不是万能的,但没有可靠的消息队列是万万不能的。"
愿你的消息系统顺序井然、可靠无虞、幂等自如! 📬✨
表情包时间 🎭
erlang
消息乱序时:
😱 先发货再下单?这什么鬼!
消息丢失时:
💔 钱扣了,订单没了...
消息重复时:
😤 为什么扣了我三次钱!
都保证了:
😊 秩序井然,稳如老狗!