75 KiB
id, title, type, domain, stack, week, est_hours, order
| id | title | type | domain | stack | week | est_hours | order | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| plan.week14 | 第 14 周:后端补强——消息队列、gRPC、Redis / MySQL 专题、全部项目部署、CI/CD | week | plan |
|
|
12 | 14 |
第 14 周:后端补强——消息队列、gRPC、Redis / MySQL 专题、全部项目部署、CI/CD
第四段的第 1 周。上周
week13-agent-m4-eval-lowcode.md,下周week15-package-resume.md。
为什么有这一周:项目三完成后,作品里已经有 Redis、Redis Streams、Docker。但招聘里“熟悉 MySQL、Redis、消息队列、微服务”的要求,面试会顺着问下去(索引为什么失效、隔离级别、缓存击穿、消息重复消费……)。这一周用能运行的小实验把这些高频题过一遍,再把三个项目部署上线、配好 CI/CD。
原则:每个专题做到“能跑一个最小例子 + 能用自己的话回答面试题”,不深挖源码。Go 网关是可选内容,时间不够直接跳过(见总纲第 6 节)。
根据 BOSS直聘 2026-10-23 调研的调整:Go 只有 2 个岗位是硬性要求,所以 Go 网关继续保持可选;调研中出现的 ES 混合检索、MongoDB、K8s、数据管道、Node.js、Java(各有若干岗位提到)以及计算机基础,放到下面的“第 7 天(调整)”做了解级扩展:每项跑通专题里的示例、读完面试题,不做项目。
本周环境(2026-10 实际验证):RabbitMQ 4.0、Redis 7 以上、MySQL 8(我在 MariaDB 11.8 上跑过同样的 SQL,EXPLAIN 的主要列相同)、Python 3.12 + aio-pika 9 / redis-py / PyMySQL / grpcio 1.84、Go 1.27。
参考资料
- RabbitMQ 教程:https://www.rabbitmq.com/tutorials ;aio-pika:https://docs.aio-pika.com/
- Kafka 介绍:https://kafka.apache.org/intro
- gRPC Python:https://grpc.io/docs/languages/python/ ;Protocol Buffers:https://protobuf.dev/
- Redis 文档:https://redis.io/docs/latest/
- MySQL 8 参考手册(索引、EXPLAIN、InnoDB 事务):https://dev.mysql.com/doc/refman/8.0/en/
本周最终目录
backend-extend/
├── pyproject.toml
├── docker-compose.dev.yml # rabbitmq、redis、mysql
├── proto/backend_extend/grpcgen/run.proto
├── scripts/gen_proto.py
├── src/backend_extend/
│ ├── __init__.py
│ ├── mq_rabbit.py # 声明交换机 / 队列 / 死信、发布、幂等消费
│ ├── grpcgen/__init__.py # 生成的代码放这里(run_pb2*.py 不手改)
│ ├── grpc_service.py # gRPC 服务:一元调用 + 服务端流
│ ├── redis_patterns.py # 分布式锁、缓存三大问题、滑动窗口限流
│ └── mysql_lab.py # 索引 / EXPLAIN、隔离级别、原子扣减、转账
└── tests/
├── conftest.py # 测试前自动生成 gRPC 代码
├── test_rabbit.py
├── test_grpc.py
├── test_redis_patterns.py
└── test_mysql_lab.py
deploy/ # 服务器上的总部署
├── docker-compose.yml
├── nginx/conf.d/apps.conf
└── deploy.md
go-gateway/ # 可选:Go 写的鉴权网关
├── main.go
└── main_test.go
.github/workflows/ci.yml # 或 .gitea/workflows/ci.yml
第 1 天:消息队列——RabbitMQ 动手 + Kafka 概念
学习内容
1. 为什么要消息队列
- 说明:消息队列把“发起方”和“处理方”解耦:发起方把消息放进队列就返回,处理方按自己的速度慢慢处理。三个典型用途:异步(注册后发欢迎邮件不阻塞接口)、削峰(瞬间涌入的请求先排队)、解耦(一个事件多个系统各自订阅)。代价是系统变复杂:要考虑消息丢失、重复、顺序、积压。
- 案例:项目三里“创建任务 → 后台执行”现在是数据库表 + 看门狗实现的;如果执行器要部署成多台机器、任务量很大,就可以改成“接口写库并发消息 → 执行器从队列取任务”。
2. RabbitMQ 的核心概念
| 概念 | 是什么 | 类比 |
|---|---|---|
| 生产者 / 消费者 | 发消息 / 收消息的程序 | |
| 交换机(exchange) | 接收消息,按规则转发给队列;类型有 direct(精确匹配路由键)、topic(通配符 task.*)、fanout(广播) |
邮局分拣 |
| 队列(queue) | 存消息的地方,消费者从这里取 | 信箱 |
| 绑定(binding) | 交换机和队列之间的转发规则 | |
| 确认(ack) | 消费者处理完告诉服务器“可以删了”;没确认就断开,消息会重新投递 | |
| 死信(dead letter) | 被拒绝、过期或超过长度的消息转到另一个交换机,方便排查和重试 | 退信 |
| 预取(prefetch) | 每个消费者最多同时拿多少条未确认的消息,防止一个消费者被压垮 |
- 说明:消息不丢要三处配合:交换机和队列声明为持久(durable)+ 消息设为持久(delivery_mode=2)、生产者开启发布确认(publisher confirms)、消费者处理成功后再 ack。
- 说明:消息会重复:网络抖动、消费者处理完但 ack 前崩溃,都会导致同一条消息被投递两次。所以消费者必须幂等:用消息 ID(或业务唯一键)去重,生产环境去重记录放在 Redis(
SET NX+ 过期)或数据库唯一索引里。
3. 动手:交换机 + 队列 + 死信 + 幂等消费
先启动本周的依赖:
# 文件:backend-extend/docker-compose.dev.yml
services:
rabbitmq:
image: rabbitmq:4-management # 带管理界面:http://localhost:15672(guest / guest,只能本机登录)
ports: ["5672:5672", "15672:15672"]
redis:
image: redis:7
ports: ["6379:6379"]
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: learn
MYSQL_USER: app
MYSQL_PASSWORD: app
ports: ["3306:3306"]
# backend-extend/pyproject.toml(节选)
[project]
name = "backend-extend"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["aio-pika>=9", "redis>=5", "pymysql>=1.1", "grpcio>=1.70"]
[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.24", "grpcio-tools>=1.70"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = ["src"]
# 文件:backend-extend/src/backend_extend/__init__.py
# 文件:backend-extend/src/backend_extend/mq_rabbit.py
"""RabbitMQ 最小示例:topic 交换机 + 持久队列 + 死信队列 + 幂等消费。"""
import json
from collections.abc import Awaitable, Callable
import aio_pika
from aio_pika import DeliveryMode, ExchangeType, Message
from aio_pika.abc import AbstractChannel, AbstractExchange, AbstractIncomingMessage, AbstractQueue
async def declare(channel: AbstractChannel, prefix: str) -> tuple[AbstractExchange, AbstractQueue, AbstractQueue]:
"""声明是幂等的:已经存在且参数相同就直接用;参数不同会报错(改参数要先删队列)。"""
dlx = await channel.declare_exchange(f"{prefix}.dlx", ExchangeType.DIRECT, durable=True)
dead = await channel.declare_queue(f"{prefix}.dead", durable=True)
await dead.bind(dlx, routing_key=prefix)
events = await channel.declare_exchange(f"{prefix}.events", ExchangeType.TOPIC, durable=True)
tasks = await channel.declare_queue(
f"{prefix}.tasks", durable=True,
arguments={"x-dead-letter-exchange": f"{prefix}.dlx", "x-dead-letter-routing-key": prefix})
await tasks.bind(events, routing_key="task.*") # task.created、task.retry 都会进来
return events, tasks, dead
async def publish(exchange: AbstractExchange, routing_key: str, body: dict, message_id: str) -> None:
msg = Message(json.dumps(body, ensure_ascii=False).encode(), content_type="application/json",
delivery_mode=DeliveryMode.PERSISTENT, message_id=message_id)
await exchange.publish(msg, routing_key=routing_key) # 通道开了发布确认时,这里会等服务器确认
class IdempotentConsumer:
"""按 message_id 去重。演示用内存集合;生产环境用 Redis SET NX 或数据库唯一索引。"""
def __init__(self, handler: Callable[[dict], Awaitable[None]]):
self.handler = handler
self.seen: set[str] = set()
self.duplicates = 0
self.failed = 0
async def handle(self, message: AbstractIncomingMessage) -> None:
# 正常结束 → ack;抛出异常 → reject(requeue=False) → 进入死信队列
async with message.process(requeue=False):
if message.message_id in self.seen:
self.duplicates += 1
return
await self.handler(json.loads(message.body))
self.seen.add(message.message_id or "")
async def drain(queue: AbstractQueue, consumer: IdempotentConsumer, max_messages: int, timeout: float = 2.0) -> int:
"""逐条取消息处理(测试和脚本用)。常驻服务用 queue.consume(consumer.handle) 或 queue.iterator()。"""
n = 0
while n < max_messages:
msg = await queue.get(timeout=timeout, fail=False)
if msg is None:
break
try:
await consumer.handle(msg)
except Exception:
consumer.failed += 1
n += 1
return n
async def connect(url: str) -> tuple[aio_pika.abc.AbstractRobustConnection, AbstractChannel]:
conn = await aio_pika.connect_robust(url) # robust:断线自动重连
channel = await conn.channel(publisher_confirms=True)
await channel.set_qos(prefetch_count=10)
return conn, channel
# 文件:backend-extend/tests/test_rabbit.py
import os
import uuid
import pytest
from backend_extend.mq_rabbit import IdempotentConsumer, connect, declare, drain, publish
AMQP_URL = os.environ.get("AMQP_URL")
pytestmark = pytest.mark.skipif(not AMQP_URL, reason="未设置 AMQP_URL(例如 amqp://guest:guest@localhost/)")
async def test_idempotent_consume_and_dead_letter():
prefix = f"t{uuid.uuid4().hex[:8]}"
conn, ch = await connect(AMQP_URL)
try:
events, tasks, dead = await declare(ch, prefix)
handled: list[dict] = []
async def handler(body: dict) -> None:
if body.get("fail"):
raise ValueError("处理失败")
handled.append(body)
await publish(events, "task.created", {"id": 1}, message_id="m1")
await publish(events, "task.created", {"id": 1}, message_id="m1") # 重复投递
await publish(events, "task.retry", {"id": 2, "fail": True}, message_id="m2")
await publish(events, "other.created", {"id": 3}, message_id="m3") # 路由键不匹配,不进 tasks
consumer = IdempotentConsumer(handler)
assert await drain(tasks, consumer, max_messages=10, timeout=1) == 3
assert handled == [{"id": 1}] and consumer.duplicates == 1 and consumer.failed == 1
dl = await dead.get(timeout=2, fail=False)
assert dl is not None and dl.message_id == "m2" # 失败的消息进了死信队列
await dl.ack()
finally:
for q in (f"{prefix}.tasks", f"{prefix}.dead"):
await ch.queue_delete(q)
for ex in (f"{prefix}.events", f"{prefix}.dlx"):
await ch.exchange_delete(ex)
await conn.close()
4. Kafka 概念(不动手,能讲清即可)
| 维度 | RabbitMQ | Kafka |
|---|---|---|
| 模型 | 消息代理:交换机按规则把消息推给队列,消费确认后删除 | 分布式日志:消息追加写入主题(topic)的分区(partition),按保留时间保存,消费者自己记录读到的位移(offset) |
| 擅长 | 业务任务分发、灵活路由、延迟 / 死信、单条确认 | 高吞吐的事件流、日志收集、多个系统重复消费同一份数据、按时间回放 |
| 顺序 | 单个队列 + 单个消费者时有序 | 同一分区内有序;需要按订单有序时,用订单号做消息 key,保证进同一分区 |
| 扩展消费 | 同一队列多个消费者轮流取 | 消费组(consumer group):组内每个分区只给一个消费者,分区数决定最大并行度 |
| 重复 | 有,需要幂等 | 有(位移提交前崩溃会重读),需要幂等 |
- 说明:选型的一句话:业务任务、需要复杂路由和单条确认用 RabbitMQ;海量事件流、需要多方订阅和回放用 Kafka;量小、已经有 Redis 时用 Redis Streams 就够了(第 3 周做过)。
易错点 / 面试常问
- 易错点:
declare_queue时参数(比如死信设置)和已经存在的队列不一致会报错PRECONDITION_FAILED;改参数要先删掉旧队列(生产环境用新名字迁移)。 - 易错点:自动确认(
no_ack=True)模式下,消息一发给消费者就被删除,处理到一半崩溃就丢了;业务消息都用手动确认。 - 易错点:失败消息无限
requeue=True会形成“毒消息”死循环,一直占着消费者;失败要么进死信队列,要么记录重试次数,超过上限再进死信。 - 面试常问:如何保证消息不丢(持久化 + 发布确认 + 处理后再 ack);如何处理重复消费(幂等:消息 ID / 业务唯一键去重);如何处理消息积压(先扩容消费者、排查慢的原因,必要时临时转储);“写数据库 + 发消息”如何保证一致(发件箱模式,第 9 周的实验 B)。
要建的文件
backend-extend/初始化(uv init)、docker-compose.dev.yml、mq_rabbit.py、tests/test_rabbit.pynotes/mq.md:上面的对比表 + 4 个面试题用自己的话回答
运行方式
cd backend-extend && docker compose -f docker-compose.dev.yml up -d
uv add aio-pika redis pymysql grpcio && uv add --dev pytest pytest-asyncio grpcio-tools
AMQP_URL=amqp://guest:guest@localhost/ uv run pytest tests/test_rabbit.py -v
打开 http://localhost:15672 ,在 Queues 页面看队列和消息数的变化(测试结束会删除自己建的队列)。
完成标准
- 测试通过;能在管理界面里找到交换机、队列、绑定;能脱稿回答上面 4 个面试题。
第 2 天:微服务概念 + gRPC
学习内容
1. 微服务讲到什么程度
- 说明:微服务是把一个大系统按业务拆成多个可以独立部署的小服务。好处是团队可以独立开发和发布、按需扩容;代价是网络调用会失败、数据分散带来一致性问题、排查要靠链路追踪、部署运维更复杂。面试的重点是“什么时候不该拆”:团队小、业务还在变的时候,单体(内部分好模块)更合适。
- 要能说清的词:服务发现和注册(如 Consul、etcd、Nacos)、API 网关(统一鉴权、限流、路由)、配置中心、熔断和降级、超时和重试、链路追踪(如 OpenTelemetry)、分布式事务(发件箱、Saga 补偿)。
- 案例:你的三个项目其实已经是“多个服务”:任务看板(Go)、RAG(Python)、Agent(Python),各自部署,前面用 Nginx 统一入口——这就是一个最小的“网关 + 多服务”结构,第 7 天可以再用 Go 写一个真正的网关。
2. gRPC 是什么
- 说明:gRPC 是 Google 开源的远程调用框架:先用 Protocol Buffers(proto 文件) 定义接口和消息,再用工具生成各语言的客户端和服务端代码。基于 HTTP/2,二进制编码,比 JSON 更小更快,接口有强类型约束,支持流式调用。常用于服务之间的内部调用;对浏览器和第三方一般还是提供 REST / JSON。
- 四种调用方式:一元(一次请求一次响应)、服务端流(一次请求、多次响应,像 SSE)、客户端流、双向流。
- 错误用状态码表示(
INVALID_ARGUMENT、NOT_FOUND、DEADLINE_EXCEEDED、UNAVAILABLE……),客户端调用要设超时(deadline)。 - 案例:把“创建 Agent 任务 + 订阅任务事件”定义成 gRPC 接口:
// 文件:backend-extend/proto/backend_extend/grpcgen/run.proto
syntax = "proto3";
package agentrun.v1;
service RunService {
rpc CreateRun(CreateRunRequest) returns (Run); // 一元调用
rpc WatchRun(WatchRunRequest) returns (stream RunEvent); // 服务端流
}
message CreateRunRequest {
string question = 1;
string idempotency_key = 2; // 同一个 key 重复调用只创建一次
}
message Run {
string id = 1;
string status = 2;
string question = 3;
}
message WatchRunRequest {
string run_id = 1;
int64 after_seq = 2; // 断线重连时从这个序号之后继续,和 SSE 的 Last-Event-ID 一样
}
message RunEvent {
int64 seq = 1;
string type = 2;
string data = 3;
}
- 说明:字段后面的数字是字段编号,编码时用它而不是字段名。所以已经上线的字段不能改编号、不能复用删掉的编号;新增字段用新编号,老客户端会忽略不认识的字段——这就是 proto 的向后兼容。
生成代码的脚本(proto 文件放在 proto/backend_extend/grpcgen/ 下,是为了让生成的代码里的导入路径是 from backend_extend.grpcgen import run_pb2,可以直接在包里用):
# 文件:backend-extend/scripts/gen_proto.py
"""生成 gRPC 代码:uv run python scripts/gen_proto.py"""
import pathlib
import sys
from grpc_tools import protoc
ROOT = pathlib.Path(__file__).resolve().parents[1]
def main() -> None:
proto = ROOT / "proto/backend_extend/grpcgen/run.proto"
out = ROOT / "src"
code = protoc.main([
"grpc_tools.protoc", f"-I{ROOT / 'proto'}",
f"--python_out={out}", f"--grpc_python_out={out}", f"--pyi_out={out}", str(proto)])
if code != 0:
sys.exit(code)
print("生成完成:src/backend_extend/grpcgen/run_pb2*.py")
if __name__ == "__main__":
main()
# 文件:backend-extend/src/backend_extend/grpcgen/__init__.py
# 文件:backend-extend/src/backend_extend/grpc_service.py
"""gRPC 服务端(grpc.aio 异步版)。先运行 scripts/gen_proto.py 生成代码。"""
import asyncio
import itertools
import grpc
from backend_extend.grpcgen import run_pb2, run_pb2_grpc
class RunService(run_pb2_grpc.RunServiceServicer):
def __init__(self) -> None:
self.runs: dict[str, run_pb2.Run] = {}
self.events: dict[str, list[run_pb2.RunEvent]] = {}
self.by_key: dict[str, str] = {}
self._ids = itertools.count(1)
async def CreateRun(self, request: run_pb2.CreateRunRequest, context: grpc.aio.ServicerContext) -> run_pb2.Run:
if not request.question.strip():
await context.abort(grpc.StatusCode.INVALID_ARGUMENT, "question 不能为空")
if request.idempotency_key and request.idempotency_key in self.by_key:
return self.runs[self.by_key[request.idempotency_key]]
run_id = f"r{next(self._ids)}"
run = run_pb2.Run(id=run_id, status="queued", question=request.question)
self.runs[run_id] = run
if request.idempotency_key:
self.by_key[request.idempotency_key] = run_id
# 演示:预先放好几条事件;真实项目里由后台执行器写入
self.events[run_id] = [run_pb2.RunEvent(seq=i, type=t, data=d) for i, (t, d) in enumerate(
[("run_started", ""), ("node_finished", "proposer"), ("run_completed", "结论")], start=1)]
return run
async def WatchRun(self, request: run_pb2.WatchRunRequest, context: grpc.aio.ServicerContext):
if request.run_id not in self.runs:
await context.abort(grpc.StatusCode.NOT_FOUND, f"任务 {request.run_id} 不存在")
for ev in self.events[request.run_id]:
if ev.seq > request.after_seq:
yield ev
await asyncio.sleep(0) # 让出事件循环;真实场景是等待新事件
async def start_server(address: str = "127.0.0.1:0") -> tuple[grpc.aio.Server, int]:
server = grpc.aio.server()
run_pb2_grpc.add_RunServiceServicer_to_server(RunService(), server)
port = server.add_insecure_port(address) # 生产环境用 TLS:add_secure_port
await server.start()
return server, port
async def main() -> None: # 不自动运行:常驻服务
server, port = await start_server("0.0.0.0:50051")
print(f"gRPC 服务已启动:{port}")
await server.wait_for_termination()
if __name__ == "__main__":
asyncio.run(main())
# 文件:backend-extend/tests/conftest.py
import pathlib
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
# 生成的代码不提交(加入 .gitignore),测试前自动生成一次
if not (ROOT / "src/backend_extend/grpcgen/run_pb2.py").exists():
subprocess.run([sys.executable, str(ROOT / "scripts/gen_proto.py")], check=True)
# 文件:backend-extend/tests/test_grpc.py
import grpc
import pytest
from backend_extend.grpc_service import start_server
from backend_extend.grpcgen import run_pb2, run_pb2_grpc
async def test_unary_stream_and_errors():
server, port = await start_server()
try:
async with grpc.aio.insecure_channel(f"127.0.0.1:{port}") as channel:
stub = run_pb2_grpc.RunServiceStub(channel)
run = await stub.CreateRun(run_pb2.CreateRunRequest(question="选技术栈", idempotency_key="k1"), timeout=3)
again = await stub.CreateRun(run_pb2.CreateRunRequest(question="选技术栈", idempotency_key="k1"), timeout=3)
assert run.id == again.id and run.status == "queued"
events = [ev async for ev in stub.WatchRun(run_pb2.WatchRunRequest(run_id=run.id, after_seq=1), timeout=3)]
assert [e.seq for e in events] == [2, 3] and events[-1].type == "run_completed"
with pytest.raises(grpc.aio.AioRpcError) as e:
await stub.CreateRun(run_pb2.CreateRunRequest(question=" "), timeout=3)
assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
with pytest.raises(grpc.aio.AioRpcError) as e:
[ev async for ev in stub.WatchRun(run_pb2.WatchRunRequest(run_id="nope"), timeout=3)]
assert e.value.code() == grpc.StatusCode.NOT_FOUND
finally:
await server.stop(grace=None)
- Go 版(了解):Go 里用
protoc+protoc-gen-go+protoc-gen-go-grpc生成代码,服务端实现生成的接口即可。同一个 proto 文件,Go 服务和 Python 服务可以互相调用——这是 gRPC 跨语言的价值。命令和插件版本以官方文档为准:https://grpc.io/docs/languages/go/quickstart/ 。
易错点 / 面试常问
- 易错点:生成的
*_pb2.py不要手改,改 proto 后重新生成;生成目录加进.gitignore,或者统一提交并在 CI 里检查是否最新(团队二选一)。 - 易错点:客户端调用不设超时,下游卡住时上游的连接和协程会越积越多;每次调用都要带
timeout,并且一路往下传(下游的超时要比上游短)。 - 易错点:只对
UNAVAILABLE这类临时错误重试,INVALID_ARGUMENT重试没有意义;有副作用的接口重试要配合幂等键(本例的idempotency_key)。 - 面试常问:gRPC 和 REST 的区别(proto 强类型 vs JSON、HTTP/2 vs 多为 HTTP/1.1、支持流式、浏览器支持差);proto 怎么做向后兼容(只加字段不改编号);微服务的优缺点和拆分时机;服务之间调用失败怎么处理(超时、重试、熔断、降级)。
要建的文件
proto/.../run.proto、scripts/gen_proto.py、grpc_service.py、tests/conftest.py、tests/test_grpc.py;.gitignore加src/backend_extend/grpcgen/run_pb2*notes/microservices.md:微服务优缺点、什么时候不该拆、gRPC vs REST
运行方式
uv run python scripts/gen_proto.py
uv run pytest tests/test_grpc.py -v
完成标准
- 测试通过;能解释 proto 字段编号的作用和流式调用的用途。
第 3 天:Redis 面试专题——锁、缓存三大问题、限流
学习内容
1. 五种基本数据结构和典型用途(先背下这张表)
| 类型 | 典型用途 | 常用命令 |
|---|---|---|
| String | 缓存对象(JSON)、计数器、分布式锁 | SET k v NX PX 3000、INCR、GET |
| Hash | 存一个对象的多个字段(用户资料) | HSET、HGETALL |
| List | 简单队列、最新 N 条 | LPUSH、RPOP、LRANGE |
| Set | 去重、共同好友、标签 | SADD、SISMEMBER、SINTER |
| Sorted Set(ZSet) | 排行榜、延迟队列、滑动窗口限流 | ZADD、ZRANGE、ZREMRANGEBYSCORE |
另外要知道:Stream(消息队列,第 3 周用过)、Bitmap(签到)、HyperLogLog(UV 估算)。
2. 分布式锁
- 说明:多个进程要互斥地做一件事(比如同一时间只允许一个进程重建某个缓存)。加锁用
SET key 随机值 NX PX 过期毫秒:NX保证只有一个人能设置成功,PX保证持有者崩溃后锁会自动释放。释放锁时必须确认是自己的锁(比较随机值),而“比较 + 删除”要用 Lua 脚本一次完成,否则两步之间锁可能已经过期、被别人拿走,你会删掉别人的锁。
3. 缓存的三大问题
| 问题 | 现象 | 常用解决办法 |
|---|---|---|
| 穿透 | 查根本不存在的数据,缓存里永远没有,每次都打到数据库(常见于恶意请求) | 缓存空值(短过期);参数校验;布隆过滤器 |
| 击穿 | 一个热点 key 过期的瞬间,大量请求同时去查数据库 | 互斥锁:只让一个请求重建缓存,其他请求稍等再读;热点数据逻辑过期、后台刷新 |
| 雪崩 | 大量 key 同时过期或 Redis 整体不可用,请求全部压到数据库 | 过期时间加随机值;Redis 高可用(主从 + 哨兵 / 集群);限流降级 |
4. 限流
- 说明:固定窗口(
INCR+EXPIRE)实现最简单,但窗口边界可能放过两倍流量;滑动窗口用 ZSet 记录每次请求的时间戳,统计最近 N 秒内的数量,更平滑;令牌桶允许一定突发(第 3 周 Go 项目里用过)。
5. 代码:把上面三件事写成可测试的函数
# 文件:backend-extend/src/backend_extend/redis_patterns.py
import asyncio
import json
import random
import secrets
import time
from collections.abc import Awaitable, Callable
from typing import Any
from redis.asyncio import Redis
# 比较 + 删除必须原子执行:只有值等于自己的令牌才删除
RELEASE_LUA = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
NULL = "__null__" # 空值标记:防止缓存穿透
async def acquire_lock(r: Redis, key: str, ttl_ms: int) -> str | None:
token = secrets.token_hex(8)
return token if await r.set(key, token, nx=True, px=ttl_ms) else None
async def release_lock(r: Redis, key: str, token: str) -> bool:
return await r.eval(RELEASE_LUA, 1, key, token) == 1
async def get_or_load(r: Redis, key: str, loader: Callable[[], Awaitable[Any | None]], ttl: int = 300,
null_ttl: int = 30, jitter: float = 0.1, wait_s: float = 2.0) -> Any | None:
"""旁路缓存:先读缓存,没有再查库并回填。同时处理穿透、击穿、雪崩。"""
cached = await r.get(key)
if cached is not None:
return None if cached == NULL else json.loads(cached)
lock_key = f"lock:{key}"
token = await acquire_lock(r, lock_key, ttl_ms=5000)
if token is None: # 击穿:别人正在重建,等一会儿再读缓存
deadline = time.monotonic() + wait_s
while time.monotonic() < deadline:
await asyncio.sleep(0.05)
cached = await r.get(key)
if cached is not None:
return None if cached == NULL else json.loads(cached)
# 等太久就自己查(兜底),不要让请求一直卡住
try:
data = await loader()
if data is None:
await r.set(key, NULL, ex=null_ttl) # 穿透:空值也缓存,但过期短
else:
await r.set(key, json.dumps(data, ensure_ascii=False),
ex=int(ttl * (1 + random.uniform(0, jitter)))) # 雪崩:过期时间加随机
return data
finally:
if token:
await release_lock(r, lock_key, token)
async def allow_request(r: Redis, key: str, limit: int, window_s: float) -> bool:
"""滑动窗口限流:最近 window_s 秒内最多 limit 次。"""
now = time.time()
member = f"{now}-{secrets.token_hex(4)}"
async with r.pipeline(transaction=True) as pipe: # MULTI / EXEC:几条命令一起执行
pipe.zremrangebyscore(key, 0, now - window_s) # 删掉窗口外的记录
pipe.zadd(key, {member: now})
pipe.zcard(key)
pipe.expire(key, int(window_s) + 1)
_, _, count, _ = await pipe.execute()
if count > limit:
await r.zrem(key, member) # 被拒绝的请求不占名额
return False
return True
# 文件:backend-extend/tests/test_redis_patterns.py
import asyncio
import os
import uuid
import pytest
from redis.asyncio import Redis
from backend_extend.redis_patterns import acquire_lock, allow_request, get_or_load, release_lock
REDIS_URL = os.environ.get("REDIS_URL")
pytestmark = pytest.mark.skipif(not REDIS_URL, reason="未设置 REDIS_URL(例如 redis://localhost:6379/0)")
@pytest.fixture
async def r():
client = Redis.from_url(REDIS_URL, decode_responses=True)
prefix = f"t:{uuid.uuid4().hex[:8]}"
yield client, prefix
keys = [k async for k in client.scan_iter(f"*{prefix}*")]
if keys:
await client.delete(*keys)
await client.aclose()
async def test_lock_only_owner_can_release(r):
client, p = r
t1 = await acquire_lock(client, f"{p}:lock", 2000)
assert t1 and await acquire_lock(client, f"{p}:lock", 2000) is None
assert not await release_lock(client, f"{p}:lock", "别人的令牌")
assert await release_lock(client, f"{p}:lock", t1)
assert await acquire_lock(client, f"{p}:lock", 2000)
async def test_breakdown_only_one_loader(r):
client, p = r
calls = 0
async def loader():
nonlocal calls
calls += 1
await asyncio.sleep(0.2) # 模拟慢查询
return {"id": 1, "name": "看板"}
results = await asyncio.gather(*[get_or_load(client, f"{p}:board:1", loader) for _ in range(20)])
assert calls == 1 and all(x == {"id": 1, "name": "看板"} for x in results)
assert 300 <= await client.ttl(f"{p}:board:1") <= 330 # 过期时间带了随机值
async def test_penetration_caches_null(r):
client, p = r
calls = 0
async def loader():
nonlocal calls
calls += 1
return None
assert await get_or_load(client, f"{p}:board:404", loader) is None
assert await get_or_load(client, f"{p}:board:404", loader) is None
assert calls == 1
async def test_sliding_window(r):
client, p = r
got = [await allow_request(client, f"{p}:rl:u1", limit=3, window_s=1) for _ in range(5)]
assert got == [True, True, True, False, False]
await asyncio.sleep(1.1)
assert await allow_request(client, f"{p}:rl:u1", limit=3, window_s=1)
易错点 / 面试常问
- 易错点:加锁写成
SETNX+EXPIRE两条命令不是原子的,中间崩溃就会留下永不过期的锁;用一条SET ... NX PX。 - 易错点:锁的过期时间比业务执行时间短,锁会提前释放,两个进程同时执行;要么估算足够长的时间,要么持有期间定期续期(Java 的 Redisson 叫“看门狗”)。
- 易错点:生产环境不要用
KEYS *(会阻塞 Redis),用SCAN分批遍历;大 key(一个 Hash 几百万字段)删除和读取都会卡,要拆分。 - 面试常问:
- 缓存和数据库怎么保持一致(先更新数据库再删缓存;删除失败时重试或用消息队列 / 订阅 binlog 补偿;第 3 周讲过为什么是删而不是更新)。
- Redis 为什么快(内存操作、单线程执行命令避免锁竞争、I/O 多路复用);Redis 6 以后的多线程只用在网络读写上。
- 持久化 RDB 和 AOF 的区别(快照 vs 追加日志,恢复速度和数据完整性的取舍)。
- 过期删除策略(惰性删除 + 定期删除)和内存淘汰策略(如
allkeys-lru)。 - Redis 分布式锁在主从切换时可能失效(主节点没同步就宕机),强一致需求用数据库行锁或 etcd / ZooKeeper;Redlock 有争议,了解即可。
要建的文件
redis_patterns.py、tests/test_redis_patterns.py;notes/redis.md:数据结构表 + 三大问题表 + 5 个面试题的回答
运行方式
REDIS_URL=redis://localhost:6379/0 uv run pytest tests/test_redis_patterns.py -v
完成标准
- 测试通过(重点看
calls == 1:20 个并发请求只查了一次“数据库”);能脱稿讲清缓存三大问题。
第 4 天:MySQL 面试专题——索引、EXPLAIN、事务和隔离级别
学习内容
1. 索引
- 说明:InnoDB 的索引是 B+ 树。主键索引(聚簇索引)的叶子节点存整行数据;二级索引的叶子节点存的是主键值,查到之后还要回主键索引取整行,这叫回表。如果要查的列都在索引里,就不用回表,叫覆盖索引(
EXPLAIN的 Extra 显示Using index)。 - 联合索引
(user_id, status, created_at)按“先 user_id、再 status、再 created_at”排序,所以只有从最左边的列开始用才有效——最左前缀原则。只按status查用不上这个索引。 - 索引失效的常见情况:不满足最左前缀;对索引列用函数或计算(
WHERE user_id + 0 = 5、DATE(created_at) = ...);隐式类型转换(字符串列和数字比较);LIKE '%abc'前面带通配符;OR连接了没有索引的列。
2. EXPLAIN 看什么
| 列 | 看什么 |
|---|---|
type |
访问方式,从好到差大致是 const > eq_ref > ref > range > index(扫整个索引)> ALL(全表扫描) |
key |
实际用了哪个索引,NULL 表示没用 |
rows |
估计要扫描的行数 |
Extra |
Using index(覆盖索引)、Using filesort(额外排序,要注意)、Using temporary(用了临时表) |
3. 事务和隔离级别
- 说明:事务的 ACID:原子性(全做或全不做)、一致性、隔离性(并发事务互不干扰的程度)、持久性(提交后不丢)。
- 并发问题:脏读(读到别人没提交的数据)、不可重复读(同一事务里两次读同一行,结果不同,因为别人提交了修改)、幻读(两次按条件查询,行数不同,因为别人插入了新行)。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| 读未提交 READ UNCOMMITTED | 可能 | 可能 | 可能 | 基本不用 |
| 读已提交 READ COMMITTED | 不会 | 可能 | 可能 | PostgreSQL 默认 |
| 可重复读 REPEATABLE READ | 不会 | 不会 | 普通读不会(快照);加锁读靠间隙锁防止 | MySQL InnoDB 默认 |
| 串行化 SERIALIZABLE | 不会 | 不会 | 不会 | 性能最差 |
- 说明:MVCC(多版本并发控制):每行保留多个版本,普通
SELECT读的是事务开始时(可重复读)或每条语句开始时(读已提交)的快照,读不加锁、不阻塞写。SELECT ... FOR UPDATE是“当前读”,读最新数据并加锁。
4. 代码:用两个连接亲眼看到隔离级别的区别
# 文件:backend-extend/src/backend_extend/mysql_lab.py
"""MySQL 实验:索引 / EXPLAIN、隔离级别、原子扣减、转账。连接串:mysql://app:app@127.0.0.1:3306/learn"""
import random
from urllib.parse import urlparse
import pymysql
from pymysql.cursors import DictCursor
def connect(url: str) -> pymysql.Connection:
u = urlparse(url)
return pymysql.connect(host=u.hostname, port=u.port or 3306, user=u.username, password=u.password,
database=u.path.lstrip("/"), charset="utf8mb4", cursorclass=DictCursor,
autocommit=False)
def setup_orders(conn: pymysql.Connection, n: int = 3000) -> None:
with conn.cursor() as cur:
cur.execute("DROP TABLE IF EXISTS lab_orders")
cur.execute("""
CREATE TABLE lab_orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
status VARCHAR(16) NOT NULL,
amount INT NOT NULL,
created_at DATETIME NOT NULL,
KEY idx_user_status_time (user_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
rnd = random.Random(42)
rows = [(rnd.randint(1, 300), rnd.choice(["paid", "unpaid", "refund"]), rnd.randint(1, 999),
f"2026-{rnd.randint(1, 12):02d}-{rnd.randint(1, 28):02d} 10:00:00") for _ in range(n)]
cur.executemany("INSERT INTO lab_orders (user_id, status, amount, created_at) VALUES (%s, %s, %s, %s)", rows)
cur.execute("ANALYZE TABLE lab_orders") # 更新统计信息,让优化器的估算更准
cur.fetchall()
conn.commit()
def explain(conn: pymysql.Connection, sql: str, args: tuple = ()) -> dict:
with conn.cursor() as cur:
cur.execute("EXPLAIN " + sql, args)
row = cur.fetchone()
return {"type": row["type"], "key": row["key"], "rows": row["rows"], "Extra": row["Extra"] or ""}
def setup_accounts(conn: pymysql.Connection, balances: dict[int, int]) -> None:
with conn.cursor() as cur:
cur.execute("DROP TABLE IF EXISTS lab_accounts")
cur.execute("CREATE TABLE lab_accounts (id INT PRIMARY KEY, balance INT NOT NULL) ENGINE=InnoDB")
cur.executemany("INSERT INTO lab_accounts VALUES (%s, %s)", list(balances.items()))
conn.commit()
def balance(conn: pymysql.Connection, account_id: int) -> int:
with conn.cursor() as cur:
cur.execute("SELECT balance FROM lab_accounts WHERE id = %s", (account_id,))
return cur.fetchone()["balance"]
def read_twice(a: pymysql.Connection, b: pymysql.Connection, isolation: str) -> tuple[int, int]:
"""事务 A 读两次余额,中间事务 B 修改并提交。返回 A 两次读到的值。"""
with a.cursor() as cur:
cur.execute(f"SET SESSION TRANSACTION ISOLATION LEVEL {isolation}")
a.begin()
first = balance(a, 1)
with b.cursor() as cur:
cur.execute("UPDATE lab_accounts SET balance = balance + 100 WHERE id = 1")
b.commit()
second = balance(a, 1)
a.commit()
return first, second
def deduct(conn: pymysql.Connection, account_id: int, amount: int) -> bool:
"""原子扣减:条件写在 UPDATE 里,并发也不会扣成负数。看影响行数判断是否成功。"""
with conn.cursor() as cur:
n = cur.execute("UPDATE lab_accounts SET balance = balance - %s WHERE id = %s AND balance >= %s",
(amount, account_id, amount))
conn.commit()
return n == 1
def transfer(conn: pymysql.Connection, src: int, dst: int, amount: int) -> None:
"""转账:一个事务里扣一方、加一方。按 id 顺序加锁,避免两笔反向转账互相等待(死锁)。"""
try:
with conn.cursor() as cur:
cur.execute("SELECT id, balance FROM lab_accounts WHERE id IN (%s, %s) ORDER BY id FOR UPDATE", (src, dst))
rows = {r["id"]: r["balance"] for r in cur.fetchall()}
if rows.get(src, 0) < amount:
raise ValueError("余额不足")
cur.execute("UPDATE lab_accounts SET balance = balance - %s WHERE id = %s", (amount, src))
cur.execute("UPDATE lab_accounts SET balance = balance + %s WHERE id = %s", (amount, dst))
conn.commit()
except Exception:
conn.rollback()
raise
# 文件:backend-extend/tests/test_mysql_lab.py
import os
import threading
import pytest
from backend_extend.mysql_lab import (balance, connect, deduct, explain, read_twice, setup_accounts,
setup_orders, transfer)
MYSQL_URL = os.environ.get("MYSQL_URL")
pytestmark = pytest.mark.skipif(not MYSQL_URL, reason="未设置 MYSQL_URL(例如 mysql://app:app@127.0.0.1:3306/learn)")
@pytest.fixture
def conn():
c = connect(MYSQL_URL)
yield c
c.close()
def test_index_usage(conn):
setup_orders(conn)
assert explain(conn, "SELECT * FROM lab_orders WHERE user_id = %s", (5,))["key"] == "idx_user_status_time"
# 最左前缀:跳过 user_id 只按 status 查,用不上联合索引
assert explain(conn, "SELECT * FROM lab_orders WHERE status = %s", ("paid",))["type"] == "ALL"
# 对索引列做计算,索引失效
assert explain(conn, "SELECT * FROM lab_orders WHERE user_id + 0 = %s", (5,))["type"] == "ALL"
# 覆盖索引:要的列都在索引里,不用回表
covered = explain(conn, "SELECT user_id, status, created_at FROM lab_orders WHERE user_id = %s", (5,))
assert "Using index" in covered["Extra"]
# 等值条件 + 按索引下一列排序:不需要额外排序
ordered = explain(conn, "SELECT * FROM lab_orders WHERE user_id = %s AND status = %s ORDER BY created_at", (5, "paid"))
assert ordered["key"] == "idx_user_status_time" and "filesort" not in ordered["Extra"]
def test_isolation_levels():
a, b = connect(MYSQL_URL), connect(MYSQL_URL)
try:
setup_accounts(a, {1: 100})
first, second = read_twice(a, b, "READ COMMITTED")
assert second == first + 100 # 不可重复读:同一事务里两次结果不同
first, second = read_twice(a, b, "REPEATABLE READ")
assert second == first # 可重复读:读的是事务开始时的快照
assert balance(a, 1) == first + 100 # 新事务才看得到最新值
finally:
a.close()
b.close()
def test_atomic_deduct_under_concurrency(conn):
setup_accounts(conn, {1: 50})
results: list[bool] = []
def worker():
c = connect(MYSQL_URL)
try:
results.append(deduct(c, 1, 10))
finally:
c.close()
threads = [threading.Thread(target=worker) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert results.count(True) == 5 and balance(conn, 1) == 0 # 只有 5 次成功,不会扣成负数
def test_transfer_rolls_back(conn):
setup_accounts(conn, {1: 100, 2: 0})
transfer(conn, 1, 2, 30)
with pytest.raises(ValueError):
transfer(conn, 2, 1, 999)
assert (balance(conn, 1), balance(conn, 2)) == (70, 30)
conn.commit()
易错点 / 面试常问
- 易错点:PyMySQL 默认不自动提交,忘了
commit()数据就不会保存;而在“可重复读”下,长时间不结束的事务会一直读旧快照,看起来像“数据没更新”。 - 易错点:“先查余额、再在代码里减、再写回”在并发下会丢失更新;用
UPDATE ... SET balance = balance - ? WHERE balance >= ?这样的原子语句,或者SELECT ... FOR UPDATE加锁。 - 易错点:索引不是越多越好:每个索引都会让写入变慢、占空间;区分度很低的列(如性别)单独建索引意义不大。
- 面试常问:
- 为什么 InnoDB 用 B+ 树(树矮、磁盘读取次数少;叶子节点有序相连,适合范围查询)。
- 聚簇索引和二级索引、回表、覆盖索引、最左前缀、索引下推(了解)。
- 一条慢 SQL 怎么排查(慢查询日志 →
EXPLAIN→ 看type/key/rows/Extra→ 调整索引或改写 SQL)。 - MySQL 默认隔离级别是什么,怎么用 MVCC 实现可重复读;什么是间隙锁。
- 死锁怎么产生、怎么避免(固定加锁顺序、事务尽量短);InnoDB 会自动检测死锁并回滚其中一个事务,应用要能重试。
- 和 PostgreSQL 的差异(第 2 周的速查表:默认隔离级别、自增写法、
RETURNING、JSON 支持等)。
要建的文件
mysql_lab.py、tests/test_mysql_lab.py;notes/mysql.md:索引失效清单、EXPLAIN 列表、隔离级别表、6 个面试题的回答
运行方式
MYSQL_URL=mysql://app:app@127.0.0.1:3306/learn uv run pytest tests/test_mysql_lab.py -v
docker compose -f docker-compose.dev.yml exec mysql mysql -uapp -papp learn # 手动试 EXPLAIN
完成标准
- 测试全部通过;能对着
EXPLAIN的输出解释“为什么这个查询没用上索引”。
第 5 天:把三个项目部署到一台服务器
学习内容
1. 服务器基础(Linux 必会命令)
- 说明:招聘里的“熟悉 Linux”在应用开发岗位上,通常指能自己登录服务器、部署服务、看日志、排查端口和磁盘问题。下面这些命令每个都要亲手用过。
| 场景 | 命令 |
|---|---|
| 登录和密钥 | ssh-keygen -t ed25519、ssh-copy-id user@服务器,之后在 /etc/ssh/sshd_config 关闭密码登录 |
| 用户和权限 | adduser deploy、usermod -aG docker deploy(用普通用户部署,不用 root)、chmod、chown |
| 防火墙 | ufw allow 22,80,443/tcp && ufw enable(只开必须的端口;数据库、Redis 不对外开放) |
| 进程和端口 | ss -lntp(谁在监听哪个端口)、top / htop、`ps aux |
| 磁盘和内存 | df -h、du -sh *、free -h;Docker 占满磁盘时 docker system df、docker image prune |
| 日志 | docker compose logs -f --tail=100 服务名、journalctl -u docker |
| 文件 | tail -f、less、grep -rn 关键词 目录、scp / rsync |
2. 部署结构:一台服务器、一个 Compose、一个 Nginx
- 说明:三个项目各自已经有 Dockerfile(第 3、6、13 周)。服务器上建一个
deploy/目录,用一个总的 Compose 把它们和公共依赖(PostgreSQL、Redis)一起启动;Nginx 是唯一对外的入口,按域名转发到不同服务。每个前端用自己的子域名,这样前端代码里的/api不用改。
浏览器 ──HTTPS──> Nginx(:443)
├─ board.example.com → 看板前端静态文件 + /api → task-board:8080
├─ rag.example.com → 问答前端静态文件 + /api → rag-qa:8000
└─ agent.example.com → agent-mini:8100(自带页面)
内部网络:postgres、redis(不映射端口到公网)
# 文件:deploy/docker-compose.yml
services:
postgres:
image: postgres:17
environment:
POSTGRES_USER: ${PG_USER}
POSTGRES_PASSWORD: ${PG_PASSWORD}
POSTGRES_DB: taskboard
volumes: [pg-data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PG_USER}"]
interval: 5s
retries: 10
restart: unless-stopped
redis:
image: redis:7
command: ["redis-server", "--appendonly", "yes"]
volumes: [redis-data:/data]
restart: unless-stopped
task-board:
image: ${REGISTRY}/task-board:${TAG:-latest}
env_file: ./env/task-board.env
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
restart: unless-stopped
task-board-worker:
image: ${REGISTRY}/task-board:${TAG:-latest}
command: ["/app/worker"]
env_file: ./env/task-board.env
depends_on: [task-board]
restart: unless-stopped
rag-qa:
image: ${REGISTRY}/rag-qa:${TAG:-latest}
env_file: ./env/rag-qa.env
volumes: [rag-data:/app/data]
depends_on: [redis]
restart: unless-stopped
agent-mini:
image: ${REGISTRY}/agent-mini:${TAG:-latest}
env_file: ./env/agent-mini.env
volumes: [agent-data:/app/data]
restart: unless-stopped
nginx:
image: nginx:1.27
ports: ["80:80", "443:443"]
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./www:/usr/share/nginx/html:ro # 两个前端的构建产物:www/board、www/rag
- /etc/letsencrypt:/etc/letsencrypt:ro # HTTPS 证书
depends_on: [task-board, rag-qa, agent-mini]
restart: unless-stopped
volumes:
pg-data:
redis-data:
rag-data:
agent-data:
# 文件:deploy/nginx/conf.d/apps.conf(只列出看板和 Agent,问答站点照着看板写)
server {
listen 80;
server_name board.example.com rag.example.com agent.example.com;
return 301 https://$host$request_uri; # 统一跳转 HTTPS
}
server {
listen 443 ssl;
server_name board.example.com;
ssl_certificate /etc/letsencrypt/live/board.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/board.example.com/privkey.pem;
root /usr/share/nginx/html/board;
location / {
try_files $uri $uri/ /index.html; # 单页应用:刷新子路由不 404
}
location /assets/ {
expires 30d; # 文件名带哈希,可以长缓存
}
location /api/ {
proxy_pass http://task-board:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
server_name agent.example.com;
ssl_certificate /etc/letsencrypt/live/agent.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/agent.example.com/privkey.pem;
location / {
proxy_pass http://agent-mini:8100;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_buffering off; # SSE:不缓冲,事件实时推给浏览器
proxy_read_timeout 1h; # SSE:长连接不要 60 秒就断
}
}
3. HTTPS 证书
- 说明:用 Let's Encrypt 的免费证书,
certbot申请,90 天有效期,要配置自动续期。最简单的做法是先在宿主机用certbot certonly --standalone -d board.example.com ...申请(申请时要临时停掉占用 80 端口的 Nginx),证书在/etc/letsencrypt/live/下,挂进 Nginx 容器。具体命令以官方文档为准:https://certbot.eff.org/ 。没有域名时,先用 IP + HTTP 部署,并录好演示视频。
4. 部署步骤(写进 deploy/deploy.md)
# 服务器上(以 deploy 用户)
mkdir -p ~/deploy/env ~/deploy/www && cd ~/deploy
# 1. 上传 docker-compose.yml、nginx/,在 env/ 下写好三个 .env(只在服务器上,不进仓库)
# 2. 上传两个前端的构建产物到 www/board、www/rag
# 3. 拉镜像并启动
docker compose pull && docker compose up -d
# 4. 跑看板的数据库迁移(命令见第 3 周 deploy.md)
# 5. 检查
docker compose ps && docker compose logs --tail=50 agent-mini
curl -I https://board.example.com
易错点 / 面试常问
- 易错点:数据库和 Redis 不要映射端口到公网(Compose 里不写
ports),服务之间走内部网络。 - 易错点:
.env文件里有密钥,权限设为chmod 600,不进仓库、不打进镜像。 - 易错点:Nginx 的
proxy_pass后面带不带结尾的/行为不同:proxy_pass http://app:8080;原样转发路径,proxy_pass http://app:8080/;会去掉location匹配到的前缀。 - 易错点:容器重建后数据丢失——数据库、Chroma、Agent 的 SQLite 都必须挂数据卷,并定期备份(
pg_dump、复制数据卷目录)。 - 面试常问:一次部署的完整流程;服务挂了怎么发现(健康检查、
restart: unless-stopped、监控告警);怎么做到发布时不中断(多实例滚动更新;单机至少做到“先拉镜像再重启”,缩短中断时间)。
要建的文件
deploy/docker-compose.yml、deploy/nginx/conf.d/apps.conf、deploy/deploy.md(步骤、备份方法、回滚方法)
运行方式
- 有服务器:按上面的步骤部署,三个网址都能访问。
- 没有服务器:在本机用
docker compose up -d跑起来,/etc/hosts里把三个域名指向127.0.0.1,先去掉 HTTPS 部分验证。
完成标准
- 三个项目都能通过浏览器访问;Agent 页面的流式输出是实时的(不是最后一次性出来);
deploy.md写清了备份和回滚。
第 6 天:CI/CD——测试、构建镜像、自动部署
学习内容
- 说明:
- CI(持续集成):每次推送自动跑检查和测试,保证主分支随时可用。
- CD(持续部署 / 交付):测试通过后自动构建镜像、推到镜像仓库,再登录服务器更新服务。
- 你的仓库在
git.gozeros.com。如果它是 Gitea / Forgejo,用 Gitea Actions(语法基本兼容 GitHub Actions,文件放.gitea/workflows/,需要管理员开启并注册 Runner);如果把作品同步到 GitHub,用 GitHub Actions(.github/workflows/)。先确认平台支持哪一种,具体以平台文档为准:https://docs.gitea.com/usage/actions/overview 、https://docs.github.com/actions 。 - 密钥(镜像仓库密码、服务器 SSH 私钥)放在平台的 Secrets 设置里,工作流里用
${{ secrets.名字 }}引用,不写进文件。
- 案例:一个工作流,三个项目各自测试,
main分支通过后构建并部署。
# 文件:.github/workflows/ci.yml(Gitea Actions 放 .gitea/workflows/ci.yml)
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
go:
runs-on: ubuntu-latest
defaults: { run: { working-directory: task-board } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: stable }
- run: go vet ./... && go test ./... -race
python:
runs-on: ubuntu-latest
strategy:
matrix: { project: [rag-qa, agent-mini, backend-extend] }
defaults: { run: { working-directory: "${{ matrix.project }}" } }
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6 # 版本号以官方文档为准
- run: uv sync --frozen
- run: uv run ruff check . && uv run pytest -q # 没有外部服务的测试会自动跳过
web:
runs-on: ubuntu-latest
defaults: { run: { working-directory: web } }
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 24, cache: pnpm, cache-dependency-path: web/pnpm-lock.yaml }
- run: pnpm install --frozen-lockfile && pnpm lint && pnpm -r typecheck && pnpm test && pnpm -r build
deploy:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [go, python, web]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 构建并推送镜像
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ vars.REGISTRY }}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
for app in task-board rag-qa agent-mini; do
docker build -t "${{ vars.REGISTRY }}/$app:${{ github.sha }}" "$app"
docker push "${{ vars.REGISTRY }}/$app:${{ github.sha }}"
done
- name: 登录服务器更新
run: |
install -m 600 /dev/null key && echo "${{ secrets.DEPLOY_SSH_KEY }}" > key
ssh -i key -o StrictHostKeyChecking=accept-new "${{ secrets.DEPLOY_HOST }}" \
"cd ~/deploy && TAG=${{ github.sha }} docker compose pull && TAG=${{ github.sha }} docker compose up -d"
rm -f key
- 说明:镜像标签用提交号(
github.sha),不要只用latest:出问题时可以明确地切回上一个版本。回滚:在服务器上TAG=上一个提交号 docker compose up -d。注意:上面的TAG只在这一条命令里生效,服务器上手动docker compose up -d会回到latest;更稳的做法是把当前标签写进~/deploy/.env(Compose 会自动读取)。 - 说明:没有镜像仓库时,也可以在服务器上直接
git pull && docker compose build,但构建会占用服务器资源,适合个人项目。
易错点 / 面试常问
- 易错点:工作流日志是公开(或团队可见)的,不要
echo密钥;平台会自动遮盖 Secrets,但拼接、编码后的值不会被遮盖。 - 易错点:CI 里用
--frozen/--frozen-lockfile安装依赖,锁文件和项目配置不一致时直接失败,避免“我本地能跑”。 - 易错点:测试依赖外部服务(数据库、Redis、RabbitMQ)时,要么在 CI 里用
services:启动它们,要么让这些测试在没有连接串时自动跳过(本计划的写法)。 - 面试常问:CI/CD 的流程;怎么回滚;数据库迁移在发布流程里放在哪一步(先执行向后兼容的迁移,再发新代码;破坏性的改动分两次发布)。
要建的文件
.github/workflows/ci.yml或.gitea/workflows/ci.yml;deploy/deploy.md补充“自动部署和回滚”
运行方式
- 推送一次提交,在平台的 Actions 页面看到四个任务;故意让一个测试失败,确认
deploy不会执行。
完成标准
- 推送到
main后自动测试、构建、部署;能说出回滚的具体命令。
第 7 天(可选):用 Go 写一个鉴权网关
时间不够就跳过这一天(总纲第 6 节)。做了的话,简历上 Go 的部分会更有说服力:“用 Go 写了统一鉴权网关,透传 SSE”。
学习内容
- 说明:API 网关是所有请求的统一入口,负责鉴权、限流、请求 ID、日志、路由转发,后面的业务服务就不用各自实现。这里用 Go 标准库的
httputil.ReverseProxy写一个最小网关放在agent-mini前面:- 校验 JWT(复用第 2 周的写法),把用户 ID 放进
X-User-ID请求头转发给后端,并删掉客户端自己带的同名请求头(防伪造); - 给每个请求一个
X-Request-ID,日志里能把网关和后端的记录串起来; - SSE 要能实时透传。
ReverseProxy对Content-Type: text/event-stream的响应会自动立即刷新,所以不用额外配置;测试里专门验证这一点。
- 校验 JWT(复用第 2 周的写法),把用户 ID 放进
- 案例:
// 文件:go-gateway/main.go
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/golang-jwt/jwt/v5"
)
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// requestID 给每个请求一个 ID;客户端带了就沿用
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
b := make([]byte, 8)
_, _ = rand.Read(b)
id = hex.EncodeToString(b)
r.Header.Set("X-Request-ID", id)
}
w.Header().Set("X-Request-ID", id)
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("request", "id", id, "method", r.Method, "path", r.URL.Path, "cost", time.Since(start))
})
}
// auth 校验 JWT,把用户 ID 写进 X-User-ID
func auth(secret []byte, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Header.Del("X-User-ID") // 先删掉:客户端自己带的不可信
raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "缺少令牌"})
return
}
tok, err := jwt.Parse(raw, func(*jwt.Token) (any, error) { return secret, nil },
jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired())
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "令牌无效或已过期"})
return
}
sub, err := tok.Claims.GetSubject()
if err != nil || sub == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "令牌缺少用户"})
return
}
r.Header.Set("X-User-ID", sub)
next.ServeHTTP(w, r)
})
}
// NewGateway:/api/* 鉴权后去掉 /api 前缀转发给 upstream;/healthz 不鉴权
func NewGateway(upstream *url.URL, secret []byte) http.Handler {
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(upstream)
pr.SetXForwarded()
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
slog.Error("upstream", "err", err, "id", r.Header.Get("X-Request-ID"))
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "后端服务不可用"})
},
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.Handle("/api/", http.StripPrefix("/api", auth(secret, proxy)))
return requestID(mux)
}
func main() {
secret := os.Getenv("JWT_SECRET")
if len(secret) < 32 {
slog.Error("JWT_SECRET 至少 32 个字符")
os.Exit(1)
}
upstream, err := url.Parse(envOr("UPSTREAM", "http://127.0.0.1:8100"))
if err != nil {
slog.Error("UPSTREAM 无效", "err", err)
os.Exit(1)
}
srv := &http.Server{
Addr: envOr("ADDR", ":8080"),
Handler: NewGateway(upstream, []byte(secret)),
ReadHeaderTimeout: 5 * time.Second,
// 不设 WriteTimeout:它会掐断 SSE 长连接
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
slog.Info("gateway listening", "addr", srv.Addr, "upstream", upstream.String())
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("listen", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// 文件:go-gateway/main_test.go
package main
import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
var secret = []byte("test-secret-test-secret-test-secret!")
func token(t *testing.T, sub string, exp time.Duration) string {
t.Helper()
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": sub, "exp": time.Now().Add(exp).Unix(),
}).SignedString(secret)
if err != nil {
t.Fatal(err)
}
return s
}
func newGateway(t *testing.T, upstream http.Handler) *httptest.Server {
t.Helper()
up := httptest.NewServer(upstream)
t.Cleanup(up.Close)
u, _ := url.Parse(up.URL)
gw := httptest.NewServer(NewGateway(u, secret))
t.Cleanup(gw.Close)
return gw
}
func get(t *testing.T, gw *httptest.Server, path, tok string, extra map[string]string) *http.Response {
t.Helper()
req, _ := http.NewRequest(http.MethodGet, gw.URL+path, nil)
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
for k, v := range extra {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func TestAuthAndForward(t *testing.T) {
gw := newGateway(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"path": r.URL.Path, "user": r.Header.Get("X-User-ID"), "rid": r.Header.Get("X-Request-ID"),
})
}))
cases := []struct {
name string
tok string
want int
}{
{"没有令牌", "", http.StatusUnauthorized},
{"令牌过期", token(t, "u1", -time.Minute), http.StatusUnauthorized},
{"正常", token(t, "u1", time.Hour), http.StatusOK},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resp := get(t, gw, "/api/runs", c.tok, map[string]string{"X-User-ID": "admin"})
defer resp.Body.Close()
if resp.StatusCode != c.want {
t.Fatalf("status = %d, want %d", resp.StatusCode, c.want)
}
if c.want != http.StatusOK {
return
}
var body map[string]string
_ = json.NewDecoder(resp.Body).Decode(&body)
if body["path"] != "/runs" || body["user"] != "u1" || body["rid"] == "" {
t.Fatalf("upstream saw %+v", body) // 伪造的 X-User-ID=admin 必须被替换
}
})
}
resp := get(t, gw, "/healthz", "", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("healthz = %d", resp.StatusCode)
}
}
func TestSSEIsStreamedImmediately(t *testing.T) {
release := make(chan struct{})
gw := newGateway(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "id: 1\ndata: first\n\n")
w.(http.Flusher).Flush()
<-release // 第二条事件要等测试读到第一条之后才发
fmt.Fprint(w, "id: 2\ndata: second\n\n")
}))
t.Cleanup(func() { close(release) }) // 先于 Close 执行,避免上游处理函数卡住
resp := get(t, gw, "/api/runs/r1/events", token(t, "u1", time.Hour), nil)
defer resp.Body.Close()
line := make(chan string, 1)
go func() {
l, _ := bufio.NewReader(resp.Body).ReadString('\n')
line <- l
}()
select {
case l := <-line:
if l != "id: 1\n" {
t.Fatalf("got %q", l)
}
case <-time.After(2 * time.Second):
t.Fatal("第一条事件没有及时到达:被网关缓冲了")
}
}
func TestUpstreamDown(t *testing.T) {
u, _ := url.Parse("http://127.0.0.1:1") // 没有服务监听的端口
gw := httptest.NewServer(NewGateway(u, secret))
defer gw.Close()
resp := get(t, gw, "/api/runs", token(t, "u1", time.Hour), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("status = %d", resp.StatusCode)
}
}
- 说明:
agent-mini那边读取X-User-ID作为用户(代替请求体里的user_id),并且只监听内部网络——否则别人绕过网关直接访问后端,就能伪造请求头。
易错点 / 面试常问
- 易错点:网关后面的服务如果也对公网开放,“网关鉴权”就形同虚设;后端只在内部网络可访问。
- 易错点:
http.Server设置WriteTimeout会把 SSE 长连接在超时后掐断;需要超时保护时用ReadHeaderTimeout,并在业务层控制。 - 易错点:
t.Cleanup按“后注册先执行”的顺序运行,测试里关闭release通道要先于关闭上游服务,否则Close会一直等待卡住的请求。 - 面试常问:网关负责什么、不该负责什么(鉴权、限流、路由、日志可以;业务逻辑不要放进网关);限流放在网关还是服务里(全局限流放网关,按业务的精细限流放服务);Go 的
ReverseProxy怎么处理流式响应。
要建的文件
go-gateway/main.go、go-gateway/main_test.go、go-gateway/Dockerfile(照第 3 周的多阶段构建);deploy/docker-compose.yml里加上网关,agent-mini改成只暴露给网关
运行方式
cd go-gateway && go mod init go-gateway && go get github.com/golang-jwt/jwt/v5
go vet ./... && go test ./... -race -v
JWT_SECRET=$(openssl rand -hex 32) UPSTREAM=http://127.0.0.1:8100 go run .
完成标准
- 测试通过;通过网关访问 Agent 页面时流式输出依然实时。
本周相关专题
本节由
tools/gen_week_topics.py根据专题文件的week字段自动生成,请勿手改;想调整就修改对应专题文件的 frontmatter 后重新生成。
- AI 编程练习(每周):本周至少一个功能用 AI 编程工具(Cursor / Claude Code / Codex 等)完成,按 AI 编程:工作流 写清需求和规则,按 验证与证据 保存测试结果和会话记录(脱敏后),第 15 周整理成“AI 编程 + 验证方法”的应聘案例。
| 技术栈 | 课程 / 场景 / 面试题 |
|---|---|
| 模型侧基础(了解级) | 场景:公司级大模型网关 |
| 数据管道基础 | 课程:数据管道基础;场景:第三方平台订单同步;面试题 |
| Elasticsearch | 课程:Elasticsearch;场景:知识库混合检索;面试题 |
| gRPC 与微服务 | 课程:gRPC 和 Protobuf;课程:服务治理;场景:单体拆分;面试题 |
| Java / Spring Boot(了解级) | 课程:Java / Spring Boot(了解级);场景:Python Agent 调用公司已有的 Java 业务系统;面试题 |
| MongoDB | 课程:MongoDB 基础;场景:AI 客服会话存储;面试题 |
| 消息队列(RabbitMQ / Kafka) | 课程:RabbitMQ;课程:Kafka;课程:可靠性;场景:订单超时自动取消;面试题 |
| MySQL | 课程:事务、隔离级别、MVCC 和锁(行锁、间隙锁、死锁);课程:运维与扩展;场景:大表深分页和历史数据;面试题 |
| Node.js(了解级) | 课程:Node.js 后端(了解级);场景:用 Node.js 给 AI 应用做 BFF;面试题 |
| Redis | 课程:分布式锁和限流;场景:抢购库存预扣;面试题 |
| 网络与操作系统基础 | 课程:网络基础;课程:操作系统基础;场景:线上连接问题排查;面试题 |
| Web 安全基础 | 课程:Web 安全基础;场景:AI 客服上线前的安全评审;面试题 |
| CI/CD | 课程:发布与回滚;场景:多语言仓库的流水线;面试题 |
| Docker 与 Compose | 场景:一条命令拉起开发环境;面试题 |
| Kubernetes 基础 | 课程:Kubernetes 基础;场景:AI Agent 服务上 K8s;面试题 |
| Linux 运维基础 | 课程:常用命令;课程:线上排查;场景:线上故障排查;面试题 |
| Nginx | 课程:HTTPS、SSE / WebSocket、限流和灰度;场景:AI 应用网关;面试题 |
跨技术栈场景:内容审核、数据同步、秒杀 / 高并发抢购、多租户 SaaS、报表生成 Agent、工单系统
第 7 天(调整):了解级扩展——ES、MongoDB、K8s、数据管道、Node.js、Java、计算机基础
依据 BOSS直聘 2026-10-23 调研新增。时间有限时按顺序做,前 3 项优先(与 RAG / Agent 项目关系最紧);Go 网关(上面的可选第 7 天)放到时间富余时再做。
| 顺序 | 内容 | 用时 | 做什么 | 专题 |
|---|---|---|---|---|
| 1 | ES 混合检索 | 1.5 小时 | 跑通 es_hybrid.py(BM25 + kNN + RRF + 别名切换),对比第 6 周自己写的 BM25 |
Elasticsearch |
| 2 | 数据管道 | 1 小时 | 读懂并运行 incremental-sync(水位线、限流、重试、幂等、质量检查),想清楚项目二的文档同步怎么改造 |
数据管道 |
| 3 | K8s 基础 | 1 小时 | 读懂 agent-api 清单,用 kubeconform 校验;有条件用 kind / minikube 部署一次 |
Kubernetes |
| 4 | MongoDB | 45 分钟 | 单节点副本集 + mongo_demo.py(建模、ESR 索引、事务、变更流) |
MongoDB |
| 5 | Node.js / Java | 45 分钟 | Express BFF 示例跑测试;java OrderDemo.java 跑一次,能读懂 Spring Boot 分层 |
Node.js、Java |
| 6 | 计算机基础 | 1 小时 | 网络(粘包、多路复用示例)和 Web 安全(secure-api 13 个攻击测试);算法放到第 16 周 |
网络与操作系统、Web 安全 |
完成标准
- 上表每项的示例都在本机运行过(或写明为什么没有运行,例如没有 K8s 集群),每个专题的面试题能用自己的话回答“基础”部分。
notes/extend.md记录每项“我能讲清楚的 3 句话”。
本周验收
- RabbitMQ、gRPC、Redis、MySQL 四组测试在本机全部通过(连接对应服务,不是跳过)
notes/mq.md、notes/microservices.md、notes/redis.md、notes/mysql.md写完,每个面试题都能脱稿回答- 三个项目部署完成(或本机 Compose 全部跑通),
deploy/deploy.md写清备份和回滚 - CI 能自动跑测试;(有服务器时)推送后自动部署
- 第 7 天(调整)的了解级扩展完成,
notes/extend.md写完 - (可选)Go 网关测试通过并接入部署
- 在总纲进度表勾掉第 14 周