对方变慢了,先倒下的却是我们
目标
把分给一个上层请求的时间当作预算,亲手制作一个把这份预算分配给连接、读取、重试和下层调用的客户端。预算不够时不发起调用、尽快失败,并通过请求头把剩余预算向下传递。
为什么重要
把超时设得宽松的习惯看似稳妥,其实正相反。对方变慢到 30 秒时,如果我们的上限是 60 秒,我们的工作进程就会被占住 30 秒,而从调用我们的一方来看,变慢的是我们。这种延迟还会再向上一层蔓延。 所以超时不是体谅对方、等一等,而是保护我们的断路器。而且它不是一个值,而是两个值——连接是到达的时间,花了几秒就不是慢而是不通;读取是对方干活的时间,因业务而异。 用预算的角度来想,规则是三行。为上层请求确定总预算,每次调用的读取上限取那一刻剩余的预算,剩余预算小于下限时不发起调用。重试及其等待时间也要从同一份预算中扣除。 评分器不会相信你写的句子。评分器会把变慢的合作方启动在评分器所选的端口上,实际运行你的调用器,同时测量耗时和输出中的区段名称。
步骤
- 创建 /root/budget/partner.py 并在端口 8014 上启动,测量三种失败形态,写入 /root/budget/probe.json。
- 创建 /root/budget/call.py,分别设定连接和读取的上限,并说明是在哪个区段断开的。
- 创建 /root/budget/deadline.py,用一个总预算进行多次调用,并让剩余预算成为下一次调用的读取上限。
- 给 deadline.py 加上下限(
--min-ms),剩余预算小于下限时,不发起调用,以no_budget跳过。 - 创建 /root/budget/retry.py,让重试的等待按指数增长并加入随机数,同时把这段等待也从预算中扣除。
- 创建 /root/budget/fanout.py,把当时的剩余预算通过
X-Budget-Ms请求头传给下层调用。 - 在 /root/budget/budget_plan.json 中用数字写出一个画面的预算表。
- 在 /root/budget/budget_report.md 中分四节进行汇报。
参考
- 合作方的运行契约:
python3 /root/budget/partner.py --port <포트>(占位符为端口)。/health返回{"ok": true},/call?ms=<n>&fail=<k>&key=<s>会在 n 毫秒之后应答,并且同一个 key 的前 k 次返回 503。/budget?ms=<n>会在 n 毫秒之后返回{"received": <X-Budget-Ms 헤더 값>}(占位符为请求头的值)。 - 三种失败形态:已关闭的端口(
http://127.0.0.1:9/)几乎立刻被拒绝,不会路由到任何地方的地址(http://192.0.2.1/——RFC 5737 预留给文档使用的地址段)会一直等到连接上限才断开,缓慢的响应会一直等到读取上限才断开。probe.json 是由{"kind": "refused"|"connect_timeout"|"read_timeout", "target": ..., "elapsed_ms": ..., "error": ...}组成的三行列表。 - 调用器的运行契约:
python3 call.py --url <U> --connect <초> --read <초>(占位符为秒数)会返回{"ok": ..., "status": ..., "phase": "done"|"connect"|"read", "elapsed_ms": ..., "error": ...}。像连接被拒绝这样在到达对方之前就结束的失败,也属于connect区段。4xx、5xx 响应是已经收到的,所以 phase 是done,只有 ok 为 false。 - 预算调用器的运行契约:
python3 deadline.py --budget-ms <n> --url <U> [--url <U> ...] [--connect <초>] [--min-ms <n>](占位符为秒数)。输出为{"budget_ms": ..., "calls": [...], "skipped": k, "total_ms": ..., "exceeded": ...},每个 call 中有urlokphaseelapsed_msread_timeout_ms。read_timeout_ms是发起该调用时剩余的预算。--min-ms在第 3 步中也要作为参数接收(默认 0),下限的判定在第 4 步加上。exceeded在因预算不足而跳过了调用、或读取被预算截断时为 true。 - 重试器的运行契约:
python3 retry.py --url <U> --budget-ms <n> --attempts <k> [--base-ms <b>] [--connect <초>] [--min-ms <n>](占位符为秒数)会返回{"ok": ..., "attempts": ..., "elapsed_ms": ..., "waits_ms": [...], "stopped_reason": "ok"|"attempts"|"budget"}。等待上限是base-ms * 2^(시도-1)(指数中的韩文词意为“尝试次数”),实际等待在 0 到该上限之间选取。 - 扇出器的运行契约:
python3 fanout.py --base <BASE_URL> --budget-ms <n> --n <횟수> [--connect <초>](占位符依次为次数、秒数)每次都调用<BASE>/budget?ms=200,并在X-Budget-Ms请求头中带上当时剩余的预算。输出中的每个 call 是sent_budget_msreceivedelapsed_ms。 - 预算表格式:
{"total_ms": ..., "reserve_ms": ..., "worst_case_ms": ..., "calls": [{"name": ..., "budget_ms": ..., "max_attempts": ...}]}。调用至少 3 个,reserve_ms至少为 100,worst_case_ms是各调用预算之和再加上预留量,并且不得超过total_ms。 - 常见错误:只把超时设成一个值;重试的等待不从预算中扣除;剩余预算几乎没有了却仍去发起调用;不告诉下层调用我们的预算。
- 不要制作压力测试。一次评分的预算是 60 秒。
测量失败的三种形态
创建 /root/budget/partner.py 并在端口 8014 上启动,亲自测量连接被拒绝、连接超时、读取超时这三种情况,并以 kind·target·elapsed_ms·error 写成三行,保存到 /root/budget/probe.json。
缓慢的响应由合作方用 sleep 来模拟。连接被拒绝用没有人监听的 127.0.0.1 端口,连接超时用不会路由到任何地方的地址来制造。三种情况的耗时彼此有何不同,是这一步的关键。
把连接和读取分开设定
创建 /root/budget/call.py,分别接收 --connect 和 --read,失败时在 phase 中写明是 connect 还是 read。如果收到了响应,即使状态码是 4xx、5xx,phase 也是 done。
requests 的 timeout 接收一个装着两个值的元组。异常也分了种类,所以可以区分连接一侧和读取一侧——不过连接被拒绝不是超时异常,所以要单独捕获,而且它是在到达对方之前就结束的事,所以属于 connect 区段。
剩余预算就是下一次调用的上限
创建 /root/budget/deadline.py,用一个 --budget-ms 依次调用多个 --url。每次调用的读取上限必须是那一刻剩余的预算,并且要把这个值写入 read_timeout_ms。
把上一步的 call.py 作为模块引入使用,就不必把同样的代码写两遍。记下一次起始时刻,在每次调用之前计算“预算减去到目前为止已用的时间”。这样一来,无论调用有多少个,整体都不可能超出预算。
没有希望的调用就不发起
给 deadline.py 加上 --min-ms,剩余预算小于该值时,不发起调用,将 phase 记录为 no_budget,并增加 skipped。被跳过的调用的 elapsed_ms 为 0。
只剩 200ms,却去发起平时要花 400ms 的调用,是把失败推迟了 200ms。如果当场就失败,还能用剩下的时间做出哪怕一个部分响应。下限要通过参数接收,这样才能针对不同情形给出不同的值。
重试也要从预算里出
创建 /root/budget/retry.py,让它重新发起失败的调用,但等待上限按 base-ms * 2^(시도-1) 增长(指数中的韩文词意为“尝试次数”),实际等待在 0 到该上限之间随机选取。如果等待之后预算里没有再发起一次调用的空间,就把 stopped_reason 设为 budget 并停止。
如果不加入随机数,在同一时刻失败的客户端会在同一时刻再次涌来。而且等待时间也要从预算中出,所以要在睡眠之前先问自己,醒来之后是否还有空间发起调用。把停止的原因分成三种来记录,之后只看日志就知道原因。
把剩余预算向下传递
创建 /root/budget/fanout.py,调用 --n 次 <BASE>/budget?ms=200,每次都在 X-Budget-Ms 请求头中带上当时剩余的预算。在输出的每个 call 中写入 sent_budget_ms·received·elapsed_ms。
如果我们只剩 1200ms,而下游服务按自己的标准等待 5 秒,多出的 4 秒就被用来生成没有人看的答案。请求头名称是我们约定的,接收的一方把它的值当作自己的上限。合作方的 /budget 会原样返回收到的请求头的值,所以可以确认是否真的传递了。
一个画面的预算表
在 /root/budget/budget_plan.json 中写出预算表。需要 total_ms·reserve_ms·worst_case_ms,以及至少 3 个 calls(名称·budget_ms·max_attempts)。reserve_ms 至少为 100,worst_case_ms 是各调用预算之和再加上预留量,并且不得超过 total_ms。
预留(reserve)是我们这边序列化、模板渲染这类不属于调用的时间。如果漏掉这部分,把预算全都分给调用,画面总是在上限边缘险险通过。带有重试的调用,意思是重试也必须在该调用的预算之内完成。
预算检查报告
在 /root/budget/budget_report.md 中分为 ## 지금 무엇이 시간을 먹는가 ## 구간을 어떻게 나눴나 ## 재시도가 먹는 몫 ## 예산을 넘겼을 때 무엇을 하나 四节来写(韩文,依次意为“现在什么在吃掉时间”“如何划分区段”“重试占用的份额”“超出预算时怎么办”)。probe.json 和 budget_plan.json 中的数字必须写进正文。
读者是那个说“请把超时调大一点”的人。请用数字向他展示,调大之后什么会变糟。重试的份额,是把尝试次数乘以等待上限,计算出最坏情况并写下来。