Files

5.1 KiB
Raw Permalink Blame History

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
mq
可靠投递
幂等
发件箱
Outbox
消息积压
最终一致性
intermediate
backend.mq.rabbitmq
3
backend.mq.kafka
backend.mq.scenario.delayed-order-cancel
backend.mq.interview
backend.postgresql.scenario.order-state-audit
3
14
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。核心片段:

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 和数据库不在一个事务里,过期或丢数据后仍会重复,关键业务以数据库唯一约束为准。
  • 失败无限重试:毒消息堵住队列。设置最大重试次数和死信。
  • 发件箱表不清理:定期删除或归档已发送的记录。

面试常问

见消息队列面试题:怎么保证消息不丢、怎么保证幂等、怎么保证顺序、积压了怎么办、分布式事务怎么做。