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