skip to content
月与羽

异步网络请求的竞态条件

/ 11 min read

1. 原因:什么是竞态条件?

核心定义:
竞态条件发生在当多个异步操作并发执行,而最终结果依赖于这些操作完成的顺序时。由于异步操作的完成时间是不可预测的(受网络延迟、服务器处理速度等因素影响),我们无法保证它们会按照我们期望的顺序返回。当一个“慢”的、但“旧”的请求结果覆盖了一个“快”的、但“新”的请求结果时,就会产生数据不一致或UI错误。 发生的根本原因:

  1. 并发性 (Concurrency):多个网络请求被同时或在很短的时间间隔内发起。
  2. 不确定性 (Unpredictability):网络请求的耗时是不可预测的。先发起的请求不一定先完成。
  3. 共享状态 (Shared State):这些并发的请求最终都会尝试更新同一个状态或UI部分(例如,同一个数据变量、同一个DOM元素)。 简单来说,就是**“后来者居上,但这个‘后来者’却是过时的请求”**。

2. 案例:典型的竞态条件场景

这里有几个非常经典的案例,可以帮助你更好地理解这个问题。

案例一:搜索框自动补全(最经典的例子)

这是一个几乎所有开发者都会遇到的问题。 场景描述:
用户在一个搜索框中快速输入文本,每输入一个字符(或停顿一小段时间),应用就会向服务器发送一个异步请求以获取搜索建议。 问题发生过程:

  1. T1 时间:用户输入 “a”,应用发起请求 A: GET /api/search?q=a
  2. T2 时间:网络有点慢,请求 A 仍在传输中。用户继续输入,变成了 “ap”,应用发起请求 B: GET /api/search?q=ap
  3. T3 时间:用户再次快速输入,变成了 “app”,应用发起请求 C: GET /api/search?q=app
  4. T4 时间:由于某种网络原因(比如请求 C 的服务器响应更快,或者网络路径更优),请求 C 的响应最先到达。UI 更新,显示 “apple”, “application” 等建议。这符合预期。
  5. T5 时间请求 A 的响应现在才到达。它的回调函数被触发,用 “a” 的搜索结果(如 “ant”, “art”)覆盖了刚刚显示的 “app” 的结果。
  6. T6 时间:最后,请求 B 的响应也到达了,它又用 “ap” 的结果覆盖了 “a” 的结果。 最终结果: 用户明明在搜索框里输入了 “app”,但看到的搜索建议却是 “ap” 相关的,造成了极大的困惑和糟糕的用户体验。

案例二:分页或筛选数据列表

场景描述:
一个商品列表页面,用户可以点击不同的分类按钮(如“电子产品”、“服装”)来筛选商品。 问题发生过程:

  1. T1 时间:用户点击了“电子产品”按钮。应用发起请求 A: GET /api/products?category=electronics,并显示一个加载动画。
  2. T2 时间:在请求 A 返回之前,用户改变主意,又快速点击了“服装”按钮。应用发起请求 B: GET /api/products?category=clothing。加载动画仍在显示。
  3. T3 时间:请求 A(电子产品)恰好是一个比较大的查询,服务器处理较慢。而请求 B(服装)的查询很快,它的响应先到达了。UI 更新,显示了服装列表。
  4. T4 时间:过了一会儿,请求 A(电子产品)的响应终于到达。它的回调函数执行,用电子产品列表覆盖了刚刚显示的服装列表。 最终结果: 用户最后点击的是“服装”,但页面上却显示着“电子产品”,这完全是错误的。

3. 解决方案:如何避免竞态条件

解决竞态条件的核心思想是:确保只有最后一次(或最新一次)用户意图触发的请求结果才会被用来更新UI。 以下是几种常用且有效的解决方案,从简单到复杂:

方案一:在发起新请求前,取消上一个未完成的请求 (Cancellation)

这是最理想、最干净的解决方案。当一个新的请求即将发起时,我们检查是否存在一个正在进行中的、同类型的旧请求。如果存在,就主动取消它。

  • 优点

    • 逻辑清晰:只处理我们真正关心的那个请求。
    • 节省资源:避免了服务器处理无用的旧请求,也节省了用户的网络带宽。
  • 实现方式

    • **AbortController** (现代标准):这是 fetch API 的标准配套方案。
    let abortController = new AbortController();
    async function handleSearch(query) {
    // 1. 如果存在旧的请求,取消它
    abortController.abort();
    // 2. 为新请求创建一个新的 AbortController
    abortController = new AbortController();
    const signal = abortController.signal;
    try {
    const response = await fetch(`/api/search?q=${query}`, { signal });
    const data = await response.json();
    // 3. 更新 UI
    updateUI(data);
    } catch (error) {
    if (error.name === 'AbortError') {
    // 这是我们主动取消的,是正常行为,不需要报错
    console.log('Fetch aborted');
    } else {
    // 其他网络错误
    console.error('Search failed:', error);
    }
    }
    }
    // 在 input 事件监听器中调用 handleSearch
    searchInput.addEventListener('input', (e) => handleSearch(e.target.value));

方案二:忽略过时的请求响应 (Ignoring Outdated Responses)

如果不方便或无法取消请求(比如使用的库不支持),我们可以采用一种“忽略”策略。即在请求的回调函数中进行判断,只有当这个响应是来自最新的请求时,才更新UI。

  • 优点

    • 实现相对简单,不需要复杂的取消逻辑。
  • 缺点

    • 无法节省服务器和网络资源,过时的请求仍然会执行完毕。
  • 实现方式

    • 使用请求ID或时间戳:在每次请求时生成一个唯一的标识符,并将其保存在一个全局变量中。当响应返回时,比较响应携带的标识符与全局变量中的最新标识符是否一致。
    let latestRequestId = 0;
    function handleSearch(query) {
    // 1. 为当前请求生成一个唯一的 ID
    const currentRequestId = ++latestRequestId;
    fetch(`/api/search?q=${query}`)
    .then(res => res.json())
    .then(data => {
    // 2. 检查这个响应是否还“新鲜”
    if (currentRequestId === latestRequestId) {
    // 只有当它是最新请求的响应时,才更新UI
    updateUI(data);
    } else {
    // 这是一个过时的响应,直接忽略
    console.log('Ignoring outdated response for request ID:', currentRequestId);
    }
    })
    .catch(error => console.error(error));
    }
    searchInput.addEventListener('input', (e) => handleSearch(e.target.value));

方案三:使用函数库的特定功能 (Leveraging Library Features)

许多现代数据请求库(如 RxJS, TanStack Query/React Query)已经内置了处理竞态条件和其他复杂异步场景的机制。

  • RxJS (响应式编程)

    • RxJS 非常擅长处理事件流。对于搜索框这类场景,可以使用 switchMap 操作符。
    • switchMap 的作用是:当一个新的事件(如新的输入)到来时,它会自动取消前一个由 switchMap 内部产生的异步操作(如上一个 fetch 请求)。这完美地解决了竞态条件问题。
    import { fromEvent } from 'rxjs';
    import { map, switchMap, debounceTime, distinctUntilChanged } from 'rxjs/operators';
    const searchInput = document.getElementById('search');
    fromEvent(searchInput, 'input').pipe(
    map(event => event.target.value),
    debounceTime(300), // 防抖:等待300毫秒无输入才继续
    distinctUntilChanged(), // 只有当值改变时才继续
    switchMap(query => fetch(`/api/search?q=${query}`)) // 核心:自动取消旧请求
    ).subscribe(response => {
    // ...处理响应
    });
  • TanStack Query (React Query)

    • 这个库在底层会自动处理竞-态条件。它会跟踪每个查询的最新实例,并确保只有最新的数据才会被用于更新状态。开发者通常不需要手动处理这个问题。

总结

解决方案核心思想优点缺点适用场景
取消请求 (AbortController)主动终止旧请求资源高效,逻辑清晰需要请求库支持(fetchaxios 都支持)首选方案,特别适合搜索框、频繁筛选等场景
忽略响应 (Request ID)让旧请求的响应“失效”实现简单,不依赖特定API浪费服务器和网络资源当无法取消请求,或逻辑非常简单时
使用函数库 (RxJS, etc.)利用库内置的高级操作符功能强大,代码声明式,能处理复杂场景有学习曲线,可能引入额外的依赖适合复杂的、基于事件流的异步交互

在现代前端开发中,使用 **AbortController** 取消请求被认为是处理竞态条件最标准和最高效的方法。