阶段3(下):RAG、向量数据库专题;接入 BOSS直聘调研(research/、岗位对照和延伸技术栈);新增 cs 领域和 12 个技术栈骨架;面试题全部补齐追问并加入校验
This commit is contained in:
1 parent
7f52513ffc
commit
6650f7eb6d
52 files changed
+2777
-38
No files matched your search
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: ai.agent-design.index
|
||||
title: Agent 设计:规划、上下文工程与多 Agent
|
||||
type: index
|
||||
domain: ai
|
||||
stack: [agent-design]
|
||||
tags: []
|
||||
week: [10, 11, 13]
|
||||
---
|
||||
|
||||
# Agent 设计:规划、上下文工程与多 Agent
|
||||
|
||||
ReAct、Planning、Context Engineering、Skills、多 Agent 与 A2A、Agentic RAG。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: ai.ai-coding.index
|
||||
title: AI 编程工具与产出验证
|
||||
type: index
|
||||
domain: ai
|
||||
stack: [ai-coding]
|
||||
tags: []
|
||||
week: [1, 8, 10, 15]
|
||||
---
|
||||
|
||||
# AI 编程工具与产出验证
|
||||
|
||||
Claude Code / Cursor / Codex 工作流、Skills 与规则、评审和测试验证、会话记录作为作品证据。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: ai.llm-serving.index
|
||||
title: 模型接入与部署(了解级)
|
||||
type: index
|
||||
domain: ai
|
||||
stack: [llm-serving]
|
||||
tags: []
|
||||
week: [10, 13]
|
||||
---
|
||||
|
||||
# 模型接入与部署(了解级)
|
||||
|
||||
多模型 API 路由与降级、vLLM 部署基础、LoRA / QLoRA 概念。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: ai.rag.pipeline-chunking
|
||||
title: RAG 流水线和文档切分:加载、清洗、切分策略、元数据
|
||||
type: lesson
|
||||
domain: ai
|
||||
stack: [rag]
|
||||
tags: [RAG, 文档加载, 切分, chunk, 元数据, 嵌入]
|
||||
level: basic
|
||||
prerequisites: [python.python.syntax-for-js]
|
||||
est_hours: 3
|
||||
related: [ai.rag.hybrid-rerank, ai.vector-db.ann-metrics, ai.rag.interview]
|
||||
week: [5]
|
||||
order: 1
|
||||
---
|
||||
|
||||
# RAG 流水线和文档切分:加载、清洗、切分策略、元数据
|
||||
|
||||
## 解释说明
|
||||
|
||||
**为什么需要 RAG**:大模型不知道公司内部资料,也不知道训练截止之后的信息;直接问会“编”(幻觉)。RAG 把相关资料放进提示词,让模型“开卷答题”,并且能给出引用来源。和微调相比:知识更新只需要更新索引,成本低、可追溯。
|
||||
|
||||
**两条流水线**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 离线:建索引
|
||||
A[加载文档 PDF/Word/网页] --> B[清洗] --> C[切分 chunk] --> D[嵌入向量] --> E[(向量库 + 元数据)]
|
||||
end
|
||||
subgraph 在线:问答
|
||||
Q[用户问题] --> R[检索 top-k] --> F[权限过滤 / 重排] --> P[拼提示词] --> L[大模型生成] --> O[答案 + 引用]
|
||||
end
|
||||
E --> R
|
||||
```
|
||||
|
||||
**加载和清洗**:PDF 的页眉页脚、目录、页码、表格是最常见的噪音;扫描件需要 OCR。清洗质量直接决定检索质量。
|
||||
|
||||
**切分(chunking)**——RAG 效果最关键的环节之一:
|
||||
|
||||
| 策略 | 做法 | 适合 |
|
||||
| --- | --- | --- |
|
||||
| 固定长度 + 重叠 | 每 N 个字符 / token 一块,相邻块重叠一部分 | 没有结构的长文本 |
|
||||
| 按结构切分 | 按标题、段落、列表切,节太长再细分 | 制度、手册、Markdown、网页(**首选**) |
|
||||
| 按句子 / 语义切分 | 句子为单位,按语义相似度合并 | 长篇叙述 |
|
||||
| 父子块 | 用小块检索(精准),返回所在的大块给模型(上下文完整) | 问答需要上下文的场景 |
|
||||
|
||||
块的大小:太小(一句话)上下文不完整,“满十年 10 天”不知道说的是年假还是病假;太大则包含多个主题,检索不精准、浪费 token。中文制度类文档常见几百字一块,需要用评测来调。
|
||||
|
||||
**元数据**:每个块带上文档 ID、标题、小节路径、页码、更新时间、权限标签。用途:引用来源、按权限过滤、按时间过滤、增量更新时删除旧块。**把标题路径拼进块的正文**(“员工手册 / 年假:……”)能明显改善孤立块的检索。
|
||||
|
||||
**嵌入(embedding)**:把文本变成向量,语义相近的文本向量距离近。选择嵌入模型要看:中文效果、向量维度、最大输入长度、价格 / 是否能私有部署。**建索引和查询必须用同一个模型**,换模型要全量重建索引。具体模型和参数以各服务商官方文档为准。
|
||||
|
||||
## 案例
|
||||
|
||||
公司制度问答第一版:把 PDF 直接按 500 字切分,结果“病假”一节被切成两半,问“病假工资怎么发”检索到的块只有前半段;页眉“内部资料 第 3 页”混进每个块。改进:先转 Markdown 去掉页眉页脚,按标题切分,每块带“文档 / 小节”前缀,节太长再按句子切并保留重叠。
|
||||
|
||||
## 代码示例
|
||||
|
||||
按标题切分 + 句子级细分 + 重叠 + 标题前缀(摘自 [examples/mini-rag/mini_rag.py](examples/mini-rag/mini_rag.py)):
|
||||
|
||||
```python
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chunk:
|
||||
id: str
|
||||
doc_id: str
|
||||
text: str
|
||||
acl: set[str]
|
||||
|
||||
|
||||
def split(doc_id: str, title: str, text: str, acl: set[str], max_len: int = 80, overlap: int = 15) -> list[Chunk]:
|
||||
out: list[Chunk] = []
|
||||
for sec in re.split(r"\n(?=# )", text.strip()): # 按一级标题分节
|
||||
heading, _, body = sec.partition("\n")
|
||||
heading = heading.lstrip("# ").strip()
|
||||
sentences = [s for s in re.split(r"(?<=[。!?])", body.strip()) if s]
|
||||
buf, pieces = "", []
|
||||
for s in sentences: # 节内按句子累积,超长就切
|
||||
if buf and len(buf) + len(s) > max_len:
|
||||
pieces.append(buf)
|
||||
buf = buf[-overlap:] # 保留重叠,避免句子间信息断开
|
||||
buf += s
|
||||
if buf:
|
||||
pieces.append(buf)
|
||||
for p in pieces:
|
||||
out.append(Chunk(f"{doc_id}#{len(out)}", doc_id, f"【{title} / {heading}】{p}", acl))
|
||||
return out
|
||||
|
||||
|
||||
doc = """# 年假
|
||||
入职满一年的正式员工享有带薪年假 5 天,满十年 10 天,满二十年 15 天。年假需提前 3 个工作日在 OA 中申请。
|
||||
# 病假
|
||||
病假需提供医院证明,每年累计 10 天以内按全额工资发放。"""
|
||||
for c in split("hr-leave", "员工手册", doc, {"all"}, max_len=40, overlap=8):
|
||||
print(c.id, c.text)
|
||||
```
|
||||
|
||||
输出中每一块都带有“【员工手册 / 年假】”这样的前缀,即使块本身只有“满十年 10 天”,也能被“年假”相关的问题检索到。
|
||||
|
||||
## 易错点
|
||||
|
||||
- **不看切分结果就建索引**:一定要抽样打印切分后的块,检查是否切断了表格、步骤、条款。
|
||||
- **按字符数重叠会截断词语**:上面的输出里第二块以“十年 15 天”开头(原文是“满二十年 15 天”)。实际项目更常用“重叠最后一两句”而不是固定字符数。
|
||||
- **只用固定长度切分**:结构化文档的标题信息丢失。
|
||||
- **块没有元数据**:无法引用来源、无法做权限过滤、更新文档时删不掉旧块。
|
||||
- **建索引和查询用了不同的嵌入模型**(或同一模型不同版本):向量空间不一致,检索结果混乱。
|
||||
- **文档更新后只追加不删除**:新旧版本同时被检索到,答案自相矛盾。用文档 ID 先删后加。
|
||||
- **把整篇文档塞进提示词**:成本高、模型容易忽略中间内容,而且受上下文长度限制。
|
||||
|
||||
## 面试常问
|
||||
|
||||
见 [RAG 面试题](interview.md):RAG 和微调的区别、切分策略怎么选、chunk 大小怎么定、元数据有什么用。
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
id: ai.rag.hybrid-rerank
|
||||
title: 检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写
|
||||
type: lesson
|
||||
domain: ai
|
||||
stack: [rag]
|
||||
tags: [混合检索, BM25, RRF, 重排, rerank, 查询改写]
|
||||
level: intermediate
|
||||
prerequisites: [ai.rag.pipeline-chunking]
|
||||
est_hours: 3
|
||||
related: [ai.rag.generation-eval, ai.vector-db.ann-metrics, ai.rag.interview]
|
||||
week: [6]
|
||||
order: 2
|
||||
---
|
||||
|
||||
# 检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写
|
||||
|
||||
## 解释说明
|
||||
|
||||
**向量检索和关键词检索各有短板**
|
||||
|
||||
| | 向量检索 | 关键词检索(BM25) |
|
||||
| --- | --- | --- |
|
||||
| 擅长 | 语义相近、换种说法(“带薪假” ↔ “年假”) | 精确词:产品型号、错误码、人名、条款编号 |
|
||||
| 不擅长 | 精确的编号、罕见专有名词 | 同义词、口语化的问题 |
|
||||
|
||||
所以生产环境常用**混合检索**:两路各取 top-N,再融合。
|
||||
|
||||
**融合:RRF(Reciprocal Rank Fusion)**:`score(d) = Σ 1 / (k + rank_i(d))`,`k` 常取 60。只用排名、不用原始分数,不需要处理两种分数的量纲差异,简单稳健。也可以按权重加权归一化后的分数。
|
||||
|
||||
**中文关键词检索要先分词**(jieba 等),否则 BM25 按单字或整句计算,效果很差。Elasticsearch / OpenSearch 需要安装中文分词插件;PostgreSQL 可以用 `pg_trgm` 或中文分词扩展。
|
||||
|
||||
**重排(rerank)**:第一阶段召回 20~50 个候选,用交叉编码器(cross-encoder)重排模型对“问题 + 候选块”逐对打分,取前 3~5 个给大模型。重排更准但更慢,只对少量候选使用。重排模型可以用云服务或私有部署的开源模型,以对应官方文档为准。
|
||||
|
||||
**查询改写**
|
||||
|
||||
- 多轮对话中,“那病假呢?”要结合历史改写成独立问题“病假怎么规定的?”再检索;
|
||||
- 把一个复杂问题拆成多个子问题分别检索;
|
||||
- 生成假设答案再用它去检索(HyDE)。
|
||||
改写会多一次模型调用,增加延迟和成本,按需使用。
|
||||
|
||||
**元数据过滤**:按权限、文档类型、时间等过滤,**在检索阶段**完成(向量库的 filter 或预过滤),见[权限场景](scenarios/permission-aware-kb.md)。
|
||||
|
||||
## 案例
|
||||
|
||||
IT 知识库:用户问“报错 0x80070005 怎么办”,纯向量检索返回的都是“权限问题”的泛泛文章,没有命中包含这个错误码的那篇;加入 BM25 后,精确匹配错误码的文档排第一。反过来,“在家怎么连公司网络”在 BM25 下命中很差(文档里写的是“VPN 远程办公”),向量检索能找到。混合之后两类问题都能覆盖。
|
||||
|
||||
## 代码示例
|
||||
|
||||
RRF 融合(摘自 [examples/mini-rag](examples/mini-rag/),示例中还包含向量检索和 BM25 检索的实现):
|
||||
|
||||
```python
|
||||
def rrf(result_lists: list[list[str]], k: int = 60, top: int = 5) -> list[tuple[str, float]]:
|
||||
"""result_lists:每一路检索按相关度排好序的文档 ID 列表。"""
|
||||
scores: dict[str, float] = {}
|
||||
for results in result_lists:
|
||||
for rank, doc_id in enumerate(results, start=1):
|
||||
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
|
||||
return sorted(scores.items(), key=lambda kv: -kv[1])[:top]
|
||||
|
||||
|
||||
vector_hits = ["vpn-guide", "permission-faq", "printer"] # 语义检索结果
|
||||
bm25_hits = ["error-0x80070005", "vpn-guide", "office-install"] # 关键词检索结果
|
||||
for doc_id, score in rrf([vector_hits, bm25_hits]):
|
||||
print(f"{doc_id:18s} {score:.4f}")
|
||||
# vpn-guide 两路都排得靠前,融合后排第一;只出现在一路的文档按名次排在后面
|
||||
```
|
||||
|
||||
中文 BM25(`rank_bm25` + `jieba`):
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
import jieba
|
||||
from rank_bm25 import BM25Okapi
|
||||
|
||||
jieba.setLogLevel(logging.WARNING)
|
||||
docs = ["Windows 更新报错 0x80070005 的处理方法", "VPN 远程办公连接指南", "打印机共享设置"]
|
||||
bm25 = BM25Okapi([jieba.lcut(d) for d in docs])
|
||||
print(bm25.get_scores(jieba.lcut("报错 0x80070005 怎么办")).round(2)) # 第一篇得分最高
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- **只做向量检索**:错误码、型号、人名这类精确查询效果差。
|
||||
- **中文 BM25 不分词**:分数几乎没有意义。
|
||||
- **直接把两路的原始分数相加**:余弦相似度在 0~1,BM25 可能是 0~20,量纲不同。用 RRF 或先归一化。
|
||||
- **重排所有候选**:重排模型慢,只对召回的前几十个使用。
|
||||
- **top-k 设得很大以为更保险**:无关内容增多,模型更容易被干扰,成本也更高。
|
||||
- **多轮对话直接用最后一句检索**:“那病假呢”检索不到东西,需要改写。
|
||||
|
||||
## 面试常问
|
||||
|
||||
见 [RAG 面试题](interview.md):为什么要混合检索、RRF 原理、重排的作用、多轮对话怎么检索。
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
id: ai.rag.generation-eval
|
||||
title: 生成与评测:带引用的提示词、拒答、检索评测和答案评测
|
||||
type: lesson
|
||||
domain: ai
|
||||
stack: [rag]
|
||||
tags: [提示词, 引用, 拒答, 幻觉, Recall@k, MRR, 评测集]
|
||||
level: intermediate
|
||||
prerequisites: [ai.rag.hybrid-rerank]
|
||||
est_hours: 3
|
||||
related: [ai.rag.scenario.permission-aware-kb, ai.prompt-engineering.index, ai.agent-ops.index, ai.rag.interview]
|
||||
week: [6]
|
||||
order: 3
|
||||
---
|
||||
|
||||
# 生成与评测:带引用的提示词、拒答、检索评测和答案评测
|
||||
|
||||
## 解释说明
|
||||
|
||||
**提示词的关键要素**
|
||||
|
||||
1. 角色和任务:“你是公司制度问答助手”;
|
||||
2. **只依据资料回答**,资料中没有就明确说没有(减少幻觉);
|
||||
3. 资料编号 + 要求用 `[编号]` 标注依据(可追溯、可校验);
|
||||
4. 输出格式和长度要求;
|
||||
5. 资料放在问题前面,并用清晰的分隔符包起来。
|
||||
|
||||
**拒答**:检索结果的最高相似度低于阈值、或者重排分数太低时,不调用大模型,直接回答“知识库中没有找到相关内容”,并引导到人工渠道。宁可不答,不要编。阈值需要用评测数据调。
|
||||
|
||||
**引用校验**:检查答案中的 `[编号]` 是否都在给出的资料范围内;可以进一步检查被引用的资料里是否包含答案中的关键数字。
|
||||
|
||||
**评测**——没有评测就无法判断改动是变好还是变坏:
|
||||
|
||||
| 层面 | 指标 | 怎么做 |
|
||||
| --- | --- | --- |
|
||||
| 检索 | Recall@k(正确资料是否在前 k 个里)、MRR(正确资料的排名倒数的平均值) | 人工标注“问题 → 应命中的文档 / 块”,几十到几百条 |
|
||||
| 生成 | 忠实度(答案是否都有资料支撑)、相关性、正确性 | 人工抽查 + 用大模型按评分标准打分(LLM-as-judge) |
|
||||
| 线上 | 点赞 / 点踩、转人工率、无答案率 | 埋点、定期分析差评样本 |
|
||||
|
||||
评测集要覆盖:常见问题、需要精确匹配的问题(编号、数字)、多轮追问、**知识库里没有答案的问题**(测试拒答)、无权限的问题。
|
||||
|
||||
工具:Ragas 等框架提供了忠实度、上下文召回等指标的实现,接口变化较快,以官方文档为准(https://docs.ragas.io/)。
|
||||
|
||||
## 案例
|
||||
|
||||
制度问答助手上线前建立了 9 条以上的检索评测集([examples/mini-rag](examples/mini-rag/) 中的 `EVAL`,真实项目要几十到上百条),比较纯向量、纯 BM25、混合三种检索的 Recall@3 和 MRR;之后每次调整切分或检索参数都跑一遍,指标下降就不合并。
|
||||
|
||||
## 代码示例
|
||||
|
||||
检索评测(Recall@k、MRR):
|
||||
|
||||
```python
|
||||
def recall_and_mrr(results: dict[str, list[str]], expected: dict[str, str], k: int = 3) -> tuple[float, float]:
|
||||
"""results:问题 → 检索出的文档 ID(已排序);expected:问题 → 应命中的文档 ID。"""
|
||||
hit, rr = 0, 0.0
|
||||
for q, gold in expected.items():
|
||||
top = results[q][:k]
|
||||
if gold in top:
|
||||
hit += 1
|
||||
rr += 1 / (top.index(gold) + 1)
|
||||
n = len(expected)
|
||||
return hit / n, rr / n
|
||||
|
||||
|
||||
results = {"年假几天": ["hr-leave", "fin"], "VPN 连不上": ["hr-leave", "it-vpn"], "报销时限": ["it-vpn", "x", "y"]}
|
||||
expected = {"年假几天": "hr-leave", "VPN 连不上": "it-vpn", "报销时限": "fin"}
|
||||
print(recall_and_mrr(results, expected)) # (0.666..., 0.5):第 1 题排第 1,第 2 题排第 2,第 3 题没命中
|
||||
```
|
||||
|
||||
带引用的提示词和拒答、引用校验:
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
|
||||
def build_prompt(question: str, chunks: list[str]) -> str:
|
||||
context = "\n".join(f"[{i}] {c}" for i, c in enumerate(chunks, start=1))
|
||||
return ("你是公司制度问答助手。只根据<资料>回答;资料中没有的内容,回答“资料中没有相关规定”。"
|
||||
"每个结论后用 [编号] 标注依据。\n"
|
||||
f"<资料>\n{context}\n</资料>\n问题:{question}")
|
||||
|
||||
|
||||
def answer_or_refuse(question: str, hits: list[tuple[str, float]], min_score: float = 0.3) -> str:
|
||||
if not hits or hits[0][1] < min_score:
|
||||
return "知识库中没有找到相关内容,建议咨询 HR 或提交工单。"
|
||||
return build_prompt(question, [text for text, _ in hits]) # 交给大模型
|
||||
|
||||
|
||||
def citations_valid(answer: str, n_chunks: int) -> bool:
|
||||
cited = {int(x) for x in re.findall(r"\[(\d+)\]", answer)}
|
||||
return bool(cited) and cited <= set(range(1, n_chunks + 1))
|
||||
|
||||
|
||||
print(answer_or_refuse("股票期权怎么行权", [("差旅住宿标准……", 0.12)]))
|
||||
print(citations_valid("满十年年假 10 天 [1]。", 3), citations_valid("答案 [5]", 3)) # True False
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- **没有评测集,靠几个问题“感觉效果不错”**:改了参数也不知道变好还是变坏。
|
||||
- **评测集里没有“无答案”的问题**:拒答能力从未被测试。
|
||||
- **拒答阈值拍脑袋**:要用评测数据画出“阈值 vs 准确率 / 拒答率”来选。
|
||||
- **完全依赖 LLM 打分**:评分模型本身有偏差,要和人工标注对比校准。
|
||||
- **提示词里资料和指令混在一起**:资料里的内容可能被模型当成指令(提示词注入),用分隔符包裹并在指令中说明。
|
||||
- **引用编号不校验**:模型可能引用不存在的编号,或者引用了但内容对不上。
|
||||
|
||||
## 面试常问
|
||||
|
||||
见 [RAG 面试题](interview.md):怎么减少幻觉、怎么评测 RAG、拒答怎么做、线上效果怎么监控。
|
||||
@@ -0,0 +1,205 @@
|
||||
# requires: jieba, rank_bm25, numpy
|
||||
"""最小 RAG 流水线:切分 → 嵌入 → 混合检索(向量 + BM25,RRF 融合)→ 权限过滤 → 拒答阈值 → 带引用的提示词 → 检索评测。
|
||||
|
||||
为了不依赖外部服务,这里的“嵌入”用字符 n-gram 哈希向量代替真实的嵌入模型(只能捕捉字面相似,不懂语义),
|
||||
目的是把流程跑通、把评测方法讲清楚。换成真实嵌入模型时,只需要替换 embed() 函数。
|
||||
运行:python mini_rag.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import jieba
|
||||
import numpy as np
|
||||
from rank_bm25 import BM25Okapi
|
||||
|
||||
jieba.setLogLevel(logging.WARNING)
|
||||
|
||||
DIM = 512
|
||||
|
||||
|
||||
@dataclass
|
||||
class Doc:
|
||||
id: str
|
||||
title: str
|
||||
text: str
|
||||
acl: set[str] # 允许访问的部门;"all" 表示全员
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chunk:
|
||||
id: str
|
||||
doc_id: str
|
||||
title: str
|
||||
text: str
|
||||
acl: set[str]
|
||||
vec: np.ndarray = field(repr=False, default=None)
|
||||
|
||||
|
||||
DOCS = [
|
||||
Doc("hr-leave", "员工手册-休假制度", """# 年假
|
||||
入职满一年的正式员工享有带薪年假 5 天,满十年 10 天,满二十年 15 天。年假需提前 3 个工作日在 OA 中申请。
|
||||
# 病假
|
||||
病假需提供医院证明,每年累计 10 天以内按全额工资发放。
|
||||
# 调休
|
||||
加班可在 3 个月内申请调休,过期作废。""", {"all"}),
|
||||
Doc("it-vpn", "IT 指南-远程办公", """# VPN
|
||||
在家办公需先安装公司 VPN 客户端,使用工号和动态口令登录。连接失败请检查系统时间是否准确。
|
||||
# 设备
|
||||
笔记本电脑丢失需在 24 小时内报告 IT 部门并修改所有密码。""", {"all"}),
|
||||
Doc("fin-reimburse", "财务制度-报销", """# 差旅报销
|
||||
出差住宿标准:一线城市每晚 500 元以内,其他城市 350 元以内。发票须在出差结束后 30 天内提交。
|
||||
# 餐费
|
||||
出差餐补每天 100 元,无需发票。""", {"all"}),
|
||||
Doc("hr-salary", "薪酬制度(仅 HR)", """# 调薪
|
||||
年度调薪在每年 3 月进行,幅度由绩效等级决定,A 级 10% 至 15%。
|
||||
# 保密
|
||||
薪酬信息属于保密信息,不得在公开渠道讨论。""", {"hr"}),
|
||||
]
|
||||
|
||||
|
||||
def split(doc: Doc, max_len: int = 80, overlap: int = 15) -> list[Chunk]:
|
||||
"""按 Markdown 标题分节;节内超过 max_len 再按句子切,相邻块保留 overlap 个字符的重叠。
|
||||
每块前面加上“文档标题 / 小节标题”,让孤立的块也有上下文(例如“满十年 10 天”属于“年假”)。"""
|
||||
out: list[Chunk] = []
|
||||
for sec in re.split(r"\n(?=# )", doc.text.strip()):
|
||||
heading, _, body = sec.partition("\n")
|
||||
heading = heading.lstrip("# ").strip()
|
||||
sentences = [s for s in re.split(r"(?<=[。!?])", body.strip()) if s]
|
||||
buf = ""
|
||||
pieces: list[str] = []
|
||||
for s in sentences:
|
||||
if buf and len(buf) + len(s) > max_len:
|
||||
pieces.append(buf)
|
||||
buf = buf[-overlap:]
|
||||
buf += s
|
||||
if buf:
|
||||
pieces.append(buf)
|
||||
for p in pieces:
|
||||
out.append(Chunk(f"{doc.id}#{len(out)}", doc.id, doc.title, f"【{doc.title} / {heading}】{p}", doc.acl))
|
||||
return out
|
||||
|
||||
|
||||
def embed(text: str) -> np.ndarray:
|
||||
"""字符 1~2 gram 哈希到 DIM 维并 L2 归一化(真实项目替换为嵌入模型 API)。"""
|
||||
v = np.zeros(DIM, dtype=np.float32)
|
||||
t = re.sub(r"\s+", "", text)
|
||||
for n in (1, 2):
|
||||
for i in range(len(t) - n + 1):
|
||||
h = int.from_bytes(hashlib.md5(t[i:i + n].encode()).digest()[:4], "little")
|
||||
v[h % DIM] += 1.0
|
||||
norm = np.linalg.norm(v)
|
||||
return v / norm if norm else v
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
return [w for w in jieba.lcut(text) if w.strip() and not re.fullmatch(r"[,。、:;!?【】/\s]+", w)]
|
||||
|
||||
|
||||
class Index:
|
||||
def __init__(self, docs: list[Doc]) -> None:
|
||||
self.chunks = [c for d in docs for c in split(d)]
|
||||
for c in self.chunks:
|
||||
c.vec = embed(c.text)
|
||||
self.matrix = np.stack([c.vec for c in self.chunks])
|
||||
self.bm25 = BM25Okapi([tokenize(c.text) for c in self.chunks])
|
||||
|
||||
def allowed(self, user_depts: set[str]) -> np.ndarray:
|
||||
return np.array([("all" in c.acl) or bool(c.acl & user_depts) for c in self.chunks])
|
||||
|
||||
def vector_search(self, q: str, mask: np.ndarray, k: int) -> list[tuple[int, float]]:
|
||||
scores = self.matrix @ embed(q) # 已归一化,点积 = 余弦相似度
|
||||
scores[~mask] = -np.inf # 权限过滤:检索阶段就排除,而不是生成后再删
|
||||
order = np.argsort(-scores)[:k]
|
||||
return [(int(i), float(scores[i])) for i in order if np.isfinite(scores[i])]
|
||||
|
||||
def bm25_search(self, q: str, mask: np.ndarray, k: int) -> list[tuple[int, float]]:
|
||||
scores = np.array(self.bm25.get_scores(tokenize(q)))
|
||||
scores[~mask] = -np.inf
|
||||
order = np.argsort(-scores)[:k]
|
||||
return [(int(i), float(scores[i])) for i in order if np.isfinite(scores[i]) and scores[i] > 0]
|
||||
|
||||
def hybrid_search(self, q: str, mask: np.ndarray, k: int, rrf_k: int = 60) -> list[tuple[int, float]]:
|
||||
"""RRF:score = Σ 1 / (rrf_k + 排名)。只用排名,不用担心两种分数量纲不同。"""
|
||||
fused: dict[int, float] = {}
|
||||
for results in (self.vector_search(q, mask, 20), self.bm25_search(q, mask, 20)):
|
||||
for rank, (i, _) in enumerate(results, start=1):
|
||||
fused[i] = fused.get(i, 0.0) + 1.0 / (rrf_k + rank)
|
||||
return sorted(fused.items(), key=lambda kv: -kv[1])[:k]
|
||||
|
||||
|
||||
def answer_context(index: Index, q: str, user_depts: set[str], k: int = 3, min_sim: float = 0.25) -> dict:
|
||||
mask = index.allowed(user_depts)
|
||||
hits = index.hybrid_search(q, mask, k)
|
||||
# 拒答:最相关块的向量相似度太低,说明知识库里很可能没有答案
|
||||
best_sim = max((float(index.matrix[i] @ embed(q)) for i, _ in hits), default=0.0)
|
||||
if not hits or best_sim < min_sim:
|
||||
return {"refuse": True, "prompt": None, "sources": []}
|
||||
sources = [index.chunks[i] for i, _ in hits]
|
||||
context = "\n".join(f"[{n}] {c.text}" for n, c in enumerate(sources, start=1))
|
||||
prompt = (
|
||||
"你是公司制度问答助手。只根据下面的资料回答,资料里没有的内容回答“资料中没有相关规定”。"
|
||||
"回答时用 [编号] 标注依据。\n\n"
|
||||
f"资料:\n{context}\n\n问题:{q}"
|
||||
)
|
||||
return {"refuse": False, "prompt": prompt, "sources": [c.id for c in sources], "best_sim": round(best_sim, 3)}
|
||||
|
||||
|
||||
# ---------------- 检索评测 ----------------
|
||||
EVAL = [ # (问题, 期望命中的文档)
|
||||
("年假有几天", "hr-leave"),
|
||||
("工作满十年有多少天带薪假", "hr-leave"),
|
||||
("病假要什么材料", "hr-leave"),
|
||||
("加班多久内可以调休", "hr-leave"),
|
||||
("VPN 连不上怎么办", "it-vpn"),
|
||||
("电脑丢了怎么处理", "it-vpn"),
|
||||
("出差住酒店能报多少钱", "fin-reimburse"),
|
||||
("发票什么时候交", "fin-reimburse"),
|
||||
("出差吃饭有补贴吗", "fin-reimburse"),
|
||||
]
|
||||
|
||||
|
||||
def evaluate(index: Index, method: str, k: int = 3) -> tuple[float, float]:
|
||||
mask = index.allowed({"all"})
|
||||
hit, rr = 0, 0.0
|
||||
for q, expected in EVAL:
|
||||
results = getattr(index, f"{method}_search")(q, mask, k)
|
||||
docs = [index.chunks[i].doc_id for i, _ in results]
|
||||
if expected in docs:
|
||||
hit += 1
|
||||
rr += 1 / (docs.index(expected) + 1)
|
||||
return hit / len(EVAL), rr / len(EVAL)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
index = Index(DOCS)
|
||||
print(f"共 {len(DOCS)} 篇文档,切成 {len(index.chunks)} 块")
|
||||
|
||||
print("\n检索评测(Recall@3 / MRR):")
|
||||
scores = {}
|
||||
for m in ("vector", "bm25", "hybrid"):
|
||||
recall, mrr = evaluate(index, m)
|
||||
scores[m] = recall
|
||||
print(f" {m:7s} recall@3={recall:.2f} MRR={mrr:.2f}")
|
||||
|
||||
r = answer_context(index, "满十年的年假几天?", {"dev"})
|
||||
print("\n问:满十年的年假几天? 引用:", r["sources"])
|
||||
print(r["prompt"][:120], "……")
|
||||
|
||||
r_hr = answer_context(index, "A 级调薪幅度是多少", {"hr"})
|
||||
r_dev = answer_context(index, "A 级调薪幅度是多少", {"dev"})
|
||||
print("\nHR 提问调薪:", r_hr["sources"][:1], "| 研发提问调薪:", r_dev["sources"])
|
||||
assert r_hr["sources"][0].startswith("hr-salary")
|
||||
assert not any(s.startswith("hr-salary") for s in r_dev["sources"]) # 无权限的内容不会进入上下文
|
||||
|
||||
r_none = answer_context(index, "公司股票期权怎么行权", {"dev"})
|
||||
print("问:公司股票期权怎么行权 → 拒答" if r_none["refuse"] else f"未拒答:{r_none}")
|
||||
assert r_none["refuse"]
|
||||
assert scores["hybrid"] >= max(scores["vector"], scores["bm25"]) - 1e-9
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+33
-3
@@ -4,12 +4,42 @@ title: RAG 检索增强生成
|
||||
type: index
|
||||
domain: ai
|
||||
stack: [rag]
|
||||
tags: []
|
||||
tags: [RAG, 检索, 知识库, 评测]
|
||||
week: [5, 6]
|
||||
---
|
||||
|
||||
# RAG 检索增强生成
|
||||
|
||||
加载切分、嵌入、检索、混合检索与重排、引用与拒答、评测。
|
||||
RAG(Retrieval-Augmented Generation):先从知识库检索相关资料,再让大模型基于资料回答。这是企业 AI 应用里落地最多的形态(制度问答、客服知识库、文档助手),也是 AI 应用岗位面试的必考题。重点不在“调通一个 demo”,而在**切分、混合检索、权限、引用和拒答、评测**这些决定效果和能否上线的环节。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [RAG 流水线和文档切分:加载、清洗、切分策略、元数据](01-pipeline-chunking.md) | 基础 | 3 小时 |
|
||||
| 2 | [检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写](02-hybrid-rerank.md) | 进阶 | 3 小时 |
|
||||
| 3 | [生成与评测:带引用的提示词、拒答、检索评测和答案评测](03-generation-eval.md) | 进阶 | 3 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [带权限的企业知识库问答:不同部门看到不同内容](scenarios/permission-aware-kb.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [RAG 面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/mini-rag](examples/mini-rag/):不依赖外部服务的最小 RAG 流水线——按标题切分、混合检索(向量 + BM25 + RRF)、权限过滤、拒答阈值、带引用的提示词、Recall@3 / MRR 评测(需要 `jieba`、`rank_bm25`、`numpy`)
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 5 周:RAG(一)](../../../plan/week05-rag-1.md)、[第 6 周:RAG(二)](../../../plan/week06-rag-2.md)
|
||||
|
||||
## 官方资料
|
||||
|
||||
RAG 相关的框架和模型服务更新很快,接口和参数以官方文档为准:
|
||||
|
||||
- LangChain 检索相关文档:https://docs.langchain.com/
|
||||
- LlamaIndex:https://developers.llamaindex.ai/
|
||||
- Ragas(RAG 评测):https://docs.ragas.io/
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
id: ai.rag.interview
|
||||
title: RAG 面试题
|
||||
type: interview
|
||||
domain: ai
|
||||
stack: [rag]
|
||||
tags: [RAG, 检索, 评测, 面试]
|
||||
level: intermediate
|
||||
related: [ai.rag.pipeline-chunking, ai.rag.hybrid-rerank, ai.rag.generation-eval]
|
||||
week: [5, 6, 16]
|
||||
---
|
||||
|
||||
# RAG 面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】什么是 RAG?和微调有什么区别?
|
||||
|
||||
**参考答案**:RAG 先从外部知识库检索相关资料,再把资料和问题一起交给大模型生成答案。和微调相比:知识更新只需要更新索引,成本低;答案可以附引用、可追溯;能做权限控制。微调改变的是模型的行为和风格(格式、领域语气、特定任务能力),不适合频繁变化的事实性知识。很多场景两者结合,但先做 RAG。
|
||||
|
||||
**追问**:什么情况下需要 RAG 和微调结合?→ 既要最新的企业知识,又要固定的输出格式或领域表达时:RAG 提供知识,微调(或更常见的:提示词 + 少量示例)约束格式。先用 RAG 和提示词解决,确实不够再考虑微调。
|
||||
|
||||
### 【基础】RAG 的完整流程是怎样的?
|
||||
|
||||
**参考答案**:离线:加载文档 → 解析清洗 → 切分 → 嵌入 → 写入向量库(带元数据),关键词检索还要写入倒排索引。在线:问题(必要时改写)→ 检索(向量 / 关键词 / 混合)→ 权限和元数据过滤 → 重排 → 拼接提示词 → 大模型生成 → 返回答案和引用;全程记录日志用于评测和分析。
|
||||
|
||||
**追问**:文档解析这一步常见的问题有哪些?→ PDF 表格被拆散、多栏排版顺序错乱、扫描件需要 OCR、页眉页脚和水印混入正文。表格转成 Markdown 表格整体入库,解析结果要抽样人工检查。
|
||||
|
||||
### 【基础】chunk 大小怎么定?切分有哪些策略?
|
||||
|
||||
**参考答案**:没有固定答案,要用评测集对比。太小上下文不完整,太大主题混杂、浪费 token。策略:固定长度加重叠、按文档结构(标题 / 段落)切分、按句子 / 语义切分、父子块(小块检索、返回大块)。结构化文档优先按结构切,并把标题路径加到块内容里。
|
||||
|
||||
**追问**:父子块(小块检索、返回大块)解决什么问题?→ 小块语义集中、检索准,但上下文不完整;命中小块后把它所在的大块(父块)交给模型,兼顾检索精度和上下文完整。
|
||||
|
||||
### 【基础】为什么要混合检索?
|
||||
|
||||
**参考答案**:向量检索擅长语义相似,但对错误码、型号、人名等精确词效果差;BM25 擅长精确匹配,但不理解同义表达。两者互补,用 RRF 等方法融合后效果通常更稳定。中文关键词检索要先分词。
|
||||
|
||||
**追问**:中文 BM25 要注意什么?Elasticsearch 怎么做混合检索?→ 中文要先分词(jieba、ES 的 IK 等分词插件),否则按单字匹配效果差;ES 可以同时做 BM25 和 kNN 向量检索,再用 RRF 等方式融合,具体语法随版本变化,以官方文档为准。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】怎么减少 RAG 的幻觉?
|
||||
|
||||
**参考答案**:检索层面:提高召回质量(切分、混合检索、重排),召回不到时拒答;提示词层面:要求只依据资料回答、资料中没有就说没有、每个结论标注引用编号;生成后:校验引用编号是否存在、关键数字是否出现在被引用的资料中;评测:用忠实度指标和人工抽查持续监控。
|
||||
|
||||
**追问**:模型给出的引用编号是对的,内容却不在资料里,怎么发现?→ 生成后做校验:引用编号必须存在;抽取答案中的关键数字 / 实体,检查是否出现在被引用的资料里;不通过时降级为“只返回相关资料”或提示人工核对,并记录为 Bad Case。
|
||||
|
||||
### 【进阶】怎么评测一个 RAG 系统?
|
||||
|
||||
**参考答案**:分层评测。检索层:人工标注问题和应命中的文档,计算 Recall@k、MRR;生成层:忠实度(答案是否有资料支撑)、答案正确性和相关性,用人工标注 + LLM 评分并互相校准;端到端:拒答率、无权限问题的处理;线上:用户反馈、转人工率、差评样本分析。评测集要覆盖精确词、多轮、无答案、无权限等类型,改动前后都跑。
|
||||
|
||||
**追问**:评测集从哪里来?→ 初期人工根据文档编写(每类问题若干条),上线后从真实日志和差评中补充;可以用大模型辅助生成候选问题,但必须人工审核;评测集要版本化管理。
|
||||
|
||||
### 【进阶】重排(rerank)是什么?为什么不直接用重排模型检索全部文档?
|
||||
|
||||
**参考答案**:重排模型(通常是交叉编码器)把问题和候选文本拼在一起计算相关度,比向量的双塔模型更准,但每一对都要单独计算,速度慢、成本高。所以先用向量 / BM25 快速召回几十个候选,再用重排模型精排取前几个。
|
||||
|
||||
**追问**:重排之后取几条给模型?→ 用评测集测试:太少可能漏掉答案,太多增加成本和干扰,常见 3~8 条;同时设置重排分数阈值,低于阈值的不放进提示词。
|
||||
|
||||
### 【进阶】什么是 Agentic RAG?和普通 RAG 有什么区别?
|
||||
|
||||
**参考答案**:普通 RAG 是固定流程:检索一次、生成一次。Agentic RAG 让模型作为 Agent 决定是否检索、检索什么、检索结果不够时改写问题再检索、从多个知识源(文档、数据库、搜索)中选择,并在生成后自我检查。适合复杂多跳问题,代价是延迟和成本更高、流程更难控制,需要设置最大步数和评测。详见 [Agent 设计专题](../agent-design/index.md)。
|
||||
|
||||
**追问**:Agentic RAG 怎么防止无限检索?→ 设置最大步数 / 最大检索次数和总超时;每一步记录状态,达到上限后基于已有资料回答或拒答;用评测集对比它和普通 RAG 的效果、延迟和成本,确认值得。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】知识库里有不同部门的保密文档,怎么保证员工只能问到有权限的内容?
|
||||
|
||||
**参考答案**:每个块在入库时带权限元数据(可见部门 / 角色);问答时先鉴权得到用户的部门和角色,在检索阶段作为过滤条件(先过滤再取 top-k),无权限内容不进入提示词;缓存要区分权限;日志脱敏;评测集包含越权问题做回归。不能只靠提示词约束模型。详见[带权限的知识库场景](scenarios/permission-aware-kb.md)。
|
||||
|
||||
**追问**:用户的部门变了,之前的缓存答案怎么办?→ 缓存 key 包含权限维度(部门 / 角色的哈希),或者敏感知识库不缓存答案;权限变更事件触发相关缓存失效。
|
||||
|
||||
### 【场景题】用户反馈“答案不对”,你怎么排查?
|
||||
|
||||
**参考答案**:用请求 ID 找到这次的完整链路记录:改写后的问题、检索到的块和分数、重排结果、最终提示词、模型输出。判断问题在哪一层:正确资料没被召回(切分、嵌入、检索参数问题)→ 召回了但排序靠后(重排)→ 资料在提示词里但模型答错(提示词、模型能力)→ 资料本身过期或错误(数据问题)。把它加入评测集(Bad Case),修复后回归。
|
||||
|
||||
**追问**:怎么把这类问题变成可持续改进的流程?→ 建立 Bad Case 记录:每个问题标注失败环节(解析 / 切分 / 检索 / 排序 / 生成 / 数据),定期汇总找出占比最高的环节优先优化,修复后加入评测集防止回归。
|
||||
|
||||
### 【场景题】文档每天都在更新,怎么保持索引最新?
|
||||
|
||||
**参考答案**:监听文档源的变更(事件、定时比较更新时间或内容哈希),只处理变化的文档;每个块带 `doc_id` 和版本号,更新时先删除该文档的旧块再写入新块(或写入新版本后原子切换);删除的文档同步删除;入库是异步任务,失败重试、幂等;监控入库延迟和失败数。跨栈的数据同步场景见 [scenarios/](../../../scenarios/index.md)。
|
||||
|
||||
**追问**:嵌入成本很高,大量文档频繁小改动怎么办?→ 按块计算内容哈希,只有内容变化的块才重新嵌入;元数据(如权限)变化只更新元数据,不重新嵌入。
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
id: ai.rag.scenario.permission-aware-kb
|
||||
title: 带权限的企业知识库问答:不同部门看到不同内容
|
||||
type: scenario
|
||||
domain: ai
|
||||
stack: [rag, vector-db, elasticsearch, fastapi]
|
||||
tags: [企业知识库, 权限过滤, 文档解析, 混合检索, 重排, 拒答, 增量更新]
|
||||
level: advanced
|
||||
prerequisites: [ai.rag.generation-eval]
|
||||
est_hours: 3
|
||||
related: [ai.rag.interview, ai.vector-db.scenario.multi-tenant-index, backend.elasticsearch.index]
|
||||
week: [5, 6]
|
||||
---
|
||||
|
||||
# 带权限的企业知识库问答:不同部门看到不同内容
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
公司把员工手册、IT 指南、财务制度、各部门内部文档都导入知识库,做一个“制度问答助手”。试运行时发现:
|
||||
|
||||
1. 研发同事问“A 级调薪幅度多少”,助手根据 HR 内部的薪酬文档给出了答案——**越权**;
|
||||
2. PDF 里的表格(差旅标准)被解析成一串数字,问“一线城市住宿标准”答错;
|
||||
3. 问“报错 0x80070005 怎么办”,检索不到包含这个错误码的文档;
|
||||
4. 制度更新后,新旧两个版本都被检索到,答案自相矛盾;
|
||||
5. 知识库里没有的问题(股票期权),助手也一本正经地回答。
|
||||
|
||||
## 约束
|
||||
|
||||
- 权限以现有的组织架构(部门、角色)为准,文档有“全员 / 指定部门 / 指定角色”三种可见范围。
|
||||
- 文档格式:PDF(含表格、扫描件)、Word、Confluence 类网页。
|
||||
- 答案必须给出引用来源,方便员工核对原文。
|
||||
- 回答延迟目标:首字 2 秒以内(流式)。
|
||||
|
||||
## 方案设计
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 入库(异步任务)
|
||||
S[文档源] --> P[解析:PDF/Word/HTML → Markdown<br/>表格转 Markdown 表格,扫描件 OCR] --> C[按标题切分 + 标题路径前缀]
|
||||
C --> E[嵌入] --> V[(向量库<br/>块 + acl + doc_id + version)]
|
||||
C --> K[(Elasticsearch<br/>中文分词 BM25)]
|
||||
end
|
||||
subgraph 问答
|
||||
U[用户] --> A[鉴权:取部门 / 角色] --> R1[向量检索 + acl 过滤]
|
||||
A --> R2[ES 检索 + acl 过滤]
|
||||
R1 & R2 --> F[RRF 融合 → 重排 top 5] --> G{最高分 ≥ 阈值?}
|
||||
G -->|否| N[拒答 + 转人工入口]
|
||||
G -->|是| L[带编号资料的提示词 → 流式生成] --> O[答案 + 引用]
|
||||
end
|
||||
```
|
||||
|
||||
1. **权限在检索阶段过滤**:每个块带 `acl`(可见的部门 / 角色列表),检索时作为过滤条件,**无权限的内容根本不进入提示词**。不要“先检索再让模型别说”。
|
||||
2. **文档解析**:PDF 先转结构化文本,表格转成 Markdown 表格整体作为一个块;扫描件走 OCR;去掉页眉页脚。解析结果抽样人工检查。
|
||||
3. **混合检索**:向量检索负责语义,Elasticsearch(IK 等中文分词)负责错误码、编号等精确词;RRF 融合后用重排模型取前 5。
|
||||
4. **版本和增量更新**:块带 `doc_id` 和 `version`;文档更新时按 `doc_id` 删除旧块再写入新块(或写入新版本后切换),避免新旧共存。
|
||||
5. **拒答**:重排后最高分低于阈值直接拒答,给出“提交工单 / 联系 HR”入口。
|
||||
6. **评测和回归**:评测集包含有权限 / 无权限 / 无答案 / 精确词 / 表格类问题,每次改动跑一遍。
|
||||
|
||||
## 关键代码
|
||||
|
||||
权限过滤和拒答的最小实现(完整代码 [examples/mini-rag/mini_rag.py](../examples/mini-rag/mini_rag.py)):
|
||||
|
||||
```python
|
||||
def allowed(self, user_depts: set[str]) -> np.ndarray:
|
||||
return np.array([("all" in c.acl) or bool(c.acl & user_depts) for c in self.chunks])
|
||||
|
||||
|
||||
def vector_search(self, q: str, mask: np.ndarray, k: int):
|
||||
scores = self.matrix @ embed(q)
|
||||
scores[~mask] = -np.inf # 无权限的块直接排除,不会进入提示词
|
||||
...
|
||||
```
|
||||
|
||||
示例里验证了:HR 部门问调薪,命中薪酬制度;研发部门问同一个问题,薪酬制度不会出现在候选中(结果为空 → 拒答);问知识库中没有的“股票期权”→ 拒答。
|
||||
|
||||
真实向量库的过滤写法(以 Chroma 为例,`where` 条件;其他向量库语法不同,以官方文档为准):
|
||||
|
||||
```python
|
||||
results = collection.query(
|
||||
query_embeddings=[q_vec],
|
||||
n_results=20,
|
||||
where={"$or": [{"acl_all": True}, {"dept": {"$in": list(user_depts)}}]},
|
||||
)
|
||||
```
|
||||
|
||||
## 取舍
|
||||
|
||||
| 选择 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| **检索时按 acl 过滤(本方案)** | 安全,无权限内容不会进入模型 | 权限变化时要更新块的 acl |
|
||||
| 按部门分别建索引 | 隔离彻底 | 跨部门文档要重复存储;部门多时管理困难 |
|
||||
| 检索后再过滤 | 实现简单 | top-k 被无权限内容占满后,有权限的内容反而召回不到 |
|
||||
| 只靠提示词让模型不说 | —— | **不可接受**,模型可能泄露 |
|
||||
| 向量库 + ES 两套存储 | 混合检索效果好 | 两边数据同步、一致性要处理 |
|
||||
| 只用支持混合检索的单一引擎 | 运维简单 | 依赖具体产品的能力(ES、部分向量库已支持向量 + 关键词),以官方文档为准 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 权限只在前端或网关判断,检索层不过滤。
|
||||
- 先取 top-5 再过滤权限:结果可能被过滤成空,实际上还有相关的有权限内容。先过滤再取 top-k。
|
||||
- 用户换部门后缓存的答案仍可见:缓存 key 要包含权限维度,或不缓存敏感问答。
|
||||
- 表格被拆成多块:一行数据和表头分离,数字失去含义。
|
||||
- 文档删除 / 下线后向量库里还有残留。
|
||||
- 日志里记录了完整的提示词(包含敏感文档内容),日志本身成为泄露渠道。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “企业知识库最大的问题是权限。我在每个块上带了 acl 元数据,检索阶段就过滤,无权限的内容不会进入提示词,而不是靠提示词让模型别说;检索用向量加 ES 的混合检索再重排,解决错误码这类精确查询;文档解析时把表格转成 Markdown 整体入库;文档按 doc_id 和版本增量更新;最高分低于阈值就拒答。评测集专门包含无权限和无答案的问题,每次改动都跑回归。”
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
id: ai.vector-db.ann-metrics
|
||||
title: 向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引
|
||||
type: lesson
|
||||
domain: ai
|
||||
stack: [vector-db]
|
||||
tags: [向量检索, 余弦相似度, 内积, L2, HNSW, IVF, PQ, 召回率]
|
||||
level: basic
|
||||
prerequisites: [ai.rag.pipeline-chunking]
|
||||
est_hours: 2.5
|
||||
related: [ai.vector-db.selection-pgvector, ai.rag.hybrid-rerank, ai.vector-db.interview]
|
||||
week: [5]
|
||||
order: 1
|
||||
---
|
||||
|
||||
# 向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引
|
||||
|
||||
## 解释说明
|
||||
|
||||
**嵌入(embedding)** 把一段文本变成一个固定长度的浮点数组(例如 768、1024、1536 维),语义相近的文本向量也相近。向量数据库要解决的问题就是:给定一个查询向量,从几百万个向量中**快速**找出最相近的 k 个(k 近邻,kNN)。
|
||||
|
||||
### 距离度量
|
||||
|
||||
| 度量 | 含义 | 什么时候用 |
|
||||
| --- | --- | --- |
|
||||
| 余弦相似度(cosine) | 两个向量夹角的余弦,只看方向不看长度 | 文本嵌入最常用 |
|
||||
| 内积(inner product, IP / dot) | 对应元素相乘求和 | 向量已归一化时**和余弦等价且更快** |
|
||||
| 欧氏距离(L2) | 两点之间的直线距离 | 部分模型推荐;归一化向量的 L2 排序和余弦排序一致 |
|
||||
|
||||
规则:**用嵌入模型文档推荐的度量**;如果向量已归一化(长度为 1),三种度量的排序结果一致。注意“距离”和“相似度”方向相反:余弦距离 = 1 − 余弦相似度,越小越相近。
|
||||
|
||||
### 暴力检索和近似检索
|
||||
|
||||
- **暴力检索(精确)**:查询向量和所有向量逐一计算,复杂度 O(N·d)。几万条以内完全够用(numpy 一次矩阵乘法),而且结果是精确的。
|
||||
- **近似最近邻(ANN)**:用索引结构牺牲一点准确度,换取数量级的速度提升。衡量指标是**召回率**:近似结果和精确结果的重合比例。
|
||||
|
||||
### 常见索引
|
||||
|
||||
| 索引 | 原理 | 关键参数 | 特点 |
|
||||
| --- | --- | --- | --- |
|
||||
| **HNSW** | 多层“小世界”图,从稀疏的上层快速定位,逐层向下精细搜索 | `M`(每个点的连接数)、`ef_construction`(建索引时的候选数)、`ef_search`(查询时的候选数) | 查询快、召回高,**最常用**;内存占用大,建索引慢 |
|
||||
| **IVF**(倒排文件) | 先用聚类把向量分成 `nlist` 个桶,查询时只搜最近的 `nprobe` 个桶 | `nlist` / `lists`、`nprobe` / `probes` | 建索引快、省内存;要先有数据才能训练聚类中心,数据分布变化后要重建 |
|
||||
| **PQ**(乘积量化) | 把向量切段并压缩成编码 | 段数、码本大小 | 大幅省内存,精度下降,常和 IVF 组合(IVF_PQ) |
|
||||
| DiskANN 等磁盘索引 | 索引放在 SSD 上 | —— | 数据量超出内存时使用 |
|
||||
|
||||
参数的规律:`ef_search` / `nprobe` 调大 → 召回率上升、延迟上升。**用自己的数据和评测集测出合适的值**,不要照抄。
|
||||
|
||||
### 过滤和近似索引的冲突
|
||||
|
||||
带条件的查询(只搜某租户 / 某部门)在近似索引上有一个经典问题:索引先返回 top-N 个近邻,再按条件过滤,如果满足条件的数据很少,过滤后可能不足 k 个甚至为空。解决办法:数据库内置的过滤遍历(例如 pgvector 0.8 的迭代索引扫描、Milvus 的过滤搜索)、增大候选数、按租户分区 / 分集合、过滤条件非常严格时直接走精确检索。
|
||||
|
||||
## 案例
|
||||
|
||||
制度问答知识库有 3 万个块:直接用 numpy 暴力检索,单次查询几毫秒,没有召回损失。业务扩展到 50 个租户、800 万个块后,暴力检索需要几百毫秒,改用 HNSW 索引;上线前用 200 个评测问题对比 HNSW 和暴力检索的结果,调整 `ef_search` 使召回率达到 0.95 以上,同时 P99 延迟满足要求。
|
||||
|
||||
## 代码示例
|
||||
|
||||
用 numpy 实现暴力检索,验证“归一化后余弦 = 内积,L2 排序一致”:
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
docs = rng.normal(size=(1000, 64)).astype(np.float32)
|
||||
q = rng.normal(size=64).astype(np.float32)
|
||||
|
||||
docs_n = docs / np.linalg.norm(docs, axis=1, keepdims=True)
|
||||
q_n = q / np.linalg.norm(q)
|
||||
|
||||
cos = docs_n @ q_n # 余弦相似度(越大越近)
|
||||
ip = docs_n @ q_n # 归一化后内积就是余弦
|
||||
l2 = np.linalg.norm(docs_n - q_n, axis=1) # 欧氏距离(越小越近)
|
||||
|
||||
top_cos = np.argsort(-cos)[:5]
|
||||
top_l2 = np.argsort(l2)[:5]
|
||||
assert list(top_cos) == list(top_l2) # 归一化后排序一致
|
||||
print(top_cos, np.round(cos[top_cos], 3))
|
||||
|
||||
# 未归一化时内积会偏向长度大的向量,排序可能不同
|
||||
top_ip_raw = np.argsort(-(docs @ q))[:5]
|
||||
print("未归一化内积 top5:", top_ip_raw)
|
||||
```
|
||||
|
||||
近似索引和精确检索的召回率对比(Chroma HNSW、pgvector HNSW)见 [examples/vector-stores](examples/vector-stores/)。
|
||||
|
||||
## 易错点
|
||||
|
||||
- 嵌入模型和度量不匹配:模型输出未归一化却用内积,结果偏向长向量。
|
||||
- 入库和查询使用了**不同的嵌入模型**(或同一模型的不同版本):向量空间不一致,检索结果完全不对。换模型必须全量重新嵌入。
|
||||
- 把“距离”当“相似度”用:阈值方向写反。
|
||||
- 只看近似检索的结果,不和暴力检索对比,不知道召回率是多少。
|
||||
- 数据量很小也上复杂的向量数据库:几万条以内 numpy / pgvector 精确检索就够了。
|
||||
- IVF 索引在空表上建立:聚类中心没有意义,召回率很低。应在导入有代表性的数据之后再建。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- 余弦相似度、内积、L2 的区别?什么时候结果一样?
|
||||
- HNSW 的原理和关键参数?`ef_search` 调大会怎样?
|
||||
- HNSW 和 IVF 怎么选?
|
||||
- 带过滤条件的向量检索为什么可能召回不足?怎么解决?
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
id: ai.vector-db.selection-pgvector
|
||||
title: 向量库选型和 pgvector 实战:Chroma / pgvector / Milvus / Elasticsearch
|
||||
type: lesson
|
||||
domain: ai
|
||||
stack: [vector-db, postgresql]
|
||||
tags: [向量库选型, pgvector, Chroma, Milvus, Elasticsearch, 元数据过滤]
|
||||
level: intermediate
|
||||
prerequisites: [ai.vector-db.ann-metrics]
|
||||
est_hours: 3
|
||||
related: [ai.vector-db.scenario.multi-tenant-index, backend.postgresql.index, backend.elasticsearch.index]
|
||||
week: [5, 6]
|
||||
order: 2
|
||||
---
|
||||
|
||||
# 向量库选型和 pgvector 实战:Chroma / pgvector / Milvus / Elasticsearch
|
||||
|
||||
## 解释说明
|
||||
|
||||
### 选型对比
|
||||
|
||||
| 方案 | 适合 | 优点 | 注意 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Chroma** | 原型、学习、小规模 | 安装即用,Python 内嵌模式 | 大规模、高可用要评估;版本间 API 有变化 |
|
||||
| **pgvector**(PostgreSQL 扩展) | 已有 PostgreSQL、百万级以内、需要和业务数据联查 | 一套数据库;SQL 过滤、事务、备份都复用 | 超大规模和高 QPS 时不如专用向量库;向量索引的内存占用要规划 |
|
||||
| **Milvus** | 千万到亿级、需要分布式 | 多种索引、分区、标量过滤、水平扩展 | 组件多,运维成本高(也有托管版) |
|
||||
| **Elasticsearch / OpenSearch** | 已经用 ES 做全文检索,想要关键词 + 向量混合检索 | 一个引擎做 BM25 + kNN | 向量功能的版本差异较大,以官方文档为准 |
|
||||
| FAISS | 嵌入到程序里的向量检索库 | 快,索引类型全 | 是库不是数据库:没有持久化服务、过滤、权限,需要自己做 |
|
||||
|
||||
**本计划的选择**:学习阶段用 Chroma 或 numpy 快速理解;项目使用 **pgvector**,因为第 3~4 周已经学了 PostgreSQL,业务数据、权限和向量可以放在同一个库里联查;面试时能讲清楚“为什么不用 Milvus”和“什么规模需要换”。
|
||||
|
||||
### pgvector 要点(以官方文档为准:https://github.com/pgvector/pgvector)
|
||||
|
||||
- 类型:`vector(n)`;还有 `halfvec`(半精度,省一半空间)、`sparsevec`。
|
||||
- 运算符:`<->` L2 距离,`<#>` **负**内积,`<=>` 余弦距离(都是越小越近)。
|
||||
- 索引:`USING hnsw (embedding vector_cosine_ops)`,运算符类必须和查询用的运算符一致(`vector_l2_ops` / `vector_ip_ops` / `vector_cosine_ops`),否则不走索引。
|
||||
- 查询参数:`SET hnsw.ef_search = 100;`(默认 40);IVFFlat 用 `ivfflat.probes`。
|
||||
- 过滤:`WHERE tenant_id = $1 ORDER BY embedding <=> $2 LIMIT 10`。过滤条件很严格时,0.8 版本可以开启 `hnsw.iterative_scan` 让索引继续扫描直到凑够结果;或者按租户建部分索引 / 分区。
|
||||
|
||||
## 案例
|
||||
|
||||
一个 SaaS 客服系统,50 个租户共 200 万个知识块,平均每个租户 4 万,最大租户 60 万。业务表已经在 PostgreSQL 中。选择 pgvector:块表带 `tenant_id`,HNSW 索引 + `tenant_id` 普通索引;大租户检索结果不足时开启迭代扫描。评估结论写进文档:当总量超过数千万或 QPS 超过单机能力时,再评估 Milvus。
|
||||
|
||||
## 代码示例
|
||||
|
||||
pgvector 的表结构和查询(完整可运行版本 [examples/vector-stores/pgvector_demo.py](examples/vector-stores/pgvector_demo.py),已在 PostgreSQL 17 + pgvector 0.8.0 上运行):
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
CREATE TABLE chunks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL,
|
||||
version INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding vector(1024) NOT NULL
|
||||
);
|
||||
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
|
||||
CREATE INDEX ON chunks (tenant_id);
|
||||
CREATE INDEX ON chunks (doc_id);
|
||||
|
||||
-- 查询:$1 租户,$2 查询向量
|
||||
SET hnsw.ef_search = 100;
|
||||
SELECT id, doc_id, content, 1 - (embedding <=> $2) AS similarity
|
||||
FROM chunks
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY embedding <=> $2
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
Chroma 的写入、元数据过滤、更新删除见 [examples/vector-stores/chroma_demo.py](examples/vector-stores/chroma_demo.py)。
|
||||
|
||||
## 易错点
|
||||
|
||||
- 建索引用了 `vector_l2_ops`,查询用 `<=>`:不走索引,变成全表扫描。
|
||||
- `<#>` 返回的是负内积,排序时直接 `ORDER BY embedding <#> q` 就是内积从大到小;自己再加负号就反了。
|
||||
- 小表的执行计划显示顺序扫描:数据少时规划器认为顺序扫描更便宜,这是正常的,不是索引失效。
|
||||
- HNSW 索引默认不支持太高的维度(`vector` 类型的索引上限 2000 维,`halfvec` 4000 维,以官方文档为准),高维模型要选 `halfvec` 或降维。
|
||||
- 大批量导入时边写边维护 HNSW 索引很慢:先导入再建索引,并适当调大 `maintenance_work_mem`。
|
||||
- Chroma 不同版本的客户端 / 持久化格式有变化,升级前看迁移说明。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- 为什么选 pgvector 而不是 Milvus?什么时候要换?
|
||||
- pgvector 的三种距离运算符分别是什么?索引怎么建才能生效?
|
||||
- 向量和业务数据放一个库有什么好处和风险?
|
||||
@@ -0,0 +1,40 @@
|
||||
# requires: chromadb, numpy
|
||||
"""Chroma(内存模式):写入自带向量、按元数据过滤、和暴力检索对比召回率。
|
||||
|
||||
运行:python chroma_demo.py(不需要任何外部服务,也不会下载嵌入模型,因为我们自己提供向量)
|
||||
Chroma 的 API 在版本间有变化,以官方文档为准:https://docs.trychroma.com/
|
||||
"""
|
||||
import chromadb
|
||||
import numpy as np
|
||||
|
||||
from common import brute_force, make_corpus, query_vec
|
||||
|
||||
ids, vecs, metas, centers = make_corpus()
|
||||
client = chromadb.EphemeralClient()
|
||||
col = client.create_collection("docs_demo", metadata={"hnsw:space": "cosine"}) # 距离度量要和嵌入模型匹配
|
||||
col.add(ids=ids, embeddings=vecs.tolist(), metadatas=metas)
|
||||
|
||||
q = query_vec(centers, "it")
|
||||
k = 10
|
||||
|
||||
# 1. 无过滤:近似检索结果 vs 精确结果
|
||||
res = col.query(query_embeddings=[q.tolist()], n_results=k)
|
||||
approx = res["ids"][0]
|
||||
exact = [ids[i] for i in brute_force(vecs, q, k)]
|
||||
recall = len(set(approx) & set(exact)) / k
|
||||
print(f"HNSW 召回率(相对暴力检索)@{k} = {recall:.2f}")
|
||||
print("距离(cosine distance = 1 - 相似度)前 3:", [round(d, 3) for d in res["distances"][0][:3]])
|
||||
assert all(i.startswith("it-") for i in approx[:5])
|
||||
|
||||
# 2. 元数据过滤:多租户 / 权限
|
||||
res = col.query(query_embeddings=[q.tolist()], n_results=k,
|
||||
where={"$and": [{"tenant": "tenant-1"}, {"topic": {"$in": ["it", "hr"]}}]})
|
||||
got = res["ids"][0]
|
||||
print("tenant-1 过滤后:", got[:5])
|
||||
assert got and all(metas[ids.index(i)]["tenant"] == "tenant-1" for i in got)
|
||||
|
||||
# 3. 更新和删除:按 id 覆盖、删除
|
||||
col.upsert(ids=["it-0"], embeddings=[vecs[0].tolist()], metadatas=[{"topic": "hr", "tenant": "tenant-0"}])
|
||||
col.delete(where={"topic": "finance"})
|
||||
print("删除 finance 后剩余:", col.count())
|
||||
assert col.count() == 400
|
||||
@@ -0,0 +1,33 @@
|
||||
"""示例共用:确定性的“假嵌入”(随机向量 + 主题偏移),让示例不依赖嵌入模型 API。"""
|
||||
import numpy as np
|
||||
|
||||
DIM = 64
|
||||
TOPICS = ["hr", "it", "finance"]
|
||||
|
||||
|
||||
def make_corpus(n_per_topic: int = 200, seed: int = 7):
|
||||
"""每个主题一个中心向量,文档向量 = 中心 + 噪声。返回 (ids, vectors, metadatas)。"""
|
||||
rng = np.random.default_rng(seed)
|
||||
centers = {t: rng.normal(size=DIM) for t in TOPICS}
|
||||
ids, vecs, metas = [], [], []
|
||||
for t in TOPICS:
|
||||
for i in range(n_per_topic):
|
||||
v = centers[t] + rng.normal(scale=0.8, size=DIM)
|
||||
ids.append(f"{t}-{i}")
|
||||
vecs.append(v / np.linalg.norm(v))
|
||||
metas.append({"topic": t, "tenant": f"tenant-{i % 3}"})
|
||||
return ids, np.array(vecs, dtype=np.float32), metas, centers
|
||||
|
||||
|
||||
def query_vec(centers, topic: str, seed: int = 1) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
v = centers[topic] + rng.normal(scale=0.8, size=DIM)
|
||||
return (v / np.linalg.norm(v)).astype(np.float32)
|
||||
|
||||
|
||||
def brute_force(vecs: np.ndarray, q: np.ndarray, k: int, mask=None) -> list[int]:
|
||||
"""精确的最近邻(余弦 = 归一化后的点积),作为近似检索的对照。"""
|
||||
scores = vecs @ q
|
||||
if mask is not None:
|
||||
scores = np.where(mask, scores, -np.inf)
|
||||
return list(np.argsort(-scores)[:k])
|
||||
@@ -0,0 +1,77 @@
|
||||
# requires: psycopg, numpy
|
||||
"""pgvector:建表、HNSW 索引、带过滤的相似度检索、和暴力检索对比。
|
||||
|
||||
运行:TEST_DATABASE_URL=postgresql://user:pass@localhost:5432/db python pgvector_demo.py
|
||||
需要数据库已安装 pgvector 扩展(CREATE EXTENSION vector)。没有设置环境变量时跳过。
|
||||
会在一个临时 schema 里建表,结束后删除。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import numpy as np
|
||||
import psycopg
|
||||
|
||||
from common import DIM, brute_force, make_corpus, query_vec
|
||||
|
||||
|
||||
def to_pg(v: np.ndarray) -> str:
|
||||
return "[" + ",".join(f"{x:.6f}" for x in v) + "]"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
url = os.environ.get("TEST_DATABASE_URL")
|
||||
if not url:
|
||||
print("未设置 TEST_DATABASE_URL,跳过")
|
||||
return 0
|
||||
schema = f"vec_demo_{uuid.uuid4().hex[:8]}"
|
||||
ids, vecs, metas, centers = make_corpus()
|
||||
with psycopg.connect(url, autocommit=True) as conn:
|
||||
try:
|
||||
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
conn.execute(f"CREATE SCHEMA {schema}")
|
||||
conn.execute(f"SET search_path TO {schema}, public")
|
||||
conn.execute(f"""CREATE TABLE chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
embedding vector({DIM}) NOT NULL)""")
|
||||
with conn.cursor().copy("COPY chunks (id, tenant, topic, embedding) FROM STDIN") as cp:
|
||||
for i, v, m in zip(ids, vecs, metas):
|
||||
cp.write_row((i, m["tenant"], m["topic"], to_pg(v)))
|
||||
# HNSW 索引;vector_cosine_ops 对应 <=> 余弦距离运算符
|
||||
conn.execute("CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)")
|
||||
conn.execute("CREATE INDEX ON chunks (tenant)")
|
||||
conn.execute("ANALYZE chunks")
|
||||
|
||||
q = query_vec(centers, "finance")
|
||||
k = 10
|
||||
rows = conn.execute("SELECT id, 1 - (embedding <=> %s::vector) AS sim FROM chunks "
|
||||
"ORDER BY embedding <=> %s::vector LIMIT %s", (to_pg(q), to_pg(q), k)).fetchall()
|
||||
exact = [ids[i] for i in brute_force(vecs, q, k)]
|
||||
recall = len({r[0] for r in rows} & set(exact)) / k
|
||||
print(f"pgvector HNSW 召回率@{k} = {recall:.2f},最相似:{rows[0][0]} {rows[0][1]:.3f}")
|
||||
assert all(r[0].startswith("finance-") for r in rows[:5])
|
||||
|
||||
# 只有 600 行时规划器可能选择顺序扫描(更便宜),演示时临时关闭顺序扫描看索引计划
|
||||
conn.execute("SET enable_seqscan = off")
|
||||
plan = "\n".join(r[0] for r in conn.execute(
|
||||
"EXPLAIN SELECT id FROM chunks ORDER BY embedding <=> %s::vector LIMIT 10", (to_pg(q),)).fetchall())
|
||||
conn.execute("RESET enable_seqscan")
|
||||
print("执行计划使用 HNSW 索引:", "chunks_embedding_idx" in plan)
|
||||
assert "chunks_embedding_idx" in plan
|
||||
|
||||
# 带过滤:SQL 的 WHERE 和向量排序写在一起,这是 pgvector 的便利之处
|
||||
rows = conn.execute("SELECT id, tenant FROM chunks WHERE tenant = %s "
|
||||
"ORDER BY embedding <=> %s::vector LIMIT %s", ("tenant-2", to_pg(q), k)).fetchall()
|
||||
assert rows and all(t == "tenant-2" for _, t in rows)
|
||||
mask = np.array([m["tenant"] == "tenant-2" for m in metas])
|
||||
exact_f = {ids[i] for i in brute_force(vecs, q, k, mask)}
|
||||
print(f"过滤后召回率@{k} = {len({r[0] for r in rows} & exact_f) / k:.2f}(过滤 + 近似索引可能降低召回,见课程)")
|
||||
finally:
|
||||
conn.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -4,12 +4,42 @@ title: 向量数据库
|
||||
type: index
|
||||
domain: ai
|
||||
stack: [vector-db]
|
||||
tags: []
|
||||
tags: [向量数据库, HNSW, pgvector, Chroma, Milvus]
|
||||
week: [5, 6]
|
||||
---
|
||||
|
||||
# 向量数据库
|
||||
|
||||
近似最近邻索引、距离度量、Chroma / Milvus / pgvector 选型。
|
||||
RAG 的检索基础设施。重点是讲清楚**距离度量、近似索引的召回和延迟取舍、带过滤的检索、选型理由**,而不是背某个产品的 API。项目里使用 pgvector(复用第 3~4 周学的 PostgreSQL)。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引](01-ann-metrics.md) | 基础 | 2.5 小时 |
|
||||
| 2 | [向量库选型和 pgvector 实战:Chroma / pgvector / Milvus / Elasticsearch](02-selection-pgvector.md) | 进阶 | 3 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [多租户知识库的向量索引:隔离、过滤召回和增量更新](scenarios/multi-tenant-index.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [向量数据库面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/vector-stores](examples/vector-stores/):`chroma_demo.py`(内存模式,自带向量,元数据过滤、更新删除,和暴力检索对比召回率);`pgvector_demo.py`(HNSW 索引、过滤查询、执行计划,需要 `TEST_DATABASE_URL` 和 pgvector 扩展,否则跳过)
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 5 周:RAG(一)](../../../plan/week05-rag-1.md)、[第 6 周:RAG(二)](../../../plan/week06-rag-2.md)
|
||||
|
||||
## 官方资料
|
||||
|
||||
产品更新很快,接口和参数以官方文档为准:
|
||||
|
||||
- pgvector:https://github.com/pgvector/pgvector
|
||||
- Chroma:https://docs.trychroma.com/
|
||||
- Milvus:https://milvus.io/docs
|
||||
- Elasticsearch 向量检索:https://www.elastic.co/docs/solutions/search/vector
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: ai.vector-db.interview
|
||||
title: 向量数据库面试题
|
||||
type: interview
|
||||
domain: ai
|
||||
stack: [vector-db]
|
||||
tags: [向量数据库, HNSW, pgvector, 面试]
|
||||
level: intermediate
|
||||
related: [ai.vector-db.ann-metrics, ai.vector-db.selection-pgvector, ai.rag.interview]
|
||||
week: [5, 6, 16]
|
||||
---
|
||||
|
||||
# 向量数据库面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】向量数据库解决什么问题?和传统数据库有什么区别?
|
||||
|
||||
**参考答案**:解决“找出和查询向量最相似的 k 个向量”的问题。传统数据库按精确值或范围查询(B+ 树等),向量检索按相似度排序,需要专门的近似最近邻索引(HNSW、IVF)在速度和召回率之间取舍。现在很多传统数据库也通过扩展支持向量(pgvector、ES 的 kNN)。
|
||||
|
||||
**追问**:数据量只有几万条,需要向量数据库吗?——不需要专门的向量数据库,内存暴力检索或 pgvector 精确检索就够快,而且结果精确。
|
||||
|
||||
### 【基础】余弦相似度、内积、欧氏距离有什么区别?
|
||||
|
||||
**参考答案**:余弦只看方向;内积同时受长度影响;欧氏距离是空间中两点的距离。向量归一化后三者的排序结果一致,此时内积计算最快。应该使用嵌入模型推荐的度量。
|
||||
|
||||
**追问**:pgvector 中 `<#>` 返回什么?——负内积,所以 `ORDER BY embedding <#> q` 是内积从大到小。
|
||||
|
||||
### 【基础】什么是 ANN?召回率怎么算?
|
||||
|
||||
**参考答案**:近似最近邻,用索引结构换取速度,结果不保证精确。召回率 = 近似检索的 top-k 和精确检索的 top-k 的重合数 / k。上线前要用自己的数据测试。
|
||||
|
||||
**追问**:召回率 0.95 够不够?→ 取决于业务:RAG 后面还有重排和生成,0.95 通常足够;关键是要在自己的数据上测出召回率和延迟的关系,再按延迟预算选参数。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】HNSW 的原理和关键参数?
|
||||
|
||||
**参考答案**:多层图结构,上层稀疏、下层稠密。查询从最上层的入口点开始,贪心地走向更近的邻居,逐层下降,在最底层用大小为 `ef_search` 的候选队列搜索。参数:`M` 每个节点的连接数(大 → 召回高、内存大),`ef_construction` 建索引时的候选数(大 → 索引质量高、建索引慢),`ef_search` 查询时的候选数(大 → 召回高、延迟高)。
|
||||
|
||||
**追问**:HNSW 和 IVF 怎么选?——HNSW 查询性能和召回通常更好、支持增量插入,但内存大;IVF 建索引快、省内存(配合 PQ 更省),需要先有数据训练,数据分布变化后要重建。
|
||||
|
||||
### 【进阶】带过滤条件的向量检索有什么问题?
|
||||
|
||||
**参考答案**:近似索引先找全局近邻再过滤,满足条件的数据占比很小时,结果可能不足 k 个。解决:数据库的过滤遍历能力(pgvector 0.8 的迭代扫描、Milvus 的标量过滤)、增大候选数、按租户分区、小数据量直接精确检索。
|
||||
|
||||
**追问**:pgvector 0.8 的迭代扫描是什么?→ 开启 `hnsw.iterative_scan` 后,如果过滤后结果不足,索引会继续往下扫描更多候选,直到凑够结果或达到扫描上限(`hnsw.max_scan_tuples`),参数以官方文档为准。
|
||||
|
||||
### 【进阶】更换嵌入模型要怎么做?
|
||||
|
||||
**参考答案**:新旧模型的向量空间不兼容,必须全量重新嵌入。做法:新增列 / 新集合存新向量,后台回填,用评测集对比效果,切换读路径,保留回滚能力,最后清理旧数据。记录每个块使用的模型版本。
|
||||
|
||||
**追问**:切换期间新文档写入怎么办?→ 双写:切换期间新文档同时生成新旧两种向量,回填只处理存量;切换读路径后再停止写旧向量。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】SaaS 产品多租户共用向量表,怎么防止数据串租户?
|
||||
|
||||
**参考答案**:数据访问层统一封装检索函数,`tenant_id` 必填;数据库层启用行级安全(RLS)兜底,应用使用非表所有者的角色连接,每个事务设置租户变量(连接池下必须用事务级);测试用例覆盖跨租户访问;日志审计。详见[多租户向量索引场景](scenarios/multi-tenant-index.md)。
|
||||
|
||||
**追问**:大租户和小租户检索性能差别很大怎么办?——分层:大租户单独分区 / 索引,小租户精确检索或开启迭代扫描。
|
||||
|
||||
### 【场景题】线上向量检索延迟从 50ms 涨到 500ms,怎么排查?
|
||||
|
||||
**参考答案**:先看是检索本身慢还是嵌入接口慢(分段计时);检索慢:执行计划是否走了向量索引(运算符类是否匹配)、索引是否能放进内存(`shared_buffers` / 内存不足导致大量磁盘读)、`ef_search` 是否被调大、过滤条件是否导致大量扫描、数据量是否增长、是否有大批量写入在重建索引。对照监控的时间点找变化。
|
||||
|
||||
**追问**:怎么提前发现这种问题?→ 监控检索 P95/P99 延迟、每次查询扫描的行数、索引大小和内存命中率;给慢查询设置日志阈值;数据量增长到一定程度前做容量评估。
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
id: ai.vector-db.scenario.multi-tenant-index
|
||||
title: 多租户知识库的向量索引:隔离、过滤召回和增量更新
|
||||
type: scenario
|
||||
domain: ai
|
||||
stack: [vector-db, postgresql]
|
||||
tags: [多租户, 元数据过滤, 增量更新, 召回率, SaaS]
|
||||
level: advanced
|
||||
prerequisites: [ai.vector-db.selection-pgvector]
|
||||
est_hours: 2.5
|
||||
related: [ai.rag.scenario.permission-aware-kb, ai.vector-db.interview]
|
||||
week: [6]
|
||||
---
|
||||
|
||||
# 多租户知识库的向量索引:隔离、过滤召回和增量更新
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
一个面向中小企业的 SaaS 客服产品,每个企业(租户)上传自己的知识库。上线三个月后出现:
|
||||
|
||||
1. 一个小租户(2000 个块)提问,经常返回“没有找到相关资料”,但资料明明存在;
|
||||
2. 一次代码改动漏了租户条件,A 公司的客服看到了 B 公司的产品价格;
|
||||
3. 大租户每天更新上千篇文档,删除的文档还会被检索到;
|
||||
4. 产品想换一个效果更好的嵌入模型,不知道怎么切换。
|
||||
|
||||
## 约束
|
||||
|
||||
- 租户数千个,大小差异很大(几千到几百万块)。
|
||||
- 数据存储在 PostgreSQL + pgvector,不想引入新的组件。
|
||||
- 租户之间的数据绝对不能串。
|
||||
|
||||
## 方案设计
|
||||
|
||||
1. **隔离方式按租户大小分层**:
|
||||
- 普通租户共用一张表,每行带 `tenant_id`;
|
||||
- 超大租户单独分区(PostgreSQL 声明式分区或单独的表),有自己的 HNSW 索引。
|
||||
2. **问题 1(小租户召回不足)的原因**:全表 HNSW 索引先找全局近邻,再过滤 `tenant_id`,小租户的数据很少出现在全局候选中。解决:开启 pgvector 0.8 的迭代索引扫描 `SET hnsw.iterative_scan = relaxed_order`;或者对小租户直接走精确检索(`tenant_id` 索引过滤后计算距离,几千条很快)。
|
||||
3. **问题 2(越权)**:租户条件不能依赖每个查询“记得加”。数据访问层封装统一的检索函数,`tenant_id` 是必填参数;数据库层用 **行级安全(RLS)** 兜底,连接时 `SET app.tenant_id`。
|
||||
4. **问题 3(增量更新)**:每个块带 `doc_id`、`version`、`content_hash`;文档更新在一个事务里删除旧块、插入新块;文档删除同步删除块;内容哈希不变的块跳过重新嵌入,节省成本。
|
||||
5. **问题 4(换嵌入模型)**:新增一列(或新表)存新模型的向量,后台批量回填,回填完成后对比评测集,再切换读路径,最后删除旧列。块表记录 `embed_model` 字段。
|
||||
|
||||
## 关键代码
|
||||
|
||||
```sql
|
||||
-- 行级安全:即使查询漏了 WHERE tenant_id,也只能看到当前租户的数据
|
||||
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON chunks
|
||||
USING (tenant_id = current_setting('app.tenant_id'));
|
||||
-- 注意:表的所有者和超级用户默认绕过 RLS,应用要用单独的普通角色连接,
|
||||
-- 或者对表执行 ALTER TABLE chunks FORCE ROW LEVEL SECURITY。
|
||||
|
||||
-- 每个请求(事务)开始时设置租户
|
||||
BEGIN;
|
||||
SELECT set_config('app.tenant_id', 'tenant-42', true); -- true 表示只在当前事务有效
|
||||
SET LOCAL hnsw.iterative_scan = relaxed_order; -- pgvector 0.8+,过滤后结果不足时继续扫描
|
||||
SELECT id, content FROM chunks
|
||||
ORDER BY embedding <=> $1
|
||||
LIMIT 10;
|
||||
COMMIT;
|
||||
|
||||
-- 文档更新:在一个事务里替换
|
||||
BEGIN;
|
||||
DELETE FROM chunks WHERE tenant_id = $1 AND doc_id = $2;
|
||||
INSERT INTO chunks (tenant_id, doc_id, version, content, embedding) VALUES (...);
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
过滤查询和召回率对比的可运行示例:[examples/vector-stores/pgvector_demo.py](../examples/vector-stores/pgvector_demo.py)。
|
||||
|
||||
## 取舍
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| 共享表 + `tenant_id` 过滤 | 简单,租户多时管理成本低 | 过滤召回问题;越权风险靠 RLS 兜底 |
|
||||
| 每租户一张表 / 一个集合 | 隔离彻底,索引小 | 租户上千时表和索引数量爆炸 |
|
||||
| **分层(本方案)** | 兼顾 | 有两套路径,代码复杂一点 |
|
||||
| 独立数据库(大客户私有化) | 合规要求最高时 | 成本最高 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 用表所有者账号连接,RLS 不生效却以为生效了。
|
||||
- `set_config(..., false)` 设置的是会话级变量,连接池复用连接时会串到下一个请求;应该用事务级(`true`)或每次请求都重新设置。
|
||||
- 删除文档只删了数据库里的记录,没删向量库 / ES 里的块(多存储时)。
|
||||
- 换嵌入模型时新旧向量混在一起检索。
|
||||
- 只用大租户做测试,没发现小租户的召回问题。评测集要按租户规模分组。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “多租户向量检索我遇到过两个问题:一是小租户召回不足,原因是全局 HNSW 先找近邻再过滤,小租户的数据很少进入候选,我用 pgvector 0.8 的迭代扫描和小租户走精确检索解决;二是租户隔离,我在数据访问层强制要求 tenant_id,数据库层用行级安全兜底,并且注意连接池下要用事务级变量。增量更新按 doc_id 在事务里替换,内容哈希没变的块不重新嵌入。”
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: backend.data-pipeline.index
|
||||
title: 数据管道与第三方接口对接
|
||||
type: index
|
||||
domain: backend
|
||||
stack: [data-pipeline]
|
||||
tags: []
|
||||
week: [14]
|
||||
---
|
||||
|
||||
# 数据管道与第三方接口对接
|
||||
|
||||
批处理与流处理、失败重试、幂等、数据质量告警、第三方 API 限流与字段变化。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: backend.elasticsearch.index
|
||||
title: Elasticsearch
|
||||
type: index
|
||||
domain: backend
|
||||
stack: [elasticsearch]
|
||||
tags: []
|
||||
week: [6, 14]
|
||||
---
|
||||
|
||||
# Elasticsearch
|
||||
|
||||
倒排索引、中文分词、BM25、与向量的混合检索、数据同步。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -20,6 +20,8 @@ week: [1, 2, 3, 16]
|
||||
|
||||
**参考答案**:Gin 的 `Engine` 实现了 `http.Handler` 接口,底层仍然由 `net/http` 负责监听和连接管理。Gin 增加了基于前缀树的高性能路由、`gin.Context`(封装请求和响应、参数获取、绑定、中间件间传值)、中间件链和常用工具(JSON 渲染、校验)。所以 Gin 可以和任何标准库中间件、`httptest` 一起使用。
|
||||
|
||||
**追问**:Gin 的 `*gin.Context` 和标准库的 `context.Context` 是什么关系?→ `gin.Context` 是 Gin 自己的请求上下文(参数、响应、中间件链),它也实现了 `context.Context` 接口;向下游传递取消信号时,用 `c.Request.Context()` 更明确。
|
||||
|
||||
### 【基础】Gin 怎么获取路径参数、查询参数和请求体?
|
||||
|
||||
**参考答案**:路径参数 `c.Param("id")`;查询参数 `c.Query("k")` / `c.DefaultQuery("k", "默认")`;请求体用绑定 `c.ShouldBindJSON(&req)`,查询参数也可以 `c.ShouldBindQuery(&q)` 绑定到结构体。绑定时根据结构体的 `binding` 标签做校验。
|
||||
@@ -30,10 +32,14 @@ week: [1, 2, 3, 16]
|
||||
|
||||
**参考答案**:按注册顺序依次执行,形成洋葱模型:`c.Next()` 之前的代码按顺序执行,之后的代码按相反顺序执行。`c.Next()` 立即执行后续的处理函数;`c.Abort()` 阻止后续处理函数执行,但当前函数会继续执行完,所以通常 `Abort` 后紧跟 `return`。
|
||||
|
||||
**追问**:在中间件里 `c.Abort()` 之后还需要 `return` 吗?→ 需要。`Abort` 只是阻止后续处理函数执行,当前函数后面的代码仍会继续执行。
|
||||
|
||||
### 【基础】RESTful 接口的状态码怎么选?
|
||||
|
||||
**参考答案**:查询成功 200、创建成功 201、删除成功 204、参数错误 400、未认证 401、已认证但无权限 403、不存在 404、状态冲突(如重复创建)409、请求过多 429、服务器错误 500、下游不可用 502/503。
|
||||
|
||||
**追问**:参数校验失败用 400 还是 422?→ 两种都常见,团队统一即可;关键是错误响应格式统一、带上具体的字段错误信息。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】JWT 和 Session 有什么区别?JWT 怎么实现退出登录?
|
||||
@@ -46,24 +52,36 @@ week: [1, 2, 3, 16]
|
||||
|
||||
**参考答案**:Gin 用对象池复用 `Context`,请求处理结束后它会被重置给下一个请求使用。如果后台 goroutine 还持有原来的 `c`,读到的可能是别的请求的数据。`c.Copy()` 生成只读副本。更好的做法是只把需要的值(用户 ID、请求 ID、`c.Request.Context()` 派生的 context)传给后台任务。
|
||||
|
||||
**追问**:goroutine 里需要请求的取消信号怎么办?→ 后台任务通常不应该跟随请求取消(请求结束就被取消),用 `context.WithoutCancel(c.Request.Context())` 或新的 context 并设置独立超时。
|
||||
|
||||
### 【进阶】如何设计统一的错误处理?
|
||||
|
||||
**参考答案**:定义业务错误类型(HTTP 状态 + 业务码 + 用户提示),处理函数通过 `c.Error(err)` 记录错误并返回,由一个错误中间件统一转换成标准响应;未知错误和 panic 统一返回通用提示,详细信息带请求 ID 写日志。详见本技术栈的场景文档。
|
||||
|
||||
**追问**:错误码和 HTTP 状态码是什么关系?→ HTTP 状态码表达大类(客户端错误 / 服务端错误),业务错误码表达具体原因,方便前端展示和排查;不要所有错误都返回 200。
|
||||
|
||||
### 【进阶】Gin 的路由为什么快?
|
||||
|
||||
**参考答案**:Gin 使用 httprouter 风格的前缀树(radix tree),每个 HTTP 方法一棵树,按路径公共前缀压缩存储,匹配时间与路径长度相关,基本不受路由数量影响;同时 `Context` 用对象池减少内存分配。
|
||||
|
||||
**追问**:路由冲突(例如 `/users/:id` 和 `/users/new`)会怎样?→ 新版本的 Gin 已经支持静态路由和参数路由共存,静态路由优先;旧版本会在注册时 panic。以使用的版本为准,写测试验证。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】接口被恶意刷,怎么在 Gin 里做限流?
|
||||
|
||||
**参考答案**:单实例用令牌桶(`golang.org/x/time/rate`),按用户 ID 或 IP 维护一个限流器 map(注意定期清理);多实例部署需要全局限流,用 Redis(固定窗口 `INCR+EXPIRE` 或滑动窗口 ZSet、Lua 脚本保证原子)。超限返回 429 并带 `Retry-After`。更外层可以在 Nginx 或网关做 IP 级限流,应用层做用户级、接口级限流。本计划第 3 周项目一实现过。
|
||||
|
||||
**追问**:多实例部署时限流怎么做?→ 单机限流(`x/time/rate`)只能限制单个实例;全局限流要用 Redis(滑动窗口 / 令牌桶 + Lua 原子执行)或在网关层统一做。
|
||||
|
||||
### 【场景题】一个上传接口偶尔导致服务内存暴涨,怎么处理?
|
||||
|
||||
**参考答案**:限制请求体大小(`http.MaxBytesReader` 或 Gin 的 `MaxMultipartMemory`,Nginx 的 `client_max_body_size`);大文件流式处理而不是一次读入内存;校验文件类型和大小;上传改成前端直传对象存储(预签名 URL),后端只接收回调。
|
||||
|
||||
**追问**:大文件上传怎么做更好?→ 前端直传对象存储(服务端签发预签名 URL),服务端只处理元数据;需要服务端处理时用流式读取,不要整个读进内存。
|
||||
|
||||
### 【场景题】怎么给 Gin 服务写接口测试?
|
||||
|
||||
**参考答案**:把路由组装放在一个函数里(如 `New(deps) *gin.Engine`),测试时注入内存版依赖;用 `httptest.NewRecorder()` 和 `r.ServeHTTP(w, req)` 直接调用,不启动端口;用表格驱动覆盖成功、参数错误、未登录、无权限、不存在、内部错误等情况;数据库相关的用集成测试单独跑。示例见 [examples/unified-api](examples/unified-api/)。
|
||||
|
||||
**追问**:测试需要数据库怎么办?→ 业务逻辑通过接口依赖存储层,单元测试用假实现;集成测试用真实数据库(测试库或容器),每个测试使用独立的数据并清理。
|
||||
@@ -32,6 +32,8 @@ week: [1, 3, 16]
|
||||
|
||||
**参考答案**:需要修改接收者、或者结构体很大(避免拷贝)时用指针接收者;小的、不可变的值类型可以用值接收者。同一个类型的方法风格保持一致。注意:指针接收者的方法只属于 `*T` 的方法集,所以只有 `*T` 实现了相关接口。
|
||||
|
||||
**追问**:同一个类型的方法接收者可以混用吗?→ 语法上可以,但一般建议统一:只要有一个方法需要指针接收者,就全部用指针接收者,避免实现接口时出现困惑。
|
||||
|
||||
### 【基础】Go 为什么没有异常?错误怎么处理?
|
||||
|
||||
**参考答案**:Go 把错误当成普通返回值,强制调用方显式处理,控制流清楚。用 `fmt.Errorf("...: %w", err)` 包装错误加上下文,用 `errors.Is` / `errors.As` 判断。`panic` 只用于不可恢复的错误,HTTP 服务用中间件 `recover` 兜底。
|
||||
@@ -62,10 +64,14 @@ week: [1, 3, 16]
|
||||
|
||||
**参考答案**:在调用链上传递取消信号、截止时间和请求范围的值。HTTP 请求结束或超时,所有带这个 context 的数据库查询、下游调用都会及时停止,避免资源浪费。规范:作为第一个参数传递、不要存进结构体、不要传 nil(用 `context.TODO()`)、`WithValue` 只放请求范围的元数据(如请求 ID),不放业务参数。
|
||||
|
||||
**追问**:context 里适合存什么值?→ 只存请求范围的元数据(请求 ID、用户身份、追踪信息),不要用它传递业务参数;key 用自定义的未导出类型,避免冲突。
|
||||
|
||||
### 【进阶】什么是数据竞争?怎么发现和避免?
|
||||
|
||||
**参考答案**:多个 goroutine 同时访问同一变量且至少一个是写,又没有同步。用 `go test -race` / `go run -race` 发现(只能发现实际执行到的路径)。避免:用 channel 传递所有权、`sync.Mutex` / `RWMutex` 加锁、`sync/atomic` 原子操作;并发读写 map 会直接 fatal。
|
||||
|
||||
**追问**:`-race` 能在生产环境开吗?→ 一般不开:会显著增加 CPU 和内存开销。在测试和预发环境开启,CI 中跑 `go test -race`。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】一个接口要并发调用 3 个下游服务,整体 2 秒超时,任一失败就返回错误,怎么写?
|
||||
@@ -83,3 +89,5 @@ week: [1, 3, 16]
|
||||
### 【场景题】服务发布时如何做到不丢请求?
|
||||
|
||||
**参考答案**:优雅关机:监听 SIGTERM,调用 `http.Server.Shutdown(ctx)` 停止接收新连接并等待处理中的请求完成(带超时);后台 worker 通过 context 收到取消信号,处理完当前任务再退出;最后关闭数据库连接池。部署侧配合负载均衡的健康检查,先摘流量再停进程。在本计划第 1 周第 7 天实现过。
|
||||
|
||||
**追问**:Kubernetes 环境下还要注意什么?→ 收到 SIGTERM 后先让就绪探针失败,等待负载均衡摘掉流量(或配置 preStop 等待),再关闭服务;`terminationGracePeriodSeconds` 要大于程序的优雅退出超时。
|
||||
@@ -18,46 +18,68 @@ week: [14, 16]
|
||||
|
||||
**参考答案**:gRPC 基于 HTTP/2,用 Protobuf 二进制序列化,有 IDL 和代码生成,强类型契约,支持流式调用;REST 通常基于 HTTP/1.1 + JSON,可读性好、浏览器原生支持、生态通用。内部服务之间、追求性能和契约时用 gRPC;对外开放、前端直接调用用 REST。
|
||||
|
||||
**追问**:浏览器能直接调用 gRPC 吗?→ 不能直接调用(浏览器无法完全控制 HTTP/2 帧),需要 gRPC-Web + 代理,或者网关把 gRPC 转成 HTTP/JSON(如 grpc-gateway)。
|
||||
|
||||
### 【基础】gRPC 有哪几种调用方式?
|
||||
|
||||
**参考答案**:一元调用(一请求一响应)、服务端流(一请求多响应,如推送进度)、客户端流(多请求一响应,如分片上传)、双向流(如聊天)。
|
||||
|
||||
**追问**:流式调用适合什么场景?→ 服务端流适合推送进度、大模型逐字输出;客户端流适合上传大量数据;双向流适合实时交互。流式调用的负载均衡和重试比一元调用复杂。
|
||||
|
||||
### 【基础】Protobuf 字段编号有什么规则?
|
||||
|
||||
**参考答案**:编号决定序列化后的字段标识,发布后不能修改、不能复用;删除字段用 `reserved` 保留编号和名字;新增字段使用新编号,对老客户端兼容(老客户端会忽略未知字段)。1~15 编码只占一个字节,留给高频字段。
|
||||
|
||||
**追问**:删除一个字段要怎么做?→ 不要复用它的编号和名称,用 `reserved` 声明保留,防止以后被误用导致新旧版本解析错乱。
|
||||
|
||||
### 【基础】微服务相比单体有什么优缺点?
|
||||
|
||||
**参考答案**:优点是独立部署、独立扩容、技术栈可以不同、故障隔离、团队可以独立开发。缺点是网络调用带来的延迟和失败、分布式事务和数据一致性、运维和监控复杂度、调试和排查困难。团队小、业务早期通常模块化单体更合适。
|
||||
|
||||
**追问**:团队只有几个人,适合微服务吗?→ 一般不适合。先做模块化单体(模块边界清晰、独立的数据访问层),等团队和业务规模需要独立部署和扩展时再拆。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】什么是服务雪崩?怎么防止?
|
||||
|
||||
**参考答案**:一个下游服务变慢,调用方的线程 / 连接被占住,进而调用方也变慢,最终一层层传导导致整个系统不可用。防止:所有调用设超时;熔断故障的下游;限流保护自己;降级非核心功能;隔离资源(不同下游用不同的连接池 / 并发限制);重试要有限次数并退避。
|
||||
|
||||
**追问**:限流和熔断的区别?→ 限流保护自己,限制进入的请求量;熔断保护调用方,下游故障时快速失败,不再持续调用。
|
||||
|
||||
### 【进阶】讲一下熔断器的状态机。
|
||||
|
||||
**参考答案**:关闭状态正常放行并统计失败;失败达到阈值(次数或比例)后打开,直接返回失败;冷却时间后进入半开,放少量请求试探;试探成功则关闭,失败则重新打开。只把下游故障(超时、不可用)计为失败,业务错误不算。
|
||||
|
||||
**追问**:半开状态放行多少请求?→ 少量探测请求(一个或几个),成功率达标才关闭熔断器,否则重新打开;具体阈值根据业务配置。
|
||||
|
||||
### 【进阶】重试要注意什么?
|
||||
|
||||
**参考答案**:只重试可重试的错误(如 `Unavailable`)和幂等操作;指数退避加随机抖动;限制次数,并受整体截止时间约束;只在一层做重试,避免逐层放大;熔断打开时不重试;非幂等操作需要幂等键。
|
||||
|
||||
**追问**:什么样的请求可以重试?→ 幂等的请求,以及明确可重试的错误(超时、连接失败、`UNAVAILABLE`);非幂等请求要配合幂等键才能重试。
|
||||
|
||||
### 【进阶】分布式事务有哪些方案?怎么选?
|
||||
|
||||
**参考答案**:2PC 强一致但性能差、协调者单点,很少用于互联网业务;TCC 通过 Try/Confirm/Cancel 预留资源,一致性较好但业务侵入大;Saga 把长事务拆成本地事务 + 补偿,适合流程长的业务;可靠消息(发件箱 + MQ + 幂等)实现最终一致,最常用。一般优先“可靠消息 + 对账”,对资金等强一致场景再考虑 TCC。
|
||||
|
||||
**追问**:Saga 的补偿失败怎么办?→ 补偿操作本身要幂等、可重试;多次失败后进入人工处理队列并告警,所有步骤记录状态便于对账。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】订单服务调用库存服务,库存服务偶尔超时,应该怎么处理?
|
||||
|
||||
**参考答案**:设置合理的超时(小于订单接口自身的超时);扣库存接口要求幂等(带订单号作为幂等键),这样超时后可以安全重试一两次;超时结果不确定时,不能直接认为失败——通过查询接口或后续对账确认;持续超时时熔断,下单接口返回“系统繁忙”;排查库存服务本身的慢查询或资源问题。
|
||||
|
||||
**追问**:超时时间怎么设置?→ 根据下游的 P99 延迟设定,并且上游总超时要大于下游超时之和(或通过 deadline 传递剩余时间),避免上游已经放弃,下游还在执行。
|
||||
|
||||
### 【场景题】团队想把单体系统改成微服务,你怎么推进?
|
||||
|
||||
**参考答案**:先明确要解决的问题,能用模块化、读写分离、缓存解决的不急于拆;选边界清晰、需求明确的模块先拆;用绞杀者模式:先在单体内抽接口,再实现远程服务,用开关灰度切流,随时可回退;数据分库通过双写或 CDC 迁移并校验;同时建设超时熔断、链路追踪、集中日志等基础设施。详见[单体拆分场景](scenarios/monolith-split.md)。
|
||||
|
||||
**追问**:拆分时数据怎么迁移?→ 先拆代码不拆库,再拆库:新服务通过接口访问数据;数据迁移用双写或 CDC 同步,校验一致后切换读,再切换写。
|
||||
|
||||
### 【场景题】一个请求经过 5 个服务,用户反馈偶尔很慢,怎么定位?
|
||||
|
||||
**参考答案**:靠链路追踪:入口生成 trace ID,各服务通过 OpenTelemetry 记录 span 并传递上下文,在追踪系统里按耗时筛选慢请求,看是哪一个 span 慢;再结合该服务的日志(带 trace ID)和指标(CPU、GC、连接池、数据库慢查询)找原因。没有追踪系统时,至少要在日志里统一打印请求 ID。
|
||||
|
||||
**追问**:没有链路追踪系统怎么办?→ 至少在入口生成请求 ID,所有服务透传并写进日志,用日志平台按请求 ID 搜索,并在每个服务记录调用耗时。
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: backend.java-spring.index
|
||||
title: Java / Spring Boot(了解级)
|
||||
type: index
|
||||
domain: backend
|
||||
stack: [java-spring]
|
||||
tags: []
|
||||
week: [14]
|
||||
---
|
||||
|
||||
# Java / Spring Boot(了解级)
|
||||
|
||||
了解级:全栈岗位常见要求,能读懂代码、知道分层和常用注解。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: backend.mongodb.index
|
||||
title: MongoDB
|
||||
type: index
|
||||
domain: backend
|
||||
stack: [mongodb]
|
||||
tags: []
|
||||
week: [14]
|
||||
---
|
||||
|
||||
# MongoDB
|
||||
|
||||
文档模型、查询与聚合、索引、何时选 MongoDB 而不是关系库。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -18,46 +18,68 @@ week: [14, 16]
|
||||
|
||||
**参考答案**:异步(非核心步骤不阻塞主流程,接口更快)、解耦(新增下游不用改上游)、削峰(突发流量先进队列,按能力消费)。问题:系统更复杂,多了一个要维护的组件;消息可能重复、丢失、乱序,要处理;数据变成最终一致;排查链路变长。
|
||||
|
||||
**追问**:什么情况下不该用消息队列?→ 调用方需要立即拿到结果、链路简单、数据量小的场景,用同步调用更简单;引入 MQ 的复杂度要和收益匹配。
|
||||
|
||||
### 【基础】RabbitMQ 有哪几种交换机?
|
||||
|
||||
**参考答案**:direct(routing key 完全匹配)、topic(通配符匹配,`*` 匹配一个词、`#` 匹配零个或多个词)、fanout(广播给所有绑定队列)、headers(按消息头匹配)。
|
||||
|
||||
**追问**:topic 交换机中 `order.*` 和 `order.#` 有什么区别?→ `*` 只匹配一个词,`order.*` 匹配 `order.created`,不匹配 `order.paid.notify`;`#` 匹配零个或多个词,两者都匹配。
|
||||
|
||||
### 【基础】什么是死信队列?
|
||||
|
||||
**参考答案**:消息被拒绝且不重新入队、TTL 过期、队列达到长度上限时,会被转发到队列配置的死信交换机,再路由到死信队列。用于集中处理失败消息,也可以和 TTL 组合实现延迟消息。
|
||||
|
||||
**追问**:死信队列里的消息怎么处理?→ 告警 + 人工分析原因;修复后由运维工具重新投递到原队列;记录处理结果,避免无限循环投递。
|
||||
|
||||
### 【基础】Kafka 的分区和消费组是什么关系?
|
||||
|
||||
**参考答案**:主题分成多个分区;同一个消费组里,一个分区同一时间只能被一个消费者消费,一个消费者可以消费多个分区;不同消费组互不影响、各自消费全部消息。所以组内并行度上限是分区数。
|
||||
|
||||
**追问**:消费者数量多于分区数会怎样?→ 多出来的消费者空闲,不会分到分区;想提高并行度要增加分区数(Kafka 分区只能增加不能减少,且会影响按 key 的顺序)。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】怎么保证消息不丢失?
|
||||
|
||||
**参考答案**:分三段看。生产端:开启确认(RabbitMQ publisher confirm、Kafka `acks=all`),失败重试,必要时用发件箱模式保证和数据库事务一致。broker:持久化 + 多副本(RabbitMQ quorum 队列;Kafka 副本数 >=3、`min.insync.replicas>=2`)。消费端:处理完成后再手动 ack / 提交位移。这样是“至少一次”,消费端还要幂等。
|
||||
|
||||
**追问**:发件箱模式的轮询会不会重复投递?→ 会,所以是“至少一次”;消费端必须幂等。多实例轮询时用 `FOR UPDATE SKIP LOCKED` 避免重复取同一批。
|
||||
|
||||
### 【进阶】怎么保证消费幂等?
|
||||
|
||||
**参考答案**:利用业务状态的条件更新(如 `WHERE status='pending'`);或者用消息 ID 去重表,在同一个事务里插入去重记录(唯一约束)并执行业务;或者在业务表上建唯一约束(如同一订单只发一次积分)。Redis 去重可以作为前置过滤,但关键业务要以数据库约束为准。
|
||||
|
||||
**追问**:唯一约束和 Redis 去重怎么选?→ 数据库唯一约束(和业务操作同一个事务)最可靠;Redis 去重性能高但和业务操作不在一个事务里,要处理“记录了去重但业务失败”的情况。
|
||||
|
||||
### 【进阶】怎么保证消息顺序?
|
||||
|
||||
**参考答案**:Kafka 只保证分区内有序,把需要有序的消息用同一个 key(如订单 ID)发到同一分区,消费端单线程处理该分区或按 key 分发到固定线程。RabbitMQ 同一个队列单个消费者时有序,多个消费者并发时无序,可按 key 拆到多个队列。另外也可以在消息里带版本号 / 状态,消费端丢弃过期的消息,降低对顺序的依赖。
|
||||
|
||||
**追问**:全局严格有序需要怎么做?→ 只能用单分区、单消费者,吞吐会受限;绝大多数业务只需要“同一订单 / 同一用户”内有序,按 key 分区即可。
|
||||
|
||||
### 【进阶】Kafka 为什么吞吐量高?
|
||||
|
||||
**参考答案**:顺序写磁盘(追加日志);利用操作系统页缓存;零拷贝(sendfile)发送数据给消费者;生产和消费都支持批量和压缩;分区让读写可以分布到多台 broker 并行。
|
||||
|
||||
**追问**:Kafka 的消息会丢吗?→ 配置不当会:`acks=1` 时 leader 宕机可能丢;消费端先提交位移再处理,处理失败会丢。可靠配置见“怎么保证消息不丢失”。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】下单后要扣库存、发积分、发通知,怎么保证订单和消息的一致性?
|
||||
|
||||
**参考答案**:用发件箱模式:订单和待发消息在同一事务里写入数据库,后台程序(或 CDC)把消息投递到 MQ,成功后标记;下游消费者幂等。这样订单提交了消息一定会发出(至少一次)。如果是强一致的跨服务操作(比如扣库存必须和下单同时成功),可以用 TCC 或 Saga 补偿,但复杂度高,大部分业务用“可靠消息 + 最终一致 + 对账”。
|
||||
|
||||
**追问**:为什么不直接在业务事务里发消息?→ 发消息是网络调用,事务提交和消息发送无法原子化:先发后提交可能发了消息事务却回滚,先提交后发可能发送失败。发件箱表把消息和业务数据放在同一个事务里。
|
||||
|
||||
### 【场景题】线上消息积压了几百万条,怎么处理?
|
||||
|
||||
**参考答案**:先看消费者是否正常(是否卡在某条毒消息、下游是否变慢、是否频繁再均衡)。修复问题后扩容消费者(Kafka 要看分区数是否够),提高消费并发、批量处理。积压特别大时,写一个临时程序把消息快速转存到分区更多的新主题,用大量消费者并行处理。同时评估:过期没有意义的消息(如过时的通知)可以跳过。事后加上积压监控告警。
|
||||
|
||||
**追问**:临时扩容消费者没用是为什么?→ 消费者数量已达到分区数上限,或者瓶颈在下游(数据库、第三方接口)。要先找到真正的瓶颈。
|
||||
|
||||
### 【场景题】订单 30 分钟未支付自动取消,怎么设计?
|
||||
|
||||
**参考答案**:RabbitMQ 用 TTL + 死信队列(或延迟插件)发送延迟消息;消费者用条件更新 `WHERE status='pending'` 取消并在同一事务回补库存,和支付回调天然互斥、重复消息幂等;保留低频的兜底扫描任务防止消息丢失。详见[延迟取消场景](scenarios/delayed-order-cancel.md)。
|
||||
|
||||
**追问**:订单在取消前被支付了怎么办?→ 取消时用条件更新(`WHERE status = '待支付'`),状态已变则什么都不做;支付回调同样用条件更新,两者竞争时只有一个成功。
|
||||
@@ -20,6 +20,8 @@ week: [2, 14, 16]
|
||||
|
||||
**参考答案**:B+ 树的非叶子节点只存键,一个页能放更多键,树更矮,磁盘 IO 次数少;所有数据在叶子节点且叶子之间用链表相连,范围查询和排序高效。红黑树是二叉树,数据多时太高;哈希只支持等值查询,不支持范围和排序。
|
||||
|
||||
**追问**:一棵 3 层的 B+ 树大约能存多少行?→ 粗略估算:16KB 页,非叶子节点每条约十几字节,可放一千多个指针;叶子页按每行 1KB 放 16 行,约 1000 × 1000 × 16 ≈ 两千万行。这是数量级估算,实际取决于行大小。
|
||||
|
||||
### 【基础】什么是聚簇索引、回表和覆盖索引?
|
||||
|
||||
**参考答案**:InnoDB 的主键索引叶子节点存整行数据,叫聚簇索引;二级索引叶子节点存索引列和主键值。通过二级索引找到主键再去主键索引取整行叫回表。如果查询需要的列都在二级索引里,就不用回表,叫覆盖索引(EXPLAIN 的 Extra 显示 Using index)。
|
||||
@@ -30,42 +32,62 @@ week: [2, 14, 16]
|
||||
|
||||
**参考答案**:联合索引 `(a, b, c)` 先按 a 排序,a 相同再按 b,依此类推。查询条件要从 a 开始连续使用才能利用索引定位;只用 b 或 c 用不上;中间遇到范围条件(如 `a > 1`)后,后面的列一般不能再用于定位(可以靠索引下推过滤)。
|
||||
|
||||
**追问**:`WHERE b = 1 AND a = 2` 能用上 (a, b) 索引吗?→ 能,优化器会调整条件顺序;最左前缀看的是条件中是否包含最左列,不看书写顺序。
|
||||
|
||||
### 【基础】有哪些常见的索引失效情况?
|
||||
|
||||
**参考答案**:索引列上用函数或运算;隐式类型转换(字符串列用数字比较);`LIKE` 以 `%` 开头;不满足最左前缀;`OR` 连接的列有的没索引;优化器判断走索引不如全表扫描(区分度低、要扫的行太多)。
|
||||
|
||||
**追问**:怎么确认索引有没有用上?→ 用 `EXPLAIN` 看 `type`、`key`、`rows`、`Extra`;MySQL 8.0.18+ 可以用 `EXPLAIN ANALYZE` 看实际执行情况。
|
||||
|
||||
### 【基础】redo log、undo log、binlog 分别是什么?
|
||||
|
||||
**参考答案**:redo log 是 InnoDB 的物理日志,记录“某页做了什么修改”,保证崩溃后已提交的事务不丢(持久性);undo log 记录修改前的值,用于回滚和 MVCC 读旧版本(原子性、隔离性);binlog 是 Server 层的逻辑日志,用于主从复制和按时间点恢复。redo log 和 binlog 用两阶段提交保证一致。
|
||||
|
||||
**追问**:为什么需要两阶段提交?→ redo log 和 binlog 是两份日志,必须保证一致:否则崩溃恢复后主库数据和从库(通过 binlog 复制)不一致。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】InnoDB 的可重复读是怎么实现的?能解决幻读吗?
|
||||
|
||||
**参考答案**:普通 SELECT 是快照读,事务第一次读时生成 Read View,之后一直用它判断数据版本是否可见,所以多次读结果一致,也看不到别人新插入的行。当前读(`FOR UPDATE`、`UPDATE`)读最新数据,通过临键锁(记录锁 + 间隙锁)阻止其他事务在范围内插入,从而避免幻读。但如果事务里先快照读、再当前读,仍可能看到不一致,所以不能说“完全解决”。
|
||||
|
||||
**追问**:间隙锁会带来什么问题?→ 锁住的范围比实际修改的行大,并发插入会被阻塞,也更容易死锁;如果业务能接受,可以使用读已提交隔离级别减少间隙锁。
|
||||
|
||||
### 【进阶】什么情况下会死锁?怎么排查和避免?
|
||||
|
||||
**参考答案**:两个事务以不同顺序锁定相同的资源,互相等待。InnoDB 会检测并回滚代价小的那个事务(错误 1213)。排查:`SHOW ENGINE INNODB STATUS` 的 LATEST DETECTED DEADLOCK 段,看两个事务各自持有和等待的锁。避免:固定加锁顺序(如按 id 排序)、缩小事务、更新条件走索引减少锁范围、必要时降低到读已提交减少间隙锁;应用层捕获死锁并重试。
|
||||
|
||||
**追问**:死锁发生后 MySQL 怎么处理?→ InnoDB 检测到死锁后回滚代价较小的事务,另一个继续执行;应用层要捕获死锁错误并重试整个事务。
|
||||
|
||||
### 【进阶】主从延迟的原因和处理方式?
|
||||
|
||||
**参考答案**:原因:大事务、从库重放并行度低、从库负载高或配置差、网络。处理:拆分大事务、开启并行复制、关键读走主库(写后一段时间内读主库)、监控延迟超过阈值时把读流量切回主库。
|
||||
|
||||
**追问**:刚写入就读不到,业务上怎么处理?→ 写后立即读的请求走主库(或一段时间内该用户的读走主库);或者前端直接使用写接口的返回结果。
|
||||
|
||||
### 【进阶】什么时候需要分库分表?分片键怎么选?
|
||||
|
||||
**参考答案**:单表数据量大到索引优化后仍然慢、或单库写入成为瓶颈,并且归档、缓存、读写分离都用过之后再考虑。分片键选最常用的查询维度(如用户 ID),让大部分请求只访问一个分片,并且数据分布均匀。代价是跨分片查询、分页、事务、全局 ID 和扩容迁移都变复杂。
|
||||
|
||||
**追问**:分库分表后跨分片查询怎么办?→ 尽量通过分片键路由;后台统计类查询同步到 ES 或数仓;避免跨分片 JOIN 和分布式事务。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】“先查库存再扣减”在高并发下超卖了,怎么改?
|
||||
|
||||
**参考答案**:查和扣两步之间有并发窗口。改成一条条件更新:`UPDATE stock SET num = num - 1 WHERE sku_id = ? AND num >= 1`,判断影响行数;或者 `SELECT ... FOR UPDATE` 加锁后再扣。并发非常高时(秒杀),数据库行锁会成为瓶颈,在 Redis 里预扣库存、消息队列异步落库(见 Redis 专题的库存场景和跨栈场景“秒杀”)。
|
||||
|
||||
**追问**:用乐观锁还是 `UPDATE ... WHERE stock >= 1`?→ 扣库存这种只需要一个条件的场景,直接条件更新最简单;需要读出数据做复杂计算后再写入的,用版本号乐观锁。
|
||||
|
||||
### 【场景题】一张几千万行的表,分页越往后越慢,怎么优化?
|
||||
|
||||
**参考答案**:`LIMIT offset` 需要扫描并丢弃前面的行。改为游标分页(记住上一页最后一条的排序键和 id);需要跳页时用延迟关联并限制最大页数;导出走异步任务;历史数据归档。详见[深分页场景](scenarios/deep-pagination.md)。
|
||||
|
||||
**追问**:用户要求可以跳转到任意页,怎么办?→ 深分页限制最大页数;或者用延迟关联(先在覆盖索引上查主键,再回表);超深分页的需求通常可以引导为搜索 / 筛选。
|
||||
|
||||
### 【场景题】线上一个接口突然变慢,数据库 CPU 很高,你怎么排查?
|
||||
|
||||
**参考答案**:先 `SHOW PROCESSLIST` 看正在执行的慢语句和状态(是否大量 Waiting for lock);查慢查询日志按总耗时排序;对可疑 SQL 做 EXPLAIN,看是不是最近上线的新语句、索引被删、统计信息不准导致走错索引;有锁等待时查 `information_schema` / `performance_schema` 中的锁信息找到阻塞源。处理上可以先限流或下线问题功能止血,再加索引或改 SQL,最后复盘加监控。
|
||||
|
||||
**追问**:怎么防止慢查询再次发生?→ 上线前审查 SQL 和 `EXPLAIN`;开启慢查询日志并告警;对大表的变更进行评审。
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: backend.nodejs.index
|
||||
title: Node.js 后端(Express / NestJS)
|
||||
type: index
|
||||
domain: backend
|
||||
stack: [nodejs]
|
||||
tags: []
|
||||
week: [7, 14]
|
||||
---
|
||||
|
||||
# Node.js 后端(Express / NestJS)
|
||||
|
||||
了解级:前端出身的后端选项,Express 中间件、NestJS 模块与依赖注入。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -20,22 +20,32 @@ week: [2, 16]
|
||||
|
||||
**参考答案**:`WHERE` 在分组前过滤行,不能使用聚合函数;`HAVING` 在 `GROUP BY` 之后过滤分组,可以使用聚合函数。能用 `WHERE` 过滤的尽量用 `WHERE`,减少参与分组的数据量。
|
||||
|
||||
**追问**:`WHERE` 中能使用 SELECT 里定义的别名吗?→ 不能,因为 `WHERE` 在 `SELECT` 之前执行;`ORDER BY` 可以使用别名。
|
||||
|
||||
### 【基础】`INNER JOIN` 和 `LEFT JOIN` 的区别?LEFT JOIN 的条件写在 ON 和 WHERE 里有什么不同?
|
||||
|
||||
**参考答案**:内连接只保留两边都匹配的行;左连接保留左表所有行,右表没匹配的列为 NULL。右表的过滤条件写在 `ON` 里时,不匹配的左表行仍然保留;写在 `WHERE` 里会把右表为 NULL 的行过滤掉,效果等同内连接。
|
||||
|
||||
**追问**:怎么查询“没有下过订单的用户”?→ `LEFT JOIN orders ... WHERE orders.id IS NULL`,或者 `NOT EXISTS (SELECT 1 FROM orders WHERE ...)`。
|
||||
|
||||
### 【基础】为什么 `NOT IN` 遇到 NULL 会出问题?
|
||||
|
||||
**参考答案**:`x NOT IN (a, b, NULL)` 等价于 `x <> a AND x <> b AND x <> NULL`,而 `x <> NULL` 的结果是 UNKNOWN,整个条件永远不为真,查询返回空。用 `NOT EXISTS` 或先过滤掉 NULL。
|
||||
|
||||
**追问**:`NOT EXISTS` 为什么没有这个问题?→ `NOT EXISTS` 只判断子查询是否有行,不对值做三值逻辑比较,NULL 不会影响结果。
|
||||
|
||||
### 【基础】金额应该用什么类型?时间呢?
|
||||
|
||||
**参考答案**:金额用 `NUMERIC(p, s)` 或以“分”为单位的整数,不用浮点数(有精度误差)。时间用 `TIMESTAMPTZ`,统一存 UTC,展示时按用户时区转换。
|
||||
|
||||
**追问**:`timestamptz` 存储的是什么?→ 存储的是 UTC 时间点,显示时按会话时区转换;`timestamp`(不带时区)只是一个“墙上时间”,不知道是哪个时区。
|
||||
|
||||
### 【基础】什么是 UPSERT?为什么比“先查再插”安全?
|
||||
|
||||
**参考答案**:`INSERT ... ON CONFLICT (唯一键) DO UPDATE / DO NOTHING`,一条语句完成“有则更新、无则插入”。它依赖唯一约束在数据库内部原子判断;“先查再插”在并发下两个请求都可能查不到然后都插入。
|
||||
|
||||
**追问**:`ON CONFLICT DO NOTHING` 怎么知道是否插入了?→ 加 `RETURNING id`,没有返回行就说明冲突了。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】PostgreSQL 的 MVCC 是怎么实现的?VACUUM 是干什么的?
|
||||
@@ -48,24 +58,36 @@ week: [2, 16]
|
||||
|
||||
**参考答案**:默认读已提交。可重复读下,事务看到的是开始时的快照;如果要更新的行在快照之后被其他事务修改并提交了,会报 `could not serialize access due to concurrent update`(SQLSTATE 40001),应用需要重试整个事务。
|
||||
|
||||
**追问**:可重复读下序列化失败怎么处理?→ 应用层捕获错误码 `40001`,重试整个事务;事务尽量短,减少冲突。
|
||||
|
||||
### 【进阶】一条 SQL 很慢,你怎么排查?
|
||||
|
||||
**参考答案**:先用 `EXPLAIN (ANALYZE, BUFFERS)` 看执行计划:是不是全表扫描、预估行数和实际行数是否差距大(统计信息过时就 `ANALYZE`)、哪个节点耗时最多。常见优化:按查询条件建联合索引(注意最左前缀和排序方向)、避免在索引列上用函数、用覆盖索引、改写子查询、分页改成游标(按上一页最后一个 ID 继续)。线上用 `pg_stat_statements` 找出总耗时最高的语句。
|
||||
|
||||
**追问**:`EXPLAIN` 和 `EXPLAIN ANALYZE` 的区别?→ 前者只显示计划和估算;后者真正执行 SQL 并显示实际行数和耗时。对写操作使用 `EXPLAIN ANALYZE` 要放在事务里回滚。
|
||||
|
||||
### 【进阶】`FOR UPDATE SKIP LOCKED` 有什么用?
|
||||
|
||||
**参考答案**:选中并锁定行,遇到已被其他事务锁住的行直接跳过。适合“多个 worker 并发从任务表取任务”:每个 worker 拿到不同的任务,互不阻塞,不需要额外的消息队列。
|
||||
|
||||
**追问**:除了任务队列还能用在哪?→ 多个工作进程分配待处理的数据(如待发送通知、待同步记录),每个进程取到不同的行,互不阻塞。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】订单表有几千万行,按用户查最近订单的分页越往后越慢,怎么优化?
|
||||
|
||||
**参考答案**:`OFFSET` 越大,数据库要扫描并丢弃的行越多。改成游标分页(keyset):`WHERE user_id = ? AND (created_at, id) < (上一页最后一条的值) ORDER BY created_at DESC, id DESC LIMIT 20`,配合 `(user_id, created_at DESC, id DESC)` 联合索引,每页都是索引范围扫描。代价是不能直接跳到第 N 页,通常用“加载更多”交互。更大规模时按时间分区,历史数据归档。
|
||||
|
||||
**追问**:游标分页怎么支持“上一页”?→ 用当前页第一条的排序值反向查询(条件和排序方向都反过来),再把结果倒序。
|
||||
|
||||
### 【场景题】线上要给一张大表加索引和加字段,怎么做才不影响业务?
|
||||
|
||||
**参考答案**:索引用 `CREATE INDEX CONCURRENTLY`(不阻塞写,不能在事务里执行,失败要删掉无效索引重建)。加字段:PostgreSQL 11 起加带常量默认值的列不需要重写表,很快;但要注意 DDL 需要短暂的排他锁,如果前面有长事务会排队并阻塞后续查询,所以设置 `lock_timeout`,在低峰期执行。迁移脚本要向后兼容:先加字段、发新代码,再清理旧字段。
|
||||
|
||||
**追问**:`CREATE INDEX CONCURRENTLY` 失败了怎么办?→ 会留下一个标记为 INVALID 的索引,要先删除再重新创建;它不能在事务块里执行。
|
||||
|
||||
### 【场景题】用户和支付回调同时修改订单状态,怎么保证状态正确?
|
||||
|
||||
**参考答案**:状态流转规则集中定义,修改用带版本号(或旧状态)的条件 UPDATE 做乐观锁,影响行数为 0 时视为冲突,重新读取最新状态再决定;审计日志与状态修改放在同一事务。冲突频繁的场景可以用 `SELECT ... FOR UPDATE` 排队。详见本技术栈的[订单状态机场景](scenarios/order-state-audit.md)。
|
||||
|
||||
**追问**:为什么不用 `SELECT ... FOR UPDATE` 悲观锁?→ 也可以,适合冲突很多的场景;冲突少时乐观锁或条件更新不需要持有锁,并发更好。关键是状态迁移要有条件判断。
|
||||
@@ -18,24 +18,34 @@ week: [3, 14, 16]
|
||||
|
||||
**参考答案**:数据在内存里;核心命令执行是单线程的,没有锁竞争和线程切换;使用 IO 多路复用(epoll)同时处理大量连接;数据结构针对场景做了优化(如压缩列表、跳表)。Redis 6 起网络读写可以多线程,命令执行仍是单线程。
|
||||
|
||||
**追问**:Redis 6 的多线程是怎么回事?→ 多线程只用于网络读写(解析请求、写回响应),命令执行仍然是单线程,所以命令的原子性不变。
|
||||
|
||||
### 【基础】Redis 有哪些数据类型?各举一个使用场景。
|
||||
|
||||
**参考答案**:String(缓存、计数器、验证码)、Hash(对象、购物车)、List(最新消息列表)、Set(标签、去重、共同关注)、ZSet(排行榜、延迟队列)、Stream(轻量消息队列);另有 Bitmap(签到)、HyperLogLog(UV 统计)、Geo(附近的人)。
|
||||
|
||||
**追问**:排行榜为什么用 ZSet?→ 有序集合按分数自动排序,插入和更新都是 O(log N),可以直接按排名范围查询。
|
||||
|
||||
### 【基础】过期的 key 是怎么删除的?内存满了怎么办?
|
||||
|
||||
**参考答案**:惰性删除(访问时发现过期就删)+ 定期删除(周期性抽样检查过期 key)。内存达到 `maxmemory` 时按淘汰策略处理:`noeviction`(拒绝写入)、`allkeys-lru`、`allkeys-lfu`、`volatile-lru`(只淘汰设置了过期的 key)等,缓存场景常用 `allkeys-lru` / `allkeys-lfu`。
|
||||
|
||||
**追问**:大 key 有什么危害?→ 读写和删除都会阻塞主线程、占用网络带宽、集群中导致数据倾斜。删除大 key 用 `UNLINK`(异步删除),并拆分数据结构。
|
||||
|
||||
### 【基础】RDB 和 AOF 的区别?
|
||||
|
||||
**参考答案**:RDB 定时生成内存快照,文件小、恢复快,但可能丢失最后一次快照之后的数据;AOF 追加记录写命令,可配置每秒刷盘,丢数据更少,但文件更大,需要重写压缩。生产常两者都开,Redis 4 起支持混合持久化。
|
||||
|
||||
**追问**:生产环境一般怎么配置持久化?→ 常见是开启 AOF(`everysec`)+ RDB 备份,Redis 4.0+ 可以使用混合持久化;只做缓存的场景可以不开持久化。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】缓存穿透、击穿、雪崩分别是什么?怎么解决?
|
||||
|
||||
**参考答案**:穿透是查询不存在的数据,每次都打到数据库——缓存空值、参数校验、布隆过滤器。击穿是热点 key 过期瞬间大量请求同时回源——互斥锁 / singleflight 只让一个请求回源,或逻辑过期后台刷新。雪崩是大量 key 同时过期或 Redis 不可用——过期时间加随机值、Redis 高可用、服务限流降级。
|
||||
|
||||
**追问**:布隆过滤器有什么缺点?→ 存在误判(判断存在时可能不存在),不能删除元素(普通布隆过滤器);数据变化时需要重建。
|
||||
|
||||
### 【进阶】怎么保证缓存和数据库的一致性?
|
||||
|
||||
**参考答案**:常用旁路缓存:先更新数据库,再删除缓存。删除而不是更新,是为了避免并发写导致缓存写入旧值。仍有极小概率不一致,可以用延迟双删、订阅 binlog 异步删除、短 TTL 兜底。缓存只能做到最终一致,要求强一致的数据(余额、库存最终值)以数据库为准。
|
||||
@@ -46,20 +56,30 @@ week: [3, 14, 16]
|
||||
|
||||
**参考答案**:`SET key 随机令牌 NX PX 过期时间` 加锁;用 Lua 脚本比较令牌后删除来解锁,防止误删别人的锁。问题:业务执行时间超过过期时间会导致两个持有者同时存在(续期 / 看门狗);主从异步复制,主节点宕机时锁可能丢失(Redlock 有争议);所以关键业务还要用数据库唯一约束、乐观锁等做最终保证。
|
||||
|
||||
**追问**:锁过期了业务还没执行完怎么办?→ 自动续期(看门狗);业务本身做好幂等和数据库层面的条件更新,不能完全依赖锁的正确性。
|
||||
|
||||
### 【进阶】Redis 的主从、哨兵、集群分别解决什么问题?
|
||||
|
||||
**参考答案**:主从复制解决读扩展和数据冗余;哨兵(Sentinel)监控主节点,故障时自动选新主,解决高可用;Cluster 把数据按 16384 个槽分到多个主节点,解决容量和写扩展,每个主节点也可以有从节点。Cluster 下多 key 操作(包括 Lua)要求 key 在同一个槽,可以用哈希标签 `{}`。
|
||||
|
||||
**追问**:集群模式下多个 key 的操作有什么限制?→ 多 key 命令和 Lua 脚本要求 key 在同一个槽,用哈希标签 `{order}:1` 让相关 key 落在同一个槽。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】秒杀场景怎么防止超卖和重复购买?
|
||||
|
||||
**参考答案**:库存提前加载到 Redis;Lua 脚本原子完成“是否已购买 → 库存是否充足 → 扣减 → 记录用户”;成功后发 MQ 异步创建订单,数据库唯一约束兜底;失败或超时未支付回补库存;前端、网关层层限流。详见[库存预扣场景](scenarios/stock-reservation.md)。
|
||||
|
||||
**追问**:Redis 扣了库存但创建订单失败怎么办?→ 回补库存(补偿);或者 Redis 只做预扣,以数据库为准,异步对账修正。
|
||||
|
||||
### 【场景题】线上 Redis 突然变慢,怎么排查?
|
||||
|
||||
**参考答案**:`SLOWLOG GET` 看慢命令(`KEYS *`、大 key 的 `HGETALL` / `DEL`、复杂 Lua);`INFO` 看内存、连接数、命中率、是否在做 RDB / AOF 重写(fork 导致的卡顿);`--bigkeys` / `MEMORY USAGE` 找大 key;检查是否有热点 key 集中在一个节点。处理:改用 `SCAN`、`UNLINK`,拆分大 key,热点 key 加本地缓存。
|
||||
|
||||
**追问**:怎么找出慢命令?→ `SLOWLOG GET` 查看慢日志;`--bigkeys` / `MEMORY USAGE` 找大 key;`INFO` 看内存、客户端数、持久化状态。
|
||||
|
||||
### 【场景题】一个接口要按用户限流,每分钟最多 60 次,多实例部署,怎么做?
|
||||
|
||||
**参考答案**:用 Redis 共享计数。简单做法是固定窗口 `INCR` + 过期时间;要求精确时用 ZSET 滑动窗口(Lua 脚本里删除过期记录、计数、写入);允许突发用令牌桶。返回 429 并带上重试时间。要考虑 Redis 故障时的策略(放行还是拒绝)。详见[分布式锁和限流](03-lock-ratelimit.md)。
|
||||
|
||||
**追问**:固定窗口有什么问题?→ 窗口边界处可能出现 2 倍的突发请求(上一个窗口末尾和下一个窗口开头各 60 次),需要平滑时用滑动窗口或令牌桶。
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: cs.algorithms.index
|
||||
title: 数据结构与算法(面试)
|
||||
type: index
|
||||
domain: cs
|
||||
stack: [algorithms]
|
||||
tags: []
|
||||
week: [16]
|
||||
---
|
||||
|
||||
# 数据结构与算法(面试)
|
||||
|
||||
常考数据结构、复杂度、双指针、哈希、二分、BFS/DFS、动态规划入门。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: cs.network-os.index
|
||||
title: 网络与操作系统基础
|
||||
type: index
|
||||
domain: cs
|
||||
stack: [network-os]
|
||||
tags: []
|
||||
week: [14, 16]
|
||||
---
|
||||
|
||||
# 网络与操作系统基础
|
||||
|
||||
HTTP/HTTPS、TCP/IP、DNS、进程线程、内存、IO 模型。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: cs.web-security.index
|
||||
title: Web 安全
|
||||
type: index
|
||||
domain: cs
|
||||
stack: [web-security]
|
||||
tags: []
|
||||
week: [7, 14, 16]
|
||||
---
|
||||
|
||||
# Web 安全
|
||||
|
||||
XSS、CSRF、SQL 注入、CORS、鉴权与会话安全、大模型应用的提示词注入。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -18,38 +18,56 @@ week: [14, 16]
|
||||
|
||||
**参考答案**:CI(持续集成)是开发者频繁合并代码,每次合并自动执行检查、测试和构建,尽早发现集成问题;CD 包括持续交付(每次变更都自动产出可发布的产物,发布由人触发)和持续部署(自动发布到生产)。
|
||||
|
||||
**追问**:持续交付和持续部署有什么区别?→ 持续交付:每次合并后都能随时发布,但上线需要人工确认;持续部署:通过所有检查后自动上线。
|
||||
|
||||
### 【基础】一条典型的流水线包括哪些阶段?
|
||||
|
||||
**参考答案**:代码检查(格式、lint、类型检查)→ 单元测试和集成测试 → 构建(编译、打包、构建镜像)→ 安全扫描(依赖、镜像、密钥)→ 部署到测试环境 → (审批)→ 部署生产 → 部署后验证(健康检查、冒烟测试)。
|
||||
|
||||
**追问**:单元测试和集成测试放在流水线的哪个位置?→ 快的检查放前面(格式、静态检查、单元测试),失败尽早停止;集成测试和镜像扫描放在后面,可以并行。
|
||||
|
||||
### 【基础】流水线里的密钥怎么管理?
|
||||
|
||||
**参考答案**:存放在 CI 平台的 Secrets 或专门的密钥管理系统中,以环境变量注入,不写进仓库和镜像;按环境和最小权限划分;不要在日志里打印;来自外部 fork 的合并请求默认拿不到密钥;泄露后立即轮换。
|
||||
|
||||
**追问**:第三方 Action / 插件有什么安全风险?→ 可能读取到流水线的密钥。固定到具体的提交哈希或可信版本,最小化令牌权限,优先使用 OIDC 短期凭证。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】蓝绿发布、滚动发布、金丝雀发布有什么区别?
|
||||
|
||||
**参考答案**:滚动发布逐台替换实例,资源占用少,但新旧版本会同时在线,回滚也要逐台;蓝绿发布准备一整套新环境,验证后一次性切换流量,回滚即切回,但需要双倍资源;金丝雀发布先把小比例流量导到新版本,观察错误率等指标后逐步扩大,风险最小,需要流量控制和监控能力。
|
||||
|
||||
**追问**:金丝雀发布怎么判断能否继续放量?→ 对比新旧版本的错误率、延迟、关键业务指标,设定阈值;异常时自动或人工回滚。
|
||||
|
||||
### 【进阶】怎么让数据库变更也能回滚?
|
||||
|
||||
**参考答案**:迁移脚本版本化,由工具执行;每次变更保持向后兼容,使新旧代码都能在新表结构上运行;破坏性变更拆成多次发布:先扩展(加字段 / 表),再迁移数据和切换读写,最后确认不会回滚后再收缩(删旧字段)。大表变更用在线 DDL 工具,并提前在预发环境演练。
|
||||
|
||||
**追问**:删除字段为什么要分多次发布?→ 旧版本代码还在读这个字段,回滚时也需要它;先让代码不再使用,确认稳定后再删除字段(扩展 - 收缩模式)。
|
||||
|
||||
### 【进阶】流水线太慢,怎么优化?
|
||||
|
||||
**参考答案**:缓存依赖(按锁文件哈希作为 key);任务并行;按改动路径只运行相关部分;Docker 构建利用层缓存;把慢的端到端测试放到合并后或定时执行;拆分测试并行运行;用更快的执行机器。
|
||||
|
||||
**追问**:依赖缓存的 key 怎么设计?→ 用锁文件的哈希(`go.sum`、`uv.lock`、`package-lock.json`),依赖变化时缓存自动失效。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】新版本上线后错误率升高,你怎么处理?
|
||||
|
||||
**参考答案**:先止血:确认是否与本次发布相关(时间点吻合),是的话立即回滚到上一个镜像标签,或关闭对应的特性开关;同时保留日志和错误信息。恢复后再定位原因,补充测试,复盘为什么没有在测试阶段发现。前提是平时就有一键回滚的能力,并且数据库变更向后兼容。
|
||||
|
||||
**追问**:回滚之后还要做什么?→ 确认指标恢复;保留现场(日志、版本、变更内容);复盘根因,补充测试或告警,防止再次发生。
|
||||
|
||||
### 【场景题】你们团队的发布流程是怎样的?(如果之前是前端,可以怎么回答)
|
||||
|
||||
**参考答案**:按真实情况讲流程:合并请求触发 lint / 测试 / 构建,评审通过且检查通过后合并;合并后自动构建产物并部署到测试环境;生产发布由负责人触发,发布后观察监控,有问题回滚。前端可以讲:构建产物上传 CDN、`index.html` 不缓存、带哈希的资源长缓存、通过切换入口文件回滚。再补充你在转岗项目里实现的后端流水线(本专题的示例)。不要编造没做过的经历。
|
||||
|
||||
**追问**:没有参与过发布流程怎么办?→ 如实说明前端的发布方式(构建、上传静态资源、CDN 刷新、回滚),再讲在学习项目中搭建的后端流水线和自己的设计考虑。
|
||||
|
||||
### 【场景题】线上运行的是哪个版本的代码?怎么确认?
|
||||
|
||||
**参考答案**:镜像标签使用提交哈希或语义化版本,部署记录里保存每次部署的标签;服务在健康检查或版本接口里返回构建时注入的版本号和提交哈希(Go 用 `-ldflags "-X main.version=..."`);日志和错误上报里带上版本号。这样出问题时能准确定位到代码。
|
||||
|
||||
**追问**:镜像标签为什么不用 `latest`?→ `latest` 会变化,无法确定线上具体是哪个版本,回滚也不可靠;用 Git 提交哈希或版本号做标签。
|
||||
@@ -18,42 +18,62 @@ week: [3, 14, 16]
|
||||
|
||||
**参考答案**:虚拟机虚拟化硬件,每个虚拟机有自己完整的操作系统内核,隔离性强但启动慢、占用大;容器共享宿主机内核,靠 Linux 的 namespace 做隔离(进程、网络、文件系统等)、cgroups 限制资源,启动快、占用小,但隔离性比虚拟机弱。
|
||||
|
||||
**追问**:容器的隔离靠什么实现?→ Linux 的 namespace(隔离进程、网络、文件系统等视图)和 cgroups(限制 CPU、内存等资源);容器共享宿主机内核,隔离程度弱于虚拟机。
|
||||
|
||||
### 【基础】镜像和容器是什么关系?镜像为什么要分层?
|
||||
|
||||
**参考答案**:镜像是只读模板,容器是镜像运行起来的实例,在镜像之上加了一个可写层。分层让多个镜像可以共享相同的底层(节省磁盘和下载),构建时没有变化的层可以复用缓存。
|
||||
|
||||
**追问**:修改 Dockerfile 中间的一行,为什么后面的层都要重新构建?→ 每一层的缓存依赖它之前的所有层,前面的层变了,后面的缓存全部失效。所以把变化少的步骤(安装依赖)放在前面。
|
||||
|
||||
### 【基础】`CMD` 和 `ENTRYPOINT` 的区别?`COPY` 和 `ADD` 呢?
|
||||
|
||||
**参考答案**:`ENTRYPOINT` 指定容器启动时固定执行的程序;`CMD` 提供默认参数或默认命令,`docker run` 后面的参数会覆盖 `CMD`。两者都推荐使用 exec 形式(JSON 数组),程序作为 PID 1 才能收到信号。`COPY` 只复制本地文件;`ADD` 还能自动解压 tar 和下载 URL,行为不直观,一般用 `COPY`。
|
||||
|
||||
**追问**:`ENTRYPOINT` 用 shell 形式会有什么问题?→ shell 形式由 `/bin/sh -c` 启动,进程不是 PID 1,收不到 SIGTERM,无法优雅退出;应该使用 exec 形式(JSON 数组)。
|
||||
|
||||
### 【基础】容器里的数据怎么持久化?
|
||||
|
||||
**参考答案**:容器可写层的数据随容器删除而消失。持久化用数据卷(Docker 管理的命名卷,适合数据库)或绑定挂载(把宿主机目录挂进容器,适合开发时挂源码、挂配置文件)。
|
||||
|
||||
**追问**:命名卷和绑定挂载怎么选?→ 数据库数据用命名卷(由 Docker 管理,可移植);开发时挂载源代码用绑定挂载。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】怎么减小镜像体积、加快构建?
|
||||
|
||||
**参考答案**:多阶段构建,运行阶段只保留产物;选小的基础镜像(slim、alpine、distroless);把变化少的步骤(安装依赖)放前面,充分利用层缓存;合并 RUN 并在同一层清理缓存(如 `pip install --no-cache-dir`);用 `.dockerignore` 排除无关文件。
|
||||
|
||||
**追问**:多阶段构建为什么能减小体积?→ 编译工具和中间产物留在构建阶段,最终镜像只复制编译结果,基础镜像也可以用更小的 distroless / alpine。
|
||||
|
||||
### 【进阶】`docker stop` 时发生了什么?怎么做优雅退出?
|
||||
|
||||
**参考答案**:Docker 向容器的 PID 1 发送 SIGTERM,等待超时(默认 10 秒)后发送 SIGKILL。程序要监听 SIGTERM,停止接受新请求、处理完进行中的请求、关闭连接后退出。前提是程序本身是 PID 1(exec 形式启动),否则信号被 shell 吞掉。
|
||||
|
||||
**追问**:程序没有处理 SIGTERM 会怎样?→ 等待超时(默认 10 秒)后被 SIGKILL 强制杀掉,正在处理的请求会中断。
|
||||
|
||||
### 【进阶】Compose 中服务之间怎么通信?`depends_on` 能保证依赖服务已就绪吗?
|
||||
|
||||
**参考答案**:同一个 Compose 项目的服务在同一个网络里,用服务名作为主机名访问。`depends_on` 默认只保证启动顺序,不保证就绪;要配合 `healthcheck` 和 `condition: service_healthy`,应用本身也要有连接重试。
|
||||
|
||||
**追问**:怎么让应用等数据库真正就绪?→ 数据库配置 `healthcheck`,应用的 `depends_on` 使用 `condition: service_healthy`;应用本身也要有连接重试。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】容器启动后马上退出,怎么排查?
|
||||
|
||||
**参考答案**:`docker ps -a` 看退出码,`docker logs <容器>` 看输出。常见原因:主进程是后台进程(容器以前台进程为生命周期)、配置或环境变量缺失导致程序报错退出、二进制和基础镜像不兼容(CGO 程序放进 scratch)、权限问题(非 root 用户无权读文件)。可以用 `docker run -it --entrypoint sh 镜像` 进入容器手动执行排查。
|
||||
|
||||
**追问**:容器反复重启,日志一闪而过怎么办?→ 已退出的容器日志仍然可以用 `docker logs 容器ID`(`docker ps -a` 找到 ID)或 `docker compose logs 服务名` 查看;`docker inspect` 看退出码;也可以临时把启动命令改成 `sleep infinity` 进入容器手动启动程序排查。
|
||||
|
||||
### 【场景题】应用容器连不上数据库容器,怎么排查?
|
||||
|
||||
**参考答案**:确认连接地址用的是服务名而不是 localhost;两个容器是否在同一网络(`docker network inspect`);数据库是否真的就绪(健康检查、日志);端口是否是容器内端口而不是宿主机映射端口;用户名密码和数据库名是否和初始化时一致(数据卷已存在时环境变量不会重新生效)。
|
||||
|
||||
**追问**:容器里的 `localhost` 指的是什么?→ 指容器自己,不是宿主机也不是其他容器;连接其他容器要用服务名,连接宿主机的方式随平台不同(如 `host.docker.internal`)。
|
||||
|
||||
### 【场景题】怎么避免把密钥打包进镜像?
|
||||
|
||||
**参考答案**:不在 Dockerfile 里写密钥,不把 `.env` 等文件复制进镜像(`.dockerignore` 排除);运行时通过环境变量或密钥管理系统(Kubernetes Secret、云厂商的密钥服务)注入;构建时确实需要的密钥(如私有依赖仓库的 token)用 BuildKit 的 `--secret` 挂载,不会留在镜像层里;镜像推送前用扫描工具检查。
|
||||
|
||||
**追问**:构建时需要私有仓库的令牌怎么办?→ 使用 BuildKit 的 `--secret` 挂载,令牌只在构建步骤中可见,不会写入镜像层。
|
||||
@@ -18,42 +18,62 @@ week: [15, 16]
|
||||
|
||||
**参考答案**:merge 把两条分支的历史合并,可能产生合并提交,保留真实的并行开发历史;rebase 把当前分支的提交在目标分支最新提交之后重新应用,历史变成一条直线,但会生成新的提交哈希。公共分支不要 rebase,个人功能分支可以用 rebase 同步主干、整理提交。
|
||||
|
||||
**追问**:什么时候不能用 rebase?→ 已经推送到共享分支、别人基于它开发的提交不要 rebase,否则会改写别人依赖的历史。
|
||||
|
||||
### 【基础】`git revert` 和 `git reset` 有什么区别?
|
||||
|
||||
**参考答案**:revert 生成一个新提交来抵消指定提交的改动,不改写历史,适合撤销已推送的提交;reset 移动分支指针到指定提交,`--soft` 保留改动在暂存区、`--mixed` 保留在工作区、`--hard` 丢弃改动,会改写历史,只适合未推送的本地提交。
|
||||
|
||||
**追问**:已经推送的提交想撤销用哪个?→ 用 `git revert` 生成一个反向提交,不改写历史;`reset` 后强推会影响其他人。
|
||||
|
||||
### 【基础】`git fetch` 和 `git pull` 的区别?
|
||||
|
||||
**参考答案**:fetch 只下载远程的最新提交,更新远程跟踪分支(如 `origin/main`),不改动本地分支;pull = fetch + merge(或配置为 rebase),会把远程改动合并到当前分支。习惯先 fetch 看清楚差异再决定合并方式。
|
||||
|
||||
**追问**:`git pull --rebase` 有什么好处?→ 本地提交会接在远程最新提交之后,避免产生多余的合并提交,历史更清晰。
|
||||
|
||||
### 【基础】你们用什么分支模型?
|
||||
|
||||
**参考答案**:按真实情况回答,并说明原因。例如 GitHub Flow:main 始终可发布,功能分支通过合并请求、CI 和评审后合并,合并后自动部署测试环境。有固定版本周期的(App、私有化交付)用 Git Flow 的 release / hotfix 分支。持续部署成熟的团队用主干开发 + 特性开关。
|
||||
|
||||
**追问**:分支模型怎么选?→ 持续部署的团队适合主干开发(短期功能分支 + 快速合并);有固定版本发布周期的适合 Git Flow 类的发布分支模型。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】误执行了 `git reset --hard`,提交还能找回吗?
|
||||
|
||||
**参考答案**:只要曾经提交过就能找回。`git reflog` 记录了 HEAD 的每次移动,找到 reset 之前的提交哈希,`git reset --hard 哈希` 或 `git branch 新分支 哈希` 恢复。删除的分支同理。但从未提交、也没有 stash 的工作区改动无法通过 reflog 找回。
|
||||
|
||||
**追问**:reflog 会一直保留吗?→ 不会,默认未引用的记录大约 30 天、可达的 90 天后被清理(可配置),而且 reflog 只在本地。
|
||||
|
||||
### 【进阶】`--force` 和 `--force-with-lease` 有什么区别?
|
||||
|
||||
**参考答案**:`--force` 无条件用本地覆盖远程分支;`--force-with-lease` 只有当远程分支仍然是你上次获取时的状态才覆盖,如果别人在这期间推送了新提交就会拒绝,避免误覆盖同事的工作。改写过历史的个人分支推送时应该用后者。
|
||||
|
||||
**追问**:强推前还应该做什么?→ 先 `git fetch` 并确认远程没有别人的新提交;重要分支应该设置保护,禁止强推。
|
||||
|
||||
### 【进阶】什么时候用 cherry-pick?有什么注意事项?
|
||||
|
||||
**参考答案**:需要把某个提交单独应用到另一个分支时,例如热修复同时带到 release 和 main、把某个修复回移到旧版本。注意 cherry-pick 会生成新的提交(不同哈希),如果之后两个分支合并,可能出现重复改动或冲突;大量 cherry-pick 说明分支策略可能有问题。
|
||||
|
||||
**追问**:cherry-pick 后两个分支有相同修改的不同提交,合并时会冲突吗?→ 内容相同通常能自动合并,但历史上是两个不同的提交;修改有差异时可能冲突。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】有人把密钥提交到了远程仓库,怎么处理?
|
||||
|
||||
**参考答案**:第一步立即作废并轮换密钥、检查是否被滥用;然后删除文件并加入 `.gitignore`;视情况用 `git filter-repo` 清理历史并强制推送,通知所有人重新克隆,联系平台清理缓存;最后加上提交前和 CI 中的密钥扫描。清理历史不能代替轮换。详见[热修复和密钥误提交场景](scenarios/hotfix-and-leaked-secret.md)。
|
||||
|
||||
**追问**:从历史中删除了密钥,还需要做什么?→ 必须吊销(轮换)这个密钥,因为它可能已经被克隆或抓取;然后检查是否有异常使用记录。
|
||||
|
||||
### 【场景题】线上版本有紧急 bug,但 main 上已经有很多未发布的功能,怎么修?
|
||||
|
||||
**参考答案**:从线上版本的标签或 release 分支拉热修复分支,修复并补测试,走 CI 后合入 release 分支发布补丁版本;再把修复 cherry-pick 或合并回 main,保证下个版本包含修复。不要从 main 拉分支修复,否则会把未测试的功能带上线。
|
||||
|
||||
**追问**:修复之后 main 上怎么办?→ 把修复合并回 main(或 cherry-pick),避免下一次发布时问题重新出现。
|
||||
|
||||
### 【场景题】合并时遇到大量冲突,怎么处理?
|
||||
|
||||
**参考答案**:先理解双方各自改了什么(`git log`、`git diff` 看两边的提交),逐个文件解决,不要一键全选某一方;解决后运行测试;冲突太多时可以和对方一起解决。根本上要避免长期不合并的分支:功能分支保持短小、经常同步主干。
|
||||
|
||||
**追问**:怎么减少冲突?→ 小步提交、频繁合并主干;避免同时大规模格式化;冲突较多的文件按模块拆分。
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: devops.k8s.index
|
||||
title: Kubernetes 基础
|
||||
type: index
|
||||
domain: devops
|
||||
stack: [k8s]
|
||||
tags: []
|
||||
week: [14]
|
||||
---
|
||||
|
||||
# Kubernetes 基础
|
||||
|
||||
Pod、Deployment、Service、Ingress、配置与密钥、探针、滚动发布。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
@@ -18,42 +18,62 @@ week: [14, 16]
|
||||
|
||||
**参考答案**:`ss -lntp | grep 8080`(或旧命令 `netstat -lntp`、`lsof -i :8080`),输出里有进程名和 PID。没有 root 权限时可能看不到其他用户进程的信息。
|
||||
|
||||
**追问**:`ss` 和 `netstat` 的区别?→ `ss` 更新、更快,直接从内核获取信息;`netstat` 在很多新系统上默认不再安装。
|
||||
|
||||
### 【基础】怎么实时查看日志?怎么查找包含某个关键字的行?
|
||||
|
||||
**参考答案**:`tail -f app.log` 实时跟踪;`grep -n "ERROR" app.log` 查找并显示行号,`grep -C 5` 显示上下文,`grep -r` 递归目录;systemd 服务用 `journalctl -u 服务名 -f`。
|
||||
|
||||
**追问**:日志文件很大,怎么只看某个时间段?→ 用 `sed -n '/开始时间/,/结束时间/p'` 或 `awk` 按时间字段过滤;systemd 服务用 `journalctl --since --until`。
|
||||
|
||||
### 【基础】`kill`、`kill -9`、`kill -15` 有什么区别?
|
||||
|
||||
**参考答案**:`kill` 默认发送 SIGTERM(15),进程可以捕获它做清理后退出;`kill -9` 发送 SIGKILL,不能被捕获,进程立即被杀死,来不及释放资源。应该先 SIGTERM,等待超时后再 SIGKILL。
|
||||
|
||||
**追问**:为什么不建议直接使用 `kill -9`?→ 程序无法清理资源(关闭连接、刷新缓冲、删除临时文件),可能导致数据不完整;先发 SIGTERM,等待超时后再用 -9。
|
||||
|
||||
### 【基础】软链接和硬链接的区别?
|
||||
|
||||
**参考答案**:硬链接是同一个 inode 的另一个文件名,删除原文件后硬链接仍能访问,不能跨文件系统、不能链接目录;软链接是一个单独的文件,内容是目标路径,原文件删除后软链接失效,可以跨文件系统、可以链接目录。部署中常用软链接切换版本(`current -> releases/v2`)。
|
||||
|
||||
**追问**:删除源文件后,软链接和硬链接会怎样?→ 软链接失效(指向的路径不存在);硬链接仍然可以访问数据,因为数据要等到所有硬链接都删除后才释放。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】服务器负载很高,你怎么排查?
|
||||
|
||||
**参考答案**:`uptime` 看 load 和核数对比;`top` 看 CPU 的 `us`(用户态计算)、`sy`(内核态)、`wa`(IO 等待);`wa` 高查磁盘(`iostat -x`、`iotop`),`us` 高找占用最高的进程和线程(`top -H -p`),再用语言工具(Go pprof、py-spy)定位到函数;同时看内存是否在用 swap。
|
||||
|
||||
**追问**:负载高但 CPU 使用率低是什么情况?→ 负载包含等待 I/O(D 状态)的进程,可能是磁盘 I/O 瓶颈或 NFS 等存储问题,用 `iostat`、`vmstat` 查看 `wa`。
|
||||
|
||||
### 【进阶】TIME_WAIT 和 CLOSE_WAIT 过多分别说明什么?
|
||||
|
||||
**参考答案**:TIME_WAIT 出现在主动关闭连接的一方,大量出现通常是频繁创建短连接,应该复用连接(keep-alive、连接池)。CLOSE_WAIT 表示对方已关闭、本方还没关闭,大量出现基本是程序没有关闭连接(例如 Go 没有 `resp.Body.Close()`),属于代码 bug。
|
||||
|
||||
**追问**:CLOSE_WAIT 一般是谁的问题?→ 通常是自己程序的问题:对方已经关闭连接,自己没有调用 close(例如 HTTP 响应体没有关闭)。
|
||||
|
||||
### 【进阶】磁盘满了怎么处理?
|
||||
|
||||
**参考答案**:`df -h` 确认哪个分区满,`df -i` 检查 inode 是否用尽(大量小文件);`du -sh /* | sort -h` 逐层找大目录;常见是日志、临时文件、Docker 镜像和容器日志。删除正在被进程写的文件不会释放空间,用 `lsof +L1` 查找,清空文件内容或重启进程。长期方案:logrotate、Docker 日志大小限制、磁盘使用率告警。
|
||||
|
||||
**追问**:删除了大文件但磁盘空间没有释放,为什么?→ 文件还被进程打开着,用 `lsof +L1` 或 `lsof | grep deleted` 找到进程,重启进程或清空文件。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】Go 服务内存持续上涨,最后被系统杀掉,怎么定位?
|
||||
|
||||
**参考答案**:先在内核日志确认是 OOM(`journalctl -k | grep -i oom`);服务开启 pprof,在内存上涨过程中多次采集 heap,对比哪些对象在增长(`go tool pprof -inuse_space`、`-base` 对比);同时看 goroutine 数量是否持续增长(goroutine 泄漏也会占内存)。常见原因:无上限的缓存或 map、goroutine 阻塞泄漏、读取大文件 / 大响应到内存。修复后加内存告警和 `MemoryMax` 限制。
|
||||
|
||||
**追问**:怎么确认是被 OOM Killer 杀掉的?→ `dmesg -T | grep -i oom` 或 `journalctl -k` 查看内核日志;容器中看退出码 137 和 OOMKilled 状态。
|
||||
|
||||
### 【场景题】用户反馈接口偶尔很慢,但你登录服务器时一切正常,怎么办?
|
||||
|
||||
**参考答案**:需要事后可查的数据:Nginx 日志记录 `$request_time` 和 `$upstream_response_time`,用 awk 找出慢请求的时间点和接口;应用日志带请求 ID 和每一步耗时;数据库开慢查询日志;看云监控在对应时间点的 CPU、IO、网络曲线。根据时间点关联定时任务、备份、其他服务的批处理。长期方案是接入指标监控和链路追踪。
|
||||
|
||||
**追问**:怎么让偶发问题留下证据?→ 增加带请求 ID 的结构化日志和慢请求日志;监控历史指标(CPU、GC、连接数);在问题出现时自动保存现场(如 pprof 数据)。
|
||||
|
||||
### 【场景题】怎么把一个 Go 程序部署成开机自启、崩溃自动重启的服务?
|
||||
|
||||
**参考答案**:编写 systemd 单元文件:指定运行用户、工作目录、环境变量文件、`ExecStart`、`Restart=on-failure`、`RestartSec`、停止超时;`systemctl daemon-reload`、`enable --now`;日志用 `journalctl -u` 查看。程序本身要处理 SIGTERM 优雅退出。也可以用 Docker 的 `restart: unless-stopped` 实现类似效果。
|
||||
|
||||
**追问**:日志写到哪里?→ systemd 服务输出到标准输出,由 journald 收集,用 `journalctl -u 服务名` 查看;需要长期保存时配置日志轮转或采集到日志平台。
|
||||
@@ -18,42 +18,62 @@ week: [7, 14, 16]
|
||||
|
||||
**参考答案**:正向代理代表客户端访问外部服务器,服务器看到的是代理(例如公司上网代理);反向代理代表服务器接收客户端请求,客户端看到的是代理,不知道后端有哪些服务器。Nginx 常用作反向代理,做负载均衡、TLS 终止、静态资源、限流。
|
||||
|
||||
**追问**:反向代理除了负载均衡还能做什么?→ HTTPS 终止、静态资源服务、缓存、限流、隐藏后端地址、统一的访问日志。
|
||||
|
||||
### 【基础】Nginx 的负载均衡策略有哪些?
|
||||
|
||||
**参考答案**:轮询(默认)、加权轮询(`weight`)、最少连接(`least_conn`)、IP 哈希(`ip_hash`,同一客户端固定到同一后端)、通用哈希(`hash $key`,可配合一致性哈希)。通过 `max_fails`、`fail_timeout` 做被动健康检查。
|
||||
|
||||
**追问**:怎么让同一个用户总是访问同一台后端?→ `ip_hash` 或一致性哈希(`hash $cookie_xxx consistent`);更好的做法是让服务无状态,会话存到 Redis。
|
||||
|
||||
### 【基础】location 的匹配优先级是怎样的?
|
||||
|
||||
**参考答案**:先看 `=` 精确匹配;再找最长前缀匹配,如果它带 `^~` 就直接使用;否则按顺序匹配正则(`~`、`~*`),第一个匹配的生效;没有正则匹配就用最长前缀匹配。
|
||||
|
||||
**追问**:`location /api` 和 `location /api/` 有什么区别?→ 前者也会匹配 `/apixyz`;配合 `proxy_pass` 时,结尾斜杠还会影响路径是否被替换。
|
||||
|
||||
### 【基础】前端单页应用部署到 Nginx 后刷新页面 404,为什么?
|
||||
|
||||
**参考答案**:前端路由(history 模式)的路径在服务器上没有对应文件。用 `try_files $uri $uri/ /index.html;` 让找不到的路径都返回 index.html,交给前端路由处理。
|
||||
|
||||
**追问**:`try_files` 会影响 API 请求吗?→ 如果 API 也在同一个 server 中,要给 `/api/` 单独写 location,否则不存在的 API 路径会返回 index.html 而不是 404。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】502 和 504 有什么区别?分别怎么排查?
|
||||
|
||||
**参考答案**:502 Bad Gateway 是 Nginx 连接后端失败或后端返回了无效响应——后端没启动、端口错、进程崩溃、连接被拒绝,看 Nginx 错误日志里的 `connect() failed` 等信息。504 Gateway Timeout 是后端在超时时间内没有响应——后端处理慢(慢查询、调用外部服务卡住),或者 `proxy_read_timeout` 设置太短(例如大模型长回答)。
|
||||
|
||||
**追问**:怎么区分是 Nginx 的问题还是后端的问题?→ 看 Nginx 的 `error.log`(connect refused / upstream timed out)和访问日志中的 `$upstream_response_time`、`$upstream_status`。
|
||||
|
||||
### 【进阶】代理 SSE 和 WebSocket 分别要注意什么?
|
||||
|
||||
**参考答案**:SSE 要关闭代理缓冲(`proxy_buffering off` 或后端返回 `X-Accel-Buffering: no`),否则流式内容被攒成一大段;读超时要放宽或者后端发心跳。WebSocket 需要 `proxy_http_version 1.1` 并传递 `Upgrade` 和 `Connection` 头完成协议升级,读超时也要调大。
|
||||
|
||||
**追问**:WebSocket 连接一段时间后断开是什么原因?→ `proxy_read_timeout` 默认 60 秒,没有数据传输就会断开;调大超时或在应用层发送心跳。
|
||||
|
||||
### 【进阶】Nginx 怎么限流?
|
||||
|
||||
**参考答案**:`limit_req_zone` 定义按某个 key(如 `$binary_remote_addr`)的请求速率,基于漏桶算法;`limit_req` 应用到 location,`burst` 允许排队的突发量,`nodelay` 让突发请求不排队立即处理;超出返回 `limit_req_status`(默认 503,常改为 429)。`limit_conn` 限制并发连接数。前面还有负载均衡时要用 `real_ip` 模块取真实客户端 IP。
|
||||
|
||||
**追问**:`burst` 和 `nodelay` 是什么意思?→ `burst` 允许突发的排队数量;`nodelay` 让突发的请求立即处理而不是按速率排队,超过 burst 的请求直接被拒绝。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】AI 问答的流式输出经过 Nginx 后变成一次性返回,怎么解决?
|
||||
|
||||
**参考答案**:Nginx 默认对代理响应做缓冲。给流式接口单独 location,设置 `proxy_buffering off`、`proxy_cache off`,HTTP/1.1 长连接,`proxy_read_timeout` 调大;后端响应头加 `X-Accel-Buffering: no`;确认 gzip 没有压缩 `text/event-stream`;如果前面还有 CDN 或云负载均衡,也要确认它们支持流式。详见[AI 应用网关场景](scenarios/ai-gateway-sse-canary.md)。
|
||||
|
||||
**追问**:除了 Nginx,还有哪些地方可能缓冲?→ CDN、云厂商的负载均衡、应用框架的中间件(如压缩中间件)都可能缓冲,要逐层检查;后端可以设置 `X-Accel-Buffering: no` 响应头。
|
||||
|
||||
### 【场景题】要让 10% 的用户先用新版本,Nginx 怎么做?
|
||||
|
||||
**参考答案**:用 `split_clients` 根据用户标识(如用户 ID Cookie 或 IP)哈希出 10% 映射到新版本 upstream;或者 `upstream` 中用 `weight` 按比例分配;指定用户体验用 `map $cookie_xxx`。注意同一用户要稳定落在同一版本(按用户标识哈希,而不是随机)、新旧版本接口兼容、能一键回滚。
|
||||
|
||||
**追问**:按百分比随机分流和按用户分流哪个好?→ 按用户(如用户 ID 的哈希)更好,同一个用户的体验一致,也便于排查问题;用 `split_clients` 对用户标识做分流。
|
||||
|
||||
### 【场景题】怎么做到发版时用户无感知(不中断请求)?
|
||||
|
||||
**参考答案**:后端多实例滚动发布:先把一台实例从 upstream 摘除(或标记 `down`)并 reload Nginx,等它处理完存量请求后升级,再加回去,逐台进行;后端程序要支持优雅退出。Nginx 自身修改配置用 `nginx -s reload`,旧 worker 处理完已有连接再退出,不会中断请求。
|
||||
|
||||
**追问**:`nginx -s reload` 会中断连接吗?→ 不会:新 worker 使用新配置处理新请求,旧 worker 处理完已有请求后退出。但长连接(WebSocket)会在旧 worker 退出时断开(`worker_shutdown_timeout` 控制)。
|
||||
@@ -23,6 +23,11 @@ stack: []
|
||||
| [Redis](backend/redis/index.md) | 数据结构、缓存模式、分布式锁、限流、Streams | 3、6、14 |
|
||||
| [消息队列(RabbitMQ / Kafka)](backend/mq/index.md) | 异步解耦、可靠投递、幂等消费、顺序、延迟消息 | 3、14 |
|
||||
| [gRPC 与微服务](backend/grpc-microservices/index.md) | proto、四种调用方式、超时重试、服务治理、分布式事务 | 14 |
|
||||
| [MongoDB](backend/mongodb/index.md) | 文档模型、查询与聚合、索引、何时选 MongoDB 而不是关系库 | 14 |
|
||||
| [Elasticsearch](backend/elasticsearch/index.md) | 倒排索引、中文分词、BM25、与向量的混合检索、数据同步 | 6、14 |
|
||||
| [数据管道与第三方接口对接](backend/data-pipeline/index.md) | 批处理与流处理、失败重试、幂等、数据质量告警、第三方 API 限流与字段变化 | 14 |
|
||||
| [Node.js 后端(Express / NestJS)](backend/nodejs/index.md) | 了解级:前端出身的后端选项,Express 中间件、NestJS 模块与依赖注入 | 7、14 |
|
||||
| [Java / Spring Boot(了解级)](backend/java-spring/index.md) | 了解级:全栈岗位常见要求,能读懂代码、知道分层和常用注解 | 14 |
|
||||
|
||||
## 运维与工程(`devops`)
|
||||
|
||||
@@ -33,6 +38,7 @@ stack: []
|
||||
| [Linux 运维基础](devops/linux/index.md) | 常用命令、进程与端口、日志、磁盘、权限、线上排查 | 14 |
|
||||
| [CI/CD](devops/cicd/index.md) | 流水线设计、测试与构建、镜像发布、部署与回滚 | 3、7、14 |
|
||||
| [Git](devops/git/index.md) | 分支模型、提交规范、合并与变基、误操作恢复、协作流程 | 1、8、15 |
|
||||
| [Kubernetes 基础](devops/k8s/index.md) | Pod、Deployment、Service、Ingress、配置与密钥、探针、滚动发布 | 14 |
|
||||
|
||||
## Python(`python`)
|
||||
|
||||
@@ -55,6 +61,9 @@ stack: []
|
||||
| [提示词与上下文工程](ai/prompt-engineering/index.md) | 提示词结构、结构化输出、上下文工程、提示词注入防护 | 5、11、13 |
|
||||
| [Agent 记忆、评测与可观测性](ai/agent-ops/index.md) | 短期 / 长期记忆、评测集、LLM 评分、追踪、成本 | 12、13 |
|
||||
| [Dify / Coze 低代码平台](ai/lowcode/index.md) | 知识库、工作流、插件,和写代码的取舍 | 13 |
|
||||
| [Agent 设计:规划、上下文工程与多 Agent](ai/agent-design/index.md) | ReAct、Planning、Context Engineering、Skills、多 Agent 与 A2A、Agentic RAG | 10、11、13 |
|
||||
| [模型接入与部署(了解级)](ai/llm-serving/index.md) | 多模型 API 路由与降级、vLLM 部署基础、LoRA / QLoRA 概念 | 10、13 |
|
||||
| [AI 编程工具与产出验证](ai/ai-coding/index.md) | Claude Code / Cursor / Codex 工作流、Skills 与规则、评审和测试验证、会话记录作为作品证据 | 1、8、10、15 |
|
||||
|
||||
## 前端(`frontend`)
|
||||
|
||||
@@ -67,3 +76,11 @@ stack: []
|
||||
| [Webpack](frontend/webpack/index.md) | 核心概念、Loader 与 Plugin、拆包、构建优化 | 7 |
|
||||
| [SSR(Nuxt / Next.js)](frontend/ssr/index.md) | CSR / SSR / SSG、水合、Nuxt 与 Next.js 基础 | 7 |
|
||||
| [前端测试与性能](frontend/fe-testing-perf/index.md) | Vitest、组件测试、Web Vitals、性能优化 | 7 |
|
||||
|
||||
## 计算机基础(`cs`)
|
||||
|
||||
| 技术栈 | 内容 | 用在第几周 |
|
||||
| --- | --- | --- |
|
||||
| [数据结构与算法(面试)](cs/algorithms/index.md) | 常考数据结构、复杂度、双指针、哈希、二分、BFS/DFS、动态规划入门 | 16 |
|
||||
| [网络与操作系统基础](cs/network-os/index.md) | HTTP/HTTPS、TCP/IP、DNS、进程线程、内存、IO 模型 | 14、16 |
|
||||
| [Web 安全](cs/web-security/index.md) | XSS、CSRF、SQL 注入、CORS、鉴权与会话安全、大模型应用的提示词注入 | 7、14、16 |
|
||||
@@ -18,42 +18,62 @@ week: [4, 16]
|
||||
|
||||
**参考答案**:进程有独立的内存空间,切换和通信开销大,能利用多核;线程共享进程内存,由操作系统抢占式调度,在 CPython 中受 GIL 限制不能并行执行 Python 代码;协程是用户态的、协作式调度的执行单元,在 `await` 处主动让出控制权,切换开销极小,单线程即可同时处理大量 IO 等待。
|
||||
|
||||
**追问**:Python 多线程适合什么场景?→ I/O 密集且必须使用同步库的场景;CPU 密集任务因为 GIL 用多进程。
|
||||
|
||||
### 【基础】asyncio 适合什么场景?不适合什么场景?
|
||||
|
||||
**参考答案**:适合 IO 密集、并发连接多的场景:调用大模型 API、HTTP 请求、数据库查询、WebSocket / SSE 长连接。不适合 CPU 密集计算(单线程,无法加速),这类任务用多进程或交给 C 扩展库。
|
||||
|
||||
**追问**:CPU 密集的任务混在异步服务里怎么办?→ 用 `loop.run_in_executor` 放到进程池,或者交给单独的任务队列 / 服务处理,避免阻塞事件循环。
|
||||
|
||||
### 【基础】调用 `async def` 函数但没有 `await` 会怎样?
|
||||
|
||||
**参考答案**:只会创建协程对象,函数体不会执行,Python 会给出 “coroutine was never awaited” 警告。这一点和 JS 不同——JS 的 async 函数调用后立即开始执行。
|
||||
|
||||
**追问**:怎么发现忘记 await 的问题?→ 运行时会出现 `coroutine was never awaited` 的警告;类型检查工具(mypy / pyright)和 linter 也能检查出来。
|
||||
|
||||
### 【基础】为什么在协程里不能使用 `time.sleep` 或 `requests`?
|
||||
|
||||
**参考答案**:事件循环是单线程的,阻塞调用会让整个线程停住,所有其他协程都无法运行,服务的并发能力退化为 1。应该用 `asyncio.sleep`、`httpx.AsyncClient` 等异步库;只能用同步库时用 `asyncio.to_thread` 放到线程池执行。
|
||||
|
||||
**追问**:必须使用同步库怎么办?→ 用 `asyncio.to_thread()` 放到线程池执行,或者换成异步库(`httpx.AsyncClient`、`asyncpg` 等)。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】`asyncio.gather` 和 `TaskGroup` 有什么区别?
|
||||
|
||||
**参考答案**:`gather` 在某个任务抛异常时立即把异常抛给调用方,但其余任务继续运行;加 `return_exceptions=True` 则把异常作为结果返回。`TaskGroup`(3.11+)是结构化并发:一个任务失败时取消组内其他任务,等它们全部结束后,以 `ExceptionGroup` 抛出所有异常,不会留下“孤儿任务”。
|
||||
|
||||
**追问**:`gather` 的 `return_exceptions=True` 有什么用?→ 一个任务异常时不会立即抛出,而是把异常作为结果返回,适合“部分失败可以接受”的批量调用。
|
||||
|
||||
### 【进阶】怎么限制并发数?
|
||||
|
||||
**参考答案**:用 `asyncio.Semaphore(n)`,在每个任务的实际调用外层 `async with sem:`;或者用固定数量的 worker 从 `asyncio.Queue` 取任务。注意多个请求要共享同一个 Semaphore;多进程 / 多实例时需要 Redis 等全局限流。
|
||||
|
||||
**追问**:信号量和限速有什么区别?→ 信号量限制同时进行的请求数;限速限制单位时间内的请求数(如每分钟 600 次),服务商通常两种都有限制,需要同时控制。
|
||||
|
||||
### 【进阶】asyncio 的取消机制是怎样的?
|
||||
|
||||
**参考答案**:`task.cancel()` 请求取消,任务在下一个 `await` 点收到 `CancelledError`。任务可以捕获它做清理(关闭连接、记录状态),但应该重新抛出,否则取消失效。`asyncio.timeout` / `wait_for` 超时也是通过取消内部任务实现的。`CancelledError` 继承自 `BaseException`,所以 `except Exception` 不会误吞它。
|
||||
|
||||
**追问**:捕获了 `CancelledError` 后应该怎么做?→ 做完清理后重新抛出;吞掉它会导致取消失效、`TaskGroup` / 超时无法正常工作。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】需要对 1 万条文本调用大模型生成摘要,服务商限制每分钟 600 次请求,怎么设计?
|
||||
|
||||
**参考答案**:用 Semaphore 或 worker 池限制并发,再加速率控制(令牌桶,每秒约 10 次);每次调用设超时;对 429、超时、5xx 做带抖动的指数退避重试,429 响应如果带 `Retry-After` 就按它等待;单条失败记录下来,最后汇总重跑;记录进度(已完成的 ID)以便中断后续跑;结果写入数据库而不是全放内存。规模更大时用消息队列分发给多个 worker。
|
||||
|
||||
**追问**:中途失败的任务怎么办?→ 每条记录保存处理状态,失败的记录按错误类型重试(限流错误退避重试,内容错误记录跳过),支持断点续跑。
|
||||
|
||||
### 【场景题】FastAPI 接口里调用了一个同步的 SDK,发现服务在压测时吞吐很低,为什么?怎么改?
|
||||
|
||||
**参考答案**:在 `async def` 路由里直接调用同步 SDK,阻塞了事件循环,所有请求排队。改法:换成 SDK 的异步版本;或者 `await asyncio.to_thread(sdk.call, ...)`;或者把路由定义为普通 `def`,FastAPI 会把它放到线程池执行。然后重新压测确认。
|
||||
|
||||
**追问**:`def` 路由和 `async def` 路由在这里有什么区别?→ `def` 路由会被 FastAPI 放到线程池执行,不会阻塞事件循环;`async def` 中直接调用同步 SDK 会阻塞整个事件循环。
|
||||
|
||||
### 【场景题】一个异步任务在服务关闭时总是卡住,可能是什么原因?
|
||||
|
||||
**参考答案**:任务里捕获了 `CancelledError` 却没有重新抛出(或者用了裸 `except:`),导致取消无效;或者任务在执行不响应取消的阻塞调用(同步 IO、`to_thread` 中的线程无法被强制停止);或者有后台任务没有被追踪和取消。修复:正确传播取消、给阻塞调用设超时、在应用的生命周期关闭阶段统一取消并等待后台任务。
|
||||
|
||||
**追问**:怎么给关闭过程设置上限?→ 用 `asyncio.timeout()` 或 `asyncio.wait_for()` 给关闭等待设置超时,超时后取消剩余任务并记录日志。
|
||||
@@ -18,42 +18,62 @@ week: [4, 5, 16]
|
||||
|
||||
**参考答案**:基于 Starlette(ASGI)和 pydantic,原生支持 async,适合 IO 密集的模型调用;通过类型标注自动完成参数解析和校验、生成 OpenAPI 文档;依赖注入方便组织鉴权、数据库会话和测试替换;支持流式响应和 WebSocket。AI 生态(模型 SDK、LangChain、向量库客户端)以 Python 为主,用 FastAPI 封装服务很自然。
|
||||
|
||||
**追问**:FastAPI 和 Flask、Django 怎么选?→ AI 服务、异步 I/O、需要自动接口文档时选 FastAPI;需要完整后台管理和 ORM 的传统业务系统可以选 Django。
|
||||
|
||||
### 【基础】FastAPI 怎么区分路径参数、查询参数和请求体?
|
||||
|
||||
**参考答案**:在路径模板中出现的参数是路径参数;简单类型(int、str、bool 等)的其他参数是查询参数;pydantic 模型类型的参数是请求体;请求头、Cookie 用 `Header()`、`Cookie()` 显式声明。也可以用 `Path()`、`Query()`、`Body()` 显式指定并添加校验规则。
|
||||
|
||||
**追问**:请求体可以是多个 Pydantic 模型吗?→ 可以,多个模型参数会按参数名嵌套在 JSON 中;单个模型参数想嵌套时用 `Body(embed=True)`。
|
||||
|
||||
### 【基础】路由函数用 `async def` 和 `def` 有什么区别?
|
||||
|
||||
**参考答案**:`async def` 在事件循环中直接执行,里面只能调用非阻塞的异步代码;普通 `def` 会被放到线程池中执行,适合调用同步阻塞库。在 `async def` 中调用阻塞函数会卡住整个事件循环,是最常见的性能问题。
|
||||
|
||||
**追问**:`def` 路由的线程池大小有限制吗?→ 有,默认由 AnyIO 的线程池限制(默认 40 个线程,以官方文档为准),大量阻塞调用会排队。
|
||||
|
||||
### 【基础】响应模型有什么作用?
|
||||
|
||||
**参考答案**:声明接口返回的数据结构:自动过滤掉未声明的字段(防止泄露内部字段),校验和序列化返回数据,并体现在 OpenAPI 文档中,前端可以据此生成类型。
|
||||
|
||||
**追问**:怎么避免把密码等字段返回给前端?→ 定义单独的输出模型(不包含敏感字段)并设置为 `response_model`,而不是直接返回数据库对象。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】`Depends` 的原理和常见用途?
|
||||
|
||||
**参考答案**:FastAPI 在处理请求时解析路由函数签名,递归解析依赖树,按顺序调用依赖函数并把返回值注入参数;同一请求内相同依赖默认只调用一次;带 `yield` 的依赖可以在请求结束后执行清理。用途:鉴权和获取当前用户 / 租户、数据库会话、分页参数、共享客户端;测试时通过 `dependency_overrides` 替换。
|
||||
|
||||
**追问**:依赖中的 `yield` 有什么用?→ `yield` 之前创建资源(数据库会话),之后清理资源;测试时可以用 `app.dependency_overrides` 替换依赖。
|
||||
|
||||
### 【进阶】中间件和依赖都能做鉴权,怎么选?
|
||||
|
||||
**参考答案**:中间件作用于所有请求,适合与业务无关的横切逻辑:请求 ID、日志、耗时、CORS。依赖按路由或路由器声明,能拿到类型化的结果(当前用户)并注入处理函数,能在测试中替换,适合鉴权、权限、租户。通常鉴权用依赖。
|
||||
|
||||
**追问**:中间件能访问路由的参数吗?→ 中间件在路由匹配之前执行,拿不到解析后的参数;需要按路由做权限控制的,用依赖。
|
||||
|
||||
### 【进阶】怎么实现大模型的流式输出?要注意什么?
|
||||
|
||||
**参考答案**:用 `StreamingResponse` 包装异步生成器,`media_type="text/event-stream"`,每个事件 `data: ...\n\n`。注意:响应头发出后不能改状态码,错误要以事件形式发送;检查客户端断开及时停止生成;内容用 JSON 编码避免换行破坏格式;经过 Nginx 时关闭缓冲;前端 POST 请求需要用 fetch 读取流。
|
||||
|
||||
**追问**:客户端中途断开,服务端还在调用大模型怎么办?→ 在生成器中检查 `await request.is_disconnected()`,或者依赖取消机制,及时停止上游调用以节省成本。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】压测发现 FastAPI 服务 QPS 很低,CPU 也不高,可能是什么原因?
|
||||
|
||||
**参考答案**:最常见的是在 `async def` 路由里调用了阻塞操作(同步数据库驱动、`requests`、`time.sleep`、同步 SDK),事件循环被阻塞。其他原因:每个请求新建 HTTP 客户端或数据库连接、连接池太小、下游(模型、数据库)本身慢、只启动了一个 worker。排查:看慢请求的耗时分布,用 py-spy 看事件循环线程在做什么。
|
||||
|
||||
**追问**:怎么验证是不是事件循环被阻塞了?→ 开启 asyncio 的调试模式(会记录执行过慢的回调),或者在压测时观察一个简单的 `/health` 接口延迟是否也升高。
|
||||
|
||||
### 【场景题】一个接口依赖大模型和向量数据库,怎么写单元测试?
|
||||
|
||||
**参考答案**:把模型客户端和检索器抽象成接口(Protocol),通过依赖注入获取;测试中用 `app.dependency_overrides` 替换为假实现,返回固定内容或模拟异常;用 `TestClient` 调用接口,断言状态码、响应格式、流式事件顺序、错误处理。真实模型的效果评估另外做(评测集),不放在单元测试里。
|
||||
|
||||
**追问**:测试流式接口要注意什么?→ 用 `TestClient` 的 `stream` 方法或 httpx 异步客户端逐块读取,断言事件格式和结束标记,而不只是最终文本。
|
||||
|
||||
### 【场景题】给多个业务系统提供 AI 能力,怎么防止某个系统把额度用光?
|
||||
|
||||
**参考答案**:每个调用方一个 API Key,鉴权依赖解析出租户;在服务端按租户做限流(Redis 滑动窗口 / 令牌桶)和配额(按日 / 月的 token 数);记录每次调用的 token 用量写入数据库;超过配额返回 429 并给出提示;对异常增长告警。Nginx 层可以再加一层按 IP 的粗粒度限流。
|
||||
|
||||
**追问**:额度用完时返回什么?→ 返回 429 和明确的错误码,带上 `Retry-After` 头;同时通知对应业务方,提供额度查询接口。
|
||||
@@ -18,28 +18,40 @@ week: [4, 16]
|
||||
|
||||
**参考答案**:不可变:int、float、str、tuple、frozenset、bytes;可变:list、dict、set、自定义对象(默认)。影响:函数参数传的是对象引用,函数里修改可变对象会影响调用方;只有不可变(可哈希)的对象才能做 dict 的 key 和 set 的元素。
|
||||
|
||||
**追问**:元组里包含列表,这个元组可以作为字典的 key 吗?→ 不可以。元组本身不可变,但是包含可变元素时不可哈希。
|
||||
|
||||
### 【基础】为什么不要用可变对象作为函数默认参数?
|
||||
|
||||
**参考答案**:默认值在函数定义时只创建一次,之后每次调用共享同一个对象。`def f(xs=[])` 多次调用会在同一个列表上累加。改为默认 `None`,在函数内部创建新列表。
|
||||
|
||||
**追问**:dataclass 的可变默认值怎么写?→ 用 `field(default_factory=list)`,直接写 `= []` 会报错。
|
||||
|
||||
### 【基础】`is` 和 `==` 的区别?
|
||||
|
||||
**参考答案**:`==` 比较值是否相等(调用 `__eq__`),`is` 比较是否是同一个对象(身份)。判断 `None` 用 `is None`;比较值永远用 `==`,不要依赖小整数缓存之类的实现细节。
|
||||
|
||||
**追问**:为什么小整数 `a is b` 有时候为 True?→ CPython 缓存了 -5 到 256 的小整数和部分字符串,这是实现细节,不能依赖;比较值一律用 `==`。
|
||||
|
||||
### 【基础】浅拷贝和深拷贝的区别?
|
||||
|
||||
**参考答案**:浅拷贝(`list(x)`、`x.copy()`、`copy.copy`)只复制最外层容器,内部元素仍是同一引用;深拷贝(`copy.deepcopy`)递归复制所有层级。嵌套结构修改内层元素时,浅拷贝会影响原对象。
|
||||
|
||||
**追问**:`copy.deepcopy` 有什么代价?→ 递归复制所有对象,大数据结构时很慢、占内存;很多情况下用不可变数据或只复制需要修改的部分更好。
|
||||
|
||||
### 【基础】列表推导式和生成器表达式有什么区别?
|
||||
|
||||
**参考答案**:`[x for x in xs]` 立即生成完整列表,占用内存;`(x for x in xs)` 生成器表达式按需逐个产生,内存占用恒定,但只能遍历一次。传给 `sum`、`max` 等函数时用生成器表达式即可。
|
||||
|
||||
**追问**:生成器只能遍历一次,怎么办?→ 需要多次遍历时转成列表,或者重新创建生成器;注意不要在调试时提前把它消耗掉。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】什么是 GIL?对 AI 应用开发有什么影响?
|
||||
|
||||
**参考答案**:GIL(全局解释器锁)使 CPython 同一时刻只有一个线程执行 Python 字节码,所以多线程无法利用多核做 CPU 密集计算;但 IO 等待时会释放 GIL,所以多线程 / asyncio 对 IO 密集任务有效。AI 应用大多是 IO 密集(调用模型 API、查数据库、检索),用 asyncio 很合适;CPU 密集(本地向量计算、文本解析)用多进程,或者依赖 numpy 等在 C 层释放 GIL 的库。Python 3.13 起提供了实验性的无 GIL 构建,以官方文档为准。
|
||||
|
||||
**追问**:Python 3.13 的无 GIL 版本是怎么回事?→ 3.13 开始提供实验性的自由线程(free-threaded)构建,可以关闭 GIL,3.14 起成为官方支持的可选构建;生态兼容还在推进中,生产使用以官方文档和依赖库的支持情况为准。
|
||||
|
||||
### 【进阶】装饰器是什么?写一个统计耗时的装饰器。
|
||||
|
||||
**参考答案**:装饰器是接收函数并返回新函数的函数,用来在不修改原函数的情况下增加功能(日志、重试、缓存、鉴权)。
|
||||
@@ -74,20 +86,30 @@ work(1_000_000)
|
||||
|
||||
**参考答案**:内部数据结构用 dataclass(轻量、无运行时校验);外部输入(接口请求体、配置、大模型返回的 JSON)用 pydantic,它会在运行时校验和转换类型并给出清晰的错误;只是给已有的字典加类型提示(例如 JSON 数据直接传来传去)用 TypedDict。
|
||||
|
||||
**追问**:pydantic 的性能怎么样?→ v2 的核心用 Rust 实现,校验很快;但在内部的热点循环中,dataclass 或普通对象开销更小。边界用 pydantic,内部用 dataclass。
|
||||
|
||||
### 【进阶】上下文管理器的原理是什么?
|
||||
|
||||
**参考答案**:实现了 `__enter__` 和 `__exit__` 方法的对象。`with` 进入时调用 `__enter__`,离开代码块(无论正常还是异常)时调用 `__exit__`,保证资源释放;`__exit__` 返回 True 会吞掉异常。也可以用 `contextlib.contextmanager` 装饰生成器函数实现,异步版本是 `async with` 和 `__aenter__` / `__aexit__`。
|
||||
|
||||
**追问**:`contextlib.contextmanager` 中,如果 `with` 块内抛出异常会怎样?→ 异常会在 `yield` 处被抛出,用 `try/finally` 包住 `yield` 才能保证清理代码执行。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】要处理一个 10GB 的日志文件,统计每个接口的请求数,怎么写?
|
||||
|
||||
**参考答案**:不能一次读入内存。用 `with open(...)` 逐行迭代(文件对象本身就是惰性的),解析出接口路径后用 `Counter` 计数;正则预编译;如果需要更快,可以按字节范围切分文件用多进程并行统计后合并。输出前 N 个用 `most_common`。
|
||||
|
||||
**追问**:统计结果也很大,放不进内存怎么办?→ 分片处理(按接口哈希写到多个中间文件再分别统计),或者导入数据库 / 使用 DuckDB 这类工具处理。
|
||||
|
||||
### 【场景题】一段 Python 代码在本机能跑,在服务器上报 `ModuleNotFoundError`,怎么排查?
|
||||
|
||||
**参考答案**:确认服务器上是否激活了正确的虚拟环境(`which python`、`python -c "import sys; print(sys.executable)"`);依赖是否按锁文件安装;运行目录和方式是否不同(直接运行子目录的文件 vs 从根目录 `python -m`);是否有本地文件和包同名遮蔽了真正的包;Python 版本是否一致。
|
||||
|
||||
**追问**:怎么保证服务器和本机的依赖一致?→ 使用锁文件(`uv.lock` / `requirements.txt` 固定版本),部署时用锁文件安装,最好用 Docker 镜像固定运行环境。
|
||||
|
||||
### 【场景题】你从前端转 Python,怎么保证写出来的 Python 代码质量?
|
||||
|
||||
**参考答案**:用 ruff 做 lint 和格式化,用 mypy / pyright 做类型检查,pytest 写测试并在 CI 中运行;遵循 PEP 8 和项目约定;数据边界用 pydantic 校验;对照 JS 的经验注意 Python 特有的坑(可变默认参数、浮点金额、导入路径)。可以举一个自己写过的、带测试的小工具为例。
|
||||
|
||||
**追问**:AI 帮你写的 Python 代码怎么验证?→ 运行测试和类型检查(pytest、mypy / pyright、ruff),阅读并理解每一行,补充边界情况的测试,不理解的代码不提交。
|
||||
Reference in new issue
Block a user