106 lines
5.1 KiB
Markdown
106 lines
5.1 KiB
Markdown
---
|
||
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):怎么保证消息不丢、怎么保证幂等、怎么保证顺序、积压了怎么办、分布式事务怎么做。
|