原文:https://dev.to/shubhradev/i-throttled-my-app-to-slow-3g-heres-what-my-tests-never-caught-h7m(作者 @shubhradev)
我并非主动寻找性能问题。我只是想观察,当网络慢到平时从未注意到的前提假设开始显现时,会发生什么。因此我打开了开发者工具,将节流设置为 3G(Chrome 的网络节流参考中,原先单独的“慢速 3G”预设已不复存在,现在统称为“3G”,位于“慢速 4G”和“离线”之间),然后在一个从未在正常 Wi-Fi 下出过问题的应用里四处点击。

两个问题出现了。它们都是正确性 Bug,不是“感觉慢”的抱怨,而且都藏在通过了我编写的所有测试的代码中。
Bug 一:竞态条件,搜索结果以错误顺序返回
这个应用有一个搜索框。输入查询词,它获取结果并展示出来。没什么特别的:
async function search(query) {
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
setResults(data.results);
}
这是一个竞态条件,在快速 Wi-Fi 下你很少会注意到它,因为请求和响应时间足够接近,排序很少会颠倒。这并非一种保证,而仅仅是低且稳定的延迟在大多数情况下的表现。放慢连接速度,这个情况就不再成立。如果你输入“apple”,停顿一下,然后迅速改成“banana”,那么“apple”的请求和“banana”的请求会同时处于飞行状态。无论哪个请求碰巧耗时更长——可能是它遇到了网络较慢的部分,也可能是服务器为它做了更多处理——它都可能在另一个请求之后才返回。当它返回时,其响应会用另一个查询的结果覆盖屏幕,而那个查询你甚至已经不关心了。
如果这听起来很熟悉,我在 乐观 UI 竞态条件:只在第五次点击时才出现 一文中遇到过这种 bug 的一个近亲,乱序请求导致的过时结果并非搜索框独有,任何在其生命周期内触发多个异步请求的组件都可能出现此问题。
3G 节流本身使得这种情况更容易触发,因为它会节流会话中的所有请求,无论其路径如何。但它并不能保证在任何一次运行中都会颠倒,因此为了在本文中使其可重现,我用确定性的方式强制了它:我给两个查询在服务器端设置了不同的人为延迟,apple 耗时 3 秒,banana 耗时 200 毫秒。这是服务器端的延迟,而非网络节流。对于下面的日志和计时,我在测试时关闭了节流,以确保数字清晰且仅来自服务器延迟。开启节流后,同样的竞态仍然会发生,但网络会额外增加不可预测的延迟,这使得截图中的时序更难解读。
先点击 apple,紧接着点击 banana。
sent request for "apple" sent request for "banana" got response for "banana" after 210ms got response for "apple" after 3001ms


屏幕最终显示的是 apple 的结果,因为那是最后到达的响应,而不是用户最后关心的那个查询的响应。
修复方法是停止信任到达顺序,开始跟踪哪个请求才是最新的那个:
let latestQueryId = 0;
async function search(query) {
const thisId = ++latestQueryId;
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
if (thisId !== latestQueryId) return; // a newer request has already been sent
setResults(data.results);
}
同样的两个请求,同样的到达顺序,屏幕上只有一个结果:
sent request for "apple" (id 1) sent request for "banana" (id 2) got response for "banana" (id 2) after 210ms got response for "apple" (id 1) after 3001ms IGNORED: a newer request has been sent since this one fired

另一种选择是 AbortController,它可以在新查询开始时中止之前的 fetch。这与请求 ID 是不同的权衡,而非同一机制换了种说法。中止操作会停止客户端 fetch 继续等待该响应。但它并不一定能阻止服务器完成已开始的工作。转而跟踪请求 ID 则允许旧请求完成,然后在其响应过时后明确忽略它。我在这里选择了请求 ID,因为搜索端点开销很小,我不需要取消服务器端的任何操作,只需确保迟到的响应无法胜出。
Bug 二:自动保存两次,或丢失最后一次编辑
这个 Bug 源于客户端超时机制,它假设网络往返总是很快。
如果超过 1.2 秒没有收到响应,自动保存功能就会放弃并重试。如果保存操作通常只需几百毫秒,这算合理的超时时间。但在网络受限的情况下,一次保存可能真的需要两三秒,而实际上并没有任何错误。客户端无法区分“慢”和“失败”。它只知道定时器到期了,于是假设最坏的情况发生,并在第一次保存仍在进行中、且即将自行成功时,发起了第二次保存。
save #1 sent save #1 client-side timeout, treating as failed and retrying save #2 sent save #1 confirmed (arrived after the retry already fired) save #2 confirmed

一次编辑,两次保存。如果保存端点没有严格的幂等性,那么这两次写入可能产生不同的结果(取决于期间发生了什么变化),而不仅仅是无害的重复。
我最初的修复方法是一个简单的“进行中守卫”:当一次保存请求已经发出时,跳过发起新的保存。这能阻止重复保存,但引入了另一个 bug。如果你在一次保存进行中再次编辑,那么这次编辑会被静默地丢弃,永远不会被发送出去:
user types "hello", save #1 starts user types "hello world" while save #1 is still in flight skipped (save in flight), edit "hello world" is dropped save #1 confirmed: "hello"

对于这种自动保存行为,你需要的版本不是跳过较新的编辑,而是将其排队,并在当前保存完成后立即发送:
let saveInFlight = false;
let pendingContent = null;
async function save(content) {
if (saveInFlight) {
pendingContent = content;
return;
}
saveInFlight = true;
try {
await postSave(content);
} finally {
saveInFlight = false;
}
if (pendingContent !== null) {
const next = pendingContent;
pendingContent = null;
await save(next);
}
}
这里的 try/finally 很重要,不仅仅是风格问题。如果没有它,一旦 postSave 发生拒绝(reject),saveInFlight 将永远保持为 true,之后所有的编辑都会被静默地放入队列,永远不会被发送。这比你最初遇到的 bug 更糟糕,因为它悄无声息地失败了。
save #1 sent: "hello" skipped (save in flight), queued latest: "hello world" save #1 confirmed: "hello" save #2 sent: "hello world" save #2 confirmed: "hello world"

没有重复请求,最新的编辑也得到了保存。(如果在排队的保存本身进行中时你再次编辑,它只会再次替换 pendingContent,这正是合并机制按预期工作:只有最新的编辑在排队等待。)这是排队策略的简单版本。需要明确指出的是:生产环境中的自动保存还需要一个显式策略来处理保存实际失败的情况,而不仅仅是响应慢的情况。这段代码处理的是“慢”,而不是“失败”。
这里真正的教训不是“防止重复保存”,而是客户端超时仅告诉你响应尚未到达。它并不能告诉你服务器端发生了什么,而如果操作有副作用,那么重试需要一个明确的策略来处理已经进行中的操作。
两个 Bug 的共同点
我并非专门寻找关于顺序和时序的两个 bug。我只是在探索如果我停止假设我的网络环境能代表其他人时,会暴露出什么问题。这两个 bug 都浮现出来了,因为它们各自都基于一个在我机器上恰好成立、却在其他地方失灵的假设:响应通常按你发送的顺序到达,直到它不按顺序到达。几秒钟通常足够保存完成,直到它不够。
我的两个测试套件都没有捕获到这些 bug,因为它们都没有在慢速网络上运行。它们运行在与我开发环境相同的快速、宽松的网络上,而正是这个网络隐藏了这两个 bug。这与我在一次完全不同的升级中遇到的模式相同,参见 Next.js 16 在 4 个地方搞坏了我的应用,而没有一个抛出错误 一文,这些 bug 都不会抛出错误,都不会大声失败,它们只是悄悄地做错事,直到你在触发它们的确切条件下深入探查。
如果你的代码假设响应按请求顺序到达,或将客户端超时视为失败的证据,那么你很可能在已经信任的代码库中存在这些 bug 的某个版本,无论你是否已见过它行为异常。
原文:https://dev.to/shubhradev/i-throttled-my-app-to-slow-3g-heres-what-my-tests-never-caught-h7m(作者 @shubhradev)



