Files
zhuangang/topics/backend/mq/03-reliability.md
T

106 lines
5.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: backend.mq.reliability
title: 可靠性:不丢消息、幂等消费、发件箱模式、积压处理
type: lesson
domain: backend
stack: [mq]
tags: [可靠投递, 幂等, 发件箱, Outbox, 消息积压, 最终一致性]
level: intermediate
prerequisites: [backend.mq.rabbitmq]
est_hours: 3
related: [backend.mq.kafka, backend.mq.scenario.delayed-order-cancel, backend.mq.interview, backend.postgresql.scenario.order-state-audit]
week: [3, 14]
order: 3
---
# 可靠性:不丢消息、幂等消费、发件箱模式、积压处理
## 解释说明
**消息可能在三个环节丢失**
| 环节 | 风险 | 措施 |
| --- | --- | --- |
| 生产者 → broker | 网络失败、broker 没收到 | 生产者确认(RabbitMQ confirm / Kafka `acks=all`)+ 失败重试 |
| broker 存储 | 宕机、磁盘故障 | 持久化 + 多副本(quorum 队列 / Kafka 副本) |
| broker → 消费者 | 消费者处理到一半崩溃 | 处理完成后再 ack / 提交位移 |
做到这些后,语义是**至少一次(at least once)**:不丢,但可能重复。所以**消费者必须幂等**。
**幂等消费的做法**
- 业务天然幂等:`UPDATE orders SET status='cancelled' WHERE id=? AND status='pending'`,重复执行没有副作用。
- 去重表:消息带唯一 `message_id`,消费时在**同一个事务里**插入 `processed_messages(message_id)`(唯一约束)并执行业务,插入冲突说明处理过了。
- 唯一业务键:如“同一个订单只发一次积分”,在积分流水表上建 `(order_id, type)` 唯一约束。
**发件箱模式(Transactional Outbox)**:解决“数据库写成功了但消息没发出去”(或反过来)。
1. 业务数据和一条“待发送消息”写在**同一个数据库事务**里(消息写 `outbox` 表)。
2. 后台程序轮询 `outbox` 中未发送的消息,发送到 MQ,成功后标记已发送。
3. 发送成功但标记失败会导致重发——还是至少一次,消费者幂等即可。
也可以用 CDC(订阅数据库 binlog / WAL,例如 Debezium)把 outbox 表的变化推送到 MQ,省掉轮询。
**消息积压**:消费速度跟不上生产速度。
- 先止血:临时增加消费者(Kafka 受分区数限制)、提高并发、批量处理;
- 查原因:消费者是不是卡在慢的下游(数据库、外部接口)、是否在反复重试失败消息;
- 应急:把积压的消息转存到新的、分区更多的临时主题,用更多消费者处理;非关键消息可以降级丢弃或延后。
## 案例
下单后要给用户加积分、发通知:
- 订单和 `order.created` 消息在同一事务写入(发件箱),保证“有订单就一定有消息”;
- 积分服务按 `message_id` 去重,重复消息不会重复加积分;
- 处理失败超过 3 次进入死信队列,告警后人工处理。
## 代码示例
完整可运行示例(只用标准库):[examples/outbox/outbox_demo.py](examples/outbox/outbox_demo.py)。核心片段:
```python
def create_order(db, user_id: int, amount: int) -> int:
with db: # 一个事务:订单和消息要么都写入,要么都不写入
cur = db.execute("INSERT INTO orders (user_id, amount) VALUES (?, ?)", (user_id, amount))
db.execute("INSERT INTO outbox (topic, payload) VALUES (?, ?)",
("order.created", json.dumps({"order_id": cur.lastrowid, "user_id": user_id, "amount": amount})))
return cur.lastrowid
def relay(db, broker, batch: int = 100) -> int:
"""后台投递:发送成功后再标记;可能重复发送,消费者要幂等。"""
rows = db.execute("SELECT id, topic, payload FROM outbox WHERE sent_at IS NULL ORDER BY id LIMIT ?",
(batch,)).fetchall()
for mid, topic, payload in rows:
broker.publish(topic, {"message_id": mid, **json.loads(payload)})
with db:
db.execute("UPDATE outbox SET sent_at = datetime('now') WHERE id = ?", (mid,))
return len(rows)
```
PostgreSQL 版的去重表写法:
```sql
CREATE TABLE processed_messages (
message_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 消费时,在同一个事务里:
-- INSERT INTO processed_messages (message_id) VALUES ($1) ON CONFLICT DO NOTHING;
-- 影响行数为 0 → 处理过了,直接 ack;否则执行业务,然后提交事务、ack
```
## 易错点
- **先发消息再提交事务**:事务回滚了,消息已经发出去,下游处理了一个不存在的订单。
- **先提交事务再发消息、发送失败不处理**:消息丢失。用发件箱。
- **去重记录和业务操作不在同一事务**:业务成功、去重记录没写,重复消息又执行一次。
- **只靠 Redis `SETNX` 去重**:Redis 和数据库不在一个事务里,过期或丢数据后仍会重复,关键业务以数据库唯一约束为准。
- **失败无限重试**:毒消息堵住队列。设置最大重试次数和死信。
- **发件箱表不清理**:定期删除或归档已发送的记录。
## 面试常问
见[消息队列面试题](interview.md):怎么保证消息不丢、怎么保证幂等、怎么保证顺序、积压了怎么办、分布式事务怎么做。