课程大纲

课程大纲 · 选择一节课

在增加负载之前,先测试重放、慢依赖和变化的数据

复现一个“先检查后写入”的竞态条件,在截止期限内规划重试预算,并区分经批准的规范化与凭空猜测。通过有限的测试和明确的界限来检查三种常见的故障类别,而不是假设一种标准修复方案能解决所有问题。

30 分钟

作者:FDEInterviews · 更新于

文字记录

这是《前沿部署工程基础》的第九课。我们将检查当工作重复执行、依赖消耗时间以及数据违反其契约时会发生什么。测试相关的故障和恢复路径,因为一个键、一个超时或一个验证器,只有在具备其所需的周边语义时才有用。

你有一个调用链路追踪和一个边界登记表。利用它们来选择故障测试,然后在下一个模块中针对这些边界开发生产代码。

首先选择普通演示会遗漏的故障。重放、慢依赖和变更数据是很有用的调查类别。

它们并不是一个详尽无遗的列表,也不是对每个客户事故的量化排序。而且它们可以通过受控的测试夹具进行测试。它们并非在进入生产环境之前就天然不可见。

从实际的写入路径、资源限制和输入契约入手,因为在干净数据和低负载下的成功请求无法回答这些问题。所以要问:什么样的证据才能确立故障下的行为,包括操作员能看到什么以及恢复是如何工作的。

重复投递和重复意图是不同的。队列可能根据其投递契约进行重新投递,操作员可能重新运行一个批处理,调用方可能在丢失响应后进行重试。

即使传输尝试是新的,预期的业务操作也可能是相同的。反过来,两个合法的操作也可能使用完全相同的载荷。

因此,要为逻辑操作选择身份标识,并包含适当的租户和来源范围,以及载荷一致性。仅凭一个货运 ID 无法区分是给货运打标签还是发送更新,也无法区分该货运的连续有效事件。每次重试时重新生成的时间戳会使去重失效,而作为来源真实唯一事件标识一部分的时间戳则可能是有意义的。所以不要把键设计简化为“时间戳不好,记录 ID 好”。

如果检查和效果没有协调一致,那么在写入前检查键是不够的。本课运行了一个故意破坏的、单线程的模拟,模拟两个交错的调用方。两个调用方都在任何一方提交之前检查集合。调用方一没有看到键,于是应用标签并记住了该键。调用方二已经决定写入,所以它也应用了标签。

它打印出两个业务效果和一个被记住的键。集合中只有一个键,却发生了两个效果。这就是竞态条件。它演示了竞态,而无需使用线程或触碰真实服务。

一条持久化的唯一操作记录,与本地状态变更在同一事务中原子协调,可以解决这类竞态问题。其机制如下:唯一约束会让第二次插入在与副作用相同的同一个事务内失败,因此检查与写入在时间上无法再被分离。数据库在写入时而非查询时拒绝重复。

远程副作用需要接收方的幂等契约,或显式的对账机制。本地数据库事务无法回滚一封已经发送出去的邮件。

测试重复投递、并发尝试、副作用发生前后的崩溃、同一键下的冲突载荷、授权变更以及去重记录过期等情况,然后记录操作契约以及你仍需要的失败证据,因为后续课程会给出更完整的实现练习。

现在来界定时间和工作量。依赖可能快速失败、缓慢失败,或永不返回。一个“已死”的端点也可能挂起,直到网络超时。

危险的行为是消耗超出系统容忍范围的共享资源,无论其标签是什么,因此要检查连接、请求和整体截止时间,包括客户端实际覆盖的范围。超时只是限制等待时间,它并不能证明远程已取消,也无法阻止所有已累积的工作。

限制并发数、队列大小和重试次数,决定过载时如何拒绝或延迟处理,并在依赖共享同一个连接池时保护无关的工作。

AWS 有一篇关于超时与重试的文章记录了一个很有启发性的案例:部署之后,新建安全连接的设置在低超时设置下经常失败,而复用的连接通常能成功。团队先是调整了处理方式,后来改为在承接流量之前预先建立连接。教训是:要检查超时所涵盖的工作内容,而不是照搬另一个服务的数字。

现在做一个独立的合成截止时间计算。请求的总预算为 1,000 毫秒。本地工作占 100 毫秒,允许三次尝试,每次上限 250 毫秒。两次退避等待分别为 50 和 100 毫秒。因此 100 加上 750 的尝试时间,再加上 150 的退避时间,正好是 1,000。这些只是示意性的分配,并非推荐的默认值。

现在假设本地工作实际消耗了 300 毫秒,且第一次尝试用满了 250 毫秒。那么只剩 450 毫秒。经过 50 毫秒的退避之后,最多还剩 400 毫秒,供所有剩余工作使用。

因此,实现必须在每次等待和每次尝试之前计算剩余的单调时钟截止时间,并为必要的响应处理留出余量。当本地工作超时后,绝不能照搬启动原本完整的重试计划。实际的超时覆盖范围和取消行为需要测试验证。计算只是计划而已。

多层重试会使负载成倍增加。假设三层各自最多总共重试三次:顶层有三次尝试,每次可触发中间层三次尝试,每次又可触发底层三次尝试。因此,当所有重试都被触发时,一个顶层请求最多可导致 27 次最深层调用。

因此要协调重试的归属、限制尝试次数,并采用合适的退避和抖动策略。当操作结果未知、且没有有效的可重复安全契约保护时,重试同样是不合适的。

接下来是熔断器模型及其限制。它有三种状态:关闭、打开和半开。

关闭状态放行调用。打开状态在失败足够多之后阻断调用。半开状态则放行少量探测调用,以测试依赖是否已恢复。在本课的组件中,触发失败,观察调用被阻断,然后看到有限的探测,之后恢复正常调用。

熔断器可以减少对失败依赖的调用,但它不会自动取消进行中的工作、限制所有队列、隔离所有资源,或确保业务影响的安全性。它的阈值、作用范围、探测行为和恢复交互都需要测试。它只是一种可选的控制手段,而不是每次超时后必须执行的第二个步骤。

最后,要验证约定的含义,而不仅仅是形式。一个新增字段在一种契约下可能是向后兼容的,在另一种契约下却可能是被禁止的。

状态从全大写 active 变为首字母大写的 Active 可能是有效的,前提是文档化的契约不区分大小写。如果契约要求精确的枚举值,那么未经审核就将其视为 active 就是一种没有依据的假设。

明确写出可接受的表示形式和规范结果,在需要时保留原始输入和转换来源,并且不要默默地把未知值转换成一个看似合理的默认值。结构上有效的值仍可能在语义上是错误的,例如一个看似有效却属于另一个租户的客户 ID。

在这个小练习中,约定的输入契约只接受两种写法——全大写和首字母大写——作为规范小写 active 的别名。全大写的 inactive 会变成小写 inactive。其他值一律拒绝。这是本练习给定的契约,并不是接受混合大小写的通用建议。

看看这个小写字符串。它被拒绝了,因为这份特定的输入契约没有包含它,尽管它等于规范的输出。如果这种表示形式应当被接受,就要有意地修改契约和测试。不要从输出格式反推输入语言。

隔离需要一个原因、一个负责人、保留与访问策略,以及一条重放路径。有些无效记录可以被隔离,而有些失败则需要扣留整个批次或受影响的工作流。对可操作的告警条件进行告警,但不要把敏感输入复制到宽泛的日志中。并要对已接收、已接受、已拒绝和未解决的工作进行对账,这样失败就不会从统计中消失。

本课将三个类别作为一个测试序列来运行,因为它们共享同一个起点:真实的写入路径、限制和契约,而不是演示路径。

轮到你了。在此暂停视频。运行全部三个测试夹具。解释为什么一次已记住的键没有阻止两个副作用,根据三层计划计算出 27 次最深尝试,然后将状态契约改为显式接受小写,并为其添加测试。对于你的追踪记录,为每个类别写一个失败测试和一个恢复检查,包括写入后丢失的响应、消耗完剩余时限的慢调用,以及看起来有效但领域含义错误的值。说明哪些控制属于生产者、消费者和运营负责人。并记住,在小型夹具中未发现缺陷,并不能证明在真实部署中这三个类别都得到了处理。

回顾一下。识别逻辑操作,并将去重与副作用协调起来,因为先前的键检查可能存在竞态;记住超时限制的是等待时间,而并发、队列和重试预算限制的是累积的工作量。熔断器需要明确的范围和恢复测试,而不是自动采用;只有在定义了契约的情况下才接受归一化,并对未知含义进行可见的拒绝。并将每个失败测试与对账和可用的恢复路径配对。

本课是第九课。本系列的下一课,将讲解如何在不丢失含义的情况下转换源数据。完整课程及其练习和演练见 fdeinterviews.com。

TL;DR: 检查当工作重复、依赖消耗时间以及数据违反契约时会发生什么。测试相关的失败和恢复路径;一个键、超时或校验器,只有配合它所需的周边语义才有用。

你当前的位置。 你已经有一个追踪记录和边界登记表。在下一个模块开发这些边界处的生产代码之前,先用它们来选择失败测试。

普通演示所遗漏的失败

重放、慢依赖和变更数据是有用的调查类别,但不是详尽的清单,也不是对每个客户事故的实测排序。这三类都可以用受控夹具来测试,因此它们不必等到生产环境中才被发现。

从实际的写入路径、资源限制和输入契约开始,因为在干净数据和低负载下成功的请求,无法回答这些问题。追问什么样的证据才能确立失败下的行为,包括操作员看到什么以及恢复如何运作。

重复投递与重复意图是不同的

队列可能根据其投递契约重新投递消息;运维人员可能重新运行一个批处理;调用方可能在丢失响应后重试。即使传输尝试是新的,预期的业务操作也可能是同一个;而两个合法的操作也可能恰好共享完全相同的载荷。

为逻辑操作选择标识,并配合适当的租户/来源范围和载荷一致性。仅凭一个运单 ID 无法区分“打标签”与“发送更新”,也无法区分针对该运单的连续合法事件。每次重试时新生成的时间戳会使去重失效;而作为来源真实唯一事件标识一部分的时间戳则可能是有意义的。不要把键设计简化为“时间戳不好,记录 ID 好”。

如果检查与生效没有协调一致,仅在写入前检查一个键是不够的。请故意以破坏性的方式运行这个单线程的、模拟两个调用方交错执行的仿真,它无需线程或真实服务就能展示这种竞态。

seen = set()
effects = []
key = ('tenant-a', 'source-x', 'event-7', 'review-tag')

# Both callers check before either commits.
a_should_write = key not in seen
b_should_write = key not in seen
if a_should_write:
    effects.append('tag applied')
    seen.add(key)
if b_should_write:
    effects.append('tag applied')
    seen.add(key)
print('business effects:', len(effects))
print('remembered keys:', len(seen))
assert len(effects) == 2 and len(seen) == 1
# business effects: 2
# remembered keys: 1

集合中只有一个键,却发生了两次生效。将持久化的唯一操作记录与本地状态变更原子地协调起来,可以解决这一类竞态。其机制在于:唯一性约束使第二次插入在与生效相同的同一个事务中失败,因此检查与写入在时间上不再可分离;数据库在写入时而非查询时拒绝重复。远程生效需要接收方的幂等契约或显式的对账;本地数据库事务无法回滚一封已经发送到别处的邮件。

测试重复投递、并发尝试、生效前与生效后的崩溃、同一键下冲突的载荷、变更的授权以及过期的去重保留期。后面的重放与行动课程包含更完整的实现练习;在这里,请记录操作契约以及你仍需要的失败证据。

限定时间与工作量

依赖可能快速失败、缓慢失败或永不返回,而一个“已死”的端点可能会挂起直到网络超时。危险的行为是消耗超出系统容忍范围的共享资源,无论其标签是什么。

检查连接、请求和整体截止时间,包括客户端实际覆盖的范围。超时只是限制等待;它既不能证明远端已取消,也无法阻止所有已累积的工作。限制并发、队列大小和重试次数,并决定如何拒绝或延迟过载。当多个依赖共享同一个资源池时,要保护无关的工作。

Marc Brooker 在 AWS 上关于超时与重试的文章记录了一个很有启发性的案例:部署后,较短的超时在建立安全连接时超时,而复用的连接通常能成功。团队先调整了处理方式,后来改为在承载流量之前预先建立连接。从中得到的启示是:要审视超时中所包含的工作内容,而不是照搬另一个服务的数字。

下面是一个独立的合成截止时间计算。请求总预算为 1,000 ms,本地工作 100 ms,最多允许三次尝试,每次上限 250 ms,尝试之间有 50 和 100 ms 的退避等待。这些只是示意性的分配,并非推荐的默认值。

budget_ms = 1000
local_ms = 100
attempt_limits_ms = [250, 250, 250]
backoff_ms = [50, 100]
planned_ms = local_ms + sum(attempt_limits_ms) + sum(backoff_ms)
print('planned bound:', planned_ms)
assert planned_ms == budget_ms

# If local work actually consumes 300 ms and the first attempt uses 250,
# only 450 ms remain. After 50 ms backoff, at most 400 remain for all work.
remaining_ms = budget_ms - 300 - 250
print('remaining before next backoff:', remaining_ms)
assert remaining_ms == 450
assert remaining_ms - 50 == 400
# planned bound: 1000
# remaining before next backoff: 450

实现必须在每次等待和尝试之前计算剩余的单调时钟截止时间,并为必要的响应处理留出余量。当本地工作超支后,不得再启动原本的完整重试计划。实际的超时覆盖和取消行为需要测试,因为算术只是计划。

多层重试会使负载成倍增加。如果三层各自最多进行三次总尝试,当所有重试都被触发时,一个顶层请求最多可导致 27 次最底层调用:顶层三次尝试,每次可触发中间层三次,每次又可触发底层三次,即 3 × 3 × 3。要协调重试的归属,限制尝试次数,并使用合适的退避/抖动策略。当操作结果未知且没有有效的可重复安全契约保护时,重试也是错误的。

熔断器模型及其局限

现有的组件展示了关闭、打开和半开的行为:关闭状态放行调用,打开状态在足够多的失败后阻断调用,半开状态放行少量探测调用以测试依赖是否已恢复。触发失败,观察调用被阻断,然后观察在恢复正常调用之前的有限探测。

半开:选定的成功会使其关闭,失败则使其重新打开。这些只是示意性的事件,并非网络或模型质量检查。

熔断器可以减少对失败依赖的调用,但它不会自动取消进行中的工作、约束每个队列、隔离所有资源或保证业务影响的安全。它的阈值、作用范围、探测行为和恢复交互都需要测试,而且它只是一种可选的控制手段,并非每次超时后必须执行的第二个步骤。

约定的含义,而不仅仅是形式

一个新增的字段可能在一种契约下向后兼容,而在另一种契约下被禁止。状态从

ACTIVE

变为

Active

在文档化契约不区分大小写的情况下可能是有效的。如果契约要求精确的枚举值,未经审查就将其视为 active 是一种没有依据的假设。

明确写出被接受的表示形式和规范化结果,并在需要时保留原始输入和转换来源。绝不要将未知值悄悄转换为看似合理的默认值。结构上有效的值仍可能在语义上是错误的,例如一个看似有效的客户 ID 实际属于另一个租户。

对于这个小练习,约定的输入契约只接受

ACTIVE

和

Active

作为规范值

active

;

INACTIVE

会被规范化为

inactive

。其他所有值都被拒绝。这是既定的契约,而不是接受大小写混用的通用建议。

aliases = {'ACTIVE': 'active', 'Active': 'active', 'INACTIVE': 'inactive'}

def canonical_status(value):
    if not isinstance(value, str) or value not in aliases:
        raise ValueError('Unsupported status representation')
    return aliases[value]

assert canonical_status('ACTIVE') == 'active'
assert canonical_status('Active') == 'active'
assert canonical_status('INACTIVE') == 'inactive'
for value in ['active', ' ActIve ', None, 1]:
    try:
        canonical_status(value)
    except ValueError:
        print('rejected:', repr(value))
    else:
        raise AssertionError('Unexpected acceptance')

小写字符串被拒绝,是因为这个特定的输入契约没有包含它,即使它等于规范输出。如果应该接受这种表示形式,请有意识地修改契约和测试,而不是从输出格式推断输入语言。

隔离区需要原因、负责人、保留与访问策略以及重放路径。某些无效记录可以被单独隔离,而其他失败则需要暂存整个批次或受影响的工作流。针对可采取行动的条件发出告警,且不要将敏感输入复制到宽泛的日志中。对已接收、已接受、已拒绝和未解决的工作进行对账,确保失败不会从统计中消失。

上面的主干将这三个类别作为一个测试序列运行,因为它们共享同一个起点:真实的写入路径、限制和契约,而不是演示路径。

在继续之前完成以下操作

运行全部三个测试夹具。解释为什么一个已记住的键未能阻止两个副作用;根据三层计划计算 27 次最深尝试;然后将状态契约改为显式接受小写并添加其测试。

针对你的追踪,为每个类别编写一个失败测试和一个恢复检查。包括写入后丢失的响应、消耗掉剩余截止时间慢调用,以及一个看似有效但领域含义错误的值。说明哪些控制属于生产者、消费者和运营负责人。

当你的清单能识别确切的影响、截止时间和契约,并区分已测试的行为与提议时,它就通过了。在小型夹具中未发现缺陷并不能证明在真实部署中这三个类别都得到了处理。

深入了解

  • 幂等性深入讲解操作标识和可重复安全的影响。
  • 熔断器介绍当你已有超时机制但依赖仍在退化时该怎么做。
  • 指数退避正是让“两次”问题从一开始就普遍存在的重试行为。
  • 数据质量是在边界处进行验证而非强行转换背后的学科。
  • 至少一次与恰好一次投递是与本课从同一处出发的面试题。

关键要点

  • 识别逻辑操作,并协调去重与副作用;先前的键检查可能存在竞态。
  • 超时限制等待时间,而并发、队列和重试预算则限制累积的工作量。
  • 熔断器需要明确的范围和恢复测试,而不是自动采用。
  • 只有在定义了契约的情况下才接受规范化,并以可见的方式拒绝未知含义。
  • 将每个失败测试与对账和可用的恢复路径配对。

自我检测

先作答再看答案。回忆才能让知识牢固,仅靠辨认则不行。

  • 1损坏的测试夹具在两个调用方运行后仍记住一个键。总共发生了多少次副作用?
  • 2客户端超时能否证明远程写入没有发生?
  • 3将 Active 转换为 active 是否总是不安全的猜测?

免费登录以保存进度并返回本课程。