| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529 |
- 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<Omit<DiagramEdge, 'from' | 'to' | 'label' | 'kind'>> = {},
- ): 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),
- );
- }
|