阶段2(上):后端专题 Go/Gin/PostgreSQL/MySQL/Redis/MQ/gRPC 的课程、企业场景、面试题和可运行示例

This commit is contained in:
Shikui Wang committed 2026-10-23 15:24:57 +08:00
1 parent 0bae9916a9
commit 5eb67c5d08
65 files changed
+6457 -77

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
---
id: backend.mq.rabbitmq
title: RabbitMQ:交换机、队列、确认机制、死信和延迟
type: lesson
domain: backend
stack: [mq]
tags: [RabbitMQ, AMQP, 交换机, ACK, 死信, 延迟队列]
level: basic
est_hours: 3
related: [backend.mq.reliability, backend.mq.scenario.delayed-order-cancel, backend.mq.interview]
week: [3, 14]
order: 1
---
# RabbitMQ:交换机、队列、确认机制、死信和延迟
## 解释说明
**模型**:生产者 → **交换机(Exchange)** →(按绑定规则路由)→ **队列(Queue)** → 消费者。生产者不直接发给队列,而是发给交换机并带一个 routing key。
| 交换机类型 | 路由规则 | 场景 |
| --- | --- | --- |
| direct | routing key 完全匹配 | 按类型分发任务 |
| topic | 通配符匹配,`*` 一个词、`#` 零个或多个词,如 `order.*.created` | 按业务域订阅事件 |
| fanout | 忽略 routing key,发给所有绑定的队列 | 广播 |
| headers | 按消息头匹配 | 少用 |
还有一个“默认交换机”:routing key 等于队列名时直接投递到该队列。
**可靠性三件套**
- **持久化**:交换机、队列声明为 durable,消息设置 `delivery_mode=2`(持久化),重启后不丢。
- **生产者确认(publisher confirm)**:broker 收到并处理好消息后回 ack,生产者才认为发送成功。
- **消费者手动确认(manual ack)**:处理完成后再 ack;处理失败 nack,可以选择重新入队或进入死信。消费者断开时未确认的消息会重新投递,所以消费要幂等。
**prefetch(QoS)**:每个消费者最多同时持有多少条未确认消息,防止一个消费者拿太多、其他消费者闲着。
**死信(Dead Letter)**:消息被拒绝且不重新入队、过期(TTL)、队列满时,会被转到队列配置的死信交换机。用途:失败消息集中处理、延迟消息。
**延迟消息**的两种做法:TTL + 死信(消息在没有消费者的等待队列中过期,转到处理队列);或者安装官方社区的延迟消息插件。注意:消息级 TTL 只在消息到达队头时检查,不同 TTL 的消息混在一个队列里会互相阻塞,所以**一个等待队列只放同一种延迟时间**。
**队列类型**:RabbitMQ 4.x 推荐需要高可用和数据安全的队列使用 **quorum 队列**(基于 Raft 复制),经典队列的镜像功能已移除。具体特性以官方文档为准:https://www.rabbitmq.com/docs/quorum-queues
## 案例
- 用户注册后发欢迎邮件、送新人券:注册服务发 `user.registered` 到 topic 交换机,邮件服务和营销服务各自建队列绑定,互不影响。
- 订单 30 分钟未支付自动取消:见[延迟取消场景](scenarios/delayed-order-cancel.md)。
## 代码示例
Python + aio-pika,生产者带确认、消费者手动确认,失败的消息进死信队列(示意代码,完整可运行示例见 [examples/delay-cancel](examples/delay-cancel/)):
```python
import asyncio
import json
import aio_pika
async def main() -> None:
conn = await aio_pika.connect_robust("amqp://guest:guest@localhost/")
async with conn:
ch = await conn.channel(publisher_confirms=True) # 默认就是开启的;publish 会等待 broker 确认
await ch.set_qos(prefetch_count=20)
events = await ch.declare_exchange("events", aio_pika.ExchangeType.TOPIC, durable=True)
dlx = await ch.declare_exchange("events.dlx", aio_pika.ExchangeType.FANOUT, durable=True)
dead = await ch.declare_queue("email.dead", durable=True)
await dead.bind(dlx)
q = await ch.declare_queue("email.welcome", durable=True,
arguments={"x-dead-letter-exchange": "events.dlx", "x-queue-type": "quorum"})
await q.bind(events, routing_key="user.registered")
await events.publish(
aio_pika.Message(json.dumps({"user_id": 1}).encode(), delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
message_id="user-1-registered"),
routing_key="user.registered")
async with q.iterator() as it:
async for msg in it:
# requeue=False:处理失败不重新入队,直接进死信队列,避免无限重试
async with msg.process(requeue=False):
data = json.loads(msg.body)
print("发送欢迎邮件给用户", data["user_id"])
break
asyncio.run(main())
```
## 易错点
- **自动 ack**:消息一到就确认,消费者处理到一半崩溃,消息就丢了。
- **失败消息无限重新入队**:一条有问题的消息反复失败,堵住队列、刷爆日志。设置重试次数,超过进死信。
- **以为开启持久化就不会丢**:还需要生产者确认;单节点磁盘故障仍会丢,需要 quorum 队列。
- **一个等待队列放不同 TTL 的消息**:后面短 TTL 的消息被前面长 TTL 的挡住。
- **队列声明参数不一致**:同名队列用不同参数重复声明会报错(PRECONDITION_FAILED),要删除重建或换名。
- **prefetch 设成无限**:一个消费者囤积大量消息,内存暴涨、负载不均。
## 面试常问
见[消息队列面试题](interview.md):交换机类型、怎么保证消息不丢、死信队列、延迟消息实现。
+99
View File
@@ -0,0 +1,99 @@
---
id: backend.mq.kafka
title: Kafka:主题、分区、消费组、位移和顺序
type: lesson
domain: backend
stack: [mq]
tags: [Kafka, 分区, 消费组, offset, 顺序消息, ISR]
level: intermediate
prerequisites: [backend.mq.rabbitmq]
est_hours: 3
related: [backend.mq.reliability, backend.mq.interview]
week: [14]
order: 2
---
# Kafka:主题、分区、消费组、位移和顺序
## 解释说明
**模型**:Kafka 是一个**分布式的追加日志**。消息写入**主题(Topic)**,主题分成多个**分区(Partition)**,每个分区是一个只追加的有序日志,消息有递增的**位移(offset)**。消息被消费后**不会删除**,按保留时间或大小清理,所以可以回溯重新消费。
- **生产者**:消息带 key 时,同一个 key 总是进入同一个分区(按 key 哈希);没有 key 时分散写入。
- **消费组(Consumer Group)**:同一个组里,一个分区同一时间只分配给一个消费者;不同组各自独立消费全部消息。所以**消费者数量超过分区数时,多出来的消费者会空闲**——分区数决定了组内并行度上限。
- **位移提交**:消费者记录“处理到哪里了”。先处理再提交 = 至少一次(可能重复);先提交再处理 = 至多一次(可能丢)。业务一般选至少一次 + 幂等消费。
- **副本**:每个分区有多个副本,一个 leader 负责读写,follower 同步。和 leader 保持同步的副本集合叫 **ISR**。
- **生产者 acks**:`acks=0` 不等确认;`acks=1` leader 写入即确认;`acks=all` ISR 都写入才确认(配合 `min.insync.replicas` 保证不丢)。
- **顺序**:只保证**单个分区内**有序。需要“同一订单的消息有序”时,用订单 ID 作为 key。
**和 RabbitMQ 怎么选**
| | RabbitMQ | Kafka |
| --- | --- | --- |
| 模型 | 消息被消费确认后删除 | 日志保留,可回溯 |
| 优势 | 路由灵活、延迟 / 死信 / 优先级等功能多、单条消息时延低 | 吞吐量高、可回放、适合流处理 |
| 典型场景 | 业务异步任务、订单流程 | 日志采集、埋点、数据同步、事件流 |
新版本 Kafka 已用 KRaft 取代 ZooKeeper 管理元数据(Kafka 4.0 起移除 ZooKeeper),部署细节以官方文档为准:https://kafka.apache.org/documentation/
## 案例
- 埋点数据:前端事件写入 `events` 主题(几十个分区),实时计算和离线入库两个消费组各自消费。
- 订单状态变化事件:用订单 ID 作为 key,保证同一订单的“创建 → 支付 → 发货”按顺序被消费。
- 消费者 bug 导致数据处理错误:修复后把消费组位移重置到出问题前的时间点,重新消费。
## 代码示例
Python 客户端(`confluent-kafka`,示意代码,需要运行中的 Kafka;客户端参数以官方文档为准:https://docs.confluent.io/kafka-clients/python/current/overview.html ):
```python
import json
from confluent_kafka import Consumer, Producer
producer = Producer({"bootstrap.servers": "localhost:9092", "acks": "all", "enable.idempotence": True})
def on_delivery(err, msg):
if err is not None:
print("发送失败:", err) # 这里要记录并重试或告警
for status in ("created", "paid", "shipped"):
# 同一订单用同一个 key → 进入同一分区 → 有序
producer.produce("order-events", key="order-1001", value=json.dumps({"status": status}), callback=on_delivery)
producer.flush()
consumer = Consumer({
"bootstrap.servers": "localhost:9092",
"group.id": "points-service",
"auto.offset.reset": "earliest",
"enable.auto.commit": False, # 处理成功后手动提交,实现“至少一次”
})
consumer.subscribe(["order-events"])
try:
while True:
msg = consumer.poll(1.0)
if msg is None:
continue
if msg.error():
print("消费错误:", msg.error())
continue
event = json.loads(msg.value())
print(msg.key(), msg.partition(), msg.offset(), event) # 业务处理要幂等
consumer.commit(message=msg, asynchronous=False)
finally:
consumer.close()
```
## 易错点
- **消费者比分区多**:多出来的消费者空闲,加机器也提不了速度;要先加分区(分区只能增加不能减少,增加后 key 的分布会变,影响顺序)。
- **自动提交位移**:消息还没处理完就提交,崩溃后丢消息;或者处理完还没提交就崩溃,重复消费。业务里建议手动提交 + 幂等。
- **以为整个主题有序**:只有分区内有序。
- **单条消息处理太慢**:超过 `max.poll.interval.ms` 会被踢出消费组并触发再均衡,导致重复消费。
- **`acks=1` 又要求不丢**:leader 宕机且 follower 未同步时会丢;要 `acks=all` + `min.insync.replicas>=2` + 副本数 >=3。
## 面试常问
见[消息队列面试题](interview.md):Kafka 为什么吞吐高、分区和消费组的关系、怎么保证顺序、怎么不丢消息、和 RabbitMQ 的区别。
+105
View File
@@ -0,0 +1,105 @@
---
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):怎么保证消息不丢、怎么保证幂等、怎么保证顺序、积压了怎么办、分布式事务怎么做。
@@ -0,0 +1,84 @@
# requires: aio_pika
"""RabbitMQ 延迟取消订单:消息先进带 TTL 的等待队列,过期后经死信交换机转到处理队列。
运行:AMQP_URL=amqp://guest:guest@localhost/ python delay_cancel.py
没有设置 AMQP_URL 时打印提示并正常退出。会创建并删除名字带随机后缀的交换机和队列。
"""
import asyncio
import json
import os
import sys
import time
import uuid
import aio_pika
DELAY_MS = 1000 # 演示用 1 秒;真实业务常见是 15~30 分钟
class OrderStore:
"""模拟数据库里的订单状态(真实项目里用条件 UPDATE 保证幂等)。"""
def __init__(self) -> None:
self.status: dict[str, str] = {}
def cancel_if_unpaid(self, order_id: str) -> bool:
# 等价于 UPDATE orders SET status='cancelled' WHERE id=? AND status='pending'
if self.status.get(order_id) == "pending":
self.status[order_id] = "cancelled"
return True
return False
async def main() -> int:
url = os.environ.get("AMQP_URL")
if not url:
print("未设置 AMQP_URL,跳过(示例:amqp://guest:guest@localhost/)")
return 0
suffix = uuid.uuid4().hex[:8]
conn = await aio_pika.connect_robust(url)
async with conn:
ch = await conn.channel()
await ch.set_qos(prefetch_count=10)
dlx = await ch.declare_exchange(f"order.dlx.{suffix}", aio_pika.ExchangeType.DIRECT, auto_delete=True)
# 处理队列:真正执行“超时取消”的消费者监听这里
work_q = await ch.declare_queue(f"order.cancel.{suffix}", durable=True, auto_delete=True)
await work_q.bind(dlx, routing_key="cancel")
# 等待队列:没有消费者,消息在这里过期后转到死信交换机
wait_q = await ch.declare_queue(f"order.wait.{suffix}", durable=True, auto_delete=True, arguments={
"x-message-ttl": DELAY_MS,
"x-dead-letter-exchange": dlx.name,
"x-dead-letter-routing-key": "cancel",
})
store = OrderStore()
store.status = {"o1": "pending", "o2": "pending"}
start = time.monotonic()
for oid in ("o1", "o2", "o1"): # o1 故意发两次,模拟重复消息
await ch.default_exchange.publish(
aio_pika.Message(json.dumps({"order_id": oid}).encode(),
delivery_mode=aio_pika.DeliveryMode.PERSISTENT, message_id=f"cancel-{oid}"),
routing_key=wait_q.name)
store.status["o2"] = "paid" # o2 在超时前支付了
results: list[tuple[str, bool]] = []
async with work_q.iterator() as it:
async for msg in it:
async with msg.process(): # 正常结束自动 ack,抛异常则 nack
oid = json.loads(msg.body)["order_id"]
results.append((oid, store.cancel_if_unpaid(oid)))
if len(results) == 3:
break
elapsed = time.monotonic() - start
print("处理结果:", results, f"耗时 {elapsed:.1f}s")
assert elapsed >= DELAY_MS / 1000 * 0.9, "消息应该在延迟之后才被处理"
assert store.status == {"o1": "cancelled", "o2": "paid"}, store.status
assert sorted(results) == [("o1", False), ("o1", True), ("o2", False)] # 重复消息不会重复取消
await wait_q.delete(if_unused=False, if_empty=False)
await work_q.delete(if_unused=False, if_empty=False)
print("延迟取消正确:未支付的被取消,已支付的不受影响,重复消息幂等")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
@@ -0,0 +1,105 @@
"""发件箱(Outbox)模式:业务数据和“待发送消息”写在同一个数据库事务里,由后台程序投递。
只用标准库(sqlite3)演示,直接运行:python outbox_demo.py
真实项目里把 sqlite 换成 MySQL / PostgreSQL,把 FakeBroker 换成 RabbitMQ / Kafka 客户端。
"""
import json
import sqlite3
class FakeBroker:
"""模拟消息队列:fail_times 次之内发送失败,用来演示重试。"""
def __init__(self, fail_times: int = 0) -> None:
self.sent: list[dict] = []
self.fail_times = fail_times
def publish(self, topic: str, msg: dict) -> None:
if self.fail_times > 0:
self.fail_times -= 1
raise ConnectionError("broker 暂时不可用")
self.sent.append({"topic": topic, **msg})
def init(db: sqlite3.Connection) -> None:
db.executescript("""
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, amount INTEGER NOT NULL);
CREATE TABLE outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL,
payload TEXT NOT NULL,
sent_at TEXT, -- NULL 表示还没发送
attempts INTEGER NOT NULL DEFAULT 0
);
""")
def create_order(db: sqlite3.Connection, user_id: int, amount: int, fail_after_insert: bool = False) -> int:
with db: # 一个事务:订单和消息要么都写入,要么都不写入
cur = db.execute("INSERT INTO orders (user_id, amount) VALUES (?, ?)", (user_id, amount))
order_id = cur.lastrowid
db.execute("INSERT INTO outbox (topic, payload) VALUES (?, ?)",
("order.created", json.dumps({"order_id": order_id, "user_id": user_id, "amount": amount})))
if fail_after_insert:
raise RuntimeError("模拟业务异常:事务回滚")
return order_id
def relay(db: sqlite3.Connection, broker: FakeBroker, batch: int = 100) -> int:
"""后台投递:取未发送的消息,发送成功后标记。可能重复发送(至少一次),消费者要幂等。"""
rows = db.execute("SELECT id, topic, payload FROM outbox WHERE sent_at IS NULL ORDER BY id LIMIT ?",
(batch,)).fetchall()
sent = 0
for mid, topic, payload in rows:
try:
broker.publish(topic, {"message_id": mid, **json.loads(payload)})
except ConnectionError:
with db:
db.execute("UPDATE outbox SET attempts = attempts + 1 WHERE id = ?", (mid,))
break # 保持顺序:前面的没发出去,后面的先不发
with db:
db.execute("UPDATE outbox SET sent_at = datetime('now') WHERE id = ?", (mid,))
sent += 1
return sent
class Consumer:
"""幂等消费者:按 message_id 去重(真实项目里用数据库唯一约束记录已处理的消息)。"""
def __init__(self) -> None:
self.processed: set[int] = set()
self.points: dict[int, int] = {}
def handle(self, msg: dict) -> None:
if msg["message_id"] in self.processed:
return
self.points[msg["user_id"]] = self.points.get(msg["user_id"], 0) + msg["amount"] // 100
self.processed.add(msg["message_id"])
def main() -> None:
db = sqlite3.connect(":memory:")
init(db)
create_order(db, user_id=1, amount=19900)
try:
create_order(db, user_id=2, amount=500, fail_after_insert=True)
except RuntimeError:
pass
create_order(db, user_id=1, amount=10000)
assert db.execute("SELECT count(*) FROM orders").fetchone()[0] == 2
assert db.execute("SELECT count(*) FROM outbox").fetchone()[0] == 2 # 回滚的订单没有留下消息
broker = FakeBroker(fail_times=1)
assert relay(db, broker) == 0 # 第一次 broker 不可用
assert relay(db, broker) == 2 # 重试成功
assert relay(db, broker) == 0 # 没有待发送的了
consumer = Consumer()
for m in broker.sent + broker.sent[:1]: # 模拟第一条消息被重复投递
consumer.handle(m)
assert consumer.points == {1: 199 + 100}, consumer.points
print("发件箱:订单和消息同事务写入;投递失败可重试;消费者幂等,重复消息不会重复加积分")
if __name__ == "__main__":
main()
+32 -3
View File
@@ -4,12 +4,41 @@ title: 消息队列(RabbitMQ / Kafka)
type: index
domain: backend
stack: [mq]
tags: []
tags: [消息队列, RabbitMQ, Kafka, 异步, 削峰]
week: [3, 14]
---
# 消息队列(RabbitMQ / Kafka)
异步解耦、可靠投递、幂等消费、顺序、延迟消息。
消息队列用来**异步解耦**(下单后发短信、加积分不阻塞主流程)、**削峰填谷**(抢购请求先进队列)、**广播**(一个事件多个系统消费)。招聘要求里 RabbitMQ 和 Kafka 都常见:业务系统多用 RabbitMQ,日志、埋点、数据管道多用 Kafka。面试最常问的是**可靠性、幂等、顺序、积压**。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [RabbitMQ:交换机、队列、确认机制、死信和延迟](01-rabbitmq.md) | 基础 | 3 小时 |
| 2 | [Kafka:主题、分区、消费组、位移和顺序](02-kafka.md) | 进阶 | 3 小时 |
| 3 | [可靠性:不丢消息、幂等消费、发件箱模式、积压处理](03-reliability.md) | 进阶 | 3 小时 |
## 企业场景
- [订单超时自动取消:延迟消息、幂等和兜底扫描](scenarios/delayed-order-cancel.md)
## 面试题
- [消息队列面试题](interview.md)
## 示例代码
- [examples/delay-cancel](examples/delay-cancel/):Python + aio-pika,用 TTL + 死信交换机实现延迟取消;设置 `AMQP_URL` 后运行
- [examples/outbox](examples/outbox/):只用标准库演示发件箱模式和幂等消费,直接运行
## 在 16 周计划中
- [第 3 周:项目一(异步任务)](../../../plan/week03-project1.md)、[第 14 周:后端扩展](../../../plan/week14-backend-extend-deploy.md)
## 官方资料
- RabbitMQ 文档:https://www.rabbitmq.com/docs
- Kafka 文档:https://kafka.apache.org/documentation/
- aio-pika:https://docs.aio-pika.com/
+63
View File
@@ -0,0 +1,63 @@
---
id: backend.mq.interview
title: 消息队列面试题
type: interview
domain: backend
stack: [mq]
tags: [RabbitMQ, Kafka, 可靠性, 幂等, 面试]
level: intermediate
related: [backend.mq.rabbitmq, backend.mq.kafka, backend.mq.reliability]
week: [14, 16]
---
# 消息队列面试题
## 基础
### 【基础】为什么要用消息队列?会带来什么问题?
**参考答案**:异步(非核心步骤不阻塞主流程,接口更快)、解耦(新增下游不用改上游)、削峰(突发流量先进队列,按能力消费)。问题:系统更复杂,多了一个要维护的组件;消息可能重复、丢失、乱序,要处理;数据变成最终一致;排查链路变长。
### 【基础】RabbitMQ 有哪几种交换机?
**参考答案**:direct(routing key 完全匹配)、topic(通配符匹配,`*` 匹配一个词、`#` 匹配零个或多个词)、fanout(广播给所有绑定队列)、headers(按消息头匹配)。
### 【基础】什么是死信队列?
**参考答案**:消息被拒绝且不重新入队、TTL 过期、队列达到长度上限时,会被转发到队列配置的死信交换机,再路由到死信队列。用于集中处理失败消息,也可以和 TTL 组合实现延迟消息。
### 【基础】Kafka 的分区和消费组是什么关系?
**参考答案**:主题分成多个分区;同一个消费组里,一个分区同一时间只能被一个消费者消费,一个消费者可以消费多个分区;不同消费组互不影响、各自消费全部消息。所以组内并行度上限是分区数。
## 进阶
### 【进阶】怎么保证消息不丢失?
**参考答案**:分三段看。生产端:开启确认(RabbitMQ publisher confirm、Kafka `acks=all`),失败重试,必要时用发件箱模式保证和数据库事务一致。broker:持久化 + 多副本(RabbitMQ quorum 队列;Kafka 副本数 >=3、`min.insync.replicas>=2`)。消费端:处理完成后再手动 ack / 提交位移。这样是“至少一次”,消费端还要幂等。
### 【进阶】怎么保证消费幂等?
**参考答案**:利用业务状态的条件更新(如 `WHERE status='pending'`);或者用消息 ID 去重表,在同一个事务里插入去重记录(唯一约束)并执行业务;或者在业务表上建唯一约束(如同一订单只发一次积分)。Redis 去重可以作为前置过滤,但关键业务要以数据库约束为准。
### 【进阶】怎么保证消息顺序?
**参考答案**:Kafka 只保证分区内有序,把需要有序的消息用同一个 key(如订单 ID)发到同一分区,消费端单线程处理该分区或按 key 分发到固定线程。RabbitMQ 同一个队列单个消费者时有序,多个消费者并发时无序,可按 key 拆到多个队列。另外也可以在消息里带版本号 / 状态,消费端丢弃过期的消息,降低对顺序的依赖。
### 【进阶】Kafka 为什么吞吐量高?
**参考答案**:顺序写磁盘(追加日志);利用操作系统页缓存;零拷贝(sendfile)发送数据给消费者;生产和消费都支持批量和压缩;分区让读写可以分布到多台 broker 并行。
## 场景题
### 【场景题】下单后要扣库存、发积分、发通知,怎么保证订单和消息的一致性?
**参考答案**:用发件箱模式:订单和待发消息在同一事务里写入数据库,后台程序(或 CDC)把消息投递到 MQ,成功后标记;下游消费者幂等。这样订单提交了消息一定会发出(至少一次)。如果是强一致的跨服务操作(比如扣库存必须和下单同时成功),可以用 TCC 或 Saga 补偿,但复杂度高,大部分业务用“可靠消息 + 最终一致 + 对账”。
### 【场景题】线上消息积压了几百万条,怎么处理?
**参考答案**:先看消费者是否正常(是否卡在某条毒消息、下游是否变慢、是否频繁再均衡)。修复问题后扩容消费者(Kafka 要看分区数是否够),提高消费并发、批量处理。积压特别大时,写一个临时程序把消息快速转存到分区更多的新主题,用大量消费者并行处理。同时评估:过期没有意义的消息(如过时的通知)可以跳过。事后加上积压监控告警。
### 【场景题】订单 30 分钟未支付自动取消,怎么设计?
**参考答案**:RabbitMQ 用 TTL + 死信队列(或延迟插件)发送延迟消息;消费者用条件更新 `WHERE status='pending'` 取消并在同一事务回补库存,和支付回调天然互斥、重复消息幂等;保留低频的兜底扫描任务防止消息丢失。详见[延迟取消场景](scenarios/delayed-order-cancel.md)。
@@ -0,0 +1,99 @@
---
id: backend.mq.scenario.delayed-order-cancel
title: 订单超时自动取消:延迟消息、幂等和兜底扫描
type: scenario
domain: backend
stack: [mq, redis, mysql]
tags: [延迟消息, 死信, 订单超时, 幂等, 定时任务]
level: intermediate
prerequisites: [backend.mq.rabbitmq]
est_hours: 2
related: [backend.mq.reliability, backend.redis.scenario.stock-reservation, backend.mq.interview]
week: [14]
---
# 订单超时自动取消:延迟消息、幂等和兜底扫描
## 业务背景与问题
下单后 30 分钟未支付要自动取消并释放库存。最初的做法是每分钟一个定时任务扫描 `WHERE status='pending' AND created_at < now() - 30 分钟`。订单量上来后:扫描越来越慢;取消时间最多晚 1 分钟;多实例部署时定时任务同时执行,重复取消、重复回补库存。
## 约束
- 取消时间误差在秒级即可。
- 绝不能取消已支付的订单(支付回调和超时取消可能同时发生)。
- 库存回补只能做一次。
- 已有 RabbitMQ。
## 方案设计
```mermaid
sequenceDiagram
participant O as 订单服务
participant W as 等待队列(TTL=30 分钟,无消费者)
participant D as 死信交换机
participant C as 取消消费者
participant DB as 数据库
O->>DB: 创建订单(pending)+ outbox 消息(同一事务)
O->>W: 发布 {order_id}
Note over W: 30 分钟后消息过期
W->>D: 转为死信
D->>C: 投递到处理队列
C->>DB: UPDATE ... SET status='cancelled' WHERE id=? AND status='pending'
alt 影响 1 行
C->>DB: 同一事务回补库存
else 影响 0 行(已支付 / 已取消)
C-->>C: 什么都不做,直接 ack
end
```
1. **延迟消息**:TTL + 死信交换机。一个等待队列只放同一种延迟时间(30 分钟)。
2. **幂等取消**:用条件更新 `WHERE status='pending'`,和支付回调的 `WHERE status='pending'` 天然互斥,只有一个会成功;库存回补放在同一事务里,只有取消成功才回补。
3. **兜底扫描**:消息仍可能因为故障丢失,保留一个低频(如每 10 分钟)的扫描任务,处理超过 35 分钟还是 pending 的订单;扫描任务用分布式锁保证只有一个实例执行,并走同一个幂等取消函数。
4. **订单量非常大或延迟时间很多种**:考虑延迟消息插件、Redis ZSET(分数=到期时间)或专门的延迟任务服务。
## 关键代码
完整可运行示例:[examples/delay-cancel/delay_cancel.py](../examples/delay-cancel/delay_cancel.py)(Python + aio-pika,延迟 1 秒演示):
```python
async def declare_wait_queue(ch, dlx, suffix: str):
return await ch.declare_queue(f"order.wait.{suffix}", durable=True, arguments={
"x-message-ttl": DELAY_MS, # 过期时间
"x-dead-letter-exchange": dlx.name, # 过期后转到死信交换机
"x-dead-letter-routing-key": "cancel",
})
async def consume(work_q, store):
async with work_q.iterator() as it:
async for msg in it:
async with msg.process(): # 正常结束 ack,异常 nack
oid = json.loads(msg.body)["order_id"]
store.cancel_if_unpaid(oid) # 等价于条件 UPDATE,重复消息无副作用
```
示例验证了:消息在延迟之后才被处理;超时前支付的订单不受影响;同一订单的重复消息只取消一次。
## 取舍
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| 定时扫描数据库 | 简单、可靠 | 有延迟;数据量大时扫描压力大;多实例要加锁 |
| **TTL + 死信(本方案)** | 不依赖插件、精度到秒 | 每种延迟时间一个队列;消息丢失需要兜底 |
| 延迟消息插件 | 任意延迟时间 | 需要安装插件,大量延迟消息时有性能和可靠性限制(以官方说明为准) |
| Redis ZSET 轮询 | 灵活、轻量 | 要自己处理可靠性(取出后崩溃) |
## 易错点
- 取消时不加 `status='pending'` 条件:把刚支付的订单取消了。
- 先回补库存再更新订单状态,且不在同一事务:重复消息导致库存多次回补。
- 一个队列混放 15 分钟和 30 分钟的消息:短的被长的挡住,到期也不出队。
- 完全依赖延迟消息不做兜底:消息丢了订单就一直 pending、库存一直被占。
- 消费者自动 ack:处理失败消息就没了。
## 面试怎么讲
> “订单超时取消原来是定时扫表,数据量大后又慢又不准,多实例还会重复执行。我改成 RabbitMQ 的 TTL 加死信队列做延迟消息,消费者用 `WHERE status='pending'` 的条件更新做取消,和支付回调天然互斥,库存回补放在同一事务里,所以重复消息也只会取消一次。另外保留了一个低频的兜底扫描,防止消息丢失。”
追问准备:为什么不用延迟插件?支付回调在取消之后才到怎么办(订单已取消 → 走自动退款流程)?