15A|用 AbortSignal 管理请求取消、超时与资源释放

一个 HTTP 请求已经被用户取消,服务端却仍在下载远程文件、等待重试或生成报表;一个下游接口只允许等待 3 秒,代码却只能一直挂到系统网络超时;应用准备关闭,后台循环还在继续运行。这些问题都需要“取消”能力。

Node.js 使用 AbortController 和 AbortSignal 提供统一的协作式取消协议。本章重点说明如何让 Signal 从 Express Controller 贯穿 Service,以及它与 Sequelize 查询、数据库事务和错误处理之间的边界。

本章示例基于当前项目的 Node.js 22+、TypeScript、Express 5、Sequelize 6 和 CommonJS 编译配置。


1. AbortController 与 AbortSignal 分别负责什么

最小示例:

const controller = new AbortController();

controller.signal.addEventListener(
  "abort",
  () => {
    console.log("收到取消通知", controller.signal.reason);
  },
  { once: true },
);

controller.abort(new Error("调用者不再需要结果"));

两者职责不同:

推荐由上层创建 Controller,下层只接收 Signal:

async function loadReport(signal: AbortSignal): Promise<void> {
  signal.throwIfAborted();
  // 执行工作……
}

const controller = new AbortController();
await loadReport(controller.signal);

Service 不应接收 AbortController,否则它也能取消调用者共享给其他任务的 Signal,职责会变得混乱。


2. 取消是协作协议,不是强制杀死 Promise

调用 abort() 不会自动停止任意 Promise:

const controller = new AbortController();

const task = new Promise<void>((resolve) => {
  setTimeout(resolve, 10_000);
});

controller.abort();
await task; // 这个 Promise 并不知道 signal,仍会继续等待。

任务只有满足以下条件之一,才会响应取消:

  1. Node.js API 本身接受 { signal };
  2. 第三方库明确支持 AbortSignal;
  3. 自己编写的函数监听 Signal,或在适当位置调用 throwIfAborted()。

取消的准确含义是:

调用者发出“不再需要继续”的通知,任务在安全的取消点尽快停止。

它不保证立即停止 CPU 运算,也不会撤销已经提交的数据库事务、已经发出的邮件或已经完成的付款。


3. Node.js 中哪些常用 API 支持 Signal

场景 API 示例 取消效果
HTTP 请求 fetch(url, { signal }) 停止等待和读取响应
异步定时器 node:timers/promises 提前结束等待
Stream 管道 node:stream/promises.pipeline() 销毁参与管道的 Stream
文件读写 fs.promises.readFile()、writeFile() 停止 Node.js 内部继续处理,但不保证撤销已完成的系统调用
子进程 spawn()、exec() 向子进程发送终止操作
EventEmitter 等待 events.once()、events.on() 停止等待事件

例如给下游 HTTP 请求设置 3 秒期限:

const response = await fetch("https://example.com/api/report", {
  signal: AbortSignal.timeout(3000),
});

只需要固定超时时,优先使用 AbortSignal.timeout(),不用自己维护 setTimeout() 和清理逻辑。

取消本地 fetch 只表示当前 Node.js 进程停止发送、读取或等待,不保证远端服务器停止处理。远端可能已经收到并完成写操作,因此创建订单、付款等请求仍然需要幂等键、结果查询或补偿机制,不能把 AbortSignal 当作分布式回滚。

Signal 还是一次性的:一旦进入 aborted 状态就不能恢复。每个独立请求应创建新的 Controller,不要把一个已经取消的 Signal 放进全局变量后重复使用。


4. 为什么 Signal 应从 Controller 传入 Service

当前项目已经遵循下面的职责划分:

Router
  → 决定 URL、HTTP Method 和中间件顺序
Controller
  → 读取 req、管理 HTTP 请求生命周期、调用 Service、组织 res
Service
  → 编排业务规则,不依赖 Express
Model / Sequelize
  → 访问数据库

“浏览器是否断开”和“本次 HTTP 请求最多等多久”属于 HTTP 调用上下文,所以 Controller 最适合创建或组合 Signal。

Service 只接收 AbortSignal,因此也能被其他入口复用:

不要把整个 Express Request 传进 Service。那会让业务层依赖 HTTP 框架,也会让 CLI 和测试难以复用。


5. 第一步:给 Service 设计可选 Signal

不要不断给函数追加位置参数:

// 不推荐:以后再加入 transaction、request_id 时会越来越难读。
await getAdminReport(admin_id, signal, 5000, transaction);

可以使用 Options 对象:

export interface ExecutionOptions {
  signal?: AbortSignal;
}

export async function getAdminReport(
  admin_id: string,
  options: ExecutionOptions = {},
): Promise<AdminReportDto> {
  const { signal } = options;

  signal?.throwIfAborted();

  // 读取数据库、调用下游服务、组装 DTO……
}

Signal 通常设为可选,这样已有的脚本和测试不用为“不需要取消”的调用创建无意义的 Controller。

Options 应按功能命名。如果函数同时需要事务:

import type { Transaction } from "sequelize";

export interface ExecutionOptions {
  signal?: AbortSignal;
  transaction?: Transaction;
}

这里的两个参数解决不同问题:

不能用其中一个代替另一个。


6. 第二步:在 Controller 中感知客户端断开

Node.js HTTP 请求和响应提供了请求中断、连接关闭等事件。下面先封装一个小工具,只负责把客户端断开转换成 Signal:

import type { Request, Response } from "express";

export interface RequestAbortContext {
  signal: AbortSignal;
  dispose: () => void;
}

export function createRequestAbortContext(
  req: Request,
  res: Response,
): RequestAbortContext {
  const controller = new AbortController();

  const abort_request = (): void => {
    if (!controller.signal.aborted) {
      controller.abort(new Error("HTTP_CLIENT_DISCONNECTED"));
    }
  };

  const handle_response_close = (): void => {
    // 正常发送完响应也可能发生 close;只有响应没有完成才视为客户端断开。
    if (!res.writableEnded) {
      abort_request();
    }
  };

  req.once("aborted", abort_request);
  res.once("close", handle_response_close);

  return {
    signal: controller.signal,
    dispose: () => {
      req.off("aborted", abort_request);
      res.off("close", handle_response_close);
    },
  };
}

为什么同时关注两个事件:

不能看到 close 就无条件 abort,因为正常请求完成也可能触发相关关闭事件。

完成后必须 dispose() 移除监听器。虽然这里使用了 once(),正常请求没有发生中断时监听器仍然存在到对象结束;明确清理能让资源生命周期更清楚。


7. 第三步:组合“客户端断开”和“服务端超时”

客户端可能主动断开,服务端也可以规定最长处理时间。Node.js 22 可以使用 AbortSignal.any():

const request_abort = createRequestAbortContext(req, res);
const timeout_signal = AbortSignal.timeout(5000);

const signal = AbortSignal.any([
  request_abort.signal,
  timeout_signal,
]);

任何一个 Signal 取消,组合后的 Signal 都会取消:

客户端断开 ──────┐
                  ├─→ combined signal → Service
超过 5 秒 ───────┘

原始 Signal 应保留下来,因为处理异常时需要区分究竟是客户端离开,还是服务端超时。


8. 完整 Controller → Service 示例

下面用“管理员报表”展示一条完整链路。示例代码用于说明设计,不要求现在就加入项目。

8.1 Service

import { AppError } from "../errors/app-error";
import AdminUser from "../models/admin_user";

export interface AdminReportDto {
  admin_id: string;
  username: string;
  risk_level: string;
}

export interface GetAdminReportOptions {
  signal?: AbortSignal;
}

export async function getAdminReport(
  admin_id: string,
  options: GetAdminReportOptions = {},
): Promise<AdminReportDto> {
  const { signal } = options;

  // 如果调用前已经取消,不再占用数据库连接。
  signal?.throwIfAborted();

  const admin = await AdminUser.findByPk(admin_id);

  // Sequelize 查询执行期间不能由 Signal 自动中止;查询返回后再次检查,
  // 避免继续发起后续网络请求。
  signal?.throwIfAborted();

  if (admin === null) {
    throw new AppError("ADMIN_NOT_FOUND", "管理员不存在");
  }

  const response = await fetch(
    `https://risk.example.com/admins/${encodeURIComponent(admin_id)}`,
    { signal },
  );

  if (!response.ok) {
    throw new Error(`风险服务返回 ${response.status}`);
  }

  const risk = (await response.json()) as { risk_level: string };

  signal?.throwIfAborted();

  return {
    admin_id: admin.id,
    username: admin.username,
    risk_level: risk.risk_level,
  };
}

8.2 Controller

import type { Request, Response } from "express";

import { AppError } from "../errors/app-error";
import { getAdminReport } from "../services/admin-report.service";
import { createRequestAbortContext } from "../utils/request-abort";

export async function getAdminReportController(
  req: Request<{ admin_id: string }>,
  res: Response,
): Promise<void> {
  const request_abort = createRequestAbortContext(req, res);
  const timeout_signal = AbortSignal.timeout(5000);
  const signal = AbortSignal.any([
    request_abort.signal,
    timeout_signal,
  ]);

  try {
    const report = await getAdminReport(req.params.admin_id, {
      signal,
    });

    // 在即将写响应前仍要确认连接可用。
    if (!res.writableEnded && !res.destroyed) {
      res.json({
        code: "OK",
        message: "查询成功",
        data: report,
      });
    }
  } catch (error: unknown) {
    if (request_abort.signal.aborted) {
      // 客户端已经离开,没有接收者,不再 next(error),也不再写响应。
      return;
    }

    if (timeout_signal.aborted) {
      // 客户端仍在线,转换成当前项目错误中间件能够公开的预期错误。
      throw new AppError("REQUEST_TIMEOUT", "报表生成超时,请稍后重试");
    }

    // Express 5 会把 async Controller 抛出的异常传给错误中间件。
    throw error;
  } finally {
    request_abort.dispose();
  }
}

这条错误流转是:

Service 普通业务错误 / 内部错误
  → Controller 重新 throw
  → Express 5
  → errorHandler

服务端等待超时
  → Controller 转换为可公开的 AppError
  → errorHandler 返回统一 JSON

客户端已经断开
  → Controller 安静结束
  → 不再尝试返回 JSON

不要把客户端断开包装成普通 HTTP 500。客户端已经无法收到响应,错误中间件继续 res.json() 还可能制造 headers already sent、socket hang up 等噪声。

9. 在当前登录 Service 中怎样贯穿 Signal

当前 loginAdmin() 可以逐步扩展为:

export interface LoginOptions {
  signal?: AbortSignal;
}

export async function loginAdmin(
  input: LoginInput,
  options: LoginOptions = {},
): Promise<LoginResultDto> {
  const { signal } = options;

  signal?.throwIfAborted();

  const username = input.username.trim();
  const admin = await findAdminForLogin(username);

  signal?.throwIfAborted();

  if (admin === null) {
    throw invalidCredentialsError();
  }

  const now = new Date();
  assertAdminCanLogin(admin, now);

  // 当前 Argon2 API 不会因为这个 Signal 自动中止 CPU 工作。
  const password_matches = await verifyPassword(
    admin.password_hash,
    input.password,
  );

  if (!password_matches) {
    // 失败计数属于安全状态。即使客户端刚刚断开,也应完成记录,
    // 不能让攻击者通过主动断开绕过登录失败计数。
    await recordFailedLogin(admin, new Date());
    throw invalidCredentialsError();
  }

  // 密码正确后,如果客户端已经离开,可以避免生成不会被接收的 Token。
  signal?.throwIfAborted();

  const token = signAdminAccessToken(admin);
  await recordSuccessfulLogin(admin, new Date());

  return {
    access_token: token.access_token,
    token_type: "Bearer",
    expires_in: token.expires_in,
    admin: toAdminUserDto(admin),
  };
}

这个例子说明:Signal 不是越多检查越好。取消点必须服从业务一致性和安全规则。

是否应记录“成功登录”还涉及项目对“认证成功”和“Token 已交付”的定义,应由业务契约决定,不能仅凭一个通用取消模板决定。


10. Sequelize 为什么不能被 Signal 自动取消

Sequelize 6 的常规查询参数没有统一的 signal:

await AdminUser.findByPk(admin_id, {
  // signal 不是 Sequelize 6 通用查询选项。
});

因此下面的检查只能阻止“下一步”,不能中断已经发送到 MySQL 的 SQL:

signal?.throwIfAborted();
const admin = await AdminUser.findByPk(admin_id);
signal?.throwIfAborted();

如果客户端在查询中途断开:

客户端断开
  → Signal 取消
  → MySQL 中已经开始的 SQL 仍可能继续
  → Sequelize 等到查询返回
  → 第二次 throwIfAborted() 阻止后续工作

数据库侧的慢查询和等待问题仍要使用适合数据库的机制处理:

不要为了模拟取消,随意销毁共享连接池或调用 db.close()。这会影响整个应用,而不只是当前请求。


11. AbortSignal 与事务是两条独立控制线

事务负责数据一致性,Signal 负责调用生命周期:

AbortSignal:调用者还要不要继续?
Transaction:数据库修改是一起提交还是一起回滚?

可以在事务开始前检查取消:

export async function updateAdminProfile(
  admin_id: string,
  input: UpdateAdminProfileInput,
  options: ExecutionOptions = {},
): Promise<void> {
  options.signal?.throwIfAborted();

  await db.transaction(async (transaction) => {
    options.signal?.throwIfAborted();

    const admin = await AdminUser.findByPk(admin_id, {
      transaction,
      lock: transaction.LOCK.UPDATE,
    });

    if (admin === null) {
      throw new AppError("ADMIN_NOT_FOUND", "管理员不存在");
    }

    options.signal?.throwIfAborted();

    await admin.update(
      { display_name: input.display_name },
      { transaction },
    );
  });
}

如果 throwIfAborted() 在事务回调中抛错,Sequelize 托管事务会因为回调异常而回滚。但要注意:

对于订单、付款、登录失败计数等关键写入,“即使请求断开也要完成”往往比“尽快取消”更正确。


12. 自定义异步函数怎样支持 Signal

自定义 API 应先处理“调用前已经取消”的情况,并在结束时清理监听器:

export function waitForMessage(
  signal?: AbortSignal,
): Promise<string> {
  return new Promise((resolve, reject) => {
    let timeout_id: NodeJS.Timeout | undefined;

    const cleanup = (): void => {
      if (timeout_id !== undefined) {
        clearTimeout(timeout_id);
      }
      signal?.removeEventListener("abort", handle_abort);
    };

    const handle_abort = (): void => {
      cleanup();
      reject(signal?.reason);
    };

    if (signal?.aborted) {
      handle_abort();
      return;
    }

    signal?.addEventListener("abort", handle_abort, { once: true });

    timeout_id = setTimeout(() => {
      cleanup();
      resolve("message received");
    }, 1000);
  });
}

更简单的等待不要重复造轮子,直接使用原生 API:

import { setTimeout as delay } from "node:timers/promises";

await delay(1000, undefined, { signal });

自定义实现常见错误包括:


13. 超时、取消、业务错误和内部错误要区分

这四类情况语义不同:

类型 例子 建议处理
客户端取消 浏览器关闭页面 不再写响应;低级别记录或计数
服务端超时 下游 5 秒未返回 转成稳定错误码,例如 REQUEST_TIMEOUT
业务错误 管理员不存在 使用项目允许公开的 AppError
内部错误 程序 Bug、未知数据库错误 记录完整内部日志,对外返回统一消息

可以用下面的辅助判断处理平台产生的取消错误:

export function isAbortLikeError(error: unknown): boolean {
  return (
    error instanceof Error &&
    (error.name === "AbortError" || error.name === "TimeoutError")
  );
}

但不要只依赖错误名称判断业务原因。Controller 自己保留了 request_abort 和 timeout_signal,直接查看哪个原始 Signal 已取消通常更清楚。

AbortSignal.timeout() 的原因通常表现为 TimeoutError;普通 Controller 默认取消通常产生 AbortError;如果 abort(reason) 传入自定义原因,支持 Signal 的 API 可能抛出这个原因。调用方不应假定所有取消都只有一种错误类。

日志应该记录什么

console.warn({
  event: "downstream_request_timeout",
  service: "risk_service",
  timeout_ms: 5000,
  request_id,
});

建议:


14. 哪些操作适合取消,哪些不适合

通常适合取消

需要谨慎

判断问题不应只是“API 是否支持 Signal”,还要问:

中途停止后会留下什么状态?这个状态能否识别、重试、回滚或补偿?


15. 重试必须响应同一个 Signal

如果重试循环忽略 Signal,用户取消后仍可能继续请求多次:

import { setTimeout as delay } from "node:timers/promises";

export async function retry<T>(
  task: (signal: AbortSignal) => Promise<T>,
  signal: AbortSignal,
): Promise<T> {
  let last_error: unknown;

  for (let attempt = 1; attempt <= 3; attempt += 1) {
    signal.throwIfAborted();

    try {
      return await task(signal);
    } catch (error: unknown) {
      signal.throwIfAborted();
      last_error = error;

      if (attempt < 3) {
        await delay(500, undefined, { signal });
      }
    }
  }

  throw last_error;
}

调用时,网络请求和重试等待共享同一个 Signal:

const data = await retry(
  async (signal) => {
    const response = await fetch("https://example.com/data", { signal });
    return response.json();
  },
  signal,
);

不要为每次重试创建一个与外层无关的 Controller,否则外层取消无法传到内层。


16. 优雅关闭中的应用

应用关闭时也可以使用一个全局 Signal 通知后台任务停止领取新工作:

const shutdown_controller = new AbortController();

process.once("SIGTERM", () => {
  shutdown_controller.abort(new Error("APPLICATION_SHUTDOWN"));
});

async function runWorker(signal: AbortSignal): Promise<void> {
  while (!signal.aborted) {
    await processNextJob(signal);
  }
}

对于一个 HTTP 请求,还可以组合三种限制:

const signal = AbortSignal.any([
  request_abort.signal,
  AbortSignal.timeout(5000),
  shutdown_controller.signal,
]);

但完整的优雅关闭还需要:

  1. server.close() 停止接收新连接;
  2. 通知可安全取消的等待任务停止;
  3. 给正在执行的关键事务一定完成时间;
  4. 等待 HTTP 请求或达到关闭期限;
  5. 最后调用 db.close()。

不能收到 SIGTERM 后立即 db.close(),否则其他尚未结束的请求可能正在使用同一个 Sequelize 连接池。


17. 怎样测试 Signal 贯穿是否有效

17.1 调用前已经取消

const controller = new AbortController();
controller.abort(new Error("TEST_ABORT"));

await expect(
  getAdminReport("1", { signal: controller.signal }),
).rejects.toThrow("TEST_ABORT");

这个测试证明 Service 在开始昂贵工作前检查 Signal。

17.2 下游等待期间取消

测试替身应真实读取传入的 Signal,而不是只断言“函数被调用”:

const controller = new AbortController();
const task = callDownstream({ signal: controller.signal });

controller.abort();

await expect(task).rejects.toMatchObject({
  name: "AbortError",
});

17.3 Controller 超时映射

HTTP 集成测试应验证:

17.4 数据库边界测试

可以让一条测试查询暂时等待,再取消 Signal,观察:

这个测试的目的不是制造“Sequelize 已支持取消”的假象,而是确认团队理解实际边界。

测试应设置较短且明确的期限,避免因为遗漏取消逻辑而永久挂起测试进程。


18. 新人常见错误

  1. 认为 controller.abort() 可以杀死任何 Promise。
  2. 把 AbortController 传入 Service,让下层掌握不必要的取消权。
  3. 把 Express req 传进 Service,只为了读取连接状态。
  4. 每一层重新创建 Controller,导致外层取消无法贯穿。
  5. 只监听事件,不处理调用前就已取消的 Signal。
  6. 正常完成后不清理事件监听器和定时器。
  7. 把所有取消都记录成服务器 500 错误。
  8. 客户端已断开后仍调用 res.json()。
  9. 看到响应 close 就取消,没有检查 res.writableEnded。
  10. 认为 Signal 能自动终止 Sequelize SQL 或回滚事务。
  11. 在不可逆写入之后随意设置取消点,留下半完成副作用。
  12. 允许攻击者通过主动断开绕过登录失败计数或审计。
  13. 捕获取消错误后吞掉所有其他异常。
  14. 只给外层重试循环传 Signal,内层 fetch 和等待却不用它。
  15. 为每个函数强制要求 Signal,即使调用场景根本不需要取消。
  16. 认为取消本地 fetch 等于远端写操作已经撤销。
  17. 重复使用已经取消且无法复位的 Signal。

19. 当前项目的引入顺序建议

不要一次改造所有 Service。可以按风险和收益逐步引入:

  1. 先选择一个有外部 fetch、文件处理或长时间等待的只读接口;
  2. 为 Service 增加 { signal?: AbortSignal } Options;
  3. 把 Signal 传给原生支持它的 API;
  4. 在 Controller 中组合客户端断开与固定超时;
  5. 在错误处理中区分客户端取消、超时和内部错误;
  6. 补齐取消前、等待中取消、正常完成三个测试;
  7. 再评估登录等含有数据库写入的接口,逐个决定安全取消点;
  8. 最后考虑把请求取消工具做成共享工具或中间件。

如果只有一个接口使用,不要立刻设计复杂的全局上下文。先用一个小工具验证行为,再决定是否抽象。


20. 动手练习

练习一:取消一个定时器

使用 node:timers/promises 创建 10 秒等待,在 100 毫秒后取消。分别打印 error.name、signal.aborted 和 signal.reason。

练习二:编写可取消的 Service

创建一个不依赖 Express 的 Service:

function generateReport(
  input: GenerateReportInput,
  options?: { signal?: AbortSignal },
): Promise<ReportDto>;

分别测试不传 Signal、调用前已取消、处理中取消。

练习三:贯穿 Controller 和 Service

使用本章的 createRequestAbortContext(),把客户端断开和 3 秒超时组合后传入 Service。解释为什么 Service 不需要知道 Express。

练习四:观察 Sequelize 边界

在练习数据库中执行一条可控的慢查询,并在等待时取消 Signal。记录 HTTP 逻辑与 MySQL 查询各自在什么时候结束。

练习五:评审登录取消点

为当前 loginAdmin() 标出所有可能放置 throwIfAborted() 的位置,判断哪些能节省资源、哪些可能影响失败计数和审计完整性。先写分析,不急于改代码。


21. 复盘问题

  1. AbortController 与 AbortSignal 为什么要分成两个对象?
  2. 为什么 Service 应接收 Signal,而不是 Express Request 或 Controller?
  3. AbortSignal.timeout() 与 AbortSignal.any() 分别解决什么问题?
  4. 为什么 Controller 要保留组合前的原始 Signal?
  5. 为什么响应正常完成后发生 close 不应该被当作客户端取消?
  6. Signal 能否中断当前 Sequelize 6 已经发出的查询?
  7. 在事务回调中 throwIfAborted() 什么时候可能触发回滚?
  8. 为什么登录失败计数不应随着客户端断开而取消?
  9. 自定义可取消 Promise 为什么必须处理“已经取消”的情况?
  10. 客户端取消、服务端超时、业务错误和内部错误应如何分别记录?

22. 本章结论

把 Signal 贯穿应用可以归纳成四条规则:

  1. Controller 拥有 HTTP 生命周期,创建或组合取消信号。
  2. Service 只接收可选的 AbortSignal,并把同一个 Signal 继续传给支持取消的下游 API。
  3. 取消点必须尊重事务、安全记录和不可逆副作用,不能机械地到处调用 throwIfAborted()。
  4. Sequelize 查询与事务不会被 Signal 自动取消,数据库一致性仍依靠事务、约束、幂等和数据库超时机制。

AbortSignal 最有价值的地方不是让代码“看起来现代”,而是让请求生命周期、超时策略和资源释放从隐含行为变成可以传递、测试和复盘的明确契约。