[ { "id": "01", "kind": "theory", "group": "架构选择与数据分布", "title": "从模块化单体到微服务与事件驱动", "summary": "画出一个应用加独立导出进程的方案,并说明何时值得继续拆分。", "objectives": [ "画出新增商品到下载清单的完整路线", "指出哪些箭头需要经过网络", "为一次拆分写出收益和新增成本" ], "concepts": [ { "title": "模块化单体:同一家公司里的不同部门", "body": "回看“画出模块化单体版本”的推演。商品管理、商品分类、商品评价、清单导出和进度查询可以是同一应用里的清晰模块。它们通过接口协作,应用整体发布,也可以运行多个副本。单体描述部署边界,模块化描述职责边界;一个应用并不等于所有逻辑写在一个文件。" }, { "title": "微服务:部门成为独立经营的团队", "body": "回看“只拆一个有理由的边界”的推演。把商品查询、清单导出或通知拆成独立服务,可以分别发布和扩容。一次函数调用变成网络请求后,必须面对延迟、超时、版本兼容和局部失败。拆分的价值应来自独立变化或资源需求,服务数量本身不是质量指标。" }, { "title": "事件驱动:完成一件事,再通知关心它的人", "body": "回看“比较同步调用与事件通知”的推演。“商品清单已导出”是已经发生的事实,进度通知可订阅这一事件;“请导出商品清单”则是请求某个执行者做事的命令。事件驱动描述协作方式,可以和单体或微服务组合;下游稍后完成时,需要显式状态和查询入口。" }, { "title": "商品服务的技术责任", "body": "回看“固定一条用户旅程”的推演。贯穿案例只管理商品、分类和评价,并支持商品清单导出与进度通知。服务端负责输入校验、持久状态、任务执行和结果交付;商品价格使用非负整数 price_cents,0 表示免费教学样品。业务保持简单,把注意力放在接口、数据和失败恢复上。" } ], "steps": [ { "title": "固定一条用户旅程", "body": "把“新增教学水杯→选择商品→导出→稍后下载”写成六个框。先不命名架构,为每个框填输入、输出,以及结果最终保存在哪。", "explanation": "用户新增教学水杯,填写名称、描述和整数价格,再选择几件商品导出清单,随后查询进度并下载文件。链路依次是:入口校验 → 保存商品 → 固定导出快照 → 执行任务 → 保存文件 → 更新状态并通知。先解释每个框输入什么、输出什么、谁保存事实。", "expected": "图中应能找到商品记录、导出时固定的数据和文件;只保存一个“已受理”响应还不能下载清单。" }, { "title": "画出模块化单体版本", "body": "给上一步的商品管理、分类、评价、导出受理和任务查询画同一个应用边框;把数据库、Redis、文件放到外面,分别标出跨框箭头。", "explanation": "把商品管理、分类、评价、导出受理和任务查询放在一个应用边框内,数据库、Redis 和文件存储画在边框外。模块间箭头是进程内调用,跨出边框的箭头需要网络。多开两个应用副本仍可以是单体。", "expected": "应用内部调用不经过网络;访问库、缓存和文件服务的箭头跨出进程。复制整个应用不会自动增加业务边界。" }, { "title": "只拆一个有理由的边界", "body": "现在新增一个条件:导出计算比商品查询慢得多。只移动负责计算的框,比较移动前后能否单独增加执行进程。", "explanation": "假设商品清单导出很耗时,需要独立增加执行资源,可以把执行器作为独立 Worker 进程,经队列接收任务。这里先得到“API + Worker”,未必需要拆成多个微服务。仅当商品查询或通知确实出现独立发布和扩容需求时,再考虑新的边界。", "expected": "API 继续受理和查询,独立 Worker 消费任务;可以只增加 Worker,暂时无需拆商品和分类服务。" }, { "title": "比较同步调用与事件通知", "body": "分别画“查看商品详情”和“导出完成后通知页面”的时序。把页面必须等待的箭头用实线、可稍后发生的通知用虚线。", "explanation": "商品详情和任务查询需要立即返回当前数据,适合同步读取;导出完成后的进度通知可以接收“商品清单已导出”事件。同步链条越长,用户越容易被最慢依赖拖住;异步解耦后仍要处理重复、延迟和失败。", "expected": "详情读取需要即时结果;完成通知可以延迟。等待链减少后,通知失败与重复需要独立处理。" }, { "title": "写出选择理由和转向条件", "body": "假设三个人维护全部代码,写下本轮保留同一应用的模块、独立运行的角色,以及一个未来值得再拆分的触发条件。", "explanation": "对于三人维护、需求仍在变化的商品服务,模块化单体加独立导出执行器是合理起点。记录“商品查询需要独立扩容”“通知需要独立发布”等未来拆分条件。新增组件时说明具体收益和维护成本。", "expected": "方案可从单应用加独立导出进程开始;每个新增边界都应对应具体扩容或发布需求,而非仅增加服务数量。" } ], "checks": [ "能指出模块边界与网络边界,并说明两者不一定重合。", "能用一个具体业务原因解释为什么拆服务,或为什么暂时保持单体。", "能区分“执行商品清单导出”的命令与“商品清单已导出”的事件。" ], "pitfalls": [ { "symptom": "每个功能都拆成服务,改一个字段要协调多个发布。", "cause": "按技术名词切分,没有确定稳定的业务责任与数据归属。", "fix": "先整理模块接口和数据所有权,再依据独立变化、资源或权限需求拆分。" }, { "symptom": "加了消息队列后,页面永远不知道任务有没有完成。", "cause": "只画事件流,没有业务状态和查询入口。", "fix": "为长任务定义编号、进度、终态和结果引用,让异步过程对用户可解释。" } ], "exercise": { "task": "一个三人团队的商品服务只有清单导出缓慢,其他接口负载很低。给出最小合理架构,并列出两个未来拆分服务的触发条件。", "hint": "把发布复杂度、计算资源和用户等待分别考虑。", "answer": "保留模块化 API,由数据库保存商品与导出任务,队列把导出交给独立 Worker,文件存入共享存储,用户用任务编号查询进度。API 和 Worker 分别分配资源。未来商品查询需要独立扩容,或通知功能有独立发布需求时,再考虑拆分。新增队列也带来交付和恢复成本;如果承诺关闭页面后仍能完成,就需要持久受理与恢复机制。" }, "interview": { "question": "什么时候应该从单体迁移到微服务?", "answer": "先指出现有痛点是否来自独立发布、组织边界、扩容或权限隔离,再判断清晰的数据所有权是否已经存在。微服务可以缩小某些变更和资源影响,也会引入网络失败、跨服务一致性与运维成本。能用模块化或独立 Worker 解决的问题,不必靠全面拆分解决。", "followUps": [ "单体能否横向扩容?为什么?", "同步微服务链与事件驱动链分别如何向用户表达失败?" ] }, "refs": [ { "label": "Microsoft:常见 Web 应用架构与单体边界", "url": "https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/common-web-application-architectures" }, { "label": "Microsoft:微服务架构", "url": "https://learn.microsoft.com/en-us/azure/architecture/microservices/" }, { "label": "Microsoft:事件驱动架构", "url": "https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/event-driven" } ], "intro": { "scenario": "商品能保存、清单也能导出后,团队准备增加服务器。应该先把哪部分独立运行?", "startingPoint": [ "已完成商品接口、数据库与异步任务模块,能画出一次商品导出的实际执行顺序。", "本模块只在纸面推演;准备一张纸,把 API、任务记录和文件画出来即可,不操作生产系统。" ], "outcome": "画出一个应用加独立导出进程的方案,并说明何时值得继续拆分。" } }, { "id": "02", "kind": "theory", "group": "架构选择与数据分布", "title": "高可用与负载均衡:多开一台为什么还不够", "summary": "推演副本故障后的请求去向、数据可见性和容量缺口。", "objectives": [ "让两台 API 查询到同一任务", "算出坏一台后还差多少处理能力", "为暂不可用的导出写出准确提示" ], "concepts": [ { "title": "负载均衡:把工作分给合适的窗口", "body": "回看“从两次请求看状态归属”的推演。入口将请求分给多个 API 副本。轮询适合成本接近的请求;最少连接或考虑负载的策略可改善某些长短任务混杂的情况。一次清单导出可能占用几十秒,一次状态查询只需很短时间,请求个数相等并不意味着工作量相等。" }, { "title": "高可用:坏掉一部分后仍满足用户目标", "body": "回看“检查切换后的承载能力”的推演。两个 API 副本能减小单进程故障影响,但若都依赖一台数据库,数据库故障仍会影响全站。必须沿入口、应用、存储和依赖逐层观察。高可用是整条业务路径的性质,不是某个盒子旁写“×2”就完成。" }, { "title": "故障域:会一起失效的范围", "body": "回看“推演一次副本不可用”的推演。同一主机上的两个进程可能随机器一起停止;不同主机也可能共用电源、网络、机房或错误配置。跨故障域布置副本可以降低共同失效风险,却需要承担额外网络延迟、费用和数据同步成本。" }, { "title": "无状态副本不等于系统没有状态", "body": "回看“从两次请求看状态归属”的推演。让任何 API 副本都能从持久存储读取商品和导出任务,才能随时接替工作。若任务只在 API-A 的内存,切换到 API-B 会查询不到。流式连接仍属于一个具体实例;断开后的恢复需要业务编号和可续接协议。" } ], "steps": [ { "title": "从两次请求看状态归属", "body": "画 API-A、API-B,给 A 放一条导出记录。让用户先提交到 A,再查询 B;分别试一次“记录只在 A 内存”和“记录在共享库”。", "explanation": "用户向 API-A 提交任务,下一次查询被分配到 API-B。若 B 读取共享任务库,可以继续回答;若 A 只写自己的字典,B 会误报不存在。由此理解为何负载均衡之前必须先明确数据存放位置。", "expected": "内存方案中 B 查不到;共享记录方案中 B 可返回同一 job_id 的进度,前提是共享库可达。" }, { "title": "推演一次副本不可用", "body": "在两次健康探测中间让 A 停止响应。沿在途请求与后续请求分别画箭头,再比较读取重试和提交重试。", "explanation": "A 停止响应,健康检查在下一次探测后才会发现,流量切换存在延迟,部分在途请求可能已经失败。客户端可按操作语义重试:查询通常容易重试,提交任务需要同一幂等键,避免在 B 再创建一份任务。", "expected": "探测发现前仍可能有请求失败;查询可再读,提交需沿用同一意图编号,切到 B 不能再创建一份。" }, { "title": "检查切换后的承载能力", "body": "写下 A、B 各 100 次/秒,总需求 160 次/秒。划掉 A,计算 B 的缺口,选择扩容、限制受理或降级的一种补救。", "explanation": "假设两个副本各能稳定处理 100 个轻请求/秒,平时总流量为 160。一个副本故障后,剩下 100 的能力无法承担 160。冗余之外还需要容量余量、扩容或降级;健康检查不会制造新的计算资源。", "expected": "故障后差 60 次/秒;即使入口立即发现故障并切换,剩余副本仍无法维持原服务目标。" }, { "title": "区分存活与可以接客", "body": "保持 B 的进程运行,但让它无法从数据库取得连接。分别判断“进程存在”和“能完成商品查询”两个问题。", "explanation": "进程仍活着,只能证明某个基础条件成立;数据库连接池耗尽时,它可能无法完成业务。健康判断应帮助入口选择可服务实例,同时避免把共享依赖的一次波动变成所有实例反复摘除和重启。", "expected": "进程存在可以为真,业务就绪可以为假;共享依赖故障时所有副本都可能受影响,反复重启不一定有用。" }, { "title": "把降级结果写成用户能理解的话", "body": "设文件服务停止、商品库正常。分别为商品查询、旧任务查询和新导出写一条用户会看到的结果。", "explanation": "文件存储不可用时,系统可以继续展示商品和查询现有任务,暂缓新导出并给出明确状态。不能把缺失文件标成成功。可用性目标按商品查询、导出受理和导出完成分别定义。", "expected": "查询可继续,导出明确等待或暂不受理;缺少文件的任务不能显示可下载成功。" } ], "checks": [ "能指出两个 API 副本仍共享哪些故障风险。", "能算出失去一个副本后,剩余容量是否足够。", "能说明健康检查有发现延迟,无法挽救已经中断的请求。" ], "pitfalls": [ { "symptom": "切到另一台机器后用户任务消失。", "cause": "商品或任务只保存在本机内存,负载均衡只转发请求。", "fix": "让可恢复的业务状态进入共享持久存储,连接中断通过业务编号续查。" }, { "symptom": "故障时所有流量涌向剩余副本,随后全部超时。", "cause": "副本数量增加了,但没有给故障场景预留容量。", "fix": "按故障后的容量做预算,配合限流、隔离和明确的降级行为。" } ], "exercise": { "task": "商品服务 有三台 API,每台稳定容量为 80 请求/秒,日常流量为 180。要求任意一台故障后仍承接全部流量,应如何调整?", "hint": "先计算 N−1 场景的剩余容量,再判断是否所有请求必须保留同等服务水平。", "answer": "失去一台后仅剩 160 请求/秒,小于 180,因此现方案不满足目标。可提升每台稳定容量到至少 90,但这只刚好满足数学下限,实际还要留延迟和突发余量;也可增加到四台,失去一台后理论容量为 240。若预算不允许,可在故障时优先保障查询与取消,限制新导出请求,并把这种降级写进服务目标。还需确认数据库和文件存储吞吐能承接,增加 API 并不自动扩大下游容量。" }, "interview": { "question": "部署两个实例是否就实现了高可用?", "answer": "没有。需要检查入口是否冗余、实例是否跨共同故障域、状态是否可接替、依赖是否存在单点,以及故障后剩余容量是否足够。健康检查和切换也有时间窗口,用户侧需要按请求语义恢复。最终用业务可用性目标评价整条路径。", "followUps": [ "长连接和普通短请求对负载分配有何不同?", "为什么只检查进程存活可能得到错误的健康结论?" ] }, "refs": [ { "label": "Google SRE:前端负载均衡", "url": "https://sre.google/sre-book/load-balancing-frontend/" }, { "label": "Google SRE:数据中心内负载均衡", "url": "https://sre.google/sre-book/load-balancing-datacenter/" }, { "label": "Google SRE:处理过载", "url": "https://sre.google/sre-book/handling-overload/" } ], "intro": { "scenario": "用户提交导出后,第二次查询落到另一台 API,页面却说任务不存在。多开一台为何出现新问题?", "startingPoint": [ "完成第 01 课,已有 API 与 Worker 的边界图。", "沿用同一张图,给 API 画出 A、B 两份,把任务目前保存的位置圈出来。" ], "outcome": "推演副本故障后的请求去向、数据可见性和容量缺口。" } }, { "id": "03", "kind": "theory", "group": "架构选择与数据分布", "title": "复制与分片:多份副本和分成几份有什么不同", "summary": "分别用复制和分片解释旧读与容量问题,并列出扩容时必须迁移的工作。", "objectives": [ "追踪一次写后读到旧状态的原因", "按常见查询选择记录放置规则", "找出新增分片仍解决不了的热点" ], "concepts": [ { "title": "复制:同一份账本的多个副本", "body": "回看“跟踪一次写后立即读”的推演。主副本把变化发送给其他副本,主要服务于读扩展和故障接替。异步复制先向用户确认,再让其他副本追上;同步确认要等待约定的副本达到约定阶段。等待接收、持久化和回放不是同一个条件。" }, { "title": "分片:每个分柜保存不同的一部分", "body": "回看“从查询问题反推分片键”的推演。按用户把导出任务分到不同数据库,各分片只承担部分数据和写入。一个分片内部又可以有多个副本,因此复制和分片可以同时使用。分片引入路由、扩容迁移、跨片查询和事务的额外复杂度。" }, { "title": "分片键决定哪些工作能待在一起", "body": "回看“从查询问题反推分片键”的推演。按 user_id 分片有利于读取同一用户的导出任务,但超大用户可能成为热点。按 job_id 均匀分散写入,则列出一个用户的全部任务可能需要跨片汇总。没有同时适合所有读写模式的万能分片键。" }, { "title": "副本不是历史备份", "body": "回看“把扩容当作数据迁移问题”的推演。复制通常也会把误删或坏数据传过去。备份保存可恢复的历史版本,帮助回到错误之前;副本帮助快速接替近期运行状态。前者关心恢复点和保留周期,后者关心同步进度与接管方式。" } ], "steps": [ { "title": "跟踪一次写后立即读", "body": "写出主库 running→succeeded 的时间,再让只读副本晚 2 秒应用它。安排一次落在这 2 秒内的查询并写出页面状态。", "explanation": "任务在主库已经从 running 变为 succeeded,但查询被路由到尚未回放的只读副本,用户看到 running。不是写入一定丢了,而是读到了较旧视图。可让关键写后读取走权威节点,或携带版本并等待达到该版本。", "expected": "用户可能看到 running,即使主库已完成;再次看到旧值本身不能证明写入丢失。" }, { "title": "比较同步和异步的代价", "body": "为一笔任务更新画两种确认时刻:等待远端保存后确认,以及本地保存后立即确认。随后在远端收到更新前让主库不可用。", "explanation": "若每次确认都等待远端副本,用户要承担网络和存储等待时间;远端无法响应时,写入可用性也受影响。若不等待,切换到落后的副本可能丢失已确认但未复制的变化。选择时要说明数据损失容忍度和延迟目标。", "expected": "等待远端需要额外延迟且可能阻塞写入;不等待时,切换到落后副本可能失去部分已确认变化。" }, { "title": "从查询问题反推分片键", "body": "把“本用户最近任务”和“全站导出数量”各写成一张查询卡。尝试按 user_id 分配记录,数出每张卡要访问几个位置。", "explanation": "商品服务 最常见的请求是“查询本用户最近的任务”。按用户分片可让请求只去一个位置,路由简单且更容易做用户隔离;若还需要全站导出统计,则应考虑单独汇总视图,而不是让每次请求扫所有分片。", "expected": "用户任务查询可落在一个分片;全站统计需要跨片或独立汇总,选键必须考虑查询形状。" }, { "title": "识别分片后仍存在的热点", "body": "假设用户 A 贡献 60% 的导出。画四个按用户分配的分片,再新增一个空分片,观察 A 的记录是否自动变少。", "explanation": "若一个高频导出用户贡献了 60% 的任务,增加其他分片不会自动减轻它所在分片的压力。可以为大用户单独分片、再按子键细分或隔离其配额,但需要重新考虑同用户事务和查询如何合并。", "expected": "新增空分片不会减轻 A 所在分片;拆大用户或限制配额时还要处理原来同用户查询和事务。" }, { "title": "把扩容当作数据迁移问题", "body": "把分片从 4 个增加到 5 个,列出搬迁时“读旧位置、读新位置、同时写入、切换路由”的先后与校验点。", "explanation": "新增一个分片后,必须决定谁搬过去、搬迁期间读哪边、如何处理同时写入、何时切换路由以及如何确认无遗漏。直接把取模分母从 4 改成 5,会让大量键映射到新位置;一致性哈希减少部分迁移,也不能替代迁移协议。", "expected": "只改取模公式会让许多记录被查询到错误位置;新旧路由、迁移进度与数据核对都需要明确协议。" } ], "checks": [ "能说明“3 个分片,每片 2 个副本”包含 6 个数据实例,但并非 6 份完整数据。", "能解释读到旧任务状态与写丢失之间的区别。", "能为一个分片键列出方便的查询、困难的查询和潜在热点。" ], "pitfalls": [ { "symptom": "加了很多只读副本,写入仍然很慢。", "cause": "写入瓶颈还在主节点、锁竞争或存储,读副本没有分担该写入路径。", "fix": "先确认瓶颈;通过事务缩短、索引和数据模型等办法优化,确有单节点容量限制再考虑分片。" }, { "symptom": "所有副本都有同样的误删记录。", "cause": "把复制当作可回溯的备份,错误变化也被同步了。", "fix": "分别设计副本接替与历史恢复,明确恢复点以及备份依赖。" } ], "exercise": { "task": "一个 商品服务有 1 万个用户,90% 查询按用户列任务,其中一个用户占 40% 写入。应怎样选择分片策略,并解释备份是否还能省掉?", "hint": "先利用主要访问模式,再单独处理不均匀的超大用户。", "answer": "可用用户路由表把普通用户分散到分片,使单用户查询保持局部;给超大用户独立分片和配额,必要时再按任务或时间细分。细分后需要额外的列表合并、索引和跨片操作设计,不能宣称已消除所有热点。全站统计可由异步汇总视图承担,并说明延迟。每个分片仍需合适的副本与备份:副本改善接替,备份保留误删前的历史,两者解决不同问题。" }, "interview": { "question": "分库分表之前应该考虑什么?", "answer": "先证明单节点容量或吞吐已成为实际瓶颈,再列出主要查询、事务边界、增长与热点分布。选择分片键时同时讨论数据局部性和负载均匀性,最后补充路由、迁移、跨片查询、唯一标识和恢复成本。读副本主要分担读取,无法自动解决写入热点。", "followUps": [ "按时间分片为什么容易出现最新分片热点?", "分片后跨用户报表如何设计?" ] }, "refs": [ { "label": "PostgreSQL:主备、流复制与同步复制", "url": "https://www.postgresql.org/docs/current/warm-standby.html" }, { "label": "Microsoft:分片模式与取舍", "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/sharding" }, { "label": "PostgreSQL:备份与恢复", "url": "https://www.postgresql.org/docs/current/backup.html" } ], "intro": { "scenario": "导出已完成,刷新页面却仍显示处理中;另一方面,一台数据库快装不下所有记录了。", "startingPoint": [ "完成第 02 课,能说明共享任务记录为何不能只放某台 API 内存。", "画一份任务表,标出一条记录的写入位置和查询位置,以下数字均为纸面示例。" ], "outcome": "分别用复制和分片解释旧读与容量问题,并列出扩容时必须迁移的工作。" } }, { "id": "04", "kind": "theory", "group": "架构选择与数据分布", "title": "CAP 与一致性:断网时,谁还能回答", "summary": "比较拒绝部分修改与两侧都接受修改的结果,明确商品读写各自的承诺。", "objectives": [ "推演同一旧版本在两区同时被修改", "指出每个选择牺牲了什么", "把列表读取与保存修改分别定规则" ], "concepts": [ { "title": "网络分区不是数据库分片", "body": "回看“建立两个区域的共同初始值”的推演。网络分区表示一些节点互相收不到消息,节点本身可能仍在运行,也各自能接收用户请求。数据库分片是主动把数据拆开存放,两个词描述完全不同的问题。" }, { "title": "CAP 的 C、A 各有严格含义", "body": "回看“选择由权威侧确认修改”的推演。这里的 C 指线性一致性:操作看起来按尊重实际先后关系的顺序作用于同一份数据。A 要求每个非故障节点收到的请求最终得到符合该操作的响应,不能靠一律报错充数。发生分区时,无法对所有请求同时保证这两点。" }, { "title": "最终一致性不承诺立刻同步", "body": "回看“选择两侧继续接受修改”的推演。在没有新更新、通信恢复并持续完成同步与冲突处理等前提下,副本最终收敛。它本身不给出“最多 1 秒”的界限。产品若要求几秒内可见,还需要额外的延迟目标、监测和补偿路径。" }, { "title": "一致性目标需要落实到业务操作", "body": "回看“把展示读取和修改确认分开”的推演。“同一商品版本只能接受一次修改”可由权威事务中的版本条件保护,防止后写静默覆盖先写;“商品列表稍后显示最新名称”可以接受短暂旧读。读己之写只保证用户能看到自己的已完成修改,比所有用户都共享实时顺序的要求更窄。" } ], "steps": [ { "title": "建立两个区域的共同初始值", "body": "在东、西两个框都写商品 42:name=水杯、version=7。切断两个框之间的箭头,只保留各自到用户的箭头,列出每侧无法知道的事实。", "explanation": "东区和西区都保存商品 42:name 为“水杯”,version 为 7。随后区域间断开,但用户仍能访问两区。两侧都不知道另一边是否已经修改这个商品,增加本地缓存也无法补上缺失消息。", "expected": "两侧都能收到用户请求,却不能及时知道对方是否已改过商品;“用户能访问”不等于两区能协调。" }, { "title": "选择由权威侧确认修改", "body": "规定只有联系到权威节点的一侧能确认 version=7 的修改。东区可联系,西区不可联系;写下两位用户分别得到什么响应。", "explanation": "若只有能联系权威节点或满足协调协议的一侧可以执行“WHERE version=7”的修改,另一侧拒绝或等待,就能保护同一版本只接受一次修改的约定,但部分请求无法正常完成。多数派还需要正确协议与持久化规则,不能只数节点证明一致性。", "expected": "东区可接受有效修改;西区必须拒绝或等待。同一旧版本不被两侧同时确认,但部分用户修改无法完成。" }, { "title": "选择两侧继续接受修改", "body": "取消上一步限制,让东区将名称改为白色水杯、西区改为透明水杯,并都确认成功。恢复两区通信后,尝试给出唯一结果。", "explanation": "若两区都根据本地 version=7 接受名称修改,可能分别写成“白色水杯”和“透明水杯”,并都向用户确认成功。恢复后虽然能检测冲突,却需要额外的取舍或人工确认。采用最终收敛意味着放弃本例跨区域单一修改顺序的承诺。", "expected": "会得到两份冲突的修改;需要合并、选胜者或人工确认,不能声称曾存在跨两区唯一修改顺序。" }, { "title": "把展示读取和修改确认分开", "body": "为商品列表与保存价格分别选读取/写入位置。列表允许显示刷新时间,改价仍需非负整数分和有效版本。", "explanation": "商品列表可读较近的副本并显示刷新时间,保存商品修改则访问能保护版本条件的权威边界。一个服务可以让不同操作采用不同一致性策略;价格变更也属于商品修改,price_cents 仍保持非负整数。", "expected": "列表可以接受标明时效的副本结果;确认修改需受保护的权威边界,同一产品的操作可有不同承诺。" }, { "title": "解释正常网络下仍有的权衡", "body": "重新接通两区,把每次等待远端复制增加的延迟写到时序图。此时没有断网,再列出仍要付出的时间与资源成本。", "explanation": "网络没有分区时仍要在复制等待、延迟、吞吐和成本之间选择。CAP 是特定模型下的不可能性结论,不是“平时任意选两个”的产品配置表,也不能替代对超时、故障恢复和业务冲突的完整设计。", "expected": "正常网络下复制仍有延迟和成本;CAP 的取舍发生在特定分区条件下,不能当作平时任意选两个的开关。" } ], "checks": [ "能用节点仍在运行但彼此收不到消息的例子解释网络分区。", "能说清拒绝一次无法确认的商品修改为何保护版本规则,却牺牲了该请求的可用性。", "能指出最终一致性需要收敛机制,而且不自动包含固定时间上限。" ], "pitfalls": [ { "symptom": "面试时说 CAP 可以在 C、A、P 中随便选两项。", "cause": "忽略了网络可能分区这一前提,也没有说明严格定义。", "fix": "先说明发生分区,再讨论坚持线性一致性或让隔离节点继续正常回应的代价。" }, { "symptom": "两区都确认同一商品版本修改成功,合并后一个用户的修改消失。", "cause": "只考虑数据最终收敛,没有兑现向用户承诺的修改顺序。", "fix": "让权威边界决定版本修改,或明确允许冲突并定义选择和通知规则。" } ], "exercise": { "task": "商品服务的“商品列表”和“按预期版本保存名称”都要跨区域服务。分区时应如何分别处理?", "hint": "先问旧读或并发修改会造成什么后果,再决定能否让每一侧自行作答。", "answer": "商品列表可返回本地较旧数据并说明更新时间,通信恢复后追赶;若要求读己之写,可路由至权威侧或等待目标版本。保存名称应由能维护版本条件的权威事务或协调协议决定,无法确认的一侧暂缓。前者允许旧读,后者牺牲部分写可用性。若两侧都接受修改,则必须承认并处理冲突,不能再承诺同一版本只有一个成功修改。" }, "interview": { "question": "如何准确解释 CAP,而不只是背“最多满足两个”?", "answer": "先给出跨节点消息无法送达的分区场景,说明线性一致性要求已完成写入对后续操作的顺序约束,可用性要求非故障节点的每个请求都能完成正常操作。在这个前提下,隔离侧若无法获知新写,就只能牺牲一致性或部分请求可用性。工程选择应针对具体数据和操作,正常网络下还需评估延迟与复制成本。", "followUps": [ "CAP 的一致性与 ACID 中的 C 为什么不是同一个定义?", "如果事先把不同商品的写入归属分配给不同区域,会改变什么取舍?" ] }, "refs": [ { "label": "Gilbert 与 Lynch:CAP 原始论文", "url": "https://www.cs.princeton.edu/courses/archive/spr22/cos418/papers/cap.pdf" }, { "label": "etcd:API 保证与线性一致读取", "url": "https://etcd.io/docs/v3.5/learning/api_guarantees/" } ], "intro": { "scenario": "东、西两区都在编辑商品 42,突然两区互相联系不上,但各自仍能接到用户请求。", "startingPoint": [ "完成第 03 课,知道副本可能暂时落后。", "在纸上建立两份 name=水杯、version=7 的记录;本课不进行断网或数据库故障操作。" ], "outcome": "比较拒绝部分修改与两侧都接受修改的结果,明确商品读写各自的承诺。" } }, { "id": "05", "kind": "theory", "group": "数据协同与正确性", "title": "缓存的四类难题:旧值、穿透、击穿与雪崩", "summary": "沿读写时间线定位旧值来源,并计算缓存失效后的数据库压力。", "objectives": [ "画出第一次查询和第二次查询的差别", "用交错请求复现旧价格回填", "计算失效后需要限制的回源量" ], "concepts": [ { "title": "Cache-aside:查不到时再去资料室", "body": "回看“观察一次缓存未命中”的推演。应用先读缓存,未命中再查数据库并回填;更新业务时先提交数据库,再使相关缓存失效,是常见模式。它减少重复读取,但不自动让两套存储保持强一致,也不能把缓存失败当成业务事实不存在。" }, { "title": "穿透、击穿、雪崩是不同压力来源", "body": "回看“按触发条件选择办法”的推演。穿透:反复查询根本不存在的 product_id,每次都落到数据库。击穿:一个极热键失效,许多人同时回源。雪崩:很多键同时过期或缓存整体不可用,回源量突然放大。名称不重要,先识别是无效请求、单个热点还是大面积失效。" }, { "title": "合并回源与打散过期", "body": "回看“按触发条件选择办法”的推演。同一个热点未命中时,只允许少量请求负责重建,其余等待或使用允许的旧值,可减轻击穿。让键的过期时间适度分散,可减少同步过期;但缓存集群整体故障时,TTL 抖动无法提供保护,仍要限制回源容量。" }, { "title": "旧值可接受到什么程度由业务决定", "body": "回看“推演旧数据被重新写回”的推演。商品列表可暂时显示旧名称和价格,但修改时的权限和预期版本必须由权威存储确认。缓存保存 id、name、description、is_active、price_cents 与版本。TTL 只是失效条件,慢回填和依赖故障仍会扩大可见延迟,不能把“30 秒过期”当作严格新鲜度保证。" } ], "steps": [ { "title": "观察一次缓存未命中", "body": "把商品 42 的缓存清空画在纸上,数据库保留 price_cents=1990、version=7。依次推演两次读取,数出数据库访问次数。", "explanation": "用户查询商品 42:缓存没有 → 数据库返回商品名称、price_cents=1990 和 version=7 → 应用回填并响应。第二次查询直接命中。价格统一以整数分表示;缓存与数据库使用同一字段约定。", "expected": "第一次读库并回填,第二次直接用缓存;两次请求通常只需一次回源,前提是缓存未过期且写入成功。" }, { "title": "按触发条件选择办法", "body": "分别写三张卡:大量不存在的商品编号、一个热门商品刚过期、大批商品同时过期。为每张卡选择校验/空值缓存、合并查询或错开过期。", "explanation": "不存在的编号可做输入校验、短期空值缓存或布隆过滤器预筛;布隆过滤器可能误判“可能存在”,仍需查库,并要跟随新数据更新。热点失效可合并回源,成批过期可错开时间。不要用一种办法回答所有缓存问题。", "expected": "三个压力来源不同:无效编号反复查库、同一热点并发查库、许多键同时查库,措施应对应触发原因。" }, { "title": "推演旧数据被重新写回", "body": "按 A 读到 1990/v7→A 暂停→B 写 2490/v8 并删缓存→A 回填的顺序画时间线,最后查看库和缓存各是什么。", "explanation": "请求 A 读到旧价格 1990 和 version=7 后暂停;请求 B 将价格改为 2490、版本加一并删除缓存;随后 A 把旧快照回填。可见先更新库再删缓存仍有并发窗口。版本条件、可靠失效事件和有限旧值策略可组合,但要说明各自保障范围。", "expected": "数据库是 2490/v8,缓存却可能回到 1990/v7;更新库后删除缓存仍未消除这个交错窗口。" }, { "title": "比较删除、更新与双删", "body": "在上一步时间线分别尝试“直接更新缓存”“立即删除”“延迟再删除”,再把 A 的暂停时间继续拉长。", "explanation": "直接更新缓存可能遇到并发写的先后颠倒;删除缓存让下一次读取从数据库重新获取,但会增加回源。“隔一会儿再删一次”只是缩小部分竞态窗口,无法证明所有慢请求都已经结束。强一致业务应采用能明确验证版本或读取权威数据的路径。", "expected": "更新可能乱序,删除需要重新读库,延迟双删仍可遇到更晚的旧回填;没有固定等待能证明所有慢请求已结束。" }, { "title": "计算缓存失效后的压力", "body": "用每秒 1000 次查询、95% 命中率先算正常读库量;再把命中率改成 0,算倍数并写出一个保护数据库的上限。", "explanation": "假设查询量为 1,000 次/秒、命中率 95%,正常回源约 50 次/秒。缓存全部失效会把回源需求推向 1,000 次/秒,是原来的 20 倍。应先有回源并发上限、受控预热和可解释降级,再讨论提高命中率。", "expected": "读库需求从约 50 次/秒升至 1000 次/秒,即 20 倍;回源并发与受理能力必须有边界。" } ], "checks": [ "能根据请求特征分别判断穿透、击穿和雪崩。", "能复述“旧读取在删除之后回填”的竞态时间线。", "能算出失效前后数据库需求的倍率,并指出数据库未必能承受。" ], "pitfalls": [ { "symptom": "空值永久缓存后,新建任务一直查不到。", "cause": "不存在也是会变化的状态,空值缓存没有合适的有效期或失效处理。", "fix": "给空值较短有效期,并在创建后处理对应失效;严格写后读走权威数据路径。" }, { "symptom": "缓存故障时全部请求直接查库,数据库也故障。", "cause": "把缓存当成可随时移除的优化,没有按冷缓存路径设计容量。", "fix": "限制回源并发与等待,保留必要能力,对不可承接的请求明确拒绝或降级。" } ], "exercise": { "task": "商品服务 每秒有 2,000 次商品详情查询,缓存命中率 98%,数据库稳定承受 200 次/秒。缓存整体故障时,如何避免连带故障?", "hint": "先计算正常回源量,再讨论允许什么请求等待、拒绝或返回旧值。", "answer": "正常回源约 40 次/秒,整体失效时需求会增至 2,000 次/秒,达到数据库稳定能力的 10 倍。应把回源限制在有余量的预算内,按用户和业务优先级分配,减少非必要刷新并返回明确的稍后重试信息;仅对允许旧读的展示数据使用已有旧值。恢复后分批预热、合并热点回源,避免一起重建。权限和修改版本判断不能拿未知或过期缓存值当许可。更大的缓存不能消除整体故障时的这个边界。" }, "interview": { "question": "缓存与数据库如何保持一致?", "answer": "先明确哪些操作要求新鲜到什么程度,再选读取和更新策略。Cache-aside 配合提交后失效很常见,但有删除失败和旧请求晚回填的窗口;可以借助版本、可靠失效和对账缩小或修复窗口。对严格授权或不可破坏的规则,应让权威事务边界作决定,不能单凭延迟双删宣称强一致。", "followUps": [ "为什么 TTL 加随机值不能处理缓存集群整体故障?", "布隆过滤器能否单独确认一件商品存在?" ] }, "refs": [ { "label": "Microsoft:Cache-aside 与一致性边界", "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside" }, { "label": "Amazon Builders’ Library:缓存挑战与策略", "url": "https://aws.amazon.com/builders-library/caching-challenges-and-strategies/" } ], "intro": { "scenario": "水杯详情查询变快了,但改价后偶尔仍显示旧价;缓存重启时数据库又突然变忙。", "startingPoint": [ "完成第 04 课,已能区分允许旧读的展示与必须保护版本的修改。", "沿用商品 42,价格用整数分,先写下 price_cents=1990、version=7。" ], "outcome": "沿读写时间线定位旧值来源,并计算缓存失效后的数据库压力。" } }, { "id": "06", "kind": "theory", "group": "数据协同与正确性", "title": "消息队列:可靠交付、重复与顺序", "summary": "解释早确认和晚确认各自的失败窗口,并为重复、顺序和失败上限制定规则。", "objectives": [ "标出每次交接需要什么证据", "比较两种确认时机会产生什么后果", "限定商品修改必须保持顺序的范围" ], "concepts": [ { "title": "两次交接,两种确认", "body": "回看“沿消息逐站标出负责者”的推演。生产者将“导出商品清单 42”交给消息代理,发布确认描述代理已按相应配置接受它;消费者处理后再确认,描述该次投递可以结清。发布确认不表示商品清单完成,消费确认何时发送则直接影响未完成工作会不会被遗漏。" }, { "title": "至少一次通常意味着必须面对重复", "body": "回看“推演后确认的重复窗口”的推演。在持久化、重试和可恢复等前提成立时,至少一次交付通过重新投递降低遗漏风险,消费者可能见到同一业务请求多次。至多一次倾向于不重复交付,但可能丢失。具体保证还依赖保留时间、复制、确认和故障模型。" }, { "title": "交付顺序不等于副作用完成顺序", "body": "回看“给需要有序的对象选择顺序范围”的推演。即使消息 1 比消息 2 先进入队列,两个消费者也可能让消息 2 先做完。许多日志系统只保证同一分区中的顺序,没有跨分区的全局顺序;重试、重投和并行执行还会影响处理顺序。" }, { "title": "Exactly-once 必须说明边界", "body": "回看“推演后确认的重复窗口”的推演。Kafka 的事务能力可以在受支持的读、处理、写链路中把输入进度与输出原子提交;这不自动覆盖外部文件存储、通知接口或另一个数据库。外部效果需要目标系统配合事务、幂等或其他协调,不能把代理的保证直接扩展成所有业务效果恰好一次。" } ], "steps": [ { "title": "沿消息逐站标出负责者", "body": "为导出任务 42 画 API→发布器→Broker→Worker→文件/任务状态的线路,在每次交接旁写出能查到的证据。", "explanation": "API 保存任务意图,发布器交给 Broker,Worker 取得消息后读取固定商品快照并写出清单,最后写入结果并确认消费。为每段标出证据:业务记录、发布确认、领取记录、结果引用。没有下一段证据时,不能用上一段的成功替代。", "expected": "至少区分业务记录、消息接收确认、成功领取和结果引用;任何前一段成功都不能替代下一段完成。" }, { "title": "推演先确认的遗漏窗口", "body": "让 Worker 取到任务后马上确认消息,然后在写清单文件之前崩溃。沿线路标记谁还记得这项工作未完成。", "explanation": "Worker 收到消息后立即确认,随后在导出商品清单之前崩溃。代理已认为交接结束,不再负责重投,这次任务可能永远未执行。提前确认可以减少某些重复,但把丢失风险交给了业务恢复机制。", "expected": "Broker 可能已删除待处理责任;若业务没有独立恢复记录,任务可能遗漏而不是自动重投。" }, { "title": "推演后确认的重复窗口", "body": "改为先写好文件再确认,在确认传输中切断连接;让 Broker 重投给另一名 Worker。", "explanation": "Worker 已把商品清单保存成功,消费确认却因连接中断没有到达。代理重投后,另一个 Worker 再次看到任务 42。因此把确认放在成功之后仍需业务去重,不能仅凭“晚确认”推导出执行一次。", "expected": "第二名 Worker 可能再次执行任务 42;晚确认缩小遗漏窗口,却需要按业务编号保护重复效果。" }, { "title": "给需要有序的对象选择顺序范围", "body": "对商品 42 画第 7、8 次修改,再加入商品 99 的一次修改。比较“全部串行”和“同商品串行”,并区分最新快照与逐条动作。", "explanation": "同一商品的第 7、8 次修改若要求下游逐条处理,可按 product_id 路由并串行执行,第 7 次完成后再处理第 8 次。失败消息需要明确的修复或跳过规则,不同商品可并行。若下游只维护最新商品快照,可以用版本条件拒绝旧快照;这不能代替必须逐条完成的动作。严格全局排序会让一个失败阻塞更多无关工作。", "expected": "按商品串行能保留局部顺序且不阻塞无关商品;版本过滤只适合最新快照,不能代替必须逐条执行的动作。" }, { "title": "说明失败消息何时停止重投", "body": "准备“文件服务短暂不可达”和“消息字段永远无法解析”两张故障卡,为它们写重试次数上限与达到上限后的去向。", "explanation": "临时依赖错误可在预算内重试,无法解析或持续失败的消息应进入可查询的隔离状态或死信队列。死信表示等待诊断或决定,不是问题自动解决;恢复处理仍要保留原业务标识,并考虑顺序和重复效果。", "expected": "临时错误可有界重试;持续失败进入可查询隔离状态,恢复仍需保留业务编号并检查重复和顺序。" } ], "checks": [ "能说清 Broker 确认收到了消息,为什么仍不能显示商品清单生成成功。", "能分别画出先确认导致遗漏、后确认导致重复的时间线。", "能把“有序”限定到具体对象、分区及处理环节,而不是全局口号。" ], "pitfalls": [ { "symptom": "同一条消息重复写入清单文件或发送完成通知。", "cause": "把消息只投递一次当成可靠前提,副作用目标没有去重。", "fix": "用稳定的业务操作标识保护副作用;若目标不支持幂等或查询,超时后保留未知状态,不能盲重试。" }, { "symptom": "任务状态从 succeeded 被重投的旧消息改回 running。", "cause": "消费者只照消息写状态,没有验证当前业务版本和合法转换。", "fix": "把消息当作唤醒或事实输入,让持久状态与版本规则决定是否仍可执行。" } ], "exercise": { "task": "同一导出任务有“开始导出”和“取消任务”两条消息,开始先投递却执行更慢。如何避免取消后被迟到结果重新标成成功?", "hint": "不要只要求换一个“有序队列”;还要考虑已在执行中的外部调用。", "answer": "取消应成为持久的业务请求或状态,完成结果发布时原子检查任务是否仍允许成功以及执行版本是否有效;旧执行者不满足条件就不能改写终态。同一任务可按键路由减少乱序,但不能让已经发出的外部调用瞬间消失,因此界面区分“取消申请”和“已取消”。结果文件可先单独保存,最终发布引用受状态规则约束;未采用的临时结果另作清理。代价是额外状态与查询,收益是顺序假设失效时仍保护业务结果。" }, "interview": { "question": "怎样保证消息不丢、不重复?", "answer": "应拆成两个问题。交付侧讨论持久化、复制、发布确认、消费确认、失败重投和保留期限;业务侧用稳定标识、事务或目标端幂等来处理重复效果。先确认有遗漏窗口,后确认有重复窗口。所谓恰好一次必须指出覆盖哪些输入、存储和输出,外部系统通常需要额外配合。", "followUps": [ "为什么消费者只开一个仍不能保证永远没有重复?", "严格保序时,毒消息应阻塞整个系统还是某个业务键?" ] }, "refs": [ { "label": "RabbitMQ:可靠性与确认机制", "url": "https://www.rabbitmq.com/docs/reliability" }, { "label": "RabbitMQ:队列顺序与重投", "url": "https://www.rabbitmq.com/docs/queues" }, { "label": "Apache Kafka:交付语义与外部系统边界", "url": "https://kafka.apache.org/38/design/design/" } ], "intro": { "scenario": "导出消息交给队列后,仍可能漏执行或执行两次;现在沿一条消息找出空隙。", "startingPoint": [ "完成第 05 课,并已在任务模块做过商品快照导出。", "在纸面标出任务 42 的记录、消息、执行和结果文件四个位置,不向真实队列注入消息。" ], "outcome": "解释早确认和晚确认各自的失败窗口,并为重复、顺序和失败上限制定规则。" } }, { "id": "07", "kind": "theory", "group": "数据协同与正确性", "title": "跨系统事务:本地事务、Outbox 与 Saga", "summary": "用同库待发送记录封住受理丢失窗口,并解释跨系统步骤失败后的补偿边界。", "objectives": [ "推演交换两次写入顺序仍会失败的原因", "让任务和发送意图同时存在或同时不存在", "区分重试、撤销入口与恢复剩余步骤" ], "concepts": [ { "title": "本地事务只保护同一个事务边界", "body": "回看“推演数据库与消息的双写”的推演。在同一个数据库事务中,可以让商品导出任务和待派发记录一起提交或一起回滚。数据库提交后再调用 Broker 或文件服务,不会自动进入这个事务。程序里的连续两行操作不等于跨系统原子提交。" }, { "title": "Outbox:把待发送信件先记在同一本账上", "body": "回看“让业务变化和发送意图一起落账”的推演。在同一事务中保存业务变化和待发送事件;独立发布器反复检查并发送。这样业务提交后,发送意图仍留在数据库里。但发布后标记之前崩溃会重复发送,所以它没有消除消费幂等、派发监测、保留和恢复的工作。" }, { "title": "2PC:先询问能否提交,再统一决定", "body": "回看“比较方案而不是评选万能模式”的推演。两阶段提交由协调者让支持协议的参与者先准备,再提交或中止。准备阶段可能占用资源;故障与协调信息不可达可能造成等待。它适用于参与者都支持相关事务协议的边界,普通文件服务 HTTP API 不能仅靠应用约定被纳入。" }, { "title": "Saga:一串本地完成的步骤与业务补偿", "body": "回看“用下载入口反例理解补偿”的推演。为解释补偿,假设把登记导出任务、发布下载入口和保存清单文件故意拆成跨服务步骤,且错误地先开放下载、后写文件。文件发布失败时,需撤销入口并标记任务失败。补偿是新的业务操作;已经收到的进度通知不能像数据库事务那样撤回。" }, { "title": "中间状态对其他请求仍然可见", "body": "回看“选择补偿和重试的边界”的推演。Saga 各步分别提交,通常没有单库事务的整体隔离,其他请求可能看见下载入口却取不到文件。本例用于识别这种设计代价;实际简单商品服务优先先保存文件,确认就绪后再原子发布结果引用,不要求为讲 Saga 拆分业务。" } ], "steps": [ { "title": "推演数据库与消息的双写", "body": "依次推演“先写任务、后发消息”和“先发消息、后写任务”,都在两步之间插入崩溃,记录数据库与队列分别剩什么。", "explanation": "先存任务再发消息:中间崩溃会留下没人执行的任务。先发消息再存任务:Worker 可能先收到消息却查不到任务,或者最终数据库写入失败。简单交换顺序无法让两个独立系统自动原子提交。", "expected": "第一种留下无人派发的任务;第二种留下查不到任务的消息。交换顺序没有形成跨系统原子提交。" }, { "title": "让业务变化和发送意图一起落账", "body": "现在把“任务 42”和“需要发送任务 42”的记录写进同一个数据库事务。分别在事务提交前失败与提交后 Broker 暂停时检查两条记录。", "explanation": "创建任务 42 的同一个数据库事务里写入一条“需要派发任务 42”的 Outbox 记录。事务失败时两条都没有;事务成功时即使 Broker 暂停,待发送意图仍然存在。可靠性来自持久意图与持续恢复,不是内存回调。", "expected": "提交前两条都不成立;提交后两条一起保留,Broker 暂停也不会丢掉需要派发的事实。" }, { "title": "承认发布器也会遇到未知结果", "body": "让发布器发送成功但来不及标记已发送,或发出后收不到确认。为下一轮扫描写出它知道什么、仍不知道什么。", "explanation": "发布器发出消息但没有收到确认,不知道 Broker 是否已经保存;即使收到确认,也可能来不及标记已发。它通常需要重试,因此消费者应识别相同业务事件。Outbox 的“已发送”状态也不等于任务已经执行完成。", "expected": "发布器可能只能重试,因此同一事件可能到达多次;已发送也只证明派发阶段,不能表示清单已经完成。" }, { "title": "用下载入口反例理解补偿", "body": "故意采用一个较差顺序:登记任务→开放下载入口→写文件。让最后一步失败,写下用户会遇到什么,以及要撤销哪项已发生动作。", "explanation": "假设按“登记导出任务 → 发布下载入口 → 保存清单文件”推进。文件发布失败后,撤销已开放的下载入口,再将任务标记为失败。逐步记录成功证据、重复处理方式和恢复状态。这个顺序故意暴露中间状态,并非实战推荐顺序。", "expected": "用户可能看到不能下载的入口;需要撤销入口并更新状态,但外部已经看见过的中间状态不能被抹去。" }, { "title": "选择补偿和重试的边界", "body": "继续让“撤销入口”超时,再假设完成通知已经送达。分别列出可重试动作、先核实的结果与无法真正撤回的效果。", "explanation": "撤销入口也可能遇到超时,需保存撤销意图并在预算内重试;文件是否写入未知时先按任务编号核实,避免误删已经发布的文件。进度通知已送达无法撤回,可发送准确的新状态。实际实现优先文件就绪后发布引用,用持久状态恢复剩余步骤。", "expected": "撤销意图需要保留;未知文件结果先按编号核实;已读通知只能更正。实际流程宜先有文件,再发布引用。" }, { "title": "比较方案而不是评选万能模式", "body": "分别把“同库两条记录”“多参与者统一提交”“保存派发意图”“跨服务步骤补偿”分成四张需求卡,给每张匹配一种方案并写出成本。", "explanation": "单库事务更简单,适合能放在同一数据边界内的规则;2PC 需要参与者支持并承担协调成本;Outbox 处理业务状态与发送意图的原子记录;Saga 管理跨服务工作流及补偿。它们可以配合使用,但各自没有覆盖全部业务正确性。", "expected": "可分别匹配本地事务、2PC、Outbox、Saga;它们解决的边界不同,可以组合,没有一个自动包办全部正确性。" } ], "checks": [ "能指出 Outbox 原子提交的两份记录都在哪里。", "能解释发布器重复发送窗口,以及为什么消费者仍需幂等。", "能给出一个可补偿动作和一个不能真正撤销的外部副作用。" ], "pitfalls": [ { "symptom": "有了 Outbox 就删除全部任务恢复和积压观测。", "cause": "把持久发送意图误解成自动完成承诺。", "fix": "分别观察待发布、已派发未领取、执行中和终态,明确发布器与恢复流程的持续运行责任。" }, { "symptom": "任何失败都逆序回滚,最终预算和外部账单对不上。", "cause": "把 Saga 补偿当作数据库回滚,没有区分已产生费用和未消耗预留。", "fix": "按业务事实定义补偿;未知的外部结果先核实,不可逆效果记录为实际发生。" } ], "exercise": { "task": "教学反例先发布了商品清单下载入口,随后文件保存失败。应如何补偿,以及怎样简化实际流程?", "hint": "把任务状态、入口可见性和文件存在性看成可分别核实的事实。", "answer": "先持久记录撤销入口与失败状态的处理意图,撤销已开放入口并将任务标成失败;补偿失败仍需重试和查询,已送达的通知不会自动撤回。实际商品服务先生成文件,确认文件可读后再用受版本约束的事务发布结果引用;进度通知失败可单独补发。Outbox 记录后续派发意图,Saga 用于解释不得不跨多个本地事务时的补偿边界,两者不能让文件服务自动加入数据库事务。" }, "interview": { "question": "Outbox 和 Saga 分别解决什么问题?", "answer": "Outbox 让本地业务变化与待发事件在同一事务里记录,避免提交后发送意图凭空丢失,仍可能重复发布。Saga 用多个本地事务推进跨服务业务,在失败时选择继续或做补偿,通常没有整体事务隔离。二者可组合:Saga 的某一步可通过 Outbox 可靠记录下一步通知。", "followUps": [ "2PC 的参与者需要具备什么能力?", "为什么已经送达的导出完成通知不能像数据库事务一样回滚?" ] }, "refs": [ { "label": "AWS:Transactional outbox 模式", "url": "https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html" }, { "label": "AWS:Saga 模式", "url": "https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/saga-patterns.html" }, { "label": "Microsoft:Saga 事务与补偿", "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/saga" }, { "label": "PostgreSQL:预备事务与两阶段提交", "url": "https://www.postgresql.org/docs/current/sql-prepare-transaction.html" } ], "intro": { "scenario": "数据库写好了任务,队列却没收到消息;把发送顺序换一下能解决吗?", "startingPoint": [ "完成第 06 课,能说明消息被确认与商品清单已完成是两件事。", "保留任务 42 的四个位置图,增加一个“待发送记录”框,逐步推演,不改实际库。" ], "outcome": "用同库待发送记录封住受理丢失窗口,并解释跨系统步骤失败后的补偿边界。" } }, { "id": "08", "kind": "theory", "group": "数据协同与正确性", "title": "幂等与未知结果:超时以后要不要再来一次", "summary": "为同一意图的重试找回同一任务,并保留外部结果未知时的核实路径。", "objectives": [ "区分内容相同与意图相同", "挡住两台 API 同时创建同一任务", "约定超时后查询什么、记录保留多久" ], "concepts": [ { "title": "幂等关心重复请求的业务效果", "body": "回看“比较三次外观相同的提交”的推演。重复查询商品通常不会产生新副作用;“将名称设为水杯”和“再创建一件水杯商品”语义不同。同一请求可被多次接收、检查甚至计算,只要受保护的业务效果不额外发生,就可能满足约定的幂等语义。" }, { "title": "幂等键代表同一个意图", "body": "回看“比较三次外观相同的提交”的推演。客户端为一次“导出商品清单”意图提供稳定编号,超时重试沿用它。两次有意导出同一批商品应使用不同编号。键还要和用户、操作类型及请求内容关联:同键不同参数不能静默当成原请求成功。" }, { "title": "去重记录要和所保护的效果一起考虑", "body": "回看“推演两个副本同时受理”的推演。同一数据库中的唯一键和事务可以原子地创建一次任务并记下去重结果;先查缓存、再执行、最后标记成功会留下并发和崩溃窗口。若效果发生在外部文件服务,数据库去重记录无法独自保证远端只执行一次。" }, { "title": "未知不是失败", "body": "回看“把保护延伸到外部调用”的推演。超时只说明调用方在期限内没有获得确定答复。请求可能未到达、正在执行,也可能已完成而响应丢失。结果未知时盲目重试非幂等操作,可能重复创建导出任务、写入文件或发送通知。" }, { "title": "幂等也有时间和身份边界", "body": "回看“说明有效期与异常处理”的推演。去重记录过期后,迟到重试可能被视为新请求;应依据业务允许的重试与查询周期定义保留期限。请求编号只用于识别意图,不能替代鉴权,用户也不能凭猜到别人的键读取结果。" } ], "steps": [ { "title": "比较三次外观相同的提交", "body": "将首次导出、超时后的同次重试、次日主动新导出排成三张卡。商品列表相同,为它们分配能表达用户意图的编号。", "explanation": "用户第一次点击导出,网络超时后重试,以及次日主动再导出一次,商品编号列表可能完全一致。前两次应共享一个意图编号,第三次通常是新意图。仅对请求正文做哈希去重,会误把用户的第二次真实需求吞掉。", "expected": "前两张使用同一个意图编号,第三张通常用新编号;仅看请求正文相同会误吞新的需求。" }, { "title": "定义查询得到的几种状态", "body": "为同一意图画处理中、已完成、明确失败、待确认四个状态,分别写再次收到请求时返回的任务编号或核实动作。", "explanation": "服务可记录处理中、已完成、明确失败或待确认。相同意图仍在处理中时,返回相同任务编号让用户查询;已完成时返回与原请求语义一致的结果。明确失败能否重试,要看该失败是否证明没有留下副作用。", "expected": "处理中返回原任务,完成返回原结果;明确失败与未知结果不能一律当作“重新执行即可”。" }, { "title": "推演两个副本同时受理", "body": "让 API-A、API-B 同时读到幂等记录不存在,随后都尝试创建。圈出必须由数据库一次决定胜者的位置。", "explanation": "API-A 和 API-B 同时检查同一个键,都看到“不存在”,随后都创建任务。先查再写没有挡住竞争;需要由权威存储的唯一性与事务等原子机制决定谁创建成功,另一方读取已建立的同一任务。", "expected": "仅先查后写会创建两次;唯一约束与事务使一方创建,另一方读取同一已有任务。" }, { "title": "把保护延伸到外部调用", "body": "让文件服务已经写入但响应丢失,本地任务尚未记成功。尝试凭稳定 job_id 与执行版本查询文件,再判断是否重写。", "explanation": "Worker 写入共享文件服务时,使用稳定的任务编号与本次执行版本作为对象标识;服务支持幂等请求或按标识查询时,超时后先核实。如果两种能力都没有,本地锁与数据库去重无法排除“远端已写、本地未记”的窗口。", "expected": "可按对象标识核实时先找回远端结果;没有幂等与查询能力时,本地去重无法排除重复远端写入。" }, { "title": "说明有效期与异常处理", "body": "接口承诺 7 天内可重试,记录却在 1 小时后删除。安排第 2 天一次重试,再写出记录保留和超过期限后的处理规则。", "explanation": "假设接口允许用户 7 天内重试,去重记录若 1 小时就删除,会破坏这个承诺。超过约定期限或外部结果持续未知时,需要明确的业务查询、人工确认或对账路径,不能假装永久幂等或强行显示成功。", "expected": "旧重试可能变成新执行,违反原承诺;记录期限必须覆盖承诺,并为持续未知或过期请求提供明确核实路径。" } ], "checks": [ "能区分输入相同与业务意图相同。", "能说明超时至少可能对应未执行、执行中、已执行三种情况。", "能指出本地唯一约束保护不了不配合的外部文件写入。" ], "pitfalls": [ { "symptom": "为了重试每次生成一个新的 request_id,出现多份商品清单。", "cause": "把技术请求编号当成业务幂等键,每次重试都变成新意图。", "fix": "一次业务意图沿用同一个幂等键,技术 trace_id 可以另外记录。" }, { "symptom": "记录显示 failed,就立刻重新向文件服务写入。", "cause": "把响应超时归为确定失败,没有保留未知结果。", "fix": "按操作编号查询或使用目标端幂等重试;不能确认的非幂等操作先进入待确认状态。" } ], "exercise": { "task": "用户提交商品清单导出时返回超时,前端准备换一个 UUID 再提交。后端任务实际上已创建,应该怎样修改交互和语义?", "hint": "同一次操作的业务编号要稳定,界面还需要知道自己只是暂时拿不到结果。", "answer": "前端在首次提交前生成并保存本次意图的幂等键,后续重试继续使用该键;后端将用户、操作、键和请求摘要关联,在原子事务中创建一次任务并记录结果。重复提交返回同一任务编号,参数不一致时拒绝冲突,页面显示处理中并查询该任务。技术请求仍可以各有 trace_id 便于排查。服务要明确键保存多久;若后续外部文件服务不支持幂等或查询,任务创建幂等并不能消除远端未知结果,还需独立处理。" }, "interview": { "question": "如何设计一个幂等接口?", "answer": "先定义“同一次业务操作”的键及用户作用域,再验证同键请求内容一致,用权威存储的原子机制协调并发,把去重状态与本地业务效果一起提交。重复请求得到可查询且语义一致的结果。最后讨论保留期限、处理中状态、超时未知结果和外部副作用的保护边界。", "followUps": [ "为什么不能只用请求正文的哈希作为幂等键?", "去重记录与外部效果不能在同一事务里时怎么办?" ] }, "refs": [ { "label": "Amazon Builders’ Library:安全重试与幂等 API", "url": "https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/" }, { "label": "RFC 9110:HTTP 幂等方法语义", "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2" }, { "label": "PostgreSQL:唯一约束", "url": "https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS" } ], "intro": { "scenario": "点击导出后浏览器超时了,用户再点一次:应该返回原任务,还是新建任务?", "startingPoint": [ "完成第 07 课,已有任务记录与待发送记录的方案。", "准备“首次点击、立即重试、次日再次导出”三张请求卡,商品编号列表保持相同。" ], "outcome": "为同一意图的重试找回同一任务,并保留外部结果未知时的核实路径。" } }, { "id": "09", "kind": "theory", "group": "数据协同与正确性", "title": "分布式锁与租约:过期后旧 Worker 为什么还在写", "summary": "用过期与恢复的反例检查接管协议,说明写入端如何拒绝过时结果。", "objectives": [ "按秒画出两个执行者重叠运行的窗口", "避免旧执行者删除新执行者的资格", "解释序号保护能保证和不能保证的边界" ], "concepts": [ { "title": "租约是有期限的使用资格", "body": "回看“推演租约过期的时间线”的推演。锁服务可以给 Worker 一段时间的独占资格,期限结束后允许其他 Worker 接替,避免崩溃者永远占住资源。但旧 Worker 可能只是暂停或断网,它的程序并不会因为远端键过期就自动消失。" }, { "title": "所有者标识避免删掉别人的锁", "body": "回看“保护释放与续租”的推演。释放和续期都要确认仍属于自己,检查与修改还需原子完成。随机所有者标识主要用来区分哪次持有,它不表达新旧先后,不能直接代替 fencing token。" }, { "title": "Fencing:资源端识别过时的使用者", "body": "回看“让写入端检查新旧序号”的推演。一种常见设计是在每次接管时得到单调递增的序号。受保护资源原子地记录已接受的最大序号,并拒绝更小序号的写入;这样接受新执行者后,旧执行者的迟到写不能覆盖它。只有把序号传进日志,却不在写入处校验,没有保护作用。" }, { "title": "锁、去重和事务解决的不是同一个问题", "body": "回看“先问能不能不用长时间持锁”的推演。锁协调谁可以尝试工作;幂等限制重复请求的额外效果;事务保护一个数据边界内的原子变化。任务终态发布可用持久状态和版本条件保护;外部文件服务若不识别 token 或幂等键,拿了本地锁仍不能保证只产生一份外部文件。" } ], "steps": [ { "title": "推演租约过期的时间线", "body": "在时间轴标记 A 获得 30 秒资格、10 秒暂停、31 秒 B 接管、50 秒 A 恢复,圈出两段程序都能执行的时段。", "explanation": "A 取得 30 秒租约,执行到第 10 秒暂停了 40 秒;第 31 秒 B 取得新的租约并开始工作;第 50 秒 A 恢复。现在 A 和 B 的代码都可能在运行。这个反例说明“租约同时只给一个人”与“世界上只有一个执行中的进程”不同。", "expected": "第 50 秒以后 A、B 都可能运行;租约到期没有停止 A 的操作系统进程。" }, { "title": "保护释放与续租", "body": "在第 50 秒让 A 删除锁,再问当前锁究竟属于谁;同时标记 A 暂停前已发出的文件写请求。", "explanation": "A 恢复后尝试释放锁,应先原子核对所有者;若当前锁属于 B,就不能删除。A 续租失败后应停止开始新工作,但已经发出去的远端请求可能仍在执行,协作停止不能收回网络中已交付的副作用。", "expected": "不核对所有者会删掉 B 的锁;A 停止新工作也不能收回远端已收到的写入。" }, { "title": "让写入端检查新旧序号", "body": "给 A 序号 41、B 序号 42,让结果存储先接受 42,再收到 41。把检查序号和真正写数据画在同一个提交框里。", "explanation": "设 A 的 token 为 41,B 为 42。资源先接受 B 的 42 并记录它,随后拒绝 A 的 41。校验 token、更新最大序号与写入数据必须处于同一个可靠的原子边界,否则检查后仍可能被并发插队。", "expected": "存储记录 42 后应拒绝 41;若检查与写入分离,两个请求仍可能插队破坏保护。" }, { "title": "补充 fencing 的精确边界", "body": "交换上一步的到达顺序:资源还没见过 42 就收到过期 A 的 41。只按“拒绝小于已见最大序号”规则判断它会不会被接受。", "explanation": "“只拒绝小于已见最大值”的资源,在还未见过 42 时可能接受 41;它保证新序号已被资源接受后的旧写不能覆盖,不自动证明“租约一过期就禁止任何旧写”。若需要后者,应由权威状态原子验证当前有效持有版本或采用满足该要求的协调协议。", "expected": "41 仍可能被接受;这种 fencing 保护新写不被旧序号覆盖,不自动保证租约一过期就拒绝所有旧写。" }, { "title": "先问能不能不用长时间持锁", "body": "若只要求任务终态最多发布一次,尝试允许两名 Worker 都计算,再由数据库按状态和版本决定谁能发布;另圈出文件写入保护。", "explanation": "如果目标只是同一个任务只能发布一个终态,可让数据库根据当前版本和合法状态决定发布是否成功,计算阶段允许出现重复,再限制成本。短事务、唯一约束或按键串行有时比跨多次网络调用持有长锁更清楚,但不能省略外部副作用的单独保护。", "expected": "可以把长计算放到锁外,用短的条件提交保护终态;重复计算成本与外部副作用仍需分别约束。" } ], "checks": [ "能说明租约键过期为什么不能终止旧 Worker。", "能区分随机所有者 token 和单调递增 fencing token。", "能指出真正拦截旧写的是资源端的原子校验,而不是客户端自己检查一次。" ], "pitfalls": [ { "symptom": "把锁时间延长到一天,就认为重复执行已被解决。", "cause": "只延后接替时间,没有处理进程暂停、网络延迟或失效锁。", "fix": "定义租约接管、续期失效和资源写入约束,按业务选择版本检查与幂等。" }, { "symptom": "日志里有 fencing token,旧结果仍然覆盖了新结果。", "cause": "目标存储并未在写入时校验并拒绝旧 token,或检查与写入不原子。", "fix": "让被保护的资源执行原子新旧判断;不支持此机制的外部系统需其他可证明的保护。" } ], "exercise": { "task": "A 的 token=17,租约过期后 B 获得 token=18 并成功发布商品清单。A 随后恢复,试图发布旧商品清单并删除锁。应分别怎样处理?", "hint": "结果写入和锁释放是两个资源上的动作,需要各自的判断。", "answer": "结果存储已接受 18,A 的 17 应在原子写入检查处被拒绝,不能覆盖 result_ref 或终态;锁释放也必须核对当前所有者,不能删除 B 的锁。A 的临时输出可被标为未采用并另行清理。若 A 之前已写出文件,这个外部效果不会因结果发布被拒绝而自动消失;可按执行编号识别未采用文件,再安排受控清理。若资源尚未见到 18,仅比较最大已见 token 的 fencing 不能单独证明 A 的任何写都必然被拒绝,应明确是否还需当前租约版本检查。" }, "interview": { "question": "分布式锁设置过期时间后,为什么仍可能发生并发写?", "answer": "锁服务回收的是资格,旧客户端可能暂停后恢复,并不知道自己的资格已过期;续租也存在延迟和失败。释放要原子核对所有者,受保护写入要有资源端版本或 fencing 约束。还需说明序号怎样可靠生成、资源何时接受它,以及外部系统能否配合。", "followUps": [ "为什么随机 UUID 不能直接用来判断哪个持有者更新?", "fencing 能否阻止已发出的文件写入或进度通知?" ] }, "refs": [ { "label": "Redis:分布式锁、所有者与时限假设", "url": "https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/" }, { "label": "Google Research:Chubby 锁服务论文", "url": "https://research.google/pubs/the-chubby-lock-service-for-loosely-coupled-distributed-systems/" } ], "intro": { "scenario": "旧 Worker 卡住后,新 Worker 接管了导出;旧 Worker 又恢复并试图写结果。", "startingPoint": [ "完成第 08 课,理解同一任务重复执行需要保护最终效果。", "在纸上画从 0 秒到 60 秒的轴,准备 A、B 两名执行者和一个结果发布位置。" ], "outcome": "用过期与恢复的反例检查接管协议,说明写入端如何拒绝过时结果。" } }, { "id": "10", "kind": "theory", "group": "稳定性与系统设计", "title": "超时、重试、熔断与隔离:阻止局部故障扩散", "summary": "计算等待与重试如何放大故障,并用独立预算保住商品查询。", "objectives": [ "核算剩余时间还能支持几次尝试", "算出多层重试的最大调用数", "为导出故障设计准确降级和渐进恢复" ], "concepts": [ { "title": "超时限制等待,截止时间限制整个过程", "body": "回看“给一项请求安排总预算”的推演。连接、响应读取、排队和整项任务可以有不同预算。单次调用设置 5 秒,不代表三次重试加等待仍会在 5 秒内结束。上游剩余预算应约束下游尝试,并为返回和清理留时间;超时本身不证明远端没有执行。" }, { "title": "重试会消耗更多资源", "body": "回看“算出多层重试的放大”的推演。短暂错误可能重试后成功,但下游已经过载时,更多请求会恶化情况。指数退避让后续尝试间隔变大,抖动打散很多客户端的同步重试;仍需要最大尝试次数、总时间和重试流量预算。" }, { "title": "熔断:一段时间内减少调用故障依赖", "body": "回看“让熔断和隔离一起保护其余能力”的推演。关闭状态允许请求;失败达到判断条件后进入打开状态,快速拒绝;经过等待后半开,放少量探测判断是否恢复。它不修复依赖,也不清理所有已发出的请求,误判或参数不当会延长不可用时间。" }, { "title": "隔离:不同用途不要共用全部救命资源", "body": "回看“让熔断和隔离一起保护其余能力”的推演。把慢文件写入、交互查询和批量清单导出的并发槽位或连接资源分开,一个依赖耗尽自己的配额时,其他能力仍有资源。隔离可能让部分空闲资源暂时无法借用,换来更可控的故障影响范围。" }, { "title": "降级必须保持结果诚实", "body": "回看“让熔断和隔离一起保护其余能力”的推演。文件存储不可用时,可明确告诉用户导出暂不可用,并继续提供商品查询和已有任务状态。不能返回不存在的下载链接并标成完成。保留查询、取消等轻量能力,通常比让所有请求长期等待更有价值。" } ], "steps": [ { "title": "追踪慢调用占住了什么", "body": "让文件写入从 1 秒变成 20 秒,到达速度不变。为每个未完成请求放一个筹码,标出它占用的连接、内存和执行名额。", "explanation": "商品服务 写入文件存储时,即使 CPU 很空,也可能占着连接、协程、内存和并发槽。延迟从 1 秒升到 20 秒,同样的到达速率会让更多工作同时停留在系统内,其他请求开始排队,最终形成超时。", "expected": "同时停留的工作会增多,连接与并发槽可先耗尽;CPU 低仍可能排队和超时。" }, { "title": "给一项请求安排总预算", "body": "给交互操作 6 秒预算,划掉已经读取商品的 2 秒,再尝试放入三次 3 秒的写入及等待。写出何时不应发起下一次。", "explanation": "假设一个交互操作总预算为 6 秒,前置商品读取已用 2 秒,就不能再给文件服务三次各 3 秒的尝试。预算必须包括连接、等待、尝试和退避,并根据剩余时间决定是否值得发起下一次。长任务可转为明确可查询的异步流程。", "expected": "只剩 4 秒,放不下三次 3 秒;连接、退避与池等待也占预算,耗时较长的工作应有可查询的异步状态。" }, { "title": "算出多层重试的放大", "body": "画 API 最多尝试 3 次,每次导出服务又最多写文件 3 次的树,数叶子节点。", "explanation": "API 调用导出服务最多尝试 3 次,导出服务每次又调用文件服务最多 3 次,一次用户操作最坏可触发 9 次文件服务请求。再加一层会继续乘。约定由合适的一层主导重试,并让其他层理解重试预算。", "expected": "最坏可出现 9 次文件请求;逐层重试相乘,故障时可能把下游压得更慢。" }, { "title": "区分值得重试的错误", "body": "把参数错误、无权限、短暂网络中断、限流、文件写结果未知分成五张卡,为每张选择立即返回、稍后尝试或先查状态。", "explanation": "短暂网络故障可在操作可重试且仍有预算时尝试;参数无效或权限不足,原样再发通常没有意义。限流响应应尊重服务建议的等待时间。文件写入若结果未知,先使用幂等语义或状态查询,不能把所有超时都套进自动重试。", "expected": "参数和权限问题原样重试无益;临时错误要看预算,限流尊重等待,未知写入先用幂等或查询核实。" }, { "title": "让熔断和隔离一起保护其余能力", "body": "文件服务继续失败,给导出和商品查询分别划执行名额,再设计暂停调用、少量探测、恢复放量三个阶段。", "explanation": "文件服务 A 连续失败后,熔断器暂缓新调用并少量探测;即使探测仍慢,A 的独立并发上限也防止它占满所有资源。历史查询走自己的预算,界面准确显示导出暂不可用。恢复后渐进放量,避免全部积压同时冲回。", "expected": "导出可明确暂不可用,商品查询保留资源;探测恢复后逐步放量,积压不能一次冲回依赖。" } ], "checks": [ "能把一次请求的排队、尝试和退避都纳入总时间预算。", "能算出两层各 3 次尝试的最坏下游请求数为 9。", "能说明熔断减少故障调用,隔离保留其他能力所需资源。" ], "pitfalls": [ { "symptom": "所有服务都很积极地重试,故障后流量翻了数倍。", "cause": "每层独立重试,既没有总预算,也没有区分错误性质。", "fix": "明确主导重试层,采用有上限的退避与抖动,并限制重试占比。" }, { "symptom": "熔断打开后页面仍然卡住。", "cause": "熔断只限制新调用,已有请求或共用资源仍然耗尽。", "fix": "结合单次超时、总截止时间和资源隔离,并定义快速返回的业务结果。" } ], "exercise": { "task": "一个 商品服务 请求总预算 8 秒,预处理已耗时 2 秒。文件服务一次尝试上限 3 秒,两次尝试之间要退避 1 秒,还需 1 秒返回余量。剩余预算够两次完整尝试吗?", "hint": "把所有阶段相加,不要只数真正写入文件的时间。", "answer": "剩余 6 秒,但两次完整尝试加退避与返回余量需要 3+1+3+1=8 秒,因此不够。可只安排一次完整尝试并保留余量,或根据真实延迟和剩余时间缩短后续尝试;不能为了凑次数盲目设置过短超时导致更多失败。若操作可异步,可返回已受理和任务查询,但受理必须符合持久化承诺。文件服务的未知结果还需幂等或核实,剩余时间充足也不是盲目重试的理由。" }, "interview": { "question": "重试、熔断、限流和隔离有什么区别?", "answer": "重试提高一次可安全重做操作的成功机会;熔断根据依赖状态减少对故障目标的新调用;限流控制进入或发出的速率;隔离限制某类工作占用的并发或资源。它们从不同方向减少故障扩散,需要统一的时间、容量和业务结果约束,不能相互替代。", "followUps": [ "为什么给每层相同超时容易超过用户总预算?", "熔断半开时为什么要限制探测数量?" ] }, "refs": [ { "label": "Amazon Builders’ Library:超时、重试、退避与抖动", "url": "https://d1.awsstatic.com/builderslibrary/pdfs/timeouts-retries-and-backoff-with-jitter.pdf" }, { "label": "Microsoft:熔断模式", "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker" }, { "label": "Microsoft:舱壁隔离模式", "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/bulkhead" } ], "intro": { "scenario": "文件存储变慢后,商品查询也开始超时;CPU 并不忙,服务为什么仍被拖住?", "startingPoint": [ "完成第 09 课,已区分计算、远端写入和终态发布。", "画出 API → 导出 → 文件服务三段调用,给一次交互请求写下 6 秒总预算。" ], "outcome": "计算等待与重试如何放大故障,并用独立预算保住商品查询。" } }, { "id": "11", "kind": "theory", "group": "稳定性与系统设计", "title": "限流、背压与容量:队列只能借时间", "summary": "估算积压、清空时间与受理上限,并解释为何副本扩容会改变本地限流总量。", "objectives": [ "把任务量换算成下游调用量", "计算高峰后的净消化速度", "根据最大等待时间决定何时拒绝新受理" ], "concepts": [ { "title": "四种上限各管一件事", "body": "回看“估算最简单的执行容量”的推演。速率上限限制一段时间进入多少请求,并发上限限制同时执行多少,队列容量限制多少任务等待,下游配额可能限制文件写入次数或字节吞吐。一次商品导出可能分批写多个文件片段,只在入口数请求未必能保护存储容量。" }, { "title": "队列吸收短时突发,不能修复长期超载", "body": "回看“计算积压与消化时间”的推演。像餐厅等位区,队列让客人暂时等待,不会让厨师做菜更快。若持续每秒进入 8 个任务而只能完成 5 个,每秒积压增加 3 个。无限队列最终把内存、磁盘、等待时间或任务有效期耗尽。" }, { "title": "背压:把下游忙碌传回上游", "body": "回看“在任务失去价值前决定是否受理”的推演。消费者忙时减少预取,队列满时让生产者有限等待或拒绝,入口据此降低受理。反馈链必须传到真正产生工作的地方,才能阻止积压转移到另一个无界缓冲。用户要知道任务是已受理、待提交还是被拒绝。" }, { "title": "常见限流算法各有形状", "body": "回看“把公平性与全局精度写清楚”的推演。固定窗口简单,但窗口交界处可能出现短时突发;滑动窗口让时间边界更平滑但有状态成本;令牌桶允许积攒有限突发额度;漏桶式整形以较平稳速度放出,可能增加等待。选择依据是突发容忍、精度和协调开销。" }, { "title": "Little 定律连接平均数量与平均等待", "body": "回看“估算最简单的执行容量”的推演。在统计口径一致、系统长期稳定且平均值存在等条件下,平均在系统中的工作数 L = 有效到达率 λ × 平均停留时间 W。若统计的是整个系统,W 包括排队与执行;若只统计队列,则应使用队列等待时间。持续发散的积压不能直接套成稳定容量承诺。" } ], "steps": [ { "title": "先数完整任务会放大多少调用", "body": "每秒到达 10 个导出,每个写文件平均调用 4 次。先算文件请求数,再给每次文件大小加一列,指出还缺哪种容量信息。", "explanation": "每秒 10 个商品导出任务,每个平均发出 4 次文件服务请求,下游平均需求约为每秒 40 次,还未包括重试。若文件服务还限制字节吞吐,需要结合导出文件大小;只数 HTTP 请求不足以估算实际瓶颈。", "expected": "至少需要约 40 次文件请求/秒,重试会继续增加;文件字节吞吐也可能先达到上限。" }, { "title": "估算最简单的执行容量", "body": "画 10 个执行槽,每个任务平均占 2 秒,假设没有其他瓶颈。计算满负荷平均每秒能完成多少,并标注假设。", "explanation": "10 个执行槽,每个任务平均占用 2 秒,在持续忙碌且没有其他瓶颈的理想条件下,平均吞吐约 5 个任务/秒。这是估算,不是固定保证;耗时波动、共享数据库和外部配额都会改变结果。", "expected": "估算为每秒 5 个任务;这是理想平均值,实际耗时波动与数据库等共享资源会改变能力。" }, { "title": "计算积压与消化时间", "body": "高峰以每秒 8 个到达、5 个完成持续 60 秒;之后每秒仍到达 3 个。分两段计算新增积压与清空时间。", "explanation": "若高峰每秒到达 8 个、处理 5 个,持续 60 秒会多积压约 180 个。高峰结束后如果仍每秒到达 3 个,只剩每秒 2 个的净消化能力,需要约 90 秒清空,不是简单用 180 除以 5。", "expected": "先新增约 180 个;恢复后净消化为每秒 2 个,约需 90 秒,而非 36 秒。" }, { "title": "在任务失去价值前决定是否受理", "body": "用户只愿等 30 秒,把当前积压与执行能力换算成等待估计,写出超过时限时的新任务响应与旧任务保护动作。", "explanation": "如果用户允许最多等 30 秒,而当前积压按能力估算已超过这个范围,应限制新受理、减少非必要工作或降级。队列长度之外还要看最老任务年龄;几百个轻任务与几个超长任务不能只按相同数量比较。", "expected": "不能只延长队列掩盖过期;应限制受理或降级,同时看最老年龄与任务耗时分布。" }, { "title": "把公平性与全局精度写清楚", "body": "先给每个用户划配额,再让 API 从 1 个副本变成 5 个,每个各限 100 次/秒。计算总上限并选择需要严格共享还是允许近似。", "explanation": "给用户分别设置导出速率和并发,可防止一家占满全部 Worker。若每个 API 副本各限 100 次/秒,增加到 5 个副本可能放行约 500 次/秒;严格全局上限需要共享协调或预分配配额,代价是延迟、复杂度和故障时的取舍。", "expected": "独立本地限制可能总计约 500 次/秒;要严格全局上限需协调或分配额度,也会增加成本和故障取舍。" } ], "checks": [ "能区分入口 QPS、文件服务调用数、运行并发和排队数量。", "能正确计算持续超载的积压以及有新流量时的净消化速度。", "能说明稳定平均值公式不能直接保证每个请求的尾延迟。" ], "pitfalls": [ { "symptom": "API 返回很快,用户的任务却等待几小时。", "cause": "把迅速入队当成系统吞吐提高,没有关注任务总完成时间。", "fix": "以受理到完成的目标约束队列和接入,观察最老等待、完成速率与过期任务。" }, { "symptom": "扩容 API 后突破了文件服务总配额。", "cause": "限流计数只在单个实例内,实例增加使总放行量增加。", "fix": "明确限制是局部还是全局,并通过共享协调或有边界的配额分配保护总量。" } ], "exercise": { "task": "队列已有 240 个任务,Worker 平均每秒完成 6 个,新任务仍每秒进入 4 个。多久能清空?若到达率改成每秒 7 个会怎样?", "hint": "清空速度是完成速率减去新到达速率;结果成立的前提是这些平均值保持不变。", "answer": "每秒净减少 6−4=2 个,理想估算需要 240÷2=120 秒清空。若到达率为 7,则每秒净增加 1 个,队列不会自然清空;需要降低受理、提升真实处理能力或减少任务工作量。增加队列长度只延迟容量耗尽。平均计算还忽略长尾、重试和资源争抢,所以不能据此向每个用户承诺恰好 120 秒;应结合任务年龄、分位数和明确的受理规则。" }, "interview": { "question": "如何设计一个多用户商品服务的限流与背压?", "answer": "从受保护资源出发,分别预算入口请求、运行并发、等待容量和文件读写吞吐。入口按用户保证公平,下游调用点防止分批导出突破配额,队列满或等待超预算时把反馈传回受理端。说明算法允许多大突发、计数是否全局、故障时如何处理,以及拒绝后的用户交互。", "followUps": [ "令牌桶容量与补充速率分别控制什么?", "为什么扩容 Worker 可能反而让整体更慢?" ] }, "refs": [ { "label": "Microsoft:节流与限流选择", "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/throttling" }, { "label": "Microsoft:调用速率协调模式", "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/rate-limiting-pattern" }, { "label": "Little:排队公式 L = λW 原始论文", "url": "https://pubsonline.informs.org/doi/epdf/10.1287/opre.9.3.383" } ], "intro": { "scenario": "导出任务不断排队,增加队列长度后只是更晚失败;先算清来多少、能做多少。", "startingPoint": [ "完成第 10 课,知道等待和重试都会占用资源。", "准备纸笔,统一用任务/秒与秒计算,假设耗时稳定,实际波动另行记录。" ], "outcome": "估算积压、清空时间与受理上限,并解释为何副本扩容会改变本地限流总量。" } }, { "id": "12", "kind": "theory", "group": "稳定性与系统设计", "title": "可观测性、容灾与一道完整的系统设计题", "summary": "形成含正常路径、失败恢复、观测证据和恢复顺序的商品导出设计。", "objectives": [ "画出从受理到文件下载的最小方案", "把每个失败点连到证据和恢复动作", "说明资源有限时先恢复什么及取舍理由" ], "concepts": [ { "title": "三类观测信号分别回答不同问题", "body": "回看“选能说明用户受损的观测数据”的推演。指标说明一段时间内多少、多久、失败多少;日志保留某次状态变化和原因;链路追踪连接一次请求跨组件的耗时。异步任务还需要稳定的 job_id 关联多次投递、多个尝试和多段追踪;它和单次 trace_id 不是同一个概念。" }, { "title": "业务 SLO 关注用户有没有拿到价值", "body": "回看“选能说明用户受损的观测数据”的推演。“API 99% 返回成功”不能证明“商品清单 99% 按时完成”。可分别定义受理成功率、排队到开始、受理到完成、取消确认等目标。统计必须说明时间窗口、有效请求分母和未到完成期限的任务,避免漂亮数字掩盖积压。" }, { "title": "RTO 与 RPO 是目标,不是自动保证", "body": "回看“写出灾难恢复的依赖顺序”的推演。RTO 表示灾难发生后,业务最多可接受多久恢复;RPO 表示最多可接受丢失多长时间范围的数据变化。例如 RTO 30 分钟、RPO 5 分钟,分别约束恢复时间和恢复点距离,不能互换。实际能否达到取决于整条恢复链。" }, { "title": "容灾选项体现成本与恢复速度的取舍", "body": "回看“写出灾难恢复的依赖顺序”的推演。备份恢复通常常驻资源较少,但恢复准备较多;保留核心数据的 pilot light、缩小运行规模的温备、多个站点同时服务,逐步增加常驻能力与协调要求。多地活跃也不能自动解决错误配置、误删、写冲突或外部依赖中断。" }, { "title": "系统设计题的核心是解释因果", "body": "回看“用一个明确取舍收束答案”的推演。先明确用户要什么和可接受的等待、损失、成本,再画组件和数据流。每添加一个组件,都应能回答它解决哪个问题、失败会影响谁、如何恢复和付出什么代价。术语数量不能替代这些因果关系。" } ], "steps": [ { "title": "把设计题限定到可推演的需求", "body": "写下用户提交商品清单导出后离开、稍后回来查询、下载或取消的四步。先选一组教学用数量、峰值、文件大小和完成时限,标明是假设。", "explanation": "题目:设计商品清单导出,提交后可关闭网页,稍后查询进度和下载,支持取消。商品管理只包含名称、描述、整数价格、上架状态、分类和评价。先确认每次导出商品数量、峰值任务量、文件大小、完成时限与恢复目标;本课只做推演,不要求再拆出新业务。", "expected": "需求应有可计算的输入上限与完成目标,商品字段仍限于已有简单管理范围,不需要增加支付或库存业务。" }, { "title": "给出最小完整架构", "body": "用前 11 课的图拼出从客户端到 API、任务库、发布器、队列、Worker、文件的路线;给提交和发布结果两处圈出原子边界。", "explanation": "客户端经负载均衡访问 API;API 校验身份和商品编号,在同一事务保存任务快照、幂等记录与派发意图;发布器送入有界队列;Worker 导出包含 price_cents 的固定快照,把文件保存到共享存储,再按状态和版本发布结果引用。客户端按 job_id 查询进度,完成通知可异步发送。缓存只加速允许旧读的商品查询。", "expected": "方案应保存固定商品快照、意图与派发记录;文件准备好后才发布可查询结果,缓存只服务允许旧读的位置。" }, { "title": "把失败放回发生的位置", "body": "分别在入口响应、消息发送、Worker 执行、文件返回四个位置插入失败,为每个位置写出能查询的记录和下一步。", "explanation": "入口超时靠稳定幂等键找回同一任务;Broker 暂停时保留发送意图并限制新受理;Worker 中断由持久状态和接管规则恢复;旧执行者不能覆盖已发布终态;文件服务超时保留未知状态并核实。每个恢复判断都要有数据证据,不能仅靠进程存活。", "expected": "能分别找回同一任务、重发持久意图、按版本接管、核实未知文件结果;仅看到进程活着不足以判定恢复。" }, { "title": "选能说明用户受损的观测数据", "body": "假设用户投诉“半小时还没导出”,从表中选择能定位受理、派发、等待、执行和存储阶段的数字及关联编号。", "explanation": "观察受理量、失败率、完成时间分位数、最老待发布年龄、队列等待、外部依赖延迟和文件写入吞吐。用 job_id 找到业务历程,用 message_id 与 attempt 区分投递和执行;具体编号放日志或追踪,避免把每个任务编号都做成指标标签造成高基数。", "expected": "最老等待年龄和完成时间比单纯进程数更贴近用户;job_id/attempt 用于日志关联,不逐任务生成指标标签。" }, { "title": "写出灾难恢复的依赖顺序", "body": "假设整个区域失效,将权威数据、文件、密钥配置、入口、派发器和 Worker 排成恢复次序,再写一条防止重复重放的检查。", "explanation": "区域失效后,先确认权威数据恢复点和写入归属,再恢复入口、任务协调与执行能力,重新核对未完成任务及外部副作用。数据库任务记录、商品清单文件、派发记录和密钥配置都要可用,单独恢复一张任务表并不等于服务恢复。异步重放还需防止重复效果。", "expected": "需先确认数据恢复点和写入归属,再恢复服务能力并核对未完成效果;只有任务表而没有文件仍无法下载。" }, { "title": "用一个明确取舍收束答案", "body": "资源只能先支撑部分能力,选择商品查询、任务查询或批量导出的恢复顺序,并声明可接受旧读、损失与等待边界。", "explanation": "商品列表允许短暂旧读,可以使用副本与缓存;商品修改和导出终态发布由权威事务与版本条件保护。资源有限时优先恢复商品和任务查询,批量导出稍后恢复。说明目标、限制和升级条件,避免同时承诺零故障、零损失与零等待。", "expected": "可以先恢复查询、稍后恢复导出,但必须说明目标和限制;不能同时无条件承诺零损失、零等待、零故障。" } ], "checks": [ "能分别用指标、日志和追踪回答“影响多大”“哪项任务”“慢在哪段”。", "能正确解释 RTO 与 RPO,并指出数据库恢复不等于全业务恢复。", "能按需求→容量→架构→数据规则→故障恢复→取舍讲完一道设计题。" ], "pitfalls": [ { "symptom": "监控全绿,但用户商品清单一直没有生成。", "cause": "只监控进程心跳与接口 200,没有监控异步任务完成情况。", "fix": "增加业务完成率、最老等待和各阶段持续时间,按用户目标解释异常。" }, { "symptom": "恢复数据库后直接重放全部任务,重复生成文件或发送完成通知。", "cause": "没有确认哪些外部效果已经发生,也没有保留稳定任务编号和接管边界。", "fix": "按任务状态与文件、通知证据分类恢复,未知结果先核实,重放仍受幂等与版本规则约束。" }, { "symptom": "写了 RPO=0,就认为任何故障都不会丢数据。", "cause": "把期望目标当成已经实现的保证,没有定义故障模型和确认边界。", "fix": "说明何时向用户确认、哪些副本持久化、相关对象如何保持可恢复,并区分设计目标与实际证据。" } ], "exercise": { "task": "用不超过六段话设计可恢复的商品清单导出服务。峰值 20 个任务/秒,每个任务平均 3 次文件服务调用,文件服务配额为 50 次/秒;允许有限排队,RTO 为 30 分钟,RPO 为 5 分钟。", "hint": "先找容量矛盾,再解释受理、持久状态、执行、观测和恢复,所有数字都是本题假设。", "answer": "第一段:用户需要商品清单导出、状态查询、文件下载和取消,明确排队上限及允许 5 分钟恢复点差距的数据范围。第二段:下游需求为 20×3=60 次/秒,超过 50 且未计重试,理想持续能力上限约 16.7 个任务/秒,必须限流、减少文件调用、提高配额或只承接有限突发。第三段:API 同一事务保存固定商品快照、幂等任务与 Outbox,发布器送有界队列,Worker 受控执行并按版本发布文件引用。第四段:重投沿用 job_id,文件未知结果按对象标识核实;取消与旧执行者发布均受持久状态约束。第五段:观察受理到完成时间、最老等待、派发积压和依赖错误,用 job_id 关联日志与追踪。第六段:按预算选择备份或温备,恢复同时考虑数据库、商品清单文件、配置和未完成通知,以 30 分钟内恢复约定能力为目标;5 分钟 RPO 不允许任务无声消失,应识别受影响提交并恢复可查询状态。更严目标需要额外复制与协调成本。" }, "interview": { "question": "面试中如何设计一个支持可靠导出的高可用商品服务?", "answer": "先明确商品操作、导出量、文件大小、完成时限和一致性要求,再估算数据库与文件存储需求。画入口、商品与任务状态、队列、执行器和结果存储,沿请求解释受理与完成。补充幂等、未知结果、租约接管、限流和业务观测,最后用 RTO/RPO 与故障范围说明恢复能力和成本取舍。", "followUps": [ "文件存储整体不可用时,哪些商品服务能力仍可提供?", "如何避免恢复区域与旧区域同时发布结果?", "为什么 job_id 不适合作为常规指标的无限增长标签?" ] }, "refs": [ { "label": "OpenTelemetry:可观测性基础", "url": "https://opentelemetry.io/docs/concepts/observability-primer/" }, { "label": "Google SRE:分布式系统监控", "url": "https://sre.google/sre-book/monitoring-distributed-systems/" }, { "label": "AWS:RTO 与 RPO 目标", "url": "https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/disaster-recovery-dr-objectives.html" }, { "label": "AWS:云端灾难恢复方案", "url": "https://docs.aws.amazon.com/whitepapers/latest/disaster-recovery-workloads-on-aws/disaster-recovery-options-in-the-cloud.html" } ], "intro": { "scenario": "现在为同一商品清单导出交一份完整设计:用户能离开页面,回来查看进度并下载结果。", "startingPoint": [ "完成第 01—11 课,保留边界图、失败时间线和容量计算。", "只使用名称、描述、整数价格、上下架、分类与评价这些既有商品字段;本课只做设计推演。" ], "outcome": "形成含正常路径、失败恢复、观测证据和恢复顺序的商品导出设计。" } } ]