phase 5 part 5: cs algorithms, network-os, web-security topics with tested examples; all 43 stacks pass --strict
This commit is contained in:
1 parent
f695bbd7cc
commit
691f738f5f
22 files changed
+1955
-37
No files matched your search
@@ -0,0 +1,104 @@
|
||||
---
|
||||
id: cs.algorithms.complexity-patterns
|
||||
title: 复杂度分析和九个高频模式:哈希、滑动窗口、二分、堆、BFS、拓扑排序、动态规划、LRU、区间合并
|
||||
type: lesson
|
||||
domain: cs
|
||||
stack: [algorithms]
|
||||
tags: [时间复杂度, 哈希表, 滑动窗口, 二分查找, 堆, Top K, BFS, DFS, 拓扑排序, 动态规划, LRU]
|
||||
level: basic
|
||||
prerequisites: [python.python.index]
|
||||
est_hours: 6
|
||||
related: [cs.algorithms.scenario.business-algorithms, cs.algorithms.interview]
|
||||
week: [16]
|
||||
order: 1
|
||||
---
|
||||
|
||||
# 复杂度分析和九个高频模式:哈希、滑动窗口、二分、堆、BFS、拓扑排序、动态规划、LRU、区间合并
|
||||
|
||||
> BOSS直聘 2026-10-23 的调研中有少数岗位明确提到算法 / 数据结构基础,笔试和一面中出现手写题的情况也不少。目标不是刷几百题,而是**掌握模式、能讲复杂度、写出无 bug 的中等题**。练习平台可用 LeetCode 中国站(力扣)。
|
||||
|
||||
## 解释说明
|
||||
|
||||
### 复杂度
|
||||
|
||||
| 复杂度 | 典型操作 | n = 10⁶ 时的感受 |
|
||||
| --- | --- | --- |
|
||||
| O(1) | 哈希表查找、数组下标 | 瞬间 |
|
||||
| O(log n) | 二分查找、堆操作 | 约 20 步 |
|
||||
| O(n) | 遍历 | 很快 |
|
||||
| O(n log n) | 排序 | 可以接受 |
|
||||
| O(n²) | 双重循环 | 太慢(10¹² 次) |
|
||||
|
||||
面试时先说暴力解及其复杂度,再优化,最后说明时间和空间复杂度。
|
||||
|
||||
### 九个模式(何时想到它)
|
||||
|
||||
| 模式 | 信号 | 例题 |
|
||||
| --- | --- | --- |
|
||||
| 哈希表 | “是否出现过”、“计数”、“配对” | 两数之和 |
|
||||
| 滑动窗口 | 连续子数组 / 子串 + 最长 / 最短 | 无重复字符的最长子串 |
|
||||
| 二分查找 | 有序、单调、“第一个满足条件的” | lower_bound、搜索旋转数组 |
|
||||
| 堆 | Top K、动态取最值、合并 K 个有序 | 前 K 个高频元素 |
|
||||
| BFS | 最短步数、层序遍历 | 网格最短路径 |
|
||||
| DFS / 拓扑排序 | 依赖关系、是否有环、所有方案 | 课程表 |
|
||||
| 动态规划 | 最优值 / 方案数、子问题重叠 | 编辑距离、爬楼梯、背包 |
|
||||
| 设计题 | 要求 O(1) 操作 | LRU 缓存 |
|
||||
| 排序 + 扫描 | 区间、会议室 | 合并区间 |
|
||||
|
||||
### 答题步骤
|
||||
|
||||
1. 复述题目,问清边界(空输入、重复、负数、数据规模);
|
||||
2. 说思路和复杂度,得到认可后再写;
|
||||
3. 写代码时变量命名清楚;
|
||||
4. 用一两个例子手动走一遍,包括边界;
|
||||
5. 说明可以改进的地方。
|
||||
|
||||
## 案例
|
||||
|
||||
这些模式在业务中经常出现:热门问题统计(堆)、限流的滑动窗口、任务编排的依赖顺序(拓扑排序,LangGraph / CI 流水线都用到)、文本纠错与相似度(编辑距离)、缓存淘汰(LRU)、排班时间段合并(区间合并)。见[企业场景](scenarios/business-algorithms.md)。
|
||||
|
||||
## 代码示例
|
||||
|
||||
[examples/patterns](examples/patterns/):9 个模式的实现 + 13 个测试(其中二分查找与标准库 `bisect` 随机对拍 200 次)。
|
||||
|
||||
滑动窗口:
|
||||
|
||||
```python
|
||||
def longest_unique_substring(s: str) -> int:
|
||||
last: dict[str, int] = {}
|
||||
left = best = 0
|
||||
for right, ch in enumerate(s):
|
||||
if last.get(ch, -1) >= left:
|
||||
left = last[ch] + 1
|
||||
last[ch] = right
|
||||
best = max(best, right - left + 1)
|
||||
return best
|
||||
```
|
||||
|
||||
二分查找(左闭右开,最不容易写错的模板):
|
||||
|
||||
```python
|
||||
def lower_bound(arr: list[int], target: int) -> int:
|
||||
lo, hi = 0, len(arr)
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if arr[mid] < target:
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
return lo
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- 二分查找的区间定义混乱(左闭右闭和左闭右开混用),导致死循环或越界。
|
||||
- 滑动窗口中,重复字符在窗口左边界之外时也移动左边界(例如 "abba")。
|
||||
- BFS 出队时才标记已访问,导致同一个节点多次入队。
|
||||
- 递归 DFS 在深度很大时栈溢出(Python 默认递归深度约 1000)。
|
||||
- 只写出代码不说复杂度。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- 手写:两数之和、最长无重复子串、二分查找、Top K、LRU、合并区间、课程表、编辑距离 / 爬楼梯。
|
||||
- 哈希表的原理和冲突处理?
|
||||
- 快速排序的平均和最坏复杂度?
|
||||
@@ -0,0 +1,142 @@
|
||||
"""面试高频算法模式(只用标准库)。每个函数注明时间 / 空间复杂度;`python patterns.py` 运行自检,test_patterns.py 是测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
from collections import Counter, OrderedDict, deque
|
||||
from typing import Hashable, Iterable
|
||||
|
||||
|
||||
# 1. 哈希表:两数之和 O(n) / O(n)
|
||||
def two_sum(nums: list[int], target: int) -> tuple[int, int] | None:
|
||||
seen: dict[int, int] = {}
|
||||
for i, x in enumerate(nums):
|
||||
if target - x in seen:
|
||||
return seen[target - x], i
|
||||
seen[x] = i
|
||||
return None
|
||||
|
||||
|
||||
# 2. 滑动窗口:无重复字符的最长子串 O(n) / O(字符集)
|
||||
def longest_unique_substring(s: str) -> int:
|
||||
last: dict[str, int] = {}
|
||||
left = best = 0
|
||||
for right, ch in enumerate(s):
|
||||
if last.get(ch, -1) >= left:
|
||||
left = last[ch] + 1 # 窗口左边界跳到重复字符的下一位
|
||||
last[ch] = right
|
||||
best = max(best, right - left + 1)
|
||||
return best
|
||||
|
||||
|
||||
# 3. 二分查找:第一个 >= target 的位置(lower_bound) O(log n) / O(1)
|
||||
def lower_bound(arr: list[int], target: int) -> int:
|
||||
lo, hi = 0, len(arr) # 左闭右开区间 [lo, hi)
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if arr[mid] < target:
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
return lo
|
||||
|
||||
|
||||
# 4. 堆:出现次数最多的 K 个(例如热门问题) O(n log k) / O(n)
|
||||
def top_k_frequent(items: Iterable[Hashable], k: int) -> list[Hashable]:
|
||||
counts = Counter(items)
|
||||
return [x for _, x in heapq.nlargest(k, ((c, x) for x, c in counts.items()))]
|
||||
|
||||
|
||||
# 5. BFS:网格最短路径(0 可走,1 障碍) O(行×列) / O(行×列)
|
||||
def shortest_path(grid: list[list[int]], start: tuple[int, int], end: tuple[int, int]) -> int:
|
||||
rows, cols = len(grid), len(grid[0])
|
||||
q = deque([(start, 0)])
|
||||
seen = {start}
|
||||
while q:
|
||||
(r, c), d = q.popleft()
|
||||
if (r, c) == end:
|
||||
return d
|
||||
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
nr, nc = r + dr, c + dc
|
||||
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 0 and (nr, nc) not in seen:
|
||||
seen.add((nr, nc))
|
||||
q.append(((nr, nc), d + 1))
|
||||
return -1
|
||||
|
||||
|
||||
# 6. DFS + 拓扑排序:任务依赖的执行顺序(有环返回 None) O(V+E)
|
||||
def topo_order(deps: dict[str, list[str]]) -> list[str] | None:
|
||||
"""deps[a] = [b, c] 表示 a 依赖 b 和 c(b、c 要先执行)。"""
|
||||
state: dict[str, int] = {} # 0/缺省 未访问,1 访问中,2 已完成
|
||||
order: list[str] = []
|
||||
|
||||
def visit(n: str) -> bool:
|
||||
if state.get(n) == 1:
|
||||
return False # 回到“访问中”的节点:有环
|
||||
if state.get(n) == 2:
|
||||
return True
|
||||
state[n] = 1
|
||||
for m in deps.get(n, []):
|
||||
if not visit(m):
|
||||
return False
|
||||
state[n] = 2
|
||||
order.append(n)
|
||||
return True
|
||||
|
||||
nodes = set(deps) | {m for ms in deps.values() for m in ms}
|
||||
for n in sorted(nodes):
|
||||
if not visit(n):
|
||||
return None
|
||||
return order
|
||||
|
||||
|
||||
# 7. 动态规划:编辑距离(文本相似度、纠错) O(mn) / O(n)
|
||||
def edit_distance(a: str, b: str) -> int:
|
||||
prev = list(range(len(b) + 1))
|
||||
for i in range(1, len(a) + 1):
|
||||
cur = [i] + [0] * len(b)
|
||||
for j in range(1, len(b) + 1):
|
||||
cur[j] = prev[j - 1] if a[i - 1] == b[j - 1] else 1 + min(prev[j - 1], prev[j], cur[j - 1])
|
||||
prev = cur
|
||||
return prev[-1]
|
||||
|
||||
|
||||
# 8. 设计题:LRU 缓存 get/put 均为 O(1)
|
||||
class LRUCache:
|
||||
def __init__(self, capacity: int):
|
||||
self.capacity = capacity
|
||||
self.data: OrderedDict[Hashable, object] = OrderedDict()
|
||||
|
||||
def get(self, key: Hashable) -> object | None:
|
||||
if key not in self.data:
|
||||
return None
|
||||
self.data.move_to_end(key) # 最近使用的移到末尾
|
||||
return self.data[key]
|
||||
|
||||
def put(self, key: Hashable, value: object) -> None:
|
||||
self.data[key] = value
|
||||
self.data.move_to_end(key)
|
||||
if len(self.data) > self.capacity:
|
||||
self.data.popitem(last=False) # 淘汰最久未使用的(头部)
|
||||
|
||||
|
||||
# 9. 区间合并:合并重叠的时间段(排班、会议室) O(n log n)
|
||||
def merge_intervals(intervals: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
out: list[tuple[int, int]] = []
|
||||
for s, e in sorted(intervals):
|
||||
if out and s <= out[-1][1]:
|
||||
out[-1] = (out[-1][0], max(out[-1][1], e))
|
||||
else:
|
||||
out.append((s, e))
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
assert two_sum([2, 7, 11, 15], 9) == (0, 1)
|
||||
assert longest_unique_substring("abcabcbb") == 3
|
||||
assert lower_bound([1, 3, 3, 5], 3) == 1
|
||||
assert top_k_frequent(["退货", "发票", "退货", "物流", "退货", "物流"], 2) == ["退货", "物流"]
|
||||
assert shortest_path([[0, 0, 0], [1, 1, 0], [0, 0, 0]], (0, 0), (2, 0)) == 6
|
||||
assert topo_order({"deploy": ["test"], "test": ["build"]}) == ["build", "test", "deploy"]
|
||||
assert edit_distance("kitten", "sitting") == 3
|
||||
assert merge_intervals([(1, 3), (2, 6), (8, 10)]) == [(1, 6), (8, 10)]
|
||||
print("全部自检通过")
|
||||
@@ -0,0 +1,63 @@
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from patterns import (LRUCache, edit_distance, longest_unique_substring, lower_bound, merge_intervals,
|
||||
shortest_path, top_k_frequent, topo_order, two_sum)
|
||||
|
||||
|
||||
def test_two_sum():
|
||||
assert two_sum([3, 3], 6) == (0, 1)
|
||||
assert two_sum([1, 2], 10) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("s,n", [("", 0), ("bbbb", 1), ("pwwkew", 3), ("abba", 2), ("你好你们好", 3)])
|
||||
def test_longest_unique(s, n):
|
||||
assert longest_unique_substring(s) == n
|
||||
|
||||
|
||||
def test_lower_bound_matches_bisect():
|
||||
import bisect
|
||||
rng = random.Random(0)
|
||||
for _ in range(200): # 与标准库对拍
|
||||
arr = sorted(rng.randint(0, 20) for _ in range(rng.randint(0, 15)))
|
||||
t = rng.randint(-1, 21)
|
||||
assert lower_bound(arr, t) == bisect.bisect_left(arr, t)
|
||||
|
||||
|
||||
def test_top_k():
|
||||
assert top_k_frequent([], 3) == []
|
||||
assert top_k_frequent(list("aaabbc"), 1) == ["a"]
|
||||
|
||||
|
||||
def test_shortest_path_blocked():
|
||||
assert shortest_path([[0, 1], [1, 0]], (0, 0), (1, 1)) == -1
|
||||
assert shortest_path([[0]], (0, 0), (0, 0)) == 0
|
||||
|
||||
|
||||
def test_topo_cycle_and_order():
|
||||
assert topo_order({"a": ["b"], "b": ["a"]}) is None
|
||||
order = topo_order({"app": ["db", "cache"], "cache": ["net"], "db": ["net"]})
|
||||
assert order is not None
|
||||
pos = {n: i for i, n in enumerate(order)}
|
||||
assert pos["net"] < pos["db"] < pos["app"] and pos["net"] < pos["cache"] < pos["app"]
|
||||
|
||||
|
||||
def test_edit_distance():
|
||||
assert edit_distance("", "abc") == 3
|
||||
assert edit_distance("退货申请", "退款申请") == 1
|
||||
|
||||
|
||||
def test_lru():
|
||||
c = LRUCache(2)
|
||||
c.put("a", 1)
|
||||
c.put("b", 2)
|
||||
assert c.get("a") == 1 # a 变成最近使用
|
||||
c.put("c", 3) # 淘汰 b
|
||||
assert c.get("b") is None and c.get("a") == 1 and c.get("c") == 3
|
||||
|
||||
|
||||
def test_merge_intervals():
|
||||
assert merge_intervals([]) == []
|
||||
assert merge_intervals([(1, 4), (4, 5)]) == [(1, 5)]
|
||||
assert merge_intervals([(5, 6), (1, 2)]) == [(1, 2), (5, 6)]
|
||||
@@ -4,12 +4,32 @@ title: 数据结构与算法(面试)
|
||||
type: index
|
||||
domain: cs
|
||||
stack: [algorithms]
|
||||
tags: []
|
||||
tags: [算法, 数据结构, 复杂度, 手写代码]
|
||||
week: [16]
|
||||
---
|
||||
|
||||
# 数据结构与算法(面试)
|
||||
|
||||
常考数据结构、复杂度、双指针、哈希、二分、BFS/DFS、动态规划入门。
|
||||
掌握九个高频模式、能讲复杂度、写出无 bug 的中等题。BOSS直聘 2026-10-23 的调研中有岗位提到算法 / 数据结构基础。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [复杂度分析和九个高频模式:哈希、滑动窗口、二分、堆、BFS、拓扑排序、动态规划、LRU、区间合并](01-complexity-patterns.md) | 基础 | 6 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [业务中的算法:热门问题 Top K、任务依赖排序、相似问题去重和缓存淘汰](scenarios/business-algorithms.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [数据结构与算法面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/patterns](examples/patterns/):9 个模式的实现和 13 个测试(只用标准库)
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 16 周:面试冲刺](../../../plan/week16-interview.md)
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: cs.algorithms.interview
|
||||
title: 数据结构与算法面试题
|
||||
type: interview
|
||||
domain: cs
|
||||
stack: [algorithms]
|
||||
tags: [算法, 数据结构, 手写代码, 面试]
|
||||
level: basic
|
||||
related: [cs.algorithms.complexity-patterns]
|
||||
week: [16]
|
||||
---
|
||||
|
||||
# 数据结构与算法面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】哈希表的原理是什么?冲突怎么处理?
|
||||
|
||||
**参考答案**:通过哈希函数把键映射到数组下标,平均 O(1) 查找。冲突处理:链地址法(同一位置挂链表 / 树)、开放寻址法(探测下一个空位)。负载因子过高时扩容并重新哈希。
|
||||
|
||||
**追问**:Python 的 dict 用哪种?→ 开放寻址;从 3.7 起保证插入顺序。
|
||||
|
||||
### 【基础】手写二分查找,找第一个大于等于 target 的位置。
|
||||
|
||||
**参考答案**:左闭右开 `[lo, hi)`,`arr[mid] < target` 时 `lo = mid + 1`,否则 `hi = mid`,循环条件 `lo < hi`,返回 `lo`。见[示例](examples/patterns/patterns.py)。
|
||||
|
||||
**追问**:怎么找最后一个等于 target 的位置?→ 找第一个大于 target 的位置再减 1,并检查该位置的值是否等于 target。
|
||||
|
||||
### 【基础】数组和链表的区别?
|
||||
|
||||
**参考答案**:数组连续存储,随机访问 O(1),中间插入删除 O(n),缓存友好;链表随机访问 O(n),已知节点时插入删除 O(1),额外的指针开销。
|
||||
|
||||
**追问**:LRU 为什么用哈希表 + 双向链表?→ 哈希表 O(1) 定位节点,双向链表 O(1) 移动和删除节点。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】实现 LRU 缓存,get 和 put 都是 O(1)。
|
||||
|
||||
**参考答案**:哈希表存键到节点的映射,双向链表维护使用顺序:访问时移到尾部,超容量时删除头部。Python 可用 `OrderedDict.move_to_end` 和 `popitem(last=False)`。
|
||||
|
||||
**追问**:多线程下怎么办?→ 加锁,或分段锁降低竞争;如果是分布式缓存,用 Redis 的淘汰策略(`allkeys-lru`)。
|
||||
|
||||
### 【进阶】如何从海量数据中找出出现次数最多的 K 个?
|
||||
|
||||
**参考答案**:哈希计数 + 大小为 K 的小顶堆,O(n log K)。数据放不进内存时:按哈希分片写入多个文件,分别统计 Top K,再合并。
|
||||
|
||||
**追问**:可以接受误差时?→ Count-Min Sketch 等近似计数结构,内存固定。
|
||||
|
||||
### 【进阶】怎么判断任务依赖中是否有环?
|
||||
|
||||
**参考答案**:DFS 三色标记(未访问、访问中、已完成),遇到“访问中”的节点说明有环;或者 Kahn 算法:不断移除入度为 0 的节点,最后仍有节点未移除说明有环。
|
||||
|
||||
**追问**:哪些业务会用到?→ 构建系统、CI 流水线、工作流引擎、数据管道调度。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】现场手写:无重复字符的最长子串。
|
||||
|
||||
**参考答案**:滑动窗口,记录每个字符最后出现的位置;遇到重复且在窗口内时,左边界移到重复位置的下一位;每步更新最大长度。O(n)。注意 "abba" 这类重复字符已在窗口外的情况。
|
||||
|
||||
**追问**:如果允许最多 K 个不同字符?→ 窗口内用哈希表计数,不同字符数超过 K 时收缩左边界。
|
||||
|
||||
### 【场景题】运营后台要显示每小时的热门问题 Top 20,怎么做?
|
||||
|
||||
**参考答案**:规范化文本 → 哈希计数 → 堆取 Top K;多实例用 Redis 有序集合按小时分 key 累加并设置过期;语义相近的问题再做聚类合并。详见[业务中的算法](scenarios/business-algorithms.md)。
|
||||
|
||||
**追问**:复杂度是多少?→ 计数 O(n),取 Top K 为 O(m log K),m 是不同问题的数量。
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: cs.algorithms.scenario.business-algorithms
|
||||
title: 业务中的算法:热门问题 Top K、任务依赖排序、相似问题去重和缓存淘汰
|
||||
type: scenario
|
||||
domain: cs
|
||||
stack: [algorithms, redis, langgraph]
|
||||
tags: [Top K, 拓扑排序, 编辑距离, LRU, 去重, 复杂度]
|
||||
level: intermediate
|
||||
prerequisites: [cs.algorithms.complexity-patterns]
|
||||
est_hours: 1.5
|
||||
related: [cs.algorithms.interview]
|
||||
week: [16]
|
||||
---
|
||||
|
||||
# 业务中的算法:热门问题 Top K、任务依赖排序、相似问题去重和缓存淘汰
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
AI 客服运营后台需要:
|
||||
|
||||
1. 每小时展示用户最常问的 20 个问题(每小时几十万条消息);
|
||||
2. 知识库导入任务有依赖(先解析、再分块、再嵌入、再建索引),要按正确顺序执行,并发现配置中的循环依赖;
|
||||
3. 标注 Bad Case 时,大量问题只是错别字或说法略有不同,需要聚合去重;
|
||||
4. 进程内缓存最近查询的文档,内存有限。
|
||||
|
||||
## 约束
|
||||
|
||||
- 统计要在秒级完成。
|
||||
- 去重不能误合并意思不同的问题。
|
||||
|
||||
## 方案设计
|
||||
|
||||
1. **Top K**:先对问题文本做规范化(去空白、统一全半角),用哈希表计数,再用大小为 K 的堆取前 K,O(n log K);多实例时用 Redis 有序集合 `ZINCRBY` + `ZREVRANGE` 汇总。
|
||||
2. **任务依赖**:用拓扑排序得到执行顺序,检测到环时拒绝保存配置;同层没有依赖关系的任务可以并行。
|
||||
3. **相似去重**:先按规范化文本精确去重;再对候选对计算编辑距离(短文本)或向量相似度(语义),超过阈值的合并为一组,人工确认后生效。编辑距离是 O(mn),只对长度相近的候选对计算,避免 O(n²) 两两比较全部。
|
||||
4. **缓存**:LRU 淘汰;Python 中可直接用 `functools.lru_cache` 或 `OrderedDict` 实现。
|
||||
|
||||
## 关键代码
|
||||
|
||||
```python
|
||||
from patterns import edit_distance, top_k_frequent, topo_order
|
||||
|
||||
hot = top_k_frequent((normalize(m.text) for m in messages), 20)
|
||||
|
||||
order = topo_order({"index": ["embed"], "embed": ["chunk"], "chunk": ["parse"]})
|
||||
if order is None:
|
||||
raise ValueError("任务配置存在循环依赖")
|
||||
|
||||
def similar(a: str, b: str) -> bool:
|
||||
if abs(len(a) - len(b)) > 2: # 先用长度剪枝
|
||||
return False
|
||||
return edit_distance(a, b) <= max(1, len(a) // 10)
|
||||
```
|
||||
|
||||
完整实现和测试:[examples/patterns](../examples/patterns/)。
|
||||
|
||||
## 取舍
|
||||
|
||||
| 选择 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| **精确计数 + 堆(本方案)** | 结果准确 | 内存随不同问题数增长 |
|
||||
| 近似算法(Count-Min Sketch 等) | 内存固定 | 有误差 |
|
||||
| 编辑距离去重 | 适合错别字 | 识别不了同义改写,需要配合向量相似度 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 没有规范化就计数,同一问题被拆成多个。
|
||||
- 对全部问题两两计算相似度,O(n²) 跑不完。
|
||||
- 去重阈值过松,把“退货”和“退款”合并(它们的编辑距离只有 1,但业务含义不同)——相似度只是候选,需要人工确认或结合业务规则。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “运营后台的热门问题,我先做文本规范化,用哈希表计数加大小为 K 的堆取 Top K,复杂度 O(n log K),多实例用 Redis 有序集合汇总。导入任务的依赖用拓扑排序确定顺序并检测环。Bad Case 去重先精确去重,再按长度剪枝后算编辑距离,阈值只产生候选,避免把‘退货’和‘退款’这种只差一个字但意思不同的问题合并。”
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
id: cs.network-os.http-tcp
|
||||
title: 网络基础:从输入 URL 到页面展示、TCP 握手挥手与粘包、HTTP/1.1 / 2 / 3、HTTPS、长连接与 SSE
|
||||
type: lesson
|
||||
domain: cs
|
||||
stack: [network-os]
|
||||
tags: [TCP, 三次握手, 四次挥手, TIME_WAIT, 粘包, HTTP/2, HTTP/3, HTTPS, TLS, DNS, keep-alive, SSE, WebSocket]
|
||||
level: basic
|
||||
prerequisites: []
|
||||
est_hours: 3
|
||||
related: [cs.network-os.os-basics, cs.network-os.scenario.connection-troubleshooting, devops.nginx.https-stream-limit]
|
||||
week: [14, 16]
|
||||
order: 1
|
||||
---
|
||||
|
||||
# 网络基础:从输入 URL 到页面展示、TCP 握手挥手与粘包、HTTP/1.1 / 2 / 3、HTTPS、长连接与 SSE
|
||||
|
||||
> BOSS直聘 2026-10-23 的调研中有岗位提到计算机基础(网络 / 操作系统)。前端出身的你对 HTTP 和浏览器很熟,这里补齐 TCP 和服务端视角。
|
||||
|
||||
## 解释说明
|
||||
|
||||
### 从输入 URL 到页面展示(经典题,按层回答)
|
||||
|
||||
1. **DNS 解析**:浏览器缓存 → 系统缓存 / hosts → 递归解析器 → 根 / 顶级域 / 权威服务器,得到 IP。
|
||||
2. **建立连接**:TCP 三次握手;HTTPS 再进行 TLS 握手(TLS 1.3 为 1 个往返);HTTP/3 基于 QUIC(UDP),连接和加密握手合并。
|
||||
3. **发送请求**,经过 CDN / 负载均衡 / 网关到达应用服务器。
|
||||
4. **服务器处理并响应**。
|
||||
5. **浏览器渲染**:解析 HTML 构建 DOM、CSS 构建 CSSOM、合成渲染树、布局、绘制、合成;遇到同步脚本会阻塞解析。
|
||||
|
||||
### TCP
|
||||
|
||||
- **三次握手**:SYN → SYN+ACK → ACK。为什么是三次:双方都要确认“对方能收到我发的”,并同步初始序号;两次无法防止过期的连接请求被误建立。
|
||||
- **四次挥手**:FIN → ACK → FIN → ACK(因为一方关闭发送后,另一方可能还有数据要发)。
|
||||
- **TIME_WAIT**:主动关闭方等待 2MSL,确保最后的 ACK 能被重传、旧连接的报文消失。短连接高并发时,主动关闭方(常常是作为客户端的服务)会积累大量 TIME_WAIT —— 解决办法是**连接复用**(连接池、keep-alive),而不是盲目修改内核参数。
|
||||
- **可靠传输**:序号与确认、超时重传、滑动窗口(流量控制)、拥塞控制(慢启动、拥塞避免)。
|
||||
- **字节流,没有消息边界**:“粘包 / 拆包”是应用层没有定义边界导致的,要用分隔符或长度前缀解决(示例中实际演示)。
|
||||
|
||||
### HTTP 演进
|
||||
|
||||
| 版本 | 关键点 |
|
||||
| --- | --- |
|
||||
| HTTP/1.1 | 持久连接(keep-alive);一个连接同一时间只能处理一个请求(队头阻塞),浏览器对同域开多个连接 |
|
||||
| HTTP/2 | 二进制分帧、多路复用(一个连接并发多个流)、头部压缩;TCP 层的丢包仍会阻塞所有流 |
|
||||
| HTTP/3 | 基于 QUIC(UDP),解决 TCP 层队头阻塞,连接迁移(切换网络不断开) |
|
||||
|
||||
### HTTPS
|
||||
|
||||
TLS 用非对称加密(或密钥交换)协商出会话密钥,再用对称加密传输数据;证书由 CA 签名,客户端校验证书链和域名,防止中间人。
|
||||
|
||||
### 长连接推送:轮询、SSE、WebSocket
|
||||
|
||||
| 方式 | 方向 | 适合 |
|
||||
| --- | --- | --- |
|
||||
| 轮询 | 客户端拉 | 简单、低频 |
|
||||
| **SSE** | 服务器 → 客户端,基于普通 HTTP,文本事件,浏览器自动重连 | **大模型流式输出**(OpenAI 等接口都用它) |
|
||||
| WebSocket | 双向,独立协议 | 聊天、协同编辑、实时语音 |
|
||||
|
||||
## 案例
|
||||
|
||||
AI 对话接口用 SSE 流式返回:经过 Nginx 时要关闭缓冲;HTTP/1.1 下浏览器对同一域名的并发连接数有限(通常 6 个),用户开多个标签页时 SSE 连接可能占满,HTTP/2 下多路复用没有这个问题。
|
||||
|
||||
## 代码示例
|
||||
|
||||
[examples/tcp-basics](examples/tcp-basics/)(只用标准库):
|
||||
|
||||
```text
|
||||
$ python framing.py
|
||||
发送方 send 了 3 次(每次 4 字节),接收方一次 recv 收到 12 字节 → 三条消息粘在一起,边界丢失
|
||||
长度前缀分帧:每次只读 3 字节,仍然还原出 3 条完整消息:['第0条', '第1条', '第2条']
|
||||
```
|
||||
|
||||
增量解码器(`test_framing.py` 用 100 种随机切分验证):
|
||||
|
||||
```python
|
||||
def feed(self, data: bytes) -> list[dict]:
|
||||
self.buf += data
|
||||
out = []
|
||||
while len(self.buf) >= HEADER.size:
|
||||
(n,) = HEADER.unpack_from(self.buf)
|
||||
if n > self.max_frame:
|
||||
raise ValueError("帧长度超过上限")
|
||||
if len(self.buf) < HEADER.size + n:
|
||||
break
|
||||
out.append(json.loads(bytes(self.buf[HEADER.size:HEADER.size + n])))
|
||||
del self.buf[:HEADER.size + n]
|
||||
return out
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- 认为一次 `send` 对应一次 `recv`。
|
||||
- 为了解决 TIME_WAIT 乱改内核参数,而不是复用连接。
|
||||
- 把 HTTP/2 的多路复用和 HTTP/1.1 的管线化混为一谈。
|
||||
- 不设置超时(连接超时、读超时),上游挂起时线程 / 协程被耗尽。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- 从输入 URL 到页面展示发生了什么?
|
||||
- 为什么 TCP 握手是三次、挥手是四次?TIME_WAIT 的作用?
|
||||
- HTTP/1.1、2、3 的区别?
|
||||
- SSE 和 WebSocket 的区别?大模型流式输出为什么常用 SSE?
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
id: cs.network-os.os-basics
|
||||
title: 操作系统基础:进程线程协程、I/O 模型与多路复用、内存与虚拟内存、文件描述符、死锁
|
||||
type: lesson
|
||||
domain: cs
|
||||
stack: [network-os]
|
||||
tags: [进程, 线程, 协程, GIL, I/O 多路复用, epoll, 虚拟内存, OOM, 文件描述符, 死锁]
|
||||
level: basic
|
||||
prerequisites: [cs.network-os.http-tcp]
|
||||
est_hours: 3
|
||||
related: [python.asyncio.index, devops.linux.troubleshooting, cs.network-os.interview]
|
||||
week: [14, 16]
|
||||
order: 2
|
||||
---
|
||||
|
||||
# 操作系统基础:进程线程协程、I/O 模型与多路复用、内存与虚拟内存、文件描述符、死锁
|
||||
|
||||
## 解释说明
|
||||
|
||||
### 进程、线程、协程
|
||||
|
||||
| | 进程 | 线程 | 协程 |
|
||||
| --- | --- | --- | --- |
|
||||
| 资源 | 独立地址空间 | 共享进程的内存 | 共享线程 |
|
||||
| 切换 | 内核,开销最大 | 内核,开销中等 | 用户态,开销最小 |
|
||||
| 通信 | 管道、套接字、共享内存 | 共享变量 + 锁 | 共享变量(单线程内无需锁,但要注意 await 之间的状态变化) |
|
||||
| 例子 | Gunicorn 多 worker | Java 线程池 | Go goroutine(多线程调度)、Python asyncio(单线程) |
|
||||
|
||||
**Python 的 GIL**:同一进程中同一时刻只有一个线程执行 Python 字节码;I/O 密集用线程或 asyncio 都可以,CPU 密集用多进程(Python 3.13 起有可选的无 GIL 自由线程构建,3.14 起官方支持,生态仍在适配,以官方文档为准)。
|
||||
|
||||
### I/O 模型
|
||||
|
||||
- 阻塞 I/O:一个连接占一个线程。
|
||||
- **I/O 多路复用**(select / poll / **epoll**):一个线程监视大量文件描述符,内核告诉你哪些就绪。Nginx、Redis、Node.js、asyncio 都基于它。
|
||||
- 异步 I/O(io_uring 等):提交请求,完成后通知。
|
||||
|
||||
### 内存
|
||||
|
||||
- **虚拟内存**:每个进程看到独立的地址空间,由页表映射到物理内存;缺页时从磁盘加载。
|
||||
- 栈(函数调用、局部变量)和堆(动态分配)。
|
||||
- **OOM**:内存不足时内核选择进程杀掉;容器超过内存上限被 OOMKilled(退出码 137)。
|
||||
|
||||
### 文件描述符
|
||||
|
||||
Linux 中一切皆文件:文件、套接字、管道都是文件描述符(fd)。每个进程有上限(`ulimit -n`),连接泄漏会导致 “Too many open files”。
|
||||
|
||||
### 死锁
|
||||
|
||||
四个必要条件:互斥、持有并等待、不可抢占、循环等待。预防:固定加锁顺序、加锁超时、减小锁粒度。数据库中的死锁同理(两个事务以相反顺序更新两行)。
|
||||
|
||||
## 案例
|
||||
|
||||
Python Agent 服务用 Uvicorn(asyncio):大量请求在等待模型返回(I/O),单个进程就能支撑很多并发连接;但如果在请求处理中同步调用了一个耗时的 CPU 操作(例如解析大 PDF),整个事件循环被阻塞,所有请求都卡住 —— 应该放到进程池或单独的任务队列。
|
||||
|
||||
## 代码示例
|
||||
|
||||
单线程 + `selectors`(Linux 上为 epoll)同时服务 50 个连接([multiplex_server.py](examples/tcp-basics/multiplex_server.py),运行通过):
|
||||
|
||||
```python
|
||||
sel = selectors.DefaultSelector()
|
||||
sel.register(srv, selectors.EVENT_READ, data=None)
|
||||
while not stop.is_set():
|
||||
for key, _ in sel.select(timeout=0.05):
|
||||
if key.data is None: # 新连接
|
||||
conn, _ = srv.accept()
|
||||
conn.setblocking(False)
|
||||
sel.register(conn, selectors.EVENT_READ, data="client")
|
||||
else: # 已有连接可读
|
||||
data = key.fileobj.recv(1024)
|
||||
...
|
||||
```
|
||||
|
||||
```text
|
||||
$ python multiplex_server.py
|
||||
单线程同时服务 50 个连接,全部回显正确
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- 在 asyncio 中调用阻塞函数(`time.sleep`、同步 HTTP 客户端、CPU 密集计算)。
|
||||
- 认为多线程一定能提升 Python 的 CPU 密集任务性能。
|
||||
- 连接、文件打开后不关闭,fd 耗尽。
|
||||
- 容器内存上限设得过小,进程被 OOMKilled 却在应用日志中找不到原因。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- 进程、线程、协程的区别?
|
||||
- 什么是 I/O 多路复用?epoll 相比 select 好在哪里?
|
||||
- 什么是死锁?怎么避免?
|
||||
- Python 的 GIL 是什么?对你的服务有什么影响?
|
||||
@@ -0,0 +1,92 @@
|
||||
"""TCP 是字节流:演示“粘包 / 拆包”现象,以及用“长度前缀”协议正确分帧。只用标准库,`python framing.py` 运行。
|
||||
|
||||
TCP 只保证字节按顺序可靠到达,不保留“消息边界”:发送方 send 了 3 次,接收方可能 1 次 recv 全部收到,
|
||||
也可能把一条消息分成多次收到。应用层必须自己定义边界:换行分隔(如 SSE、JSON Lines)或长度前缀(如很多 RPC 协议)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
|
||||
HEADER = struct.Struct("!I") # 4 字节无符号整数,网络字节序(大端)
|
||||
|
||||
|
||||
def encode(msg: dict) -> bytes:
|
||||
body = json.dumps(msg, ensure_ascii=False).encode()
|
||||
return HEADER.pack(len(body)) + body
|
||||
|
||||
|
||||
class FrameDecoder:
|
||||
"""增量解码:不管数据被怎样切分送进来,都能还原出完整的消息。"""
|
||||
|
||||
def __init__(self, max_frame: int = 1 << 20):
|
||||
self.buf = bytearray()
|
||||
self.max_frame = max_frame
|
||||
|
||||
def feed(self, data: bytes) -> list[dict]:
|
||||
self.buf += data
|
||||
out = []
|
||||
while len(self.buf) >= HEADER.size:
|
||||
(n,) = HEADER.unpack_from(self.buf)
|
||||
if n > self.max_frame:
|
||||
raise ValueError(f"帧长度 {n} 超过上限,可能是攻击或协议错误")
|
||||
if len(self.buf) < HEADER.size + n:
|
||||
break # 数据还不完整,等下一次
|
||||
body = bytes(self.buf[HEADER.size:HEADER.size + n])
|
||||
del self.buf[:HEADER.size + n]
|
||||
out.append(json.loads(body))
|
||||
return out
|
||||
|
||||
|
||||
def demo_sticky() -> int:
|
||||
"""连续 send 三次小消息,看接收端一次 recv 拿到几段。"""
|
||||
srv = socket.create_server(("127.0.0.1", 0))
|
||||
port = srv.getsockname()[1]
|
||||
received: list[bytes] = []
|
||||
|
||||
def server() -> None:
|
||||
conn, _ = srv.accept()
|
||||
with conn:
|
||||
threading.Event().wait(0.2) # 等发送方把三条都发完
|
||||
received.append(conn.recv(4096))
|
||||
|
||||
t = threading.Thread(target=server)
|
||||
t.start()
|
||||
with socket.create_connection(("127.0.0.1", port)) as c:
|
||||
for i in range(3):
|
||||
c.sendall(f"msg{i}".encode())
|
||||
t.join()
|
||||
srv.close()
|
||||
return len(received[0])
|
||||
|
||||
|
||||
def demo_framed() -> list[dict]:
|
||||
srv = socket.create_server(("127.0.0.1", 0))
|
||||
port = srv.getsockname()[1]
|
||||
got: list[dict] = []
|
||||
|
||||
def server() -> None:
|
||||
conn, _ = srv.accept()
|
||||
dec = FrameDecoder()
|
||||
with conn:
|
||||
while chunk := conn.recv(3): # 故意每次只读 3 字节,模拟拆包
|
||||
got.extend(dec.feed(chunk))
|
||||
|
||||
t = threading.Thread(target=server)
|
||||
t.start()
|
||||
with socket.create_connection(("127.0.0.1", port)) as c:
|
||||
c.sendall(b"".join(encode({"seq": i, "text": f"第{i}条"}) for i in range(3))) # 一次发出,模拟粘包
|
||||
t.join()
|
||||
srv.close()
|
||||
return got
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
n = demo_sticky()
|
||||
note = "三条消息粘在一起,边界丢失" if n > 4 else "这次恰好没有粘包,但不能依赖这一点"
|
||||
print(f"发送方 send 了 3 次(每次 4 字节),接收方一次 recv 收到 {n} 字节 → {note}")
|
||||
msgs = demo_framed()
|
||||
assert [m["seq"] for m in msgs] == [0, 1, 2], msgs
|
||||
print(f"长度前缀分帧:每次只读 3 字节,仍然还原出 {len(msgs)} 条完整消息:{[m['text'] for m in msgs]}")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""I/O 多路复用:单线程用 selectors(Linux 上是 epoll)同时服务多个连接的回显服务器。只用标准库,`python multiplex_server.py` 运行自检。
|
||||
|
||||
对比“一个连接一个线程”:多路复用由内核告诉我们哪些连接可读,一个线程就能处理大量空闲连接。
|
||||
Nginx、Redis、Node.js、Python asyncio 的事件循环都建立在这个机制之上。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import selectors
|
||||
import socket
|
||||
import threading
|
||||
|
||||
|
||||
def serve(srv: socket.socket, stop: threading.Event) -> int:
|
||||
sel = selectors.DefaultSelector()
|
||||
srv.setblocking(False)
|
||||
sel.register(srv, selectors.EVENT_READ, data=None)
|
||||
max_clients = 0
|
||||
while not stop.is_set():
|
||||
for key, _ in sel.select(timeout=0.05):
|
||||
if key.data is None: # 监听套接字可读 = 有新连接
|
||||
conn, _ = srv.accept()
|
||||
conn.setblocking(False)
|
||||
sel.register(conn, selectors.EVENT_READ, data="client")
|
||||
max_clients = max(max_clients, len(sel.get_map()) - 1)
|
||||
else:
|
||||
conn = key.fileobj
|
||||
data = conn.recv(1024) # type: ignore[union-attr]
|
||||
if data:
|
||||
conn.sendall(data.upper()) # type: ignore[union-attr] # 示例简化:小数据量下 sendall 不会阻塞太久
|
||||
else: # 对端关闭
|
||||
sel.unregister(conn)
|
||||
conn.close() # type: ignore[union-attr]
|
||||
sel.close()
|
||||
return max_clients
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
srv = socket.create_server(("127.0.0.1", 0))
|
||||
port = srv.getsockname()[1]
|
||||
stop = threading.Event()
|
||||
result: list[int] = []
|
||||
t = threading.Thread(target=lambda: result.append(serve(srv, stop)))
|
||||
t.start()
|
||||
clients = [socket.create_connection(("127.0.0.1", port)) for _ in range(50)] # 50 个连接同时在线
|
||||
for i, c in enumerate(clients):
|
||||
c.sendall(f"hi{i}".encode())
|
||||
replies = [c.recv(1024).decode() for c in clients]
|
||||
for c in clients:
|
||||
c.close()
|
||||
stop.set()
|
||||
t.join()
|
||||
srv.close()
|
||||
assert replies == [f"HI{i}" for i in range(50)]
|
||||
print(f"单线程同时服务 {result[0]} 个连接,全部回显正确")
|
||||
@@ -0,0 +1,23 @@
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from framing import FrameDecoder, encode
|
||||
|
||||
|
||||
def test_any_split_restores_messages():
|
||||
msgs = [{"seq": i, "text": "你好" * i} for i in range(20)]
|
||||
stream = b"".join(encode(m) for m in msgs)
|
||||
rng = random.Random(42)
|
||||
for _ in range(100): # 随机切分 100 种方式
|
||||
dec, out, i = FrameDecoder(), [], 0
|
||||
while i < len(stream):
|
||||
j = i + rng.randint(1, 17)
|
||||
out += dec.feed(stream[i:j])
|
||||
i = j
|
||||
assert out == msgs
|
||||
|
||||
|
||||
def test_rejects_oversized_frame():
|
||||
with pytest.raises(ValueError):
|
||||
FrameDecoder(max_frame=10).feed(encode({"text": "x" * 100}))
|
||||
@@ -4,12 +4,34 @@ title: 网络与操作系统基础
|
||||
type: index
|
||||
domain: cs
|
||||
stack: [network-os]
|
||||
tags: []
|
||||
tags: [TCP, HTTP, HTTPS, 进程线程, I/O 多路复用]
|
||||
week: [14, 16]
|
||||
---
|
||||
|
||||
# 网络与操作系统基础
|
||||
|
||||
HTTP/HTTPS、TCP/IP、DNS、进程线程、内存、IO 模型。
|
||||
HTTP/HTTPS、TCP、DNS、SSE 与 WebSocket;进程线程协程、I/O 多路复用、内存、文件描述符。BOSS直聘 2026-10-23 的调研中有岗位提到计算机基础。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [网络基础:从输入 URL 到页面展示、TCP 握手挥手与粘包、HTTP/1.1 / 2 / 3、HTTPS、长连接与 SSE](01-http-tcp.md) | 基础 | 3 小时 |
|
||||
| 2 | [操作系统基础:进程线程协程、I/O 模型与多路复用、内存与虚拟内存、文件描述符、死锁](02-os-basics.md) | 基础 | 3 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [线上连接问题排查:大量 TIME_WAIT、连接池耗尽、fd 用尽和事件循环阻塞](scenarios/connection-troubleshooting.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [网络与操作系统面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/tcp-basics](examples/tcp-basics/):粘包现象与长度前缀分帧(随机切分测试)、单线程 selectors 多路复用回显服务器(只用标准库)
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 14 周:后端扩展与部署](../../../plan/week14-backend-extend-deploy.md)
|
||||
- [第 16 周:面试冲刺](../../../plan/week16-interview.md)
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: cs.network-os.interview
|
||||
title: 网络与操作系统面试题
|
||||
type: interview
|
||||
domain: cs
|
||||
stack: [network-os]
|
||||
tags: [TCP, HTTP, HTTPS, 进程线程, I/O 多路复用, 面试]
|
||||
level: basic
|
||||
related: [cs.network-os.http-tcp, cs.network-os.os-basics]
|
||||
week: [14, 16]
|
||||
---
|
||||
|
||||
# 网络与操作系统面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】从输入 URL 到页面展示,发生了什么?
|
||||
|
||||
**参考答案**:DNS 解析 → TCP 握手(HTTPS 再 TLS 握手)→ 发送 HTTP 请求,经 CDN / 负载均衡 / 网关到应用 → 服务器处理返回 → 浏览器解析 HTML / CSS 构建 DOM 和 CSSOM、布局、绘制、合成,执行脚本。
|
||||
|
||||
**追问**:哪些环节可以优化?→ DNS 预解析、连接复用、HTTP/2、CDN、压缩、缓存、关键资源预加载、减少阻塞脚本。
|
||||
|
||||
### 【基础】为什么 TCP 握手三次、挥手四次?
|
||||
|
||||
**参考答案**:三次握手让双方都确认对方的收发能力并同步初始序号,同时防止过期的连接请求建立连接;挥手时一方发送 FIN 只表示自己不再发送,另一方可能还有数据要发,所以 ACK 和 FIN 分开发送。
|
||||
|
||||
**追问**:TIME_WAIT 有什么作用?→ 确保最后一个 ACK 丢失时能重传,并让旧连接的延迟报文在网络中消失,避免干扰新连接。
|
||||
|
||||
### 【基础】进程、线程、协程的区别?
|
||||
|
||||
**参考答案**:进程是资源分配单位,独立地址空间;线程是调度单位,共享进程资源,由内核切换;协程在用户态调度,切换开销最小,适合大量 I/O 等待。
|
||||
|
||||
**追问**:Go 的 goroutine 和 Python 的协程有什么不同?→ goroutine 由 Go 运行时调度到多个系统线程上,可以利用多核,遇到阻塞自动切换;Python asyncio 协程运行在单线程事件循环中,需要显式 `await`,阻塞调用会卡住整个循环。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】HTTP/1.1、HTTP/2、HTTP/3 的区别?
|
||||
|
||||
**参考答案**:1.1 有持久连接,但同一连接上请求串行(队头阻塞);2 使用二进制分帧和多路复用、头部压缩,但 TCP 丢包仍会阻塞全部流;3 基于 QUIC(UDP),解决传输层队头阻塞,握手更快,支持连接迁移。
|
||||
|
||||
**追问**:HTTP/2 下还需要合并资源、域名分片吗?→ 一般不需要,多路复用已经解决并发问题,域名分片反而增加连接开销。
|
||||
|
||||
### 【进阶】什么是 I/O 多路复用?epoll 为什么比 select 高效?
|
||||
|
||||
**参考答案**:一个线程监视多个 fd,就绪时再处理。select 每次调用都要把全部 fd 拷贝到内核并线性扫描,且有数量上限;epoll 在内核中维护关注列表,就绪的 fd 通过回调放入就绪队列,只返回就绪的,适合大量连接。
|
||||
|
||||
**追问**:水平触发和边缘触发?→ 水平触发:只要还可读就一直通知;边缘触发:状态变化时只通知一次,必须一次读完,否则可能丢失通知。
|
||||
|
||||
### 【进阶】什么是粘包?怎么解决?
|
||||
|
||||
**参考答案**:TCP 是字节流,没有消息边界,多条消息可能合并或被拆分到达。应用层定义边界:固定长度、分隔符(换行,如 SSE、JSON Lines)、长度前缀;接收方用缓冲区增量解析。
|
||||
|
||||
**追问**:长度前缀协议要注意什么安全问题?→ 限制最大帧长度,防止恶意的超大长度值导致内存耗尽。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】服务调用下游时出现大量 TIME_WAIT 和 “Cannot assign requested address”,怎么处理?
|
||||
|
||||
**参考答案**:这是作为主动关闭方的短连接过多、本地端口耗尽。根本办法是复用连接(共享 HTTP 客户端、连接池、keep-alive);同时检查超时设置和并发上限;内核参数调整只作为辅助。详见[连接问题排查](scenarios/connection-troubleshooting.md)。
|
||||
|
||||
**追问**:怎么确认修复有效?→ 压测对比 TIME_WAIT 数量、建连次数和 P99 延迟。
|
||||
|
||||
### 【场景题】异步 Python 服务中所有请求(包括健康检查)同时变慢,可能是什么原因?
|
||||
|
||||
**参考答案**:事件循环被阻塞:在协程中执行了同步 I/O 或 CPU 密集操作。排查:开启 asyncio 调试模式查看慢回调、采样分析器定位;修复:使用异步客户端、把 CPU 任务放到进程池或任务队列。
|
||||
|
||||
**追问**:为什么健康检查也慢?→ 它和其他请求运行在同一个事件循环中,循环被占用时所有协程都得不到调度。
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
id: cs.network-os.scenario.connection-troubleshooting
|
||||
title: 线上连接问题排查:大量 TIME_WAIT、连接池耗尽、fd 用尽和事件循环阻塞
|
||||
type: scenario
|
||||
domain: cs
|
||||
stack: [network-os, linux, fastapi]
|
||||
tags: [TIME_WAIT, 连接池, 文件描述符, 事件循环阻塞, 超时, 排查]
|
||||
level: intermediate
|
||||
prerequisites: [cs.network-os.http-tcp, cs.network-os.os-basics]
|
||||
est_hours: 2
|
||||
related: [cs.network-os.interview, devops.linux.scenario.incident-troubleshooting]
|
||||
week: [14, 16]
|
||||
---
|
||||
|
||||
# 线上连接问题排查:大量 TIME_WAIT、连接池耗尽、fd 用尽和事件循环阻塞
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
AI Agent 服务(FastAPI)高峰期偶发大量超时,日志中出现:
|
||||
|
||||
- 调用模型网关报 “Cannot assign requested address”;
|
||||
- 偶尔出现 “Too many open files”;
|
||||
- 所有请求的延迟同时变高,包括健康检查。
|
||||
|
||||
## 约束
|
||||
|
||||
- 不能停机排查。
|
||||
- 运维只给了容器内的只读排查权限。
|
||||
|
||||
## 方案设计
|
||||
|
||||
1. **看连接状态**:`ss -s`、`ss -tan state time-wait | wc -l` 发现几万个 TIME_WAIT,目标都是模型网关 → 代码中每次请求都新建 `httpx.AsyncClient`,用完就关闭,没有复用连接。**修复**:应用级共享一个客户端(连接池),设置池大小和 keep-alive。
|
||||
2. **看 fd**:`ls /proc/<pid>/fd | wc -l` 与 `ulimit -n` 对比 → 文件上传后临时文件没有关闭。**修复**:使用 `with` 管理资源;在监控中加入 fd 数量。
|
||||
3. **看事件循环**:所有请求同时变慢,说明事件循环被阻塞 → 用 asyncio 调试模式 / 采样分析发现同步的 PDF 解析。**修复**:移到进程池(`run_in_executor`)或任务队列。
|
||||
4. **超时与隔离**:所有外部调用设置连接超时和读超时;对模型网关设置并发上限(信号量),防止一个慢依赖耗尽所有资源。
|
||||
5. **验证**:压测对比修复前后的 TIME_WAIT 数量、fd 数量、P99 延迟。
|
||||
|
||||
## 关键代码
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 整个应用共享一个客户端:复用 TCP / TLS 连接
|
||||
app.state.llm = httpx.AsyncClient(
|
||||
base_url=LLM_GATEWAY,
|
||||
timeout=httpx.Timeout(connect=3, read=60, write=10, pool=5),
|
||||
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
|
||||
)
|
||||
yield
|
||||
await app.state.llm.aclose()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
@app.post("/parse")
|
||||
async def parse(file: UploadFile):
|
||||
data = await file.read()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(process_pool, parse_pdf, data) # CPU 密集任务不阻塞事件循环
|
||||
```
|
||||
|
||||
相关原理:[网络基础](../01-http-tcp.md)、[操作系统基础](../02-os-basics.md);Linux 排查命令见 [Linux 线上排查](../../../devops/linux/scenarios/incident-troubleshooting.md)。
|
||||
|
||||
## 取舍
|
||||
|
||||
| 选择 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| **复用连接(本方案)** | 根治 TIME_WAIT,同时降低延迟(省去握手) | 要管理客户端生命周期 |
|
||||
| 调整内核参数(端口范围、TIME_WAIT 复用) | 立刻见效 | 治标不治本,有副作用,容器内往往无权限 |
|
||||
| 增加实例 | 简单 | 掩盖问题,成本上升 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 看到 TIME_WAIT 就认为是服务器的问题——它出现在主动关闭的一方。
|
||||
- 共享的客户端没有设置池上限,慢依赖拖垮整个服务。
|
||||
- 只设置了总超时,没有区分连接超时和读超时(流式响应的读超时要考虑 token 间隔)。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “Agent 服务高峰期出现‘Cannot assign requested address’,用 ss 看到几万个 TIME_WAIT 指向模型网关,原因是每次请求新建 HTTP 客户端。改成应用级共享客户端并配置连接池和分阶段超时后,TIME_WAIT 基本消失,延迟也下降了。另外发现同步 PDF 解析阻塞事件循环,导致所有请求同时变慢,移到进程池解决。修复后用压测对比了连接数、fd 数和 P99。”
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: cs.web-security.common-attacks
|
||||
title: Web 安全基础:XSS、CSRF、SQL 注入、CORS、SSRF 的原理、防御和在 AI 应用中的新形态
|
||||
type: lesson
|
||||
domain: cs
|
||||
stack: [web-security]
|
||||
tags: [XSS, CSRF, SQL 注入, CORS, SSRF, CSP, SameSite, 参数化查询, OWASP]
|
||||
level: basic
|
||||
prerequisites: [cs.network-os.http-tcp]
|
||||
est_hours: 3
|
||||
related: [cs.web-security.scenario.ai-app-security, ai.prompt-engineering.scenario.injection-defense, cs.web-security.interview]
|
||||
week: [7, 14, 16]
|
||||
order: 1
|
||||
---
|
||||
|
||||
# Web 安全基础:XSS、CSRF、SQL 注入、CORS、SSRF 的原理、防御和在 AI 应用中的新形态
|
||||
|
||||
> BOSS直聘 2026-10-23 的调研中有岗位提到 Web 安全基础。AI 应用让老问题出现了新入口:模型输出可能包含恶意 HTML(XSS),Agent 的抓取工具可能被诱导访问内网(SSRF),用户输入可能拼接进模型生成的 SQL。参考:OWASP Top 10(https://owasp.org/www-project-top-ten/)。
|
||||
|
||||
## 解释说明
|
||||
|
||||
| 攻击 | 原理 | 防御 |
|
||||
| --- | --- | --- |
|
||||
| **XSS**(跨站脚本) | 攻击者的脚本被当作页面的一部分执行,可以窃取数据、冒充用户操作 | 输出时按上下文转义(框架默认转义,慎用 `v-html` / `dangerouslySetInnerHTML`);富文本用白名单清洗;CSP;Cookie 设 HttpOnly |
|
||||
| **CSRF**(跨站请求伪造) | 用户已登录,恶意网站让浏览器带着 Cookie 向目标站点发请求 | Cookie `SameSite=Lax/Strict`;CSRF Token(同步令牌或双重提交);校验 Origin;改变状态的操作不用 GET |
|
||||
| **SQL 注入** | 用户输入被拼接成 SQL 执行 | **参数化查询**;ORM;最小权限的数据库账号;不能参数化的部分(表名、排序字段)用白名单 |
|
||||
| **CORS 配置错误** | CORS 是浏览器放宽同源策略的机制;配置成“任意来源 + 允许凭证”或反射请求的 Origin,等于允许任何网站读取用户数据 | 来源白名单;带凭证时不能用 `*`;只开放需要的方法和请求头 |
|
||||
| **SSRF**(服务端请求伪造) | 诱导服务器访问内网地址(数据库、管理接口、云元数据 `169.254.169.254`) | 协议白名单;解析域名后检查所有 IP 是否为公网;禁止或逐跳校验重定向;网络层出口限制 |
|
||||
|
||||
**注意**:CORS 不是安全防护,而是对同源策略的“放宽”;服务端仍然必须做鉴权。CORS 也不能防 CSRF(简单请求照样会发出,只是读不到响应)。
|
||||
|
||||
### AI 应用中的新形态
|
||||
|
||||
- 模型输出渲染成 HTML / Markdown 时的 XSS:Markdown 中的原始 HTML、`javascript:` 链接、图片链接外带数据(``)。
|
||||
- 提示词注入诱导 Agent 调用工具:抓取内网地址(SSRF)、执行越权操作。见[提示词注入防护](../../ai/prompt-engineering/scenarios/injection-defense.md)。
|
||||
- Text-to-SQL:模型生成的 SQL 要用只读账号、白名单表、限制行数,并在执行前做语法分析。
|
||||
|
||||
## 案例
|
||||
|
||||
AI 客服页面把模型回答作为 Markdown 渲染。有人在知识库文档中埋入 `<img src=x onerror=...>`,模型在回答中原样引用,所有看到回答的用户都执行了这段脚本。修复:渲染前清洗(禁用原始 HTML,过滤危险协议的链接)、CSP 禁止内联脚本、会话 Cookie 设 HttpOnly。
|
||||
|
||||
## 代码示例
|
||||
|
||||
[examples/secure-api](examples/secure-api/)(FastAPI + sqlite3,13 个测试通过;每类问题都先证明“错误写法可被利用”,再证明“正确写法能防住”):
|
||||
|
||||
```python
|
||||
# SQL 注入:拼接 vs 参数化
|
||||
db.execute(f"... WHERE tenant = '{tenant}' AND id LIKE '%{keyword}%'") # keyword = "x' OR 1=1 --" 读到其他租户
|
||||
db.execute("... WHERE tenant = ? AND id LIKE ?", (tenant, f"%{keyword}%")) # 安全
|
||||
|
||||
# SSRF:解析后检查所有 IP
|
||||
for info in socket.getaddrinfo(host, port):
|
||||
if not ipaddress.ip_address(info[4][0]).is_global:
|
||||
raise ValueError("禁止访问内网地址")
|
||||
```
|
||||
|
||||
CORS 白名单 + CSRF 双重提交:
|
||||
|
||||
```python
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"], allow_credentials=True, ...)
|
||||
|
||||
if not secrets.compare_digest(csrf_cookie, x_csrf_token): # 跨站页面读不到 csrf_token Cookie,无法设置请求头
|
||||
raise HTTPException(403, "CSRF 校验失败")
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- 只在前端校验输入。
|
||||
- 用黑名单过滤 XSS(`replace("<script>", "")`),总能被绕过。
|
||||
- 用 ORM 却在原生查询处拼接字符串。
|
||||
- `allow_origins=["*"]` 加 `allow_credentials=True`,或者把请求的 Origin 原样反射回去。
|
||||
- SSRF 只校验域名字符串,不校验解析后的 IP;或者允许跟随重定向。
|
||||
- 把 JWT 存在 localStorage 中,一旦有 XSS 就被窃取(是否这样做要权衡,至少要有 CSP)。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- XSS 有哪几类?怎么防御?
|
||||
- CSRF 的原理?SameSite 能完全防住吗?
|
||||
- 为什么参数化查询能防 SQL 注入?
|
||||
- CORS 是什么?它能防 CSRF 吗?
|
||||
- 你的 Agent 有抓取网页的工具,怎么防止 SSRF?
|
||||
@@ -0,0 +1,126 @@
|
||||
# requires: fastapi, httpx
|
||||
"""Web 安全示例:SQL 注入、XSS、CSRF、CORS、SSRF(Agent 的“抓取网页”工具最容易出这个问题)。
|
||||
|
||||
每类问题都给出“错误写法”和“正确写法”,test_secure_app.py 用测试证明错误写法可被利用、正确写法可以防住。
|
||||
运行:pytest -q test_secure_app.py;直接 `python secure_app.py` 只做一次自检。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import ipaddress
|
||||
import secrets
|
||||
import socket
|
||||
import sqlite3
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import Cookie, FastAPI, Form, Header, HTTPException, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
# ---------- SQL 注入 ----------
|
||||
def make_db() -> sqlite3.Connection:
|
||||
db = sqlite3.connect(":memory:", check_same_thread=False)
|
||||
db.executescript("""
|
||||
CREATE TABLE orders (id TEXT, tenant TEXT, amount INTEGER);
|
||||
INSERT INTO orders VALUES ('A001', 't1', 100), ('A002', 't1', 200), ('B001', 't2', 999);
|
||||
""")
|
||||
return db
|
||||
|
||||
|
||||
def find_orders_unsafe(db: sqlite3.Connection, tenant: str, keyword: str) -> list[tuple]:
|
||||
# 错误:字符串拼接。keyword = "x' OR 1=1 --" 时会查出其他租户的数据
|
||||
return db.execute(f"SELECT id, tenant FROM orders WHERE tenant = '{tenant}' AND id LIKE '%{keyword}%'").fetchall()
|
||||
|
||||
|
||||
def find_orders_safe(db: sqlite3.Connection, tenant: str, keyword: str) -> list[tuple]:
|
||||
# 正确:参数化查询,数据永远不会被当成 SQL 执行
|
||||
return db.execute("SELECT id, tenant FROM orders WHERE tenant = ? AND id LIKE ?", (tenant, f"%{keyword}%")).fetchall()
|
||||
|
||||
|
||||
# ---------- XSS ----------
|
||||
def render_answer_unsafe(answer: str) -> str:
|
||||
return f"<div class='answer'>{answer}</div>" # 错误:模型输出 / 用户输入直接拼进 HTML
|
||||
|
||||
|
||||
def render_answer_safe(answer: str) -> str:
|
||||
return f"<div class='answer'>{html.escape(answer)}</div>" # 正确:输出到 HTML 时转义
|
||||
|
||||
|
||||
# ---------- SSRF ----------
|
||||
ALLOWED_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
def check_fetch_url(url: str, resolve=socket.getaddrinfo) -> str:
|
||||
"""Agent 抓取网页工具的 URL 校验:只允许 http(s),解析后的所有 IP 都必须是公网地址。"""
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme not in ALLOWED_SCHEMES or not parts.hostname:
|
||||
raise ValueError("只允许 http / https 地址")
|
||||
if parts.username or parts.password:
|
||||
raise ValueError("不允许在 URL 中携带凭证")
|
||||
infos = resolve(parts.hostname, parts.port or (443 if parts.scheme == "https" else 80))
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if not ip.is_global: # 拒绝内网、回环、链路本地(含云元数据地址 169.254.169.254)
|
||||
raise ValueError(f"禁止访问内网地址 {ip}")
|
||||
# 注意:真正发请求时还要禁止自动跟随重定向(或对每一跳重新校验),并固定使用这里解析出的 IP,防止 DNS 重绑定
|
||||
return url
|
||||
|
||||
|
||||
# ---------- 应用:CORS、CSRF、安全响应头 ----------
|
||||
TRUSTED_ORIGINS = ["https://app.example.com"]
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=TRUSTED_ORIGINS, # 白名单;带凭证时绝不能用 "*"
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["Content-Type", "X-CSRF-Token"],
|
||||
)
|
||||
DB = make_db()
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request, call_next):
|
||||
resp = await call_next(request)
|
||||
resp.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; object-src 'none'" # XSS 的第二道防线
|
||||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||
return resp
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login(response: Response) -> dict:
|
||||
csrf = secrets.token_urlsafe(16)
|
||||
# 会话 Cookie:HttpOnly(脚本读不到)、Secure、SameSite=Lax(跨站的 POST 不会带上)
|
||||
response.set_cookie("session", "user-1", httponly=True, secure=True, samesite="lax")
|
||||
# CSRF Token(双重提交):前端读取后放在请求头中,跨站页面拿不到这个值
|
||||
response.set_cookie("csrf_token", csrf, secure=True, samesite="lax")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/transfer")
|
||||
def transfer(amount: int = Form(...), session: str | None = Cookie(None), csrf_cookie: str | None = Cookie(None, alias="csrf_token"),
|
||||
x_csrf_token: str | None = Header(None)) -> dict:
|
||||
if not session:
|
||||
raise HTTPException(401, "未登录")
|
||||
if not csrf_cookie or not x_csrf_token or not secrets.compare_digest(csrf_cookie, x_csrf_token):
|
||||
raise HTTPException(403, "CSRF 校验失败")
|
||||
return {"ok": True, "amount": amount}
|
||||
|
||||
|
||||
@app.get("/orders")
|
||||
def orders(keyword: str = "") -> list[list[str]]:
|
||||
return [list(r) for r in find_orders_safe(DB, "t1", keyword)]
|
||||
|
||||
|
||||
@app.get("/answer", response_class=HTMLResponse)
|
||||
def answer(q: str = "") -> str:
|
||||
return render_answer_safe(f"你问的是:{q}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
payload = "x' OR 1=1 --"
|
||||
assert {r[1] for r in find_orders_unsafe(DB, "t1", payload)} == {"t1", "t2"}
|
||||
assert find_orders_safe(DB, "t1", payload) == []
|
||||
assert "<script>" not in render_answer_safe("<script>alert(1)</script>")
|
||||
print("自检通过:拼接 SQL 被注入读到了 t2 的数据,参数化查询没有;XSS 输出已转义")
|
||||
@@ -0,0 +1,71 @@
|
||||
# requires: fastapi, httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from secure_app import (DB, app, check_fetch_url, find_orders_safe, find_orders_unsafe, render_answer_safe,
|
||||
render_answer_unsafe)
|
||||
|
||||
client = TestClient(app, base_url="https://testserver")
|
||||
|
||||
|
||||
def test_sql_injection_unsafe_leaks_other_tenant():
|
||||
rows = find_orders_unsafe(DB, "t1", "x' OR 1=1 --")
|
||||
assert ("B001", "t2") in rows # 证明漏洞真实存在
|
||||
|
||||
|
||||
def test_sql_injection_safe():
|
||||
assert find_orders_safe(DB, "t1", "x' OR 1=1 --") == []
|
||||
assert find_orders_safe(DB, "t1", "A00") == [("A001", "t1"), ("A002", "t1")]
|
||||
|
||||
|
||||
def test_xss_escaped():
|
||||
evil = '<img src=x onerror="alert(document.cookie)">'
|
||||
assert "<img" in render_answer_unsafe(evil)
|
||||
assert "<img" not in render_answer_safe(evil) and "<img" in render_answer_safe(evil)
|
||||
r = client.get("/answer", params={"q": "<script>alert(1)</script>"})
|
||||
assert "<script>" not in r.text and "script-src 'self'" in r.headers["content-security-policy"]
|
||||
|
||||
|
||||
def test_csrf():
|
||||
c = TestClient(app, base_url="https://testserver")
|
||||
c.post("/login")
|
||||
# 模拟跨站伪造:浏览器会自动带上 Cookie,但攻击页面读不到 csrf_token,无法设置请求头
|
||||
assert c.post("/transfer", data={"amount": 100}).status_code == 403
|
||||
token = c.cookies.get("csrf_token")
|
||||
r = c.post("/transfer", data={"amount": 100}, headers={"X-CSRF-Token": token})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_cors_allowlist():
|
||||
ok = client.get("/orders", headers={"Origin": "https://app.example.com"})
|
||||
assert ok.headers.get("access-control-allow-origin") == "https://app.example.com"
|
||||
evil = client.get("/orders", headers={"Origin": "https://evil.example"})
|
||||
assert "access-control-allow-origin" not in evil.headers
|
||||
pre = client.options("/orders", headers={"Origin": "https://evil.example", "Access-Control-Request-Method": "GET"})
|
||||
assert pre.status_code == 400
|
||||
|
||||
|
||||
def fake_resolve(mapping):
|
||||
return lambda host, port: [(None, None, None, "", (mapping[host], port))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url,ip", [
|
||||
("http://169.254.169.254/latest/meta-data/", "169.254.169.254"), # 云元数据
|
||||
("http://localhost:6379/", "127.0.0.1"),
|
||||
("http://intranet.corp/", "10.0.0.8"),
|
||||
("http://rebind.example/", "192.168.1.10"), # 公网域名解析到内网
|
||||
])
|
||||
def test_ssrf_blocks_internal(url, ip):
|
||||
host = url.split("/")[2].split(":")[0]
|
||||
with pytest.raises(ValueError):
|
||||
check_fetch_url(url, resolve=fake_resolve({host: ip}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["file:///etc/passwd", "gopher://x/", "http://user:pw@example.com/"])
|
||||
def test_ssrf_blocks_schemes_and_credentials(url):
|
||||
with pytest.raises(ValueError):
|
||||
check_fetch_url(url, resolve=fake_resolve({"example.com": "93.184.215.14", "x": "93.184.215.14"}))
|
||||
|
||||
|
||||
def test_ssrf_allows_public():
|
||||
assert check_fetch_url("https://example.com/a", resolve=fake_resolve({"example.com": "93.184.215.14"}))
|
||||
@@ -1,15 +1,42 @@
|
||||
---
|
||||
id: cs.web-security.index
|
||||
title: Web 安全
|
||||
title: Web 安全基础
|
||||
type: index
|
||||
domain: cs
|
||||
stack: [web-security]
|
||||
tags: []
|
||||
tags: [XSS, CSRF, SQL 注入, CORS, SSRF, OWASP]
|
||||
week: [7, 14, 16]
|
||||
---
|
||||
|
||||
# Web 安全
|
||||
# Web 安全基础
|
||||
|
||||
XSS、CSRF、SQL 注入、CORS、鉴权与会话安全、大模型应用的提示词注入。
|
||||
XSS、CSRF、SQL 注入、CORS、SSRF 的原理和防御,以及它们在 AI 应用中的新形态(模型输出渲染、Agent 工具、Text-to-SQL)。BOSS直聘 2026-10-23 的调研中有岗位提到 Web 安全基础。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [Web 安全基础:XSS、CSRF、SQL 注入、CORS、SSRF 的原理、防御和在 AI 应用中的新形态](01-xss-csrf-sqli-cors-ssrf.md) | 基础 | 3 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [AI 客服上线前的安全评审:回答渲染 XSS、抓取工具 SSRF、Text-to-SQL 注入和跨域配置](scenarios/ai-app-security.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [Web 安全面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/secure-api](examples/secure-api/):每类攻击的错误写法与正确写法,13 个测试证明漏洞可利用、修复有效(FastAPI + sqlite3)
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 7 周:前端工程化查漏](../../../plan/week07-frontend.md)
|
||||
- [第 14 周:后端扩展与部署](../../../plan/week14-backend-extend-deploy.md)
|
||||
- [第 16 周:面试冲刺](../../../plan/week16-interview.md)
|
||||
|
||||
## 参考
|
||||
|
||||
- OWASP Top 10:https://owasp.org/www-project-top-ten/
|
||||
- OWASP LLM 应用 Top 10:https://genai.owasp.org/
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: cs.web-security.interview
|
||||
title: Web 安全面试题
|
||||
type: interview
|
||||
domain: cs
|
||||
stack: [web-security]
|
||||
tags: [XSS, CSRF, SQL 注入, CORS, SSRF, 面试]
|
||||
level: basic
|
||||
related: [cs.web-security.common-attacks]
|
||||
week: [7, 14, 16]
|
||||
---
|
||||
|
||||
# Web 安全面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】XSS 有哪几类?怎么防御?
|
||||
|
||||
**参考答案**:存储型(恶意内容存入数据库,展示给其他用户)、反射型(通过 URL 参数立即反射)、DOM 型(前端脚本把不可信数据写入 DOM)。防御:按上下文输出转义、富文本白名单清洗、避免 `innerHTML` 类 API、CSP、Cookie HttpOnly。
|
||||
|
||||
**追问**:Vue / React 默认安全吗?→ 文本插值默认转义;`v-html`、`dangerouslySetInnerHTML`、动态 `href`(`javascript:` 协议)仍有风险。
|
||||
|
||||
### 【基础】CSRF 的原理和防御?
|
||||
|
||||
**参考答案**:利用浏览器自动携带 Cookie 的特性,在恶意网站上向目标站点发起请求。防御:SameSite Cookie、CSRF Token(同步令牌或双重提交)、校验 Origin / Referer、状态变更不用 GET。
|
||||
|
||||
**追问**:用 `Authorization` 请求头传 Token(不用 Cookie)还有 CSRF 问题吗?→ 基本没有,因为浏览器不会自动附加该请求头;但 Token 存储在前端,要防 XSS。
|
||||
|
||||
### 【基础】为什么参数化查询能防 SQL 注入?
|
||||
|
||||
**参考答案**:SQL 结构和数据分开发送,数据库先编译语句结构,参数只会被当作值,不会改变语义。
|
||||
|
||||
**追问**:排序字段(ORDER BY)能参数化吗?→ 标识符一般不能参数化,要用白名单映射。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】CORS 是什么?常见的错误配置有哪些?
|
||||
|
||||
**参考答案**:浏览器的同源策略默认禁止脚本读取跨源响应,CORS 让服务器通过响应头声明允许哪些来源读取。错误配置:任意来源 + 允许凭证、原样反射请求的 Origin、白名单使用不严格的匹配(例如只检查后缀)。
|
||||
|
||||
**追问**:预检请求什么时候发生?→ 非简单请求(例如 `Content-Type: application/json`、自定义请求头、PUT / DELETE)时,浏览器先发 OPTIONS 询问。
|
||||
|
||||
### 【进阶】什么是 SSRF?怎么防?
|
||||
|
||||
**参考答案**:让服务器替攻击者访问其本不该访问的地址(内网服务、云元数据)。防御:协议白名单、解析域名后检查 IP 是否公网、禁止或逐跳校验重定向、固定使用校验过的 IP 连接(防 DNS 重绑定)、网络层限制出口。
|
||||
|
||||
**追问**:为什么只检查 URL 中的主机名不够?→ 公网域名可以解析到内网 IP;IP 有多种写法(十进制、IPv6 映射);重定向可以跳到内网。
|
||||
|
||||
### 【进阶】JWT 存在 localStorage 还是 Cookie?
|
||||
|
||||
**参考答案**:localStorage:不受 CSRF 影响,但 XSS 可以直接读取;HttpOnly Cookie:脚本读不到,但要防 CSRF(SameSite + Token)。多数场景推荐 HttpOnly + Secure + SameSite 的 Cookie,加 CSRF 防护和 CSP。
|
||||
|
||||
**追问**:Token 泄露了怎么办?→ 短有效期 + 刷新令牌、服务端维护吊销列表或会话版本号。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】AI 客服的回答以 Markdown 渲染,怎么防止 XSS?
|
||||
|
||||
**参考答案**:渲染器禁用原始 HTML,渲染后用白名单清洗,链接只允许 http(s),图片限制域名(防止数据外带),CSP 禁止内联脚本,Cookie HttpOnly;写成自动化测试。详见[AI 应用安全评审](scenarios/ai-app-security.md)。
|
||||
|
||||
**追问**:为什么图片也要限制?→ 攻击者可以通过提示词注入让模型输出 ``,浏览器加载图片时就把数据发出去了。
|
||||
|
||||
### 【场景题】你的 Agent 可以“访问用户提供的链接”,评审时要考虑什么?
|
||||
|
||||
**参考答案**:SSRF(内网和元数据地址)、重定向、DNS 重绑定、响应大小和超时、内容中的提示词注入(抓回来的网页可能包含“忽略之前的指令”)、隐私(是否允许访问带有个人信息的链接)。措施:URL 校验 + 出口代理 + 内容作为不可信数据处理 + 工具调用审计。
|
||||
|
||||
**追问**:怎么证明防住了?→ 用攻击载荷清单(内网 IP、各种 IP 写法、重定向到内网、非 http 协议)写成测试,在 CI 中运行,见[示例测试](examples/secure-api/test_secure_app.py)。
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
id: cs.web-security.scenario.ai-app-security
|
||||
title: AI 客服上线前的安全评审:回答渲染 XSS、抓取工具 SSRF、Text-to-SQL 注入和跨域配置
|
||||
type: scenario
|
||||
domain: cs
|
||||
stack: [web-security, fastapi, vue, function-calling]
|
||||
tags: [安全评审, XSS, SSRF, Text-to-SQL, CORS, CSP, 最小权限]
|
||||
level: intermediate
|
||||
prerequisites: [cs.web-security.common-attacks]
|
||||
est_hours: 2.5
|
||||
related: [cs.web-security.interview, ai.function-calling.scenario.safe-business-tools, ai.prompt-engineering.scenario.injection-defense]
|
||||
week: [14, 16]
|
||||
---
|
||||
|
||||
# AI 客服上线前的安全评审:回答渲染 XSS、抓取工具 SSRF、Text-to-SQL 注入和跨域配置
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
AI 客服即将对外上线,安全团队评审发现:
|
||||
|
||||
1. 回答用 `v-html` 渲染 Markdown 转换后的 HTML,知识库中的 HTML 片段会原样执行;
|
||||
2. Agent 有“打开链接查看内容”的工具,测试人员让它访问 `http://169.254.169.254/` 拿到了云主机元数据;
|
||||
3. 报表功能让模型生成 SQL 直接执行,账号有写权限;
|
||||
4. 后端 CORS 配置为允许所有来源并允许凭证。
|
||||
|
||||
## 约束
|
||||
|
||||
- 一周内上线,不能大改架构。
|
||||
- 功能不能砍。
|
||||
|
||||
## 方案设计
|
||||
|
||||
1. **回答渲染(XSS)**:Markdown 渲染时禁用原始 HTML;对生成的 HTML 用白名单清洗(只保留段落、列表、代码、链接等);链接只允许 `http(s)`,外链加 `rel="noopener noreferrer"`;图片只允许白名单域名(防止通过图片 URL 外带数据);全站 CSP 禁止内联脚本;会话 Cookie HttpOnly。
|
||||
2. **抓取工具(SSRF)**:协议白名单;解析域名后所有 IP 必须是公网;禁止自动重定向(或每一跳重新校验);连接时使用已校验的 IP,防止 DNS 重绑定;工具运行在只能访问公网的出口代理后面(网络层兜底);响应大小和超时限制。
|
||||
3. **Text-to-SQL**:只读数据库账号,且只授权报表视图;SQL 先解析,只允许单条 `SELECT`、白名单表、强制 `LIMIT` 和租户条件;执行超时;记录审计日志。
|
||||
4. **CORS**:白名单为自己的前端域名;同时启用 CSRF 防护(SameSite + Token)。
|
||||
5. **验证**:把每个问题写成自动化安全测试(攻击载荷 → 预期被拒),加入 CI。
|
||||
|
||||
## 关键代码
|
||||
|
||||
回答渲染(前端,示意):
|
||||
|
||||
```ts
|
||||
import DOMPurify from "dompurify";
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
const md = new MarkdownIt({ html: false, linkify: true }); // 不允许原始 HTML
|
||||
|
||||
export function renderAnswer(text: string): string {
|
||||
return DOMPurify.sanitize(md.render(text), {
|
||||
ALLOWED_TAGS: ["p", "ul", "ol", "li", "code", "pre", "strong", "em", "a", "blockquote", "table", "thead", "tbody", "tr", "th", "td"],
|
||||
ALLOWED_ATTR: ["href"],
|
||||
ALLOWED_URI_REGEXP: /^https?:\/\//i, // 只允许 http(s) 链接
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
SSRF 校验与 SQL 注入、CORS、CSRF 的可运行实现和攻击测试:[examples/secure-api](../examples/secure-api/)。
|
||||
|
||||
## 取舍
|
||||
|
||||
| 选择 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| **应用层校验 + 网络层出口限制(本方案)** | 纵深防御,一层失效还有另一层 | 实施工作量较大 |
|
||||
| 只做应用层校验 | 快 | 校验有遗漏(重定向、DNS 重绑定)时没有兜底 |
|
||||
| 去掉抓取工具 / 报表功能 | 最安全 | 业务不接受 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 认为“模型输出是我们自己生成的”就可以信任。实际上它会复述用户输入和知识库内容。
|
||||
- SSRF 只拦截了 `127.0.0.1`,没拦 `localhost`、IPv6 `::1`、十进制 IP、内网域名。
|
||||
- Text-to-SQL 用正则判断是否为 SELECT,被注释和多语句绕过。
|
||||
- 只修复了被发现的那一个载荷,没有写成测试。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “上线前安全评审发现四个问题,我按纵深防御处理:回答渲染禁用原始 HTML、白名单清洗、只允许 http 链接,加 CSP 和 HttpOnly;抓取工具解析域名后检查所有 IP 必须是公网、禁止重定向,并放在只能访问公网的出口代理后面;Text-to-SQL 换成只读账号和报表视图,SQL 解析后只允许单条 SELECT 并强制 LIMIT 和租户条件;CORS 改成白名单并加 CSRF Token。每个问题都写成了攻击测试放进 CI。”
|
||||
Reference in new issue
Block a user