02 超时取消、数据库写入与客户端中止

1. 超时后到底会不会继续执行

结论是:

默认会继续,除非具体操作支持取消,而且你主动把取消信号传给它。

app.post(
  "/api/orders",
  timeout("5s"),
  async (req, res) => {
    await createSlowOrder(req.body);

    if (req.timedout) {
      return;
    }

    res.status(201).json({ success: true });
  },
);

如果 createSlowOrder() 执行 12 秒:

0 秒   开始 createSlowOrder
5 秒   connect-timeout 产生 503 错误响应
12 秒  createSlowOrder 仍可能完成数据库写入

用户看到“失败”,数据库却可能已经成功。用户重试后,就可能生成两条订单。

2. 超时治理的核心:取消信号向下传播

可以为请求创建 AbortController

interface RequestExecution {
  controller: AbortController;
}

declare global {
  namespace Express {
    interface Request {
      execution?: RequestExecution;
    }
  }
}

教学示例可以写成中间件:

function attachCancellation(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const controller = new AbortController();

  req.execution = { controller };

  req.once("timeout", () => {
    controller.abort(
      new Error("Express request deadline exceeded"),
    );
  });

  res.once("close", () => {
    if (!res.writableFinished) {
      controller.abort(
        new Error("Client disconnected"),
      );
    }
  });

  next();
}

顺序:

app.get(
  "/api/remote-data",
  timeout("8s"),
  attachCancellation,
  remoteDataHandler,
);

因为 attachCancellation 需要监听 connect-timeout 发出的 timeout 事件,所以应放在它后面、耗时 handler 前面。

3. 取消支持 AbortSignal 的下游 HTTP 请求

Node.js 内置 fetch() 支持 AbortSignal

async function remoteDataHandler(
  req: Request,
  res: Response,
): Promise<void> {
  const signal = req.execution?.controller.signal;

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

  const data: unknown = await response.json();

  if (signal?.aborted || req.timedout) {
    return;
  }

  res.json(data);
}

错误处理时要区分取消和真正的下游故障:

try {
  // 支持 signal 的操作
} catch (error: unknown) {
  if (signal?.aborted) {
    return;
  }

  throw error;
}

AbortController.abort() 是发出取消请求,不保证所有库都能立即停止。必须查阅具体库是否接受 signal

4. 客户端主动 abort 应该怎样处理

客户端可能因为以下原因断开:

在服务端,可以监听响应连接提前关闭:

res.once("close", () => {
  if (!res.writableFinished) {
    controller.abort(
      new Error("Client disconnected before response finished"),
    );
  }
});

为什么检查 res.writableFinished

发生中止后不要再调用:

res.json(...);
res.status(...).send(...);

连接已经不可用,继续写响应没有意义,还可能出现 headers already sent 或 socket 错误。

5. 客户端断开后,所有任务都应该取消吗

不一定。应根据任务语义分类。

5.1 建议取消

用户已经不再等待,这些任务继续运行通常只会浪费资源。

5.2 不应简单取消

这类任务不应把业务成功与 HTTP 连接寿命绑定。更好的设计是:

请求校验
  → 记录任务或写入 outbox
  → 尽快返回 202 / 任务 ID
  → 后台任务可靠执行
  → 客户端查询结果

客户端断开只意味着“无法把结果通过当前连接返回”,不一定意味着业务必须撤销。

6. 写入操作为什么最危险

读查询超时通常只是浪费资源;写请求超时还会造成“结果未知”:

客户端收到超时
        ↓
写入实际上可能:
  A. 尚未开始
  B. 已回滚
  C. 已提交成功
  D. 提交结果返回途中连接断开

客户端无法仅凭 timeout 判断数据库状态。

因此,写接口不能把“客户端没收到成功响应”直接等同于“写入没有发生”。

7. 事务能解决什么,不能解决什么

事务可以保证一组数据库操作原子提交或回滚:

const transaction = await sequelize.transaction();

try {
  await Order.create(orderData, { transaction });
  await OrderItem.bulkCreate(items, { transaction });

  if (signal.aborted) {
    await transaction.rollback();
    return;
  }

  await transaction.commit();
} catch (error: unknown) {
  await transaction.rollback();
  throw error;
}

但要注意竞态:

检查 signal.aborted 为 false
        ↓
开始 commit
        ↓
客户端断开或 deadline 到期
        ↓
数据库 commit 仍可能成功

事务不能保证 HTTP 客户端一定收到提交结果,也不能自动撤销已经提交的数据。

推荐原则

8. 幂等键:解决“用户重试产生重复数据”

客户端为写请求生成唯一键:

POST /api/orders
Idempotency-Key: 8a62f410-7ea8-4dbc-a344-4778c3f10e03

数据库建立唯一约束:

CREATE UNIQUE INDEX uk_orders_idempotency_key
ON orders (idempotency_key);

第一次请求即使响应丢失,第二次携带同一个键时,服务端也不会再创建一份订单,而是返回已有结果或当前处理状态。

幂等键比“超时后猜测是否成功”可靠得多,尤其适合:

9. MySQL 写入超时的实际策略

对于当前项目的 mysql2/Sequelize,应分层处理:

Express deadline
  → 阻止 HTTP 请求无限等待

数据库语句/锁等待限制
  → 限制 MySQL 查询和锁等待

事务
  → 保证多条 SQL 原子性

唯一约束/幂等键
  → 防止超时重试产生重复写入

状态查询/对账
  → 处理 commit 结果未知

不要把销毁数据库连接当作常规取消方案。它的代价较高,并且在写操作提交边界附近仍可能无法判断最终状态。

10. 高负载请求的最佳实践

假设一个报表请求会消耗大量 CPU 和数据库资源:

客户端发起请求
  → 服务端建立 AbortController
  → 数据库查询接受自己的短 timeout
  → CPU 工作放入 Worker/任务队列
  → 客户端断开时发出取消信号
  → 可取消任务尽快停止
  → 不可取消但必须完成的任务转为后台状态管理

具体建议:

  1. 在昂贵工作开始前完成认证、参数验证、配额和限流;
  2. 为下游资源设置比 Route deadline 更短的超时;
  3. 使用 AbortSignal 传播取消;
  4. 长 CPU 任务不要运行在主事件循环;
  5. 大任务优先设计为 202 Accepted + jobId
  6. 限制单用户和全局并发;
  7. 记录取消原因:deadline、客户端断开、服务关闭;
  8. 监控取消后仍运行的任务数量和持续时间。

11. 练习题

  1. 创建一个使用 fetch() 调用慢服务的 Route,并在 connect-timeout 触发后通过 AbortController 取消 fetch。
  2. 监听 res.close,区分正常响应完成和客户端提前断开。
  3. 设计一个订单表的 idempotency_key 字段与唯一索引。
  4. 分析“数据库 commit 成功,但 HTTP 响应发送失败”时客户端应该如何查询最终结果。
  5. 将一个 30 秒报表接口改造成 202 + jobId 的异步任务接口。
  6. 为一个包含三条 SQL 的写操作设计事务,并标出检查取消状态的位置。
  7. 把搜索、订单、Webhook、审计日志四类任务分别归入“客户端断开后取消”或“独立完成”,说明理由。
  8. 设计指标,发现超时响应已经返回但后台查询仍持续执行的问题。

官方参考