export type DiagramTone = 'blue' | 'green' | 'amber' | 'red' | 'neutral'; export type DiagramPoint = [number, number]; export type DiagramNode = { id: string; title: string; detail: string; x: number; y: number; tone: DiagramTone; }; export type DiagramEdge = { from: string; to: string; label: string; kind: 'sync' | 'async' | 'risk'; fromPort?: 'left' | 'right' | 'top' | 'bottom'; toPort?: 'left' | 'right' | 'top' | 'bottom'; via?: DiagramPoint[]; labelAt?: DiagramPoint; }; export type TeachingDiagram = { id: string; module: string; lessons: string[]; overview?: boolean; title: string; summary: string; rows: string[]; nodes: DiagramNode[]; edges: DiagramEdge[]; steps: string[]; misconception: string; source?: { label: string; url: string }; }; const n = ( id: string, title: string, detail: string, column: number, row: number, tone: DiagramTone = 'blue', ): DiagramNode => ({ id, title, detail, x: 30 + column * 315, y: 65 + row * 185, tone, }); const e = ( from: string, to: string, label: string, kind: DiagramEdge['kind'] = 'sync', options: Partial> = {}, ): DiagramEdge => ({ from, to, label, kind, ...options }); export const teachingDiagrams: TeachingDiagram[] = [ { id: 'product-schema', module: 'database', lessons: ['02', '04'], overview: true, title: '从商品与分类需求,推导三张表', summary: '一件商品可以属于多个分类,一个分类也可以包含多件商品。把商品、分类和归属关系分开保存,让数据库保护关系和字段规则。', rows: [ '实体与关系:两个一对多,表达一个多对多', '设计依据:字段规则与访问路径', ], nodes: [ n('products', 'products · 商品', '主键 id;名称、描述、价格', 0, 0), n( 'links', 'product_categories', '联合主键:商品 ID + 分类 ID', 1, 0, 'green', ), n('categories', 'categories · 分类', '主键 id;唯一分类名', 2, 0), n( 'fields', '字段类型与约束', '价格按分存;非空与范围检查', 0, 1, 'amber', ), n( 'foreign-keys', '外键与删除策略', '关联必须指向已有实体', 1, 1, 'amber', ), n('queries', '按查询设计索引', '按分类找商品;排序与分页', 2, 1, 'amber'), ], edges: [ e('products', 'links', '1 → 多'), e('categories', 'links', '1 → 多', 'sync', { fromPort: 'left', toPort: 'right', }), e('products', 'fields', '字段设计'), e('links', 'foreign-keys', '关系约束'), e('categories', 'queries', '查询需求'), ], steps: [ '先列需要支持的操作:商品详情、上下架、列表分页、分类筛选。由此确定商品与分类实体;同名商品可以存在,分类名在本练习中唯一。', '商品价格 price_cents 使用非负 INT,单位是分;名称用有长度上限的 VARCHAR,描述用 TEXT,上架状态默认 false,时间按 UTC 写入 DATETIME(6)。', '关联表用 (product_id, category_id) 联合主键避免重复归类,再用两个外键禁止孤立关系;按商品和按分类读取需要检查不同索引方向。', '第 02 课先完成商品表,第 04 课补齐分类与关联表。用非法价格、重复关联、无效外键和删除实验验证设计,而不是只看建表成功。', ], misconception: '把分类名用逗号拼进商品字段,会让去重、筛选和改名变难;把所有可能用到的字段提前塞进表里,也会增加无关的业务复杂度。', }, { id: 'first-health-request', module: 'api', lessons: ['01'], title: '回看刚才那次 /health 访问', summary: '你已经在浏览器看到了 ok。现在沿着刚才写过的 main.py 和终端里的运行记录,把这次访问连起来。', rows: ['浏览器把请求交给正在运行的程序', '函数的返回值回到浏览器'], nodes: [ n('browser', '浏览器访问地址', '127.0.0.1:8000/health', 0, 0), n('server', 'Uvicorn 正在运行', '在本机 8000 端口接收请求', 1, 0), n('route', 'FastAPI 找到路由', '@app.get("/health")', 2, 0), n('function', '执行 health 函数', '返回 {"status": "ok"} 字典', 2, 1), n('response', '框架转换返回值', '200 状态与 JSON 文本', 1, 1, 'green'), n('screen', '浏览器展示结果', '页面上出现 status 和 ok', 0, 1, 'green'), ], edges: [ e('browser', 'server', '① 发出请求'), e('server', 'route', '② 交给应用'), e('route', 'function', '③ 找到函数'), e('function', 'response', '④ 转成文本'), e('response', 'screen', '⑤ 返回结果'), ], steps: [ '启动命令使 Uvicorn 留在终端中接收请求;仅保存 main.py 文件还没有启动这个过程。', '浏览器访问 /health,FastAPI 根据你写的 @app.get("/health") 找到 health 函数,再调用它。', 'health 返回的是 Python 字典。框架把它转换成 JSON 文本并附上 200 状态,浏览器才有可以展示的结果。修改 ok 为 ready 后,你看到的就是这条返回路径上的变化。', ], misconception: '直接访问 / 得到 404,是因为这一课只注册了 /health;按 Ctrl+C 停止服务后无法连接,则是整个接收请求的进程已经停止。两种现象对应不同问题。', }, { id: 'http-roundtrip', module: 'api', lessons: ['02', '03', '04', '05', '06', '07', '08', '16'], overview: true, title: '一次请求怎样找到你的 Python 函数', summary: '把 HTTP 想成带地址的信件:端口找到服务,方法和路径找到函数,参数校验决定能否进入业务处理。', rows: [ '去程:调用者等待这一份响应', '回程:状态码说明结果,响应体携带数据', ], nodes: [ n('browser', '浏览器 / 调用方', 'GET /products/42', 0, 0), n('server', 'Uvicorn 服务', '监听地址与端口', 1, 0), n('router', 'FastAPI 路由', '匹配方法、路径并校验', 2, 0), n('handler', '业务函数', '查找商品或生成错误', 2, 1), n('response', 'HTTP 响应', '状态码 + JSON 数据', 1, 1, 'green'), n('screen', '调用方处理结果', '成功展示;失败给出提示', 0, 1), ], edges: [ e('browser', 'server', '① 发请求'), e('server', 'router', '② 转交'), e('router', 'handler', '③ 调用'), e('handler', 'response', '④ 序列化'), e('response', 'screen', '⑤ 返回'), ], steps: [ '调用方连接指定的主机和端口;服务进程必须先启动并监听,函数本身不会自动接收网络请求。', 'FastAPI 按 HTTP 方法和路径选择路由,再把路径、查询串和 JSON 解析成声明的参数;校验失败会提前返回错误。', '业务函数执行后,框架将结果转换成 HTTP 响应。调用方同时检查状态码与响应体,才知道该显示数据还是错误。', ], misconception: '浏览器地址栏通常发 GET。写了 POST 路由后,只在地址栏输入同一路径,并不等于调用了这个 POST 接口。', }, { id: 'await-handoff', module: 'api', lessons: ['09', '10'], title: 'await 让出执行机会,请求依然在等待', summary: '在单个事件循环线程中,等待网络的协程可以暂停,让别的协程运行;这与把工作交给独立 worker 是两件事。', rows: [ '请求 A:从发起调用到收到结果,HTTP 请求仍未完成', '等待期间:同一个事件循环可以推进其他请求', ], nodes: [ n('a', '请求 A 开始', '准备查询商品数据', 0, 0), n('io', '等待外部 I/O', 'await 可等待的网络操作', 1, 0, 'amber'), n('resume', '请求 A 恢复', '收到结果后继续并响应', 2, 0, 'green'), n('loop', '事件循环调度', 'A 暂停期间选择就绪任务', 1, 1), n('b', '请求 B 执行', '例如处理一个健康检查', 2, 1), ], edges: [ e('a', 'io', '① 发起 I/O'), e('io', 'resume', '③ I/O 就绪'), e('io', 'loop', '② 让出执行'), e('loop', 'b', '处理就绪任务'), ], steps: [ '请求 A 发起支持异步的网络操作。在结果尚未就绪时,await 可以暂停 A,调用方还在等 A 的响应。', '事件循环利用这段等待时间处理请求 B。一个事件循环线程同一时刻仍只执行一段 Python 代码。', 'I/O 就绪后,A 再获得执行机会并返回结果。CPU 密集循环或直接调用阻塞函数仍会占住线程,需要单独选择线程池或进程池。', ], misconception: 'async def 不会自动把阻塞代码变成异步;await 也不保证每次都发生任务切换。await 更不会自动创建持久化后台任务。', source: { label: 'Python:协程与任务', url: 'https://docs.python.org/3/library/asyncio-task.html', }, }, { id: 'stream-lifetime', module: 'api', lessons: ['11', '12', '13', '14', '15'], title: '流式输出是一条持续占用资源的连接', summary: 'SSE 把一次响应分成多段送回。页面停止接收后,需要沿调用链清理资源,不能只让文字停止显示。', rows: [ '数据方向:导出进度产生,服务逐段转发', '停止方向:断开被感知后,逐层取消与清理', ], nodes: [ n('model', '进度 / 数据源', '片段 1、片段 2、片段 3', 0, 0), n('api', 'API 流生成器', '编码事件并转发', 1, 0), n('browser', '浏览器读流', '逐段解析、更新页面', 2, 0), n('stop', '用户停止 / 断网', '停止读取或断开连接', 2, 1, 'amber'), n('cleanup', 'API 清理资源', '感知断开;finally 释放', 1, 1, 'green'), n('upstream', '停止上游工作', '取消可取消调用并关连接', 0, 1), ], edges: [ e('model', 'api', '① 数据片段'), e('api', 'browser', '② SSE 事件'), e('browser', 'stop', '③ 中止'), e('stop', 'cleanup', '④ 传播取消', 'risk'), e('cleanup', 'upstream', '⑤ 释放资源'), ], steps: [ '完整响应要等结果准备完再返回;SSE 可以先发送响应头,随后持续发送事件,因此用户能更早看到内容。', '流经过 API、代理和浏览器。任意一层缓冲都可能让“已经生成”的片段迟迟无法显示;长连接还需要超时和心跳策略。', '浏览器断开后,服务应处理取消并关闭上游连接。独立队列中的任务不会因为 HTTP 连接断开而自动取消,需要另设取消协议。', ], misconception: 'SSE 是服务端向客户端发送事件;WebSocket 提供双向消息。两者都不会自动补回断线期间丢失的业务消息。', }, { id: 'transaction-boundary', module: 'database', lessons: ['01', '02', '03', '04', '05', '06', '11', '12'], overview: true, title: '事务把多次写入变成一个提交决定', summary: '以创建商品及其分类关系为例:数据库应当一起保存相关变化,避免出现只有一半完成的业务状态。', rows: ['同一连接、同一个事务内', '结束事务后:要么整体提交,要么整体回滚'], nodes: [ n('begin', 'BEGIN 开始', '应用取得一条数据库连接', 0, 0), n('note', '写入商品', '暂未提交的变更', 1, 0, 'amber'), n('tag', '写入分类关系', '约束检查可能失败', 2, 0, 'amber'), n('decision', '应用决定结束', '根据执行结果选择路径', 2, 1), n('commit', 'COMMIT 提交', '两份数据一起生效', 1, 1, 'green'), n('rollback', 'ROLLBACK 回滚', '撤销本事务未提交变更', 0, 1, 'red'), ], edges: [ e('begin', 'note', '① 执行'), e('note', 'tag', '② 执行'), e('tag', 'decision', '③ 判断'), e('decision', 'commit', '成功'), e('decision', 'rollback', '失败', 'risk', { fromPort: 'bottom', toPort: 'bottom', via: [ [765, 388], [135, 388], ], labelAt: [450, 383], }), ], steps: [ '应用在同一数据库连接上开启事务,依次执行相互关联的写入。唯一约束和外键约束由数据库统一检查。', '全部满足业务条件时执行 COMMIT;失败时显式回滚并清理事务,避免把带有未提交修改的连接交给下一次请求。', '图中是 InnoDB 数据修改事务。DDL 可能有隐式提交,外部 HTTP 调用、发邮件和发消息也不会被这个本地事务自动回滚。', ], misconception: '“放在同一段函数里”不等于“在同一事务里”。读者何时能看见变化还取决于事务隔离级别和读取方式。', }, { id: 'lock-wait', module: 'database', lessons: ['06', '07', '08', '09', '10'], title: '两个请求改同一行,锁把竞争排成等待', summary: '锁保护共享数据,但持锁时间越长,后面请求越容易排队。等待数据库锁的连接仍然被占用。', rows: [ '事务 A:先取得行锁,并尽快提交', '事务 B:竞争同一行,需要等待或超时', ], nodes: [ n('a', '事务 A 更新', 'UPDATE 商品 42', 0, 0), n('row', '商品 42 的行锁', 'A 持有,直至事务结束', 1, 0, 'amber'), n('done', 'A 提交并释放', '临界区应当短小', 2, 0, 'green'), n('b', '事务 B 更新', '也要修改商品 42', 0, 1), n('wait', 'B 等待锁', '连接占用;有等待上限', 1, 1, 'amber'), n('retry', 'B 获锁后继续', '超时 / 死锁则回滚处理', 2, 1), ], edges: [ e('a', 'row', '① 取得锁'), e('row', 'done', '③ 提交'), e('b', 'wait', '② 锁冲突'), e('wait', 'retry', '⑤ 继续 / 失败'), e('done', 'retry', '④ 释放后唤醒'), ], steps: [ 'A 对目标行执行需要加锁的操作。B 修改同一行时遇到冲突,必须等待 A 提交或回滚。', '如果 A 在事务中等待慢外部调用,锁和连接也会跟着长时间占用,造成更多排队。因此外部慢调用应尽量离开持锁区。', '多行锁获取顺序不一致可能造成死锁。数据库会选择牺牲事务,应用需要回滚并按有界重试策略重做完整业务事务。', ], misconception: '锁不一定只覆盖你看到的一行。访问路径、索引和隔离级别会影响加锁范围;增加索引也不能代替事务边界设计。', }, { id: 'session-pool', module: 'api-database', lessons: ['01', '02', '03', '04', '05', '06', '08', '12'], overview: true, title: 'Session 属于请求,连接来自有上限的池', summary: 'Session 像本次办事的工作单,连接池像有限数量的窗口。工作单不该跨并发请求共享,窗口也不能无限增加。', rows: [ '请求生命周期:独立 Session → 借连接 → 访问数据库', '请求结束:提交或回滚,再清理并归还连接', ], nodes: [ n('request', '请求 A', '创建本次独立 Session', 0, 0), n('pool', '当前进程连接池', '没有空闲连接时有界等待', 1, 0, 'amber'), n('mysql', 'MySQL', '执行查询与事务', 2, 0, 'green'), n('end', '提交 / 回滚', '明确业务结束边界', 2, 1), n('release', '连接归还池', '清理会话与事务状态', 1, 1, 'green'), n('next', '后续请求 B', '使用自己的 Session', 0, 1), ], edges: [ e('request', 'pool', '① 按需借用'), e('pool', 'mysql', '② 执行 SQL'), e('mysql', 'end', '③ 结束'), e('end', 'release', '④ 释放'), e('release', 'next', '⑤ 可供借用'), ], steps: [ '每个请求建立独立 Session;实际执行 SQL 时按需从连接池取得连接。并发协程也不应共享可变的 AsyncSession。', '连接池满时,后续请求排队到超时。多个 API 进程、worker 进程和发布期间的新旧副本,都可能各自拥有一份连接池。', '总预算可按“进程数 × 每进程连接上限”估算,再给运维和后台作业留余量。结束请求时确保提交或回滚,并在所有退出路径释放资源。', ], misconception: '把每个进程的池扩大到 100,不代表系统只会占用 100 条连接。扩副本也会放大数据库的连接和查询压力。', }, { id: 'optimistic-update', module: 'api-database', lessons: ['09', '10'], title: '版本号让数据库识别“我改的是旧版本”', summary: '两个人都读到第 7 版时,只允许第一份带 version=7 的修改成功,另一份必须重新决定如何处理冲突。', rows: [ '两个请求看到相同旧值:商品 version = 7', '写入时由数据库原子检查条件,不能只在应用里先判断', ], nodes: [ n('a', '编辑请求 A', '提交:旧版本 7 + 新内容', 0, 0), n('db', '数据库条件更新', 'WHERE id=42 AND version=7', 1, 0, 'green'), n('b', '编辑请求 B', '也提交:旧版本 7', 2, 0), n('ok', 'A 更新成功', '影响 1 行;版本变为 8', 0, 1, 'green'), n('new', '当前真值:版本 8', '只有 A 的修改已提交', 1, 1, 'green'), n('conflict', 'B 发现冲突', '影响 0 行;重新读取处理', 2, 1, 'amber'), ], edges: [ e('a', 'db', '① 先到'), e('b', 'db', '② 后到'), e('a', 'ok', '条件成立'), e('db', 'new', '提交 version+1'), e('b', 'conflict', '条件失效'), ], steps: [ '读取商品时同时拿到 version,编辑时带回这个版本。数据库在同一条 UPDATE 中检查版本并递增版本号。', 'A 先成功把版本从 7 改为 8。B 的 version=7 条件不再匹配,应用通过影响行数判断冲突。', '业务可以返回冲突让用户比较、合并,或重新计算并有界重试。幂等键另外解决“同一意图重复提交”,与版本号解决的问题不同。', ], misconception: '先 SELECT 检查版本,再执行没有版本条件的 UPDATE,中间仍然有竞争窗口;检查与写入必须由数据库原子完成。', }, { id: 'compatible-migration', module: 'api-database', lessons: ['07'], title: '兼容迁移:新旧应用共存时,数据库要同时接得住', summary: '滚动发布会让新旧版本同时运行。先扩展兼容结构,再迁移应用和数据,最后清理旧结构,可以保留明确的回退窗口。', rows: [ '扩展阶段:先增加兼容结构,再发布能共存的应用', '收缩阶段:数据校验和旧版本退出完成以后,才清理旧结构', ], nodes: [ n('expand', '扩展数据库结构', '例如先新增可空列', 0, 0, 'green'), n('coexist', '新旧应用同时运行', '都能读取和写入当前结构', 1, 0), n('backfill', '分批回填旧数据', '有进度、限速与校验', 2, 0, 'amber'), n('contract', '清理旧结构', '明确回退窗口已发生变化', 0, 1, 'amber'), n('retire', '确认旧版本退出', '包括后台进程与定时任务', 1, 1), n('verify', '校验并切换读取', '确认新字段完整且语义正确', 2, 1, 'green'), ], edges: [ e('expand', 'coexist', '① 兼容扩展'), e('coexist', 'backfill', '② 分批迁移', 'async'), e('backfill', 'verify', '③ 校验'), e('verify', 'retire', '④ 结束共存'), e('retire', 'contract', '⑤ 谨慎收缩'), ], steps: [ '先做旧应用仍能使用的结构扩展,例如新增可空字段。迁移任务独立执行,避免每个 API 副本启动时争抢执行 DDL。', '发布支持新结构的应用,按业务语义处理新旧字段共存,并分批回填历史数据;回填需要限速、断点与结果校验。', '确认旧应用和后台进程都不再使用旧结构,再做收缩清理。数据库 DDL 有自身提交与锁行为,代码版本回退不能自动撤销这些变化。', ], misconception: '“迁移脚本有 downgrade”不代表线上可以无损回滚。删除字段或不可逆数据转换后,恢复可能必须依赖备份、对账或新的前向修复。', }, { id: 'outbox-delivery', module: 'api-database', lessons: ['11'], title: 'Outbox:先把“需要发送”与业务数据一起记下来', summary: '数据库提交和消息发布属于两个系统,无法靠连续写两行代码保证一起成功。Outbox 把可恢复的发送意图保存在数据库。', rows: [ '本地事务边界:业务记录 + 待发送事件一起提交', '事务外:发布进程持续重试,消费者处理重复事件', ], nodes: [ n('api', 'API 接收请求', '创建业务记录与事件', 0, 0), n('db', 'MySQL 本地事务', '业务表 + outbox 表', 1, 0, 'green'), n('relay', 'Outbox 发布进程', '扫描未完成事件', 2, 0), n('broker', '消息 Broker', '接收事件,交给消费者', 2, 1, 'amber'), n('worker', '消费者 / Worker', '按事件 ID 幂等处理', 1, 1), n('result', '业务结果落库', '状态可查询,失败可恢复', 0, 1, 'green'), ], edges: [ e('api', 'db', '① 原子提交'), e('db', 'relay', '② 读取意图', 'async'), e('relay', 'broker', '③ 发布 / 重试', 'async'), e('broker', 'worker', '④ 可重复投递', 'async'), e('worker', 'result', '⑤ 持久化结果'), ], steps: [ 'API 在一个 MySQL 事务中写业务记录和 outbox 事件;提交后即使 API 进程退出,发送意图仍可被后续进程发现。', '独立发布进程扫描事件并发送到 Broker。发布失败可以重试;发送成功但标记完成前崩溃,则可能再次发送。', '消费者用稳定事件 ID 和数据库约束保护幂等副作用。Outbox 解决可靠交接意图,投递耐久性仍取决于 Broker 配置、确认和恢复策略。', ], misconception: 'Outbox 不能保证全链路“恰好执行一次”,也不会让下游立刻可见。业务需要接受延迟,并处理重复、积压和失败。', }, { id: 'task-lifetimes', module: 'tasks', lessons: ['01', '03', '07'], overview: true, title: '同样叫“后台”,重启后的命运却不同', summary: '是否先返回 HTTP 响应,与工作能否在进程崩溃后恢复,是两个独立问题。', rows: [ '进程内后台:工作依赖当前 API 进程继续存活', '独立任务系统:状态、交接和执行都有明确责任', ], nodes: [ n('request', 'HTTP 请求', '提交商品清单导出工作', 0, 0), n( 'local', 'API 进程内任务', 'BackgroundTasks / create_task', 1, 0, 'amber', ), n('lost', 'API 进程退出', '未完成工作可能丢失', 2, 0, 'red'), n('state', '持久任务记录', 'job_id + 状态 + outbox', 0, 1, 'green'), n('queue', '独立 Broker', '排队并按策略投递', 1, 1, 'amber'), n('worker', '独立 Worker', '执行、落库、确认;可恢复', 2, 1), ], edges: [ e('request', 'local', '进程内方案'), e('local', 'lost', '重启 / 崩溃', 'risk'), e('request', 'state', '可靠方案'), e('state', 'queue', '异步发布', 'async'), e('queue', 'worker', '异步交接', 'async'), ], steps: [ '进程内后台任务方便完成较小且可丢失的辅助工作;BackgroundTasks 在响应之后执行,create_task 调度协程,两者都依赖当前进程。', '需要恢复的任务先保存任务状态和发送意图,再由独立 Broker 与 worker 协作。API 返回 job_id,客户端通过查询接口获取进度。', '202 仅表示已受理。只有任务状态到达 succeeded,且结果引用可用,才能认为处理完成;早期内存实验不提供持久交付承诺。', ], misconception: '换成 Celery 并不自动可靠:任务状态、发布失败、重复执行、Broker 数据丢失和 worker 退出,都要分别设计。', }, { id: 'execution-choice', module: 'tasks', lessons: ['02', '04', '05', '06', '07'], title: '等待 I/O、阻塞调用、CPU 运算分别交给谁', summary: '先判断瓶颈,再选择执行方式。并发数量必须受控,否则只是把排队挪到数据库、线程池或内存里。', rows: ['工作特征决定执行位置', '三种执行方式都需要容量、超时和清理边界'], nodes: [ n('asyncio', '支持异步的 I/O', 'HTTP、数据库的异步驱动', 0, 0), n('blocking', '同步阻塞 I/O', '只能同步调用的旧 SDK', 1, 0), n('cpu', 'CPU 密集计算', '压缩、解析、大量纯计算', 2, 0), n('coroutine', '有界协程', 'await + Semaphore / Queue', 0, 1, 'green'), n('thread', '有界线程池', '把阻塞调用移出事件循环', 1, 1, 'green'), n('process', '有界进程池', '独立内存;序列化传参', 2, 1, 'green'), ], edges: [ e('asyncio', 'coroutine', '等待时让出'), e('blocking', 'thread', '线程代为等待'), e('cpu', 'process', '进程承担计算'), ], steps: [ '驱动支持异步时,协程等待 I/O 可以让出事件循环;用有界队列和并发上限避免同时发出成千上万次下游调用。', '必须使用同步阻塞 SDK 时,可把调用放入有界线程池。线程共享进程内存,复合读改写仍可能需要锁;超时等待不等于线程已停止。', '本课程常见 CPython 配置中的 CPU 密集 Python 运算适合进程池;进程间传递可序列化数据。需要跨重启恢复时,再选择独立任务队列。', ], misconception: '线程池、进程池不是持久化队列。增加并发不保证更快,也可能让下游限流、连接耗尽或内存先被打满。', }, { id: 'ack-redelivery', module: 'tasks', lessons: ['08', '09', '10', '12', '14', '15'], title: '结果已写入,但 ack 丢了:为什么会重复执行', summary: 'ack 是消费者告诉 Broker“这一份可以结束投递”的确认,它与业务结果写入通常不是同一个原子动作。', rows: [ '第一次投递:业务已成功,但消费确认未被 Broker 收到', '恢复路径:可能重投同一任务,消费者复用已完成结果', ], nodes: [ n('broker', 'Broker 中的任务', '稳定 task_id / 业务键', 0, 0, 'amber'), n('worker', 'Worker A', '执行并提交业务结果', 1, 0), n('result', 'MySQL / 结果文件', '结果已持久化成功', 2, 0, 'green'), n( 'redeliver', '未确认任务恢复', '按后端与配置决定何时重投', 0, 1, 'amber', ), n('again', 'Worker B 再收到', '先检查同一任务的结果', 1, 1), n('reuse', '复用结果并确认', '不重复产生业务副作用', 2, 1, 'green'), ], edges: [ e('broker', 'worker', '① 投递', 'async'), e('worker', 'result', '② 写结果'), e('worker', 'redeliver', '③ ack 前崩溃', 'risk', { fromPort: 'bottom', toPort: 'top', via: [ [450, 193], [135, 193], ], labelAt: [288, 188], }), e('redeliver', 'again', '④ 可能重投', 'async'), e('again', 'reuse', '⑤ 幂等处理'), ], steps: [ '在成功后确认的设计里,worker 先持久化业务结果,再确认任务完成。发布方确认和消费者 ack 是不同阶段的确认。', 'worker 在结果提交后、ack 生效前退出,Broker 仍可能认为这份任务未完成。具体恢复时间受 broker、可见性超时和 Celery 配置影响。', '重投消费者通过稳定任务键、唯一约束和可复用输出识别已完成工作,再安全确认。外部付款、发信等副作用也需要对应服务支持幂等或对账。', ], misconception: '提前 ack 会缩小重复窗口,却可能在执行前崩溃时丢工作;延后 ack 也不自动提供“恰好一次”。可靠配置与幂等必须一起考虑。', source: { label: 'RabbitMQ:确认与可靠投递原理', url: 'https://www.rabbitmq.com/docs/reliability', }, }, { id: 'task-state-source', module: 'tasks', lessons: ['11', '12', '15', '16'], title: '队列负责送任务,数据库负责回答任务发生了什么', summary: '消息出队不等于业务完成。任务状态、执行租约和结果引用需要一个可查询、可校验的数据真源。', rows: [ '执行路径:worker 先取得当前执行权,再提交结果', '查询与恢复:客户端和恢复扫描器都读取持久状态', ], nodes: [ n('queue', 'Broker', '投递 job_id', 0, 0, 'amber'), n('worker', 'Worker', '申请租约;携带执行令牌', 1, 0), n('db', 'MySQL 任务记录', '状态 + 租约 + 结果引用', 2, 0, 'green'), n('client', '客户端查状态', 'GET /exports/{job_id}', 0, 1), n('api', '查询 API', '读业务真源并返回', 1, 1), n('recovery', '恢复扫描器', '扫描过期租约并按规则恢复', 2, 1), ], edges: [ e('queue', 'worker', '① 消息交接', 'async'), e('worker', 'db', '② 条件写状态'), e('client', 'api', '③ 查进度'), e('api', 'db', '读取真值', 'sync', { fromPort: 'right', toPort: 'bottom', via: [ [607, 289], [607, 202], [765, 202], ], labelAt: [690, 196], }), e('db', 'recovery', '④ 扫描与校验', 'async'), ], steps: [ 'worker 按条件从 queued 取得执行租约,进入 running。更新成功或失败状态时带上当前执行令牌,拒绝已经失去执行权的旧 worker。', '结果文件放入可访问的持久存储,MySQL 记录结果引用;查询 API 以数据库状态为准,不能把队列长度或短期结果缓存当业务真相。', 'worker 宕机后,恢复扫描器识别过期租约,按幂等规则决定重排或失败。取消请求也应持久化,并由执行者在安全点处理。', ], misconception: '任务超过租约时间,不代表旧进程已经停止。新的执行者开始后,旧执行者仍可能返回,必须有防止旧结果覆盖的新旧执行权校验。', }, { id: 'backpressure', module: 'tasks', lessons: ['02', '10', '13', '16'], title: '队列像等候区:只能缓冲,不能凭空增加处理能力', summary: '当每秒新来 100 件工作,却只能完成 60 件时,积压会持续增加。入口、排队与下游都需要容量边界。', rows: [ '受理之前:根据容量决定接收、限流或让调用方稍后再试', '受理之后:分队列隔离,并发受下游承载能力约束', ], nodes: [ n('input', '请求入口', '持续到达:100 件 / 秒', 0, 0), n('gate', '受理门槛', '租户配额 + 总积压上限', 1, 0), n('reject', '超出容量', '429 / 503 + 合理重试', 2, 0, 'red'), n('short', '短任务队列', '独立容量,避免被长任务挡住', 0, 1, 'amber'), n('long', '长任务队列', '有界积压,记录最老等待时间', 1, 1, 'amber'), n('workers', 'Worker + 下游', '当前最多完成:60 件 / 秒', 2, 1, 'green'), ], edges: [ e('input', 'gate', '① 评估容量'), e('gate', 'reject', '已满则拒绝', 'risk'), e('gate', 'short', '② 按类型交接', 'async'), e('gate', 'long', '② 长任务', 'async'), e('short', 'workers', '③ 独立消费', 'async', { fromPort: 'bottom', toPort: 'bottom', via: [ [135, 388], [765, 388], ], labelAt: [450, 383], }), e('long', 'workers', '③ 有界消费', 'async'), ], steps: [ '如果到达率长期高于完成率,每秒多出的 40 件会累积。先在受理前设置容量门槛,明确拒绝语义和调用方的重试预算。', '将长短任务放到独立队列及消费资源中,防止一个大任务让所有短请求排在后面;监控长度也监控最老消息等待时间。', '扩 worker 前检查数据库、文件 I/O 和连接池预算。下游已经饱和时,应限并发、降级或减少工作量,而不是继续加线程。', ], misconception: '队列越长不代表系统越稳定。无限排队会把快速失败变成超时、内存消耗和过时结果,重试还可能进一步放大负载。', }, { id: 'cache-aside', module: 'redis', lessons: ['01', '02', '04', '07', '09'], overview: true, title: 'Cache Aside:Redis 保存副本,MySQL 保存真值', summary: '读缓存是为了少查数据库;缓存中没有数据是正常路径。业务必须能够从数据库重建缓存。', rows: [ '先尝试快速路径:命中缓存即可返回', '未命中时:查真源,再带 TTL 写入可重建副本', ], nodes: [ n('request', '查询 API', '读取商品 42', 0, 0), n('cache', 'Redis 缓存', '可过期、可淘汰的副本', 1, 0, 'amber'), n('hit', 'API 得到命中值', '反序列化并返回数据', 2, 0, 'green'), n('miss', 'API 发起回源', 'Redis 不会主动查询数据库', 0, 1), n('db', 'MySQL 真源', '处理 API 发来的查询', 1, 1, 'green'), n( 'fill', 'API 回填并返回', '写 Redis + TTL,再响应调用方', 2, 1, 'amber', ), ], edges: [ e('request', 'cache', '① GET'), e('cache', 'hit', '② 命中'), e('cache', 'miss', '② 未命中'), e('miss', 'db', '③ API 查库'), e('db', 'fill', '④ 返回真值'), ], steps: [ 'API 先读 Redis。命中则返回;键不存在、TTL 到期或被淘汰时,由 API 决定转去查数据库,Redis 自己不会主动回源。', 'API 查到数据后写回 Redis 并设置 TTL,再返回业务结果。不存在的数据可用短 TTL 的负缓存保护,但要考虑创建数据后的失效。', 'Redis 故障时,可以在明确容量限制内回退数据库。数据库没有足够余量时,需要限流或降级,不能让所有请求直接涌向数据库。', ], misconception: 'Redis 快不等于数据更新。缓存中的副本可能短暂过时;需要强一致的余额、权限等判断,应选择满足要求的真源读取策略。', }, { id: 'cache-refill-race', module: 'redis', lessons: ['03', '05'], title: '先提交再删缓存,仍可能发生旧值回填', summary: '按时间从左到右读两条请求:读请求提前拿到旧值,写请求删完缓存后,旧读才把旧值写回去。', rows: [ '读请求 R:先读到旧值 v1,暂停后再回填', '写请求 W:在 R 暂停期间,更新真源并删除缓存', ], nodes: [ n('read', 'R 缓存未命中', '从数据库读到 v1', 0, 0), n('pause', 'R 暂停 / 变慢', '仍持有之前读出的 v1', 1, 0, 'amber'), n('stale', 'R 晚到的回填', '把 v1 又写入 Redis', 2, 0, 'red'), n('update', 'W 修改数据', '数据库更新为 v2', 0, 1), n('commit', 'W 提交成功', '数据库真值已是 v2', 1, 1, 'green'), n('delete', 'W 删除缓存', '清掉当时已有的副本', 2, 1), ], edges: [ e('read', 'pause', '① 读到旧值'), e('pause', 'stale', '④ 稍后回填', 'risk'), e('update', 'commit', '② 更新提交'), e('commit', 'delete', '③ 失效'), e('delete', 'stale', '先删除、后回填', 'risk'), ], steps: [ 'R 读缓存未命中,从 MySQL 取到了 v1,但因为调度或网络变慢,还没有写入 Redis。', 'W 把数据库改成 v2,提交成功后删除缓存。随后 R 恢复,把手里的 v1 回填,产生“数据库 v2、缓存 v1”的短暂不一致。', 'TTL 可限制旧副本的存活窗口。更严格的要求可结合版本校验、协调回填与失效、可靠失效事件或直接读取真源;每种方案都要说明故障边界。', ], misconception: '“提交后删除”是常用基线,不是强一致证明。单纯重复删除或延迟双删,若没有时延与失败假设,也不能彻底消除竞争。', }, { id: 'cache-storms', module: 'redis', lessons: ['04', '05', '06', '09', '10'], title: '穿透、击穿、雪崩:三种压力来源不一样', summary: '先判断是“本来就不存在”“一个热点失效”还是“大量缓存同时不可用”,才能把保护放在正确的位置。', rows: [ '症状:大量请求为什么越过缓存', '对应保护:先挡住放大的请求,再受控访问真源', ], nodes: [ n('absent', '穿透', '反复查询根本不存在的键', 0, 0, 'red'), n('hot', '击穿', '同一个热门键突然过期', 1, 0, 'red'), n('many', '雪崩', '大量键失效 / Redis 故障', 2, 0, 'red'), n( 'validate', '校验 + 短期负缓存', '先拒绝无效输入,缓存不存在', 0, 1, 'green', ), n('single', '合并同键回填', '单飞、有界等待、必要时旧值', 1, 1, 'green'), n('spread', '打散 + 限流降级', 'TTL 抖动;回源总量有上限', 2, 1, 'green'), ], edges: [ e('absent', 'validate', '减少无效回源'), e('hot', 'single', '避免重复重建'), e('many', 'spread', '保护真源容量'), ], steps: [ '穿透的键在数据库也不存在。校验参数、短期缓存“查无此项”,可以减少重复无效查询;负缓存要有容量与过期策略。', '击穿集中在单个热点键。让少量请求负责重建,其余请求有界等待或读取业务允许的旧值,避免同一键触发数千次数据库查询。', '雪崩涉及很多键或整个 Redis。TTL 抖动降低同刻过期概率,故障时仍要用回源并发上限、熔断和降级保护数据库。', ], misconception: '给所有缓存都加一个锁,不能解决所有缓存问题。锁服务本身也有容量、过期和失败边界,持锁者退出后必须能够恢复。', }, { id: 'redis-roles', module: 'redis', lessons: ['07', '08', '10'], title: '缓存和任务 Broker 使用 Redis,却有不同的数据承诺', summary: '缓存允许按策略淘汰可重建副本;待消费任务被淘汰可能变成业务丢失。因此两类负载需要独立规划。', rows: [ '缓存路径:容量不足时允许丢弃可重建副本', '任务路径:消息交接必须有自己的耐久与恢复策略', ], nodes: [ n('read', 'API 查询', '优先读取查询副本', 0, 0), n('cache', '缓存 Redis 实例', 'TTL + 内存上限 + 淘汰策略', 1, 0, 'amber'), n('mysql', 'MySQL 真源', '缓存丢了可以重新生成', 2, 0, 'green'), n('publish', 'Outbox 发布进程', '准备交接待执行任务', 0, 1), n('broker', '独立 Broker 实例', '独立资源、持久化与恢复', 1, 1, 'amber'), n('worker', 'Worker', '幂等执行并保存任务状态', 2, 1), ], edges: [ e('read', 'cache', '① 查询'), e('read', 'mysql', 'API 未命中后查库', 'sync', { fromPort: 'top', toPort: 'top', via: [ [135, 50], [765, 50], ], labelAt: [570, 65], }), e('publish', 'broker', '② 发布', 'async'), e('broker', 'worker', '③ 消费', 'async'), ], steps: [ '缓存 Redis 保存可重建数据,可以在达到内存预算时按淘汰策略丢弃副本,API 回真源重建。', 'Broker 保存待执行任务,不能把“缓存可丢”直接套用到队列。为 Broker 单独规划内存、持久化、复制、可见性超时和恢复扫描。', '不同键前缀或逻辑数据库编号仍共享同一 Redis 进程和资源;需要故障与容量隔离时,应考虑独立实例及独立预算。', ], misconception: 'Redis 开启持久化并不代表零数据丢失;恢复点取决于持久化方式、复制进度与故障类型,必须通过演练验证。', }, { id: 'compose-network', module: 'docker', lessons: ['04', '06', '08', '09'], overview: true, title: 'Compose 内部用服务名,主机端口用于外部进入', summary: '每个容器有自己的网络视角。API 容器访问 MySQL 时,目标是 mysql:3306,而不是自己的 localhost。', rows: [ '主机外 / 主机入口:示例映射 8080 → 容器内 8000', 'Compose 网络内部:用服务 DNS 名称和容器端口通信', ], nodes: [ n('browser', '浏览器', '请求主机的 :8080', 0, 0), n('api', 'API 容器', '监听 0.0.0.0:8000', 1, 0), n('worker', 'Worker 容器', '独立任务执行进程', 2, 0), n('mysql', 'mysql:3306', 'MySQL 容器与数据卷', 0, 1, 'green'), n('redis', 'redis:6379', '缓存 Redis 容器', 1, 1, 'amber'), n('broker', 'broker:6379', '独立任务 Broker 容器', 2, 1, 'amber'), ], edges: [ e('browser', 'api', '① 8080:8000'), e('api', 'mysql', '② 内部访问'), e('api', 'redis', '③ 查询缓存'), e('worker', 'broker', '④ 拉取任务', 'async'), ], steps: [ '主机上的 8080:8000 端口映射,把发向主机 8080 的流量转给 API 容器内的 8000。API 要监听允许容器网络进入的地址。', '同一 Compose 网络内通过服务名解析地址,连接 mysql:3306、redis:6379。内部访问不需要绕到主机映射端口。', 'worker 也是独立容器,使用服务名访问 Broker 和数据库。depends_on 或健康检查可以协调启动,但运行中依赖故障仍需要超时与重试。', ], misconception: '容器里的 localhost 指当前容器自身。把数据库端口发布到主机并不是容器之间通信的必要条件,也不应无意暴露外部入口。', }, { id: 'image-container-volume', module: 'docker', lessons: ['01', '02', '03', '05', '07', '10'], title: '镜像可以重建,容器可以替换,业务数据要单独保管', summary: '镜像是可重复部署的材料,容器是运行中的进程环境,持久卷承载需要跨容器生命周期保留的数据。', rows: [ '应用发布:固定版本镜像创建可替换的容器', '数据生命周期:容器重建与数据备份是不同操作', ], nodes: [ n('image', '版本化应用镜像', '代码 + 运行环境 + 固定依赖', 0, 0), n('old', '旧 API 容器', '先停止接收新请求', 1, 0, 'amber'), n('new', '新 API 容器', '健康验证后接收流量', 2, 0, 'green'), n('mysql', 'MySQL 容器', '进程可以重建', 0, 1), n('volume', '独立持久卷', '数据库文件持续存在', 1, 1, 'green'), n('backup', '异地备份', '隔离恢复验证可用性', 2, 1, 'green'), ], edges: [ e('image', 'old', '运行旧版本'), e('old', 'new', '有序替换'), e('mysql', 'volume', '持久数据'), e('volume', 'backup', '备份与恢复验证', 'async'), ], steps: [ '使用明确镜像版本发布,先验证新容器健康,再排空旧容器请求并优雅退出。worker 停止时还要处理未完成任务的租约与确认。', '数据库文件放在独立持久卷,替换 MySQL 容器时复用卷;应用容器可写层中的临时文件则可能随重建丢失。', '持久卷只能跨容器保留数据,不能抵御误删、主机故障或错误写入。独立备份、恢复演练和兼容数据库迁移共同决定可回退能力。', ], misconception: '卷不是备份,镜像回滚也不等于数据结构自动回滚。删除卷会影响持久数据,发布流程不应把清理容器与清理数据混为一谈。', }, { id: 'linux-proxy', module: 'linux', lessons: ['01', '02', '03', '05', '06'], overview: true, title: '公网入口、反向代理、应用进程各守一层边界', summary: '请求先到服务器入口,再经 Nginx 转交 API。TLS、流式缓冲和连接超时可能在不同层影响同一次访问。', rows: [ '入口路径:名字找到地址,端口规则决定能否连接', '服务内部:代理转发到应用,应用再访问私有依赖', ], nodes: [ n('client', '用户浏览器', '访问 https://课程服务', 0, 0), n('network', 'DNS + 网络入口', '域名解析;防火墙规则', 1, 0), n('nginx', 'Nginx :443', 'TLS 终止、代理、访问日志', 2, 0), n('data', 'MySQL / Redis', '限制来源的内部依赖', 0, 1, 'green'), n('api', 'API 进程', '例如监听 127.0.0.1:8000', 1, 1), n('forward', '代理转发规则', '路径、连接头、超时与缓冲', 2, 1, 'amber'), ], edges: [ e('client', 'network', '① 查址并连接'), e('network', 'nginx', '② TLS / HTTP'), e('nginx', 'forward', '③ 选择上游'), e('forward', 'api', '④ 转发'), e('api', 'data', '⑤ 内部调用'), ], steps: [ '域名解析回答“去哪个地址”,防火墙和监听端口决定“能不能连上”。TLS 握手发生在 HTTP 请求处理之前。', 'Nginx 接收公网 HTTPS,再将请求转发到本机或内部网络中的 API 进程。不要默认把数据库和应用管理端口暴露给公网。', 'SSE 需要检查代理缓冲、空闲超时与心跳;WebSocket 还需要正确转发升级连接。应用自己工作正常,代理层也可能让体验异常。', ], misconception: '看到 502 并不能直接认定业务代码有错;可能是上游进程未启动、地址写错、连接被拒绝或代理与上游协议不匹配。', }, { id: 'process-supervision', module: 'linux', lessons: ['04', '07', '08', '09', '10'], title: 'systemd 管进程,发布流程决定何时把流量交给它', summary: '进程存在只是起点。可靠发布还要验证依赖可用、接口正常、旧请求排空,以及失败时的恢复路径。', rows: [ '正常发布:准备版本 → 管理进程 → 验证健康', '退出与故障:先排空,后停止;恢复必须验证业务结果', ], nodes: [ n('release', '准备新版本', '依赖、配置与兼容迁移', 0, 0), n('systemd', 'systemd 服务单元', '运行账户、目录、重启策略', 1, 0), n('health', '就绪与业务检查', '接口成功且依赖可访问', 2, 0, 'green'), n('verify', '验证恢复', '检查错误率、任务与数据', 0, 1, 'green'), n('restore', '重启 / 兼容回退', '按 runbook 选择恢复动作', 1, 1), n('drain', '排空旧进程', '拒绝新工作;完成或交还旧工作', 2, 1, 'amber'), ], edges: [ e('release', 'systemd', '① 启动'), e('systemd', 'health', '② 验证'), e('health', 'drain', '③ 交接流量'), e('drain', 'restore', '异常恢复', 'risk'), e('restore', 'verify', '④ 业务验收'), ], steps: [ 'systemd 按固定账户、工作目录和配置启动 API、worker 等独立进程,记录日志并执行约定的重启策略。', '发布流程先做就绪检查和关键业务验收,再接入流量。停止旧进程时留出排空时间;超时强停后的工作应有幂等与租约恢复路径。', '重启、回退代码或恢复备份是不同修复动作。根据证据选择后,继续验证请求成功率、任务终态和数据完整性,确认恢复目标达到。', ], misconception: '自动重启不会修复错误配置、数据库损坏或持续过载;无限重启还可能制造更多连接和告警,需要明确退出条件和人工可执行手册。', }, { id: 'trace-evidence', module: 'troubleshooting', lessons: ['01', '02', '03', '04', '07', '08', '11', '12'], overview: true, title: '同一条证据链跨过 HTTP、数据库与任务消息', summary: '指标先告诉你“哪里异常”,追踪帮助缩小“慢在哪一段”,结构化日志解释“当时发生了什么”。', rows: [ '同步请求:传播 trace 上下文,分别记录阶段耗时', '异步任务:随消息传递关联信息,并记录排队与执行时间', ], nodes: [ n('request', '用户请求', 'request_id / trace_id', 0, 0), n('api', 'API 请求跨度', '等待连接、业务处理、外部 I/O', 1, 0), n('db', '数据库调用跨度', '查询耗时与锁等待证据', 2, 0, 'green'), n('timeline', '关联追踪与日志', '按 ID 串联,不记录敏感载荷', 0, 1), n( 'queue', '消息携带上下文', 'job_id + 发布时刻 + trace 信息', 1, 1, 'amber', ), n('worker', 'Worker 执行跨度', '收到时刻、开始、完成、重试', 2, 1), ], edges: [ e('request', 'api', '① 传播上下文'), e('api', 'db', '② 子调用'), e('api', 'queue', '③ 注入消息', 'async'), e('queue', 'worker', '④ 提取上下文', 'async'), e('worker', 'timeline', '⑤ 关联证据', 'async', { fromPort: 'bottom', toPort: 'bottom', via: [ [765, 388], [135, 388], ], labelAt: [450, 383], }), ], steps: [ '从影响范围、错误率和延迟指标入手,用请求 ID 找到具体请求。指标标签用服务、路由等有限集合,避免把每个 request_id 都当标签。', '传播追踪上下文,让 API、外部请求和数据库调用能够关联。连接池等待应单独测量,否则可能误把应用排队当作慢 SQL。', '把上下文与 job_id 放进任务消息,按追踪模型建立父子关系或 span link。拆开“发布到开始的等待时间”和“实际执行时间”,才能看见队列积压。', ], misconception: '日志里都打印一个 UUID,并不自动形成分布式追踪;还需要上下文传播、跨度关系、耗时记录和可查询的采集系统。', source: { label: 'OpenTelemetry:上下文传播', url: 'https://opentelemetry.io/docs/concepts/context-propagation/', }, }, { id: 'saturation-diagnosis', module: 'troubleshooting', lessons: ['05', '06', '08', '09', '10', '12'], title: '请求变慢时,先看工作卡在哪一种等候区', summary: 'CPU 忙、连接池满、数据库锁等待和队列积压都会表现为“慢”,但加 worker 只能帮助其中一部分情况。', rows: [ '观察症状:延迟上升,但要继续定位等待发生的位置', '验证假设:找对应指标,再选择能够消除瓶颈的动作', ], nodes: [ n('loop', '事件循环迟迟不调度', '轻量健康检查也变慢', 0, 0, 'red'), n('pool', '借数据库连接很慢', '池等待高,活跃连接满', 1, 0, 'red'), n('queue', '任务越来越晚开始', '最老消息年龄持续上升', 2, 0, 'red'), n('cpu', '查阻塞与 CPU', '同步调用?计算热点?线程池?', 0, 1, 'green'), n('sql', '查事务与锁', '长事务?慢 SQL?连接未归还?', 1, 1, 'green'), n( 'capacity', '查消费与到达率', '重试风暴?下游限额?租约?', 2, 1, 'green', ), ], edges: [ e('loop', 'cpu', '收集调度证据'), e('pool', 'sql', '拆分等待时间'), e('queue', 'capacity', '区分排队与执行'), ], steps: [ '如果事件循环延迟升高,先检查阻塞 I/O 和 CPU 密集工作。CPU 指标、线程栈和请求耗时可以区分“计算忙”和“被阻塞”。', '如果连接池等待升高,再看连接占用时长、SQL 耗时与数据库锁等待。盲目扩大池可能把数据库推向更严重的过载。', '如果任务开始得越来越晚,比较到达率与完成率,核查失败重试、worker 存活、Broker 与租约恢复。先限制压力,再用修复后的业务指标确认恢复。', ], misconception: 'CPU 使用率不高不代表系统健康:锁、网络、连接池和外部配额都能让系统在“等待”中失去处理能力。', }, { id: 'distributed-evolution', module: 'distributed', lessons: ['01'], overview: true, title: '架构演进:每一次拆分都要对应一个真实压力', summary: '从容易理解的单体开始,先明确边界,再按扩容、故障隔离或团队协作需求拆分。节点增加也意味着网络与一致性成本增加。', rows: [ '业务组织方式:边界逐渐明确,但复杂度也逐渐增加', '商品服务:详情查询与导出任务使用不同执行资源', ], nodes: [ n('monolith', '单体应用', '一个部署单元,内部直接调用', 0, 0), n('modular', '模块化单体', '按领域分层,仍可一起部署', 1, 0), n('services', '独立服务', '网络调用、独立发布与扩容', 2, 0, 'amber'), n('api', '无状态 API 副本', '受理、鉴权、查询与流式输出', 0, 1), n('queue', '任务交接层', '隔离长任务,设置受理边界', 1, 1, 'amber'), n( 'worker', 'Worker + 数据真源', '独立执行预算,持久任务状态', 2, 1, 'green', ), ], edges: [ e('monolith', 'modular', '明确内部边界'), e('modular', 'services', '按需求拆分'), e('api', 'queue', '异步交接', 'async'), e('queue', 'worker', '有界消费', 'async'), ], steps: [ '面试先说业务需求、峰值流量、数据规模和一致性要求,再提出满足当前约束的最小架构。', '模块化单体可以先隔离职责,减少改动互相影响。只有遇到独立扩容、发布节奏或故障隔离需求时,才引入新的服务边界。', '商品详情查询与清单导出可能占用不同资源,可先把导出任务执行拆成 worker。拆分后要补上超时、幂等、状态与观测。', ], misconception: '微服务数量多不代表设计成熟。一次本地函数调用变成网络调用后,就新增了超时、部分失败、版本兼容和数据一致性问题。', }, { id: 'distributed-ha', module: 'distributed', lessons: ['02'], title: '负载均衡绕开坏副本,也要避免更深处的单点', summary: 'API 有多个副本只是高可用的一层。负载均衡、数据存储和跨可用区部署也需要各自的故障假设。', rows: [ '请求入口:均衡器只把新流量发给可服务的副本', '副本故障:摘除、排空和重试需要业务配合', ], nodes: [ n('client', '调用方', '请求携带身份与业务上下文', 0, 0), n('lb', '负载均衡层', '选择健康副本;自身需冗余', 1, 0), n('a', 'API 副本 A', '健康:继续接收流量', 2, 0, 'green'), n('data', '共享持久状态', '独立设计复制与恢复', 0, 1, 'green'), n('probe', '健康探测与摘除', '从路由中移除不可用副本', 1, 1), n('b', 'API 副本 B 故障', '已接收请求仍可能结果未知', 2, 1, 'red'), ], edges: [ e('client', 'lb', '① 发请求'), e('lb', 'a', '② 健康路由'), e('b', 'probe', '③ 故障证据', 'risk'), e('probe', 'lb', '④ 更新路由'), e('probe', 'data', '核查依赖状态'), ], steps: [ 'API 副本尽量不在本机内存保存唯一业务状态,这样某个副本退出后,其他副本仍能查询同一任务与用户数据。', '健康探测发现不可服务副本后,均衡器停止发送新流量。已经送出的请求不一定失败,也可能已经提交但响应没送回。', '继续检查均衡入口、数据库和共享存储是否仍是单点;跨机或跨可用区部署要权衡延迟、成本与故障范围。', ], misconception: '负载均衡不会把失败请求自动变成安全请求。写操作重试仍需要幂等,长连接故障通常需要客户端重连及应用层恢复。', }, { id: 'replication-sharding', module: 'distributed', lessons: ['03'], title: '复制是多份相同数据,分片是把数据拆成不同部分', summary: '复制常用于容错和读扩展,分片常用于容量和写入扩展。它们可以组合,但解决的问题不同。', rows: [ '复制:同一份数据传播到多个节点,会有进度差异', '分片:按路由键决定某条数据归哪个节点管理', ], nodes: [ n('primary', '主节点', '接收写入,产生变更记录', 0, 0, 'green'), n('log', '复制链路', '同步 / 异步有不同取舍', 1, 0, 'amber'), n('replica', '副本节点', '保存副本,可能暂时落后', 2, 0), n('router', '分片路由', '例如按 tenant_id 决定归属', 0, 1), n('shard1', '分片 A', '一部分租户的数据', 1, 1, 'green'), n('shard2', '分片 B', '另一部分租户的数据', 2, 1, 'green'), ], edges: [ e('primary', 'log', '① 变更传播', 'async'), e('log', 'replica', '② 应用变更', 'async'), e('router', 'shard1', '键属于 A'), e('router', 'shard2', '键属于 B', 'sync', { fromPort: 'bottom', toPort: 'bottom', via: [ [135, 388], [765, 388], ], labelAt: [450, 383], }), ], steps: [ '复制让多个节点拥有同一份数据。异步复制下,刚写主库就读副本可能看不到更新;故障切换还要考虑未复制写入。', '分片把不同数据分给不同节点。分片键决定负载是否均匀,也影响查询是否必须跨多个节点聚合。', '常见设计是在每个分片内部再做复制。面试要继续解释热点分片、扩容迁移、跨片事务与路由变化,不能止于画出两个数据库。', ], misconception: '增加只读副本通常不能直接分担主节点写入。复制也会把误删传播出去,所以它不能代替独立备份。', }, { id: 'cap-partition', module: 'distributed', lessons: ['04'], title: '网络分区时,两边能否同时承诺最新且可写', summary: '分区让节点无法确认彼此的最新状态。CAP 讨论这种条件下,一致性与可用性不能同时完全满足的限制。', rows: [ '故障事实:节点仍在运行,但互相收不到消息', '业务选择:拒绝无法保证正确的操作,或接受稍后协调', ], nodes: [ n('a', '节点 A', '已收到一份新的写入', 0, 0), n('partition', '网络分区', 'A 与 B 之间无法通信', 1, 0, 'red'), n('b', '节点 B', '无法确认 A 的最新状态', 2, 0), n( 'consistent', '优先保证一致语义', '无足够证据的一侧拒绝请求', 0, 1, 'amber', ), n('rule', '按业务操作划界', '扣款与点赞可选不同语义', 1, 1), n('available', '保持本地可用', '允许暂时分歧,之后协调', 2, 1, 'amber'), ], edges: [ e('a', 'partition', '通信中断', 'risk'), e('b', 'partition', '通信中断', 'risk'), e('a', 'consistent', '选择一'), e('b', 'available', '选择二'), ], steps: [ '这里的分区指网络通信被切断,不是数据库分片。A 和 B 进程都可能健康,但不能依赖彼此立即返回信息。', '若要求线性一致性,就不能让无法形成必要共识的一侧随意接受成功写入;若保持双方可用,就要接受并发状态和后续冲突处理。', 'CAP 的可用性与运维中的月度 SLA 不是同一概念。没有分区时,仍存在复制等待、延迟和一致性之间的工程取舍。', ], misconception: 'CAP 不是把数据库永久贴成“任意三选二”。要明确发生了什么故障、哪一类操作、采用什么一致性定义,以及拒绝请求的范围。', }, { id: 'cache-consistency-design', module: 'distributed', lessons: ['05'], title: '设计缓存前,先给“可以旧多久”一个答案', summary: '缓存一致性是业务选择:有些页面接受短暂旧值,有些授权或余额判断必须读满足一致性要求的数据源。', rows: [ '读路径按要求分流:允许旧值与要求新值使用不同策略', '更新路径:真源提交以后,再传播可恢复的缓存失效', ], nodes: [ n('read', '读请求', '声明新鲜度与权限要求', 0, 0), n('cache', '允许短暂旧值', '版本化缓存 + TTL + 降级', 1, 0, 'amber'), n('source', '需要严格判断', '读取满足一致性要求的真源', 2, 0, 'green'), n('write', '业务更新', '写真源并提交事务', 0, 1), n('event', '可靠失效意图', '重试、顺序 / 版本与告警', 1, 1), n( 'invalidate', '删除 / 更新缓存', '处理回填竞态与传播延迟', 2, 1, 'amber', ), ], edges: [ e('read', 'cache', '选择快速路径'), e('read', 'source', '选择严格路径', 'sync', { fromPort: 'top', toPort: 'top', via: [ [135, 50], [765, 50], ], labelAt: [450, 65], }), e('write', 'event', '① 持久化意图'), e('event', 'invalidate', '② 异步传播', 'async'), ], steps: [ '先区分展示内容和必须正确的业务判断,约定最大可接受陈旧窗口、缓存不可用时的行为与数据库容量余量。', '普通内容采用 Cache Aside、TTL 和提交后失效时,要承认失效传播与旧值回填窗口;更严格的要求需要额外的版本或协调机制。', '缓存故障时回源并发也必须受控。可选限流、只读降级或使用允许的旧值,并记录命中率、回源量和数据新鲜度。', ], misconception: '给缓存设置 60 秒 TTL,不等于所有读者总能在数据库更新后 60 秒内看到新值;回填竞态、时钟和多层缓存都会影响承诺。', }, { id: 'message-failure-path', module: 'distributed', lessons: ['06'], title: '消息系统要画出失败分支,不能只画生产和消费', summary: '解耦、削峰和异步交接是收益;消息是否丢失、是否重复、积压多久以及毒消息如何隔离,需要逐项说明。', rows: [ '正常路径:可靠记录意图、投递、持久化结果与确认', '失败路径:预算内重试,超限隔离,修复后可控重放', ], nodes: [ n('producer', '生产方 + 发送意图', '业务提交与交接记录关联', 0, 0), n('broker', 'Broker', '耐久策略、确认与队列容量', 1, 0, 'amber'), n('consumer', '幂等消费者', '提交副作用后按策略确认', 2, 0), n('replay', '修复后重放', '保留事件 ID,限制重放速率', 0, 1, 'green'), n('dead', '失败隔离区', '记录原因、次数与告警', 1, 1, 'red'), n('retry', '有限重试', '退避 + 抖动 + 总截止时间', 2, 1, 'amber'), ], edges: [ e('producer', 'broker', '① 发布', 'async'), e('broker', 'consumer', '② 投递', 'async'), e('consumer', 'retry', '③ 可重试失败', 'risk'), e('retry', 'dead', '④ 预算耗尽', 'risk'), e('dead', 'replay', '⑤ 修复与审核'), ], steps: [ '从发布方到 Broker 的接收确认,与消费者到 Broker 的消费确认,是两个阶段;业务结果提交还属于另一个系统。', '按至少一次交付设计幂等消费者,明确同一业务键的顺序要求。可重试错误用退避和抖动,永久错误尽早进入失败隔离。', '死信或失败表不是垃圾桶:要有告警、负责人、检查原因和重放流程。重放保留幂等键,避免修复时再次重复副作用。', ], misconception: '“用了消息队列就不会丢”和“开启重试就一定成功”都缺少故障假设。永远失败的消息无限重试会拖累正常业务。', }, { id: 'saga-compensation', module: 'distributed', lessons: ['07'], title: 'Saga 用业务补偿处理已跨出本地事务的变化', summary: '用商品清单发布作理论推演:记录导出任务、发布下载入口各自提交,后续失败时撤销入口并标记失败。补偿本身也可能失败。', rows: [ '正向步骤:每一步都有自己的本地提交与交接记录', '后续失败:按业务规则补偿,补偿动作也要幂等和可恢复', ], nodes: [ n('reserve', '登记导出任务', '任务服务本地提交', 0, 0, 'green'), n('charge', '发布下载入口', '目录服务本地提交', 1, 0, 'green'), n('fulfill', '文件发布失败', '文件服务未完成发布', 2, 0, 'red'), n('release', '标记任务失败', '可重试、记录补偿状态', 0, 1), n('refund', '撤销下载入口', '是新业务动作,可能失败', 1, 1, 'amber'), n('coordinate', '流程协调 / 事件', '决定补偿次序并追踪进度', 2, 1), ], edges: [ e('reserve', 'charge', '① 正向推进', 'async'), e('charge', 'fulfill', '② 正向推进', 'async'), e('fulfill', 'coordinate', '③ 记录失败', 'risk'), e('coordinate', 'refund', '④ 撤销入口', 'async'), e('refund', 'release', '⑤ 标记失败', 'async'), ], steps: [ '跨服务流程无法直接依赖一个数据库本地事务。每个参与者提交自己的数据,并通过可靠事件或协调器推进下一步。', '文件发布失败时,撤销已发布的下载入口并把任务标为失败。已提交的记录通过新操作修正;Outbox 可以让本地状态和发送意图一起提交。', '补偿是新的业务动作,不是把时间倒回。它需要幂等、重试、审计,长期失败时还需要人工处理和对账。', ], misconception: '此图故意把下载入口先于文件发布,以便观察补偿;实际应优先让文件就绪后再开放下载。简单商品服务不需要为了学习 Saga 拆成多个服务;两者都不是全局 ACID 保证。', }, { id: 'idempotent-retry', module: 'distributed', lessons: ['08'], title: '响应丢了,重试同一意图必须复用同一把钥匙', summary: '客户端看见超时,只能说明没有收到确定结果。服务端可能已经提交,所以再次提交要携带稳定的幂等键。', rows: [ '第一次请求:服务端已提交,但响应没能到达调用方', '重复请求:数据库原子识别同一意图,返回原结果', ], nodes: [ n('first', '首次提交', 'key=K;请求内容摘要=H', 0, 0), n('commit', '原子登记与业务写入', '唯一约束保护同一 key', 1, 0, 'green'), n('lost', '响应丢失 / 超时', '客户端不知道是否已成功', 2, 0, 'red'), n('repeat', '重试同一请求', '继续使用 K 与相同内容', 0, 1), n('lookup', '发现已处理的 K', '核对身份、范围与请求摘要', 1, 1), n('reuse', '返回已保存结果', '正在处理时返回明确状态', 2, 1, 'green'), ], edges: [ e('first', 'commit', '① 提交意图'), e('commit', 'lost', '② 返回失败', 'risk'), e('first', 'repeat', '③ 结果未知后'), e('repeat', 'lookup', '④ 原子判重'), e('lookup', 'reuse', '⑤ 复用结果'), ], steps: [ '客户端为一次业务意图生成稳定幂等键,重试时保留同一键;服务端按用户、业务类型等范围设置唯一约束。', '幂等登记应与本地业务写入原子协调,保存处理中或已完成状态及结果。相同键但不同请求内容应明确拒绝。', '对外部副作用,把同一幂等意图继续传播给支持幂等的下游;不支持时需要状态查询、对账或人工补偿。还要定义幂等记录保留期。', ], misconception: '每次重试生成新 UUID,只会创建新的业务意图。先查询“没有这个键”再无约束写入,也无法阻止并发重复。', }, { id: 'fencing-token', module: 'distributed', lessons: ['09'], title: '租约过期后,旧持有者还可能继续写', summary: '锁的有效期只是一份执行权声明,不是远程进程停止证明。受保护资源要识别并拒绝过时的执行令牌。', rows: [ '旧执行者 A:取得令牌 41 后长时间暂停,后来恢复', '新执行者 B:取得更大的令牌 42,资源拒绝更旧的写入', ], nodes: [ n('a', '执行者 A', '持有租约 / 令牌 41', 0, 0), n('pause', '长暂停超过租约', '网络、调度或进程暂停', 1, 0, 'amber'), n('reject', '旧写入被拒绝', '41 < 资源已见到的 42', 2, 0, 'red'), n('b', '执行者 B', '新租约 / 单调令牌 42', 0, 1), n('resource', '受保护资源', '原子校验并记录最大令牌', 1, 1, 'green'), n('accept', '接收 B 的写入', '当前执行权属于新持有者', 2, 1, 'green'), ], edges: [ e('a', 'pause', '① 旧持有者'), e('pause', 'reject', '④ 恢复后旧写', 'risk'), e('b', 'resource', '② 携带 42'), e('resource', 'accept', '③ 校验通过'), e('accept', 'reject', '资源已见 42'), ], steps: [ 'A 取得租约后暂停,租约过期并不会杀死 A。B 取得新租约后,A 仍可能恢复并试图写结果。', 'fencing token 是能够比较新旧顺序的令牌。B 携带 42 写资源后,资源原子拒绝随后到来的较小令牌 41。', '需要令牌签发机制提供足够的单调保证,也需要最终写入目标真正校验令牌。只在应用里打印或检查令牌,不能保护不参与校验的外部系统。', ], misconception: 'Redis 锁中的随机值可用于避免误删别人的锁,但它本身不是单调 fencing token。锁、租约、幂等和数据库约束分别承担不同责任。', }, { id: 'timeout-circuit', module: 'distributed', lessons: ['10'], title: '把一次调用的时间和尝试次数当作有限预算', summary: '超时限制一次等待,重试处理可能恢复的故障,熔断减少持续失败时的调用。三者需要放在同一总预算内。', rows: [ '调用开始:先检查总截止时间和依赖健康状态', '失败之后:只对允许重试的意图做有限次数尝试', ], nodes: [ n('caller', '上游请求', '例如总预算 3 秒', 0, 0), n('breaker', '熔断与并发门槛', '持续失败时快速拒绝', 1, 0, 'amber'), n('attempt', '一次下游调用', '连接 + 读取 + 执行等待', 2, 0), n('finish', '结束并明确结果', '成功 / 失败 / 结果未知', 0, 1), n('backoff', '退避与抖动', '剩余预算不足就停止', 1, 1, 'amber'), n('classify', '分类失败原因', '幂等?暂态错误?可查询?', 2, 1), ], edges: [ e('caller', 'breaker', '① 检查预算'), e('breaker', 'attempt', '② 允许调用'), e('attempt', 'classify', '③ 返回 / 超时'), e('classify', 'backoff', '④ 可重试'), e('backoff', 'finish', '⑤ 预算耗尽'), e('backoff', 'breaker', '预算内再试', 'async'), ], steps: [ '从用户请求的总截止时间向下分配预算,包含连接等待、排队、一次调用和重试退避;不能让每一层都独占完整超时。', '只重试明确可重试且副作用可控的操作。退避拉开尝试,随机抖动避免所有客户端同一时刻重试。', '熔断器在失败达到门槛时暂时拒绝依赖调用,并用受控探测判断是否恢复。熔断不是自动修复,应配合告警和降级。', ], misconception: '本地等待超时不代表远端已停止。三层服务各重试 3 次,最底层尝试可能相乘,造成重试风暴。', }, { id: 'capacity-design', module: 'distributed', lessons: ['11'], title: '容量预算要贯穿入口、队列、执行器和依赖', summary: '限流控制到达速度,并发限制控制在途工作,背压把容量不足传回上游。队列只能临时吸收差额。', rows: [ '三个控制点:不要让有限的下游承受无限的输入', '衡量容量:同时关注吞吐、等待时间与在途数量', ], nodes: [ n('rate', '入口限流', '按租户与全局限制受理率', 0, 0), n('queue', '有界队列', '长度上限 + 最长可接受等待', 1, 0, 'amber'), n('concurrency', '执行并发上限', '按数据库与文件 I/O 约束', 2, 0), n('arrival', '到达率 λ', '稳定期间每秒进入多少工作', 0, 1), n('time', '平均耗时 W', '含排队与实际处理时间', 1, 1), n('inflight', '平均在途量 L', '稳定系统中 L = λ × W', 2, 1, 'green'), ], edges: [ e('rate', 'queue', '① 有容量才受理', 'async'), e('queue', 'concurrency', '② 有空位才执行', 'async'), e('arrival', 'time', '容量估算输入'), e('time', 'inflight', '关联在途预算'), ], steps: [ '入口限流保护公平与总受理量;执行器并发上限限制同时占用的资源;队列满或等待过久时向调用方明确拒绝或降级。', '在稳定系统的长期平均意义下,Little 定律关联在途量、到达率和停留时间。例如每秒完成 20 件、平均停留 5 秒,对应平均约 100 件在途。', '商品查询和文件写入变慢会增加资源占用时间,即使请求数不变也可能过载。扩容时同时计算 API、worker、数据库连接和第三方配额。', ], misconception: '用平均值估算不等于覆盖峰值和长尾;持续超载的队列没有稳定平均状态,不能只套公式后宣称容量足够。', }, { id: 'design-recovery', module: 'distributed', lessons: ['12'], title: '系统设计的最后一段:如何证明它能恢复', summary: '把可观测性和容灾放回业务目标:知道什么时候算坏了、能丢多少数据、多久必须恢复,并用演练给出证据。', rows: [ '先定义目标与故障假设,再设计可观测证据', '发生故障时:按手册恢复,并核验业务与数据目标', ], nodes: [ n('goal', '业务目标', 'SLI / SLO;关键用户路径', 0, 0), n('budget', '恢复目标', 'RPO 可丢数据;RTO 恢复时间', 1, 0), n( 'evidence', '指标 / 日志 / 追踪', '能定位故障范围与阶段', 2, 0, 'green', ), n('review', '验收与改进', '验证目标;补上薄弱环节', 0, 1, 'green'), n('restore', '恢复与对账', '切换、回退、恢复备份、重放', 1, 1), n('drill', '故障演练', '节点退出、分区、队列与数据故障', 2, 1, 'amber'), ], edges: [ e('goal', 'budget', '① 明确承诺'), e('budget', 'evidence', '② 布置证据'), e('evidence', 'drill', '③ 验证告警'), e('drill', 'restore', '④ 执行手册'), e('restore', 'review', '⑤ 核验结果'), ], steps: [ '先定义关键路径的成功率、延迟与可接受故障窗口,再说明 RPO 和 RTO。它们需要落到备份频率、复制策略与恢复流程。', '用指标发现问题,用追踪和日志定位;为任务记录受理到完成的端到端时延,避免只看 API 返回 202 的成功率。', '在隔离环境演练恢复备份、切换副本、重放消息和对账,测量真实恢复时间与数据差异,再更新设计和 runbook。', ], misconception: '“有备份”“有副本”“能自动重启”都不能单独证明可恢复。未验证的备份可能无法还原,副本也可能复制了错误数据。', }, ]; export function getModuleDiagrams(module: string) { return teachingDiagrams.filter( (diagram) => diagram.module === module && diagram.overview, ); } export function getLessonDiagrams(module: string, lesson: string) { return teachingDiagrams.filter( (diagram) => diagram.module === module && diagram.lessons.includes(lesson), ); } export function hasLessonDiagrams(module: string, lesson: string) { return teachingDiagrams.some( (diagram) => diagram.module === module && diagram.lessons.includes(lesson), ); }