5.1 KiB
5.1 KiB
id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
| id | title | type | domain | stack | tags | level | prerequisites | est_hours | related | week | order | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| backend.mq.reliability | 可靠性:不丢消息、幂等消费、发件箱模式、积压处理 | lesson | backend |
|
|
intermediate |
|
3 |
|
|
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):解决“数据库写成功了但消息没发出去”(或反过来)。
- 业务数据和一条“待发送消息”写在同一个数据库事务里(消息写
outbox表)。 - 后台程序轮询
outbox中未发送的消息,发送到 MQ,成功后标记已发送。 - 发送成功但标记失败会导致重发——还是至少一次,消费者幂等即可。
也可以用 CDC(订阅数据库 binlog / WAL,例如 Debezium)把 outbox 表的变化推送到 MQ,省掉轮询。
消息积压:消费速度跟不上生产速度。
- 先止血:临时增加消费者(Kafka 受分区数限制)、提高并发、批量处理;
- 查原因:消费者是不是卡在慢的下游(数据库、外部接口)、是否在反复重试失败消息;
- 应急:把积压的消息转存到新的、分区更多的临时主题,用更多消费者处理;非关键消息可以降级丢弃或延后。
案例
下单后要给用户加积分、发通知:
- 订单和
order.created消息在同一事务写入(发件箱),保证“有订单就一定有消息”; - 积分服务按
message_id去重,重复消息不会重复加积分; - 处理失败超过 3 次进入死信队列,告警后人工处理。
代码示例
完整可运行示例(只用标准库):examples/outbox/outbox_demo.py。核心片段:
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 版的去重表写法:
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 和数据库不在一个事务里,过期或丢数据后仍会重复,关键业务以数据库唯一约束为准。 - 失败无限重试:毒消息堵住队列。设置最大重试次数和死信。
- 发件箱表不清理:定期删除或归档已发送的记录。
面试常问
见消息队列面试题:怎么保证消息不丢、怎么保证幂等、怎么保证顺序、积压了怎么办、分布式事务怎么做。