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("调用者不再需要结果"));
两者职责不同:
AbortController掌握取消权,调用abort()发出通知;AbortSignal只有只读状态,用于把取消通知传给异步任务;signal.aborted表示是否已经取消;signal.reason保存取消原因;signal.throwIfAborted()在已经取消时抛出该原因。
推荐由上层创建 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,仍会继续等待。
任务只有满足以下条件之一,才会响应取消:
- Node.js API 本身接受
{ signal }; - 第三方库明确支持
AbortSignal; - 自己编写的函数监听 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,因此也能被其他入口复用:
- HTTP Controller 可以传入“客户端断开 + 接口超时”的 Signal;
- CLI 可以传入用户按下 Ctrl+C 时取消的 Signal;
- 定时任务可以传入应用关闭 Signal;
- 单元测试可以传入已经取消或稍后取消的 Signal。
不要把整个 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;
}
这里的两个参数解决不同问题:
signal表示调用者是否还需要任务继续;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);
},
};
}
为什么同时关注两个事件:
req.aborted关注请求正文尚未完整接收时被客户端中断;res.close可以覆盖服务端处理或返回响应期间连接提前关闭;res.writableEnded用来排除正常完成响应后的关闭。
不能看到 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 不是越多检查越好。取消点必须服从业务一致性和安全规则。
- 尚未开始查询时取消:可以立即退出;
- Sequelize 查询正在运行:不能由 Signal 自动终止;
- Argon2 正在运行:当前调用不会自动停止,只能在完成后检查;
- 密码错误已经确定:应记录失败,不能用断开连接规避风控;
- 密码正确但客户端已离开:可以避免签发不会被接收的 Token;
- 数据库事务已经提交: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() 阻止后续工作
数据库侧的慢查询和等待问题仍要使用适合数据库的机制处理:
- 优化 SQL 和索引;
- 限制列表大小;
- 缩短事务;
- 设置合理的连接池等待和锁等待策略;
- 根据场景使用 MySQL 查询执行时间限制;
- 监控慢查询和连接占用。
不要为了模拟取消,随意销毁共享连接池或调用 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 托管事务会因为回调异常而回滚。但要注意:
- 它只能在代码运行到检查点时生效;
- 不能中断当时正在执行的 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 });
自定义实现常见错误包括:
- 先注册监听器,却不检查 Signal 是否早已取消;
- 正常完成后忘记移除监听器;
- 取消时忘记清理定时器、Stream 或其他资源;
- 只
resolve(),不让调用者知道任务因取消而结束; - 用
new Promise(async (...) => {})制造额外错误边界。
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,
});
建议:
- 客户端主动断开通常是
debug、info或指标,不必当作 500 报警; - 下游超时通常记
warn,包含依赖名称、期限和 request ID; - 未知异常记
error,保留错误对象和堆栈; - 不记录密码、JWT、Authorization 或完整请求体;
- 记录取消发生在哪个阶段,帮助判断资源是否仍在后台执行。
14. 哪些操作适合取消,哪些不适合
通常适合取消
- 等待下游只读 HTTP 接口;
- 大文件下载、上传、压缩和转换;
- 报表生成与只读数据组装;
- 重试间隔和轮询等待;
- 没有副作用的昂贵计算;
- 服务关闭时尚未领取的后台任务。
需要谨慎
- 正在执行的数据库事务;
- 创建订单、扣款、发券等写操作;
- 登录失败计数与审计日志;
- 已经开始发送邮件或消息;
- 已经产生部分输出的文件写入;
- 子进程及其创建的孙进程。
判断问题不应只是“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,
]);
但完整的优雅关闭还需要:
server.close()停止接收新连接;- 通知可安全取消的等待任务停止;
- 给正在执行的关键事务一定完成时间;
- 等待 HTTP 请求或达到关闭期限;
- 最后调用
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 集成测试应验证:
- 下游超过期限时返回稳定业务错误码;
- 普通内部错误仍进入
errorHandler; - 已经断开的响应不会再次调用
res.json(); - Controller 的事件监听器在成功和失败路径都会清理。
17.4 数据库边界测试
可以让一条测试查询暂时等待,再取消 Signal,观察:
- HTTP 处理逻辑最终停止;
- 已发送到 MySQL 的查询并不会自动消失;
- 查询返回后,后续下游步骤没有继续执行。
这个测试的目的不是制造“Sequelize 已支持取消”的假象,而是确认团队理解实际边界。
测试应设置较短且明确的期限,避免因为遗漏取消逻辑而永久挂起测试进程。
18. 新人常见错误
- 认为
controller.abort()可以杀死任何 Promise。 - 把
AbortController传入 Service,让下层掌握不必要的取消权。 - 把 Express
req传进 Service,只为了读取连接状态。 - 每一层重新创建 Controller,导致外层取消无法贯穿。
- 只监听事件,不处理调用前就已取消的 Signal。
- 正常完成后不清理事件监听器和定时器。
- 把所有取消都记录成服务器 500 错误。
- 客户端已断开后仍调用
res.json()。 - 看到响应
close就取消,没有检查res.writableEnded。 - 认为 Signal 能自动终止 Sequelize SQL 或回滚事务。
- 在不可逆写入之后随意设置取消点,留下半完成副作用。
- 允许攻击者通过主动断开绕过登录失败计数或审计。
- 捕获取消错误后吞掉所有其他异常。
- 只给外层重试循环传 Signal,内层 fetch 和等待却不用它。
- 为每个函数强制要求 Signal,即使调用场景根本不需要取消。
- 认为取消本地 fetch 等于远端写操作已经撤销。
- 重复使用已经取消且无法复位的 Signal。
19. 当前项目的引入顺序建议
不要一次改造所有 Service。可以按风险和收益逐步引入:
- 先选择一个有外部
fetch、文件处理或长时间等待的只读接口; - 为 Service 增加
{ signal?: AbortSignal }Options; - 把 Signal 传给原生支持它的 API;
- 在 Controller 中组合客户端断开与固定超时;
- 在错误处理中区分客户端取消、超时和内部错误;
- 补齐取消前、等待中取消、正常完成三个测试;
- 再评估登录等含有数据库写入的接口,逐个决定安全取消点;
- 最后考虑把请求取消工具做成共享工具或中间件。
如果只有一个接口使用,不要立刻设计复杂的全局上下文。先用一个小工具验证行为,再决定是否抽象。
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. 复盘问题
AbortController与AbortSignal为什么要分成两个对象?- 为什么 Service 应接收 Signal,而不是 Express Request 或 Controller?
AbortSignal.timeout()与AbortSignal.any()分别解决什么问题?- 为什么 Controller 要保留组合前的原始 Signal?
- 为什么响应正常完成后发生
close不应该被当作客户端取消? - Signal 能否中断当前 Sequelize 6 已经发出的查询?
- 在事务回调中
throwIfAborted()什么时候可能触发回滚? - 为什么登录失败计数不应随着客户端断开而取消?
- 自定义可取消 Promise 为什么必须处理“已经取消”的情况?
- 客户端取消、服务端超时、业务错误和内部错误应如何分别记录?
22. 本章结论
把 Signal 贯穿应用可以归纳成四条规则:
- Controller 拥有 HTTP 生命周期,创建或组合取消信号。
- Service 只接收可选的 AbortSignal,并把同一个 Signal 继续传给支持取消的下游 API。
- 取消点必须尊重事务、安全记录和不可逆副作用,不能机械地到处调用
throwIfAborted()。 - Sequelize 查询与事务不会被 Signal 自动取消,数据库一致性仍依靠事务、约束、幂等和数据库超时机制。
AbortSignal 最有价值的地方不是让代码“看起来现代”,而是让请求生命周期、超时策略和资源释放从隐含行为变成可以传递、测试和复盘的明确契约。