阶段2(上):后端专题 Go/Gin/PostgreSQL/MySQL/Redis/MQ/gRPC 的课程、企业场景、面试题和可运行示例
This commit is contained in:
1 parent
0bae9916a9
commit
5eb67c5d08
65 files changed
+6457
-77
No files matched your search
@@ -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):交换机类型、怎么保证消息不丢、死信队列、延迟消息实现。
|
||||
@@ -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 的区别。
|
||||
@@ -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()
|
||||
@@ -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/
|
||||
@@ -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'` 的条件更新做取消,和支付回调天然互斥,库存回补放在同一事务里,所以重复消息也只会取消一次。另外保留了一个低频的兜底扫描,防止消息丢失。”
|
||||
|
||||
追问准备:为什么不用延迟插件?支付回调在取消之后才到怎么办(订单已取消 → 走自动退款流程)?
|
||||
Reference in new issue
Block a user