CloudBase 云函数调外部接口老是超时——是执行时间不够还是网络不通?

文章导读
在 CloudBase 云函数里调第三方接口频繁超时,最容易走错的一步是直接去调大超时时间。函数超时配置、外部服务响应慢、函数运行环境到目标地址连不通,这三种情况的日志表现并不一样:配置不够时,函数通常会跑满设定时长然后被平台强制终止;外部响应慢时,日志里能看到请求已经发出但迟迟没有响应;网络不可达时,往往在连接建立阶段就失败,耗时短且伴随明确的连接错误。判断顺序建议是先分段计时看清耗时落在哪一段
📋 目录
  1. Ⅰ 在函数里给外部请求打三段计时
  2. Ⅱ 把函数超时设置和实测响应时长摆在一起比
  3. Ⅲ 验证函数所在环境能否直连目标地址
  4. Ⅳ 加上一次重试与降级返回再观察
A A

在 CloudBase 云函数里调第三方接口频繁超时,最容易走错的一步是直接去调大超时时间。函数超时配置、外部服务响应慢、函数运行环境到目标地址连不通,这三种情况的日志表现并不一样:配置不够时,函数通常会跑满设定时长然后被平台强制终止;外部响应慢时,日志里能看到请求已经发出但迟迟没有响应;网络不可达时,往往在连接建立阶段就失败,耗时短且伴随明确的连接错误。判断顺序建议是先分段计时看清耗时落在哪一段,再把实测耗时和函数超时配置放在一起比,最后单独验证一次连通性。下面按这三步给出可以直接放进函数的代码骨架和判断方法。

面对云函数调外部接口超时,建议先把一次外部请求拆成连接建立、等待响应头、读取响应体三段计时并写入日志,用分段耗时判断问题归属;再把最长一次耗时与函数当前超时配置对照,看余量是否够;连接阶段就报错时优先怀疑网络出口与目标地址可达性。分段日志只能定位方向,具体阈值和网络策略需要结合当前函数配置、运行环境和目标服务的实际表现确认,不能靠单次日志下结论。

在函数里给外部请求打三段计时

分段计时的目的不是精确统计性能,而是把“一次请求到底卡在哪里”变成日志里能直接看到的字段。Node.js 环境里用内置的 fetch(或团队已在用的 HTTP 客户端)即可,在请求开始、拿到响应头、响应体读完三个位置各记一次时间戳,统一写进同一行日志。下面的骨架可以直接替换成你实际使用的客户端:

// 通用骨架:把三段耗时写入一条结构化日志
exports.main = async (event, context) => {
  const target = process.env.TARGET_URL;
  const t0 = Date.now();
  let tHeader = null;
  let tDone = null;
  let stage = 'connect';
  try {
    stage = 'connect';
    const resp = await fetch(target, {
      method: 'GET',
      signal: AbortSignal.timeout(8000) // 单次请求级超时,需小于函数超时
    });
    tHeader = Date.now();
    stage = 'body';
    await resp.text(); // 必须读完响应体,否则耗时统计不完整
    tDone = Date.now();
    console.log(JSON.stringify({
      tag: 'ext_request',
      target,
      status: resp.status,
      connect_cost: tHeader - t0,
      body_cost: tDone - tHeader,
      total_cost: tDone - t0,
      stage: 'done'
    }));
    return { ok: true, status: resp.status };
  } catch (err) {
    const tErr = Date.now();
    console.log(JSON.stringify({
      tag: 'ext_request',
      target,
      stage,
      connect_cost: tHeader ? tHeader - t0 : tErr - t0,
      body_cost: tHeader ? tErr - tHeader : null,
      total_cost: tErr - t0,
      error_name: err.name,
      error_message: err.message
    }));
    throw err;
  }
};

日志里要固定保留 stage 字段,它记录异常发生在哪个阶段。如果 stage 停在 connect,说明问题出在 DNS 解析或 TCP/TLS 连接;如果 stage 是 body,说明连接已建立、只是在等数据。再加一个请求级超时(示例里的 AbortSignal.timeout)也很重要,它能防止单次外部调用把函数整体超时耗尽,从而让你看到的是“外部请求超时”而不是“函数被杀”。请求级超时值应明显小于函数超时值,给后面的降级逻辑留出执行时间。

把函数超时设置和实测响应时长摆在一起比

拿到分段日志后,先确认当前函数的超时配置到底是多少。在 CloudBase 控制台的函数配置页可以直接看到超时时间;如果用命令行或配置文件部署,就对应查看函数配置里的超时字段,以部署时实际生效的值为准,不要凭印象。然后从日志里取两个值:一是多次正常请求的典型耗时,二是这一批日志里最长的一次总耗时。判断余量时,建议以最长耗时为准而不是平均值,因为函数超时是按单次执行计算的,一次慢请求就足以触发。

  • 最长耗时已经接近或超过函数超时值:先调大函数超时,同时给外部请求设更短的请求级超时并配套降级,避免函数被白白拖满。
  • 最长耗时远小于函数超时值,但日志里出现函数超时:重点看是否在循环里串行调用了多次外部接口,总耗时被累加。
  • 分段日志里 body_cost 占比大:属于外部服务响应慢,调大函数超时只是让等待更久,收益有限,应优先评估对方接口稳定性或减少单次数据量。
  • 分段日志里 connect_cost 异常大或直接报错:先不要动超时配置,转到下一节验证连通性。

留余量的原则可以保守一些:函数超时值要让“外部请求最长耗时 + 降级逻辑执行时间”都装得下,而不是刚好等于典型耗时。如果目标接口的耗时波动本身很大,靠调大函数超时只能缓解,不能消除超时,需要结合降级或异步化处理。

CloudBase 云函数调外部接口老是超时——是执行时间不够还是网络不通?

验证函数所在环境能否直连目标地址

连接阶段失败时,最经济的方式是在函数代码里临时加一段最小连通性测试,部署后手动触发一次,看日志结果。可以用 Node.js 内置的 net 或 dns 模块,不依赖业务代码:

const net = require('net');
const dns = require('dns');

exports.main = async () => {
  const host = process.env.TARGET_HOST; // 只填主机名,不带协议和路径
  const port = Number(process.env.TARGET_PORT || 443);
  const t0 = Date.now();
  let resolved = null;

  try {
    resolved = await dns.promises.lookup(host);
    console.log(JSON.stringify({ tag: 'conn_test', step: 'dns', host, address: resolved.address, cost: Date.now() - t0 }));
  } catch (err) {
    console.log(JSON.stringify({ tag: 'conn_test', step: 'dns', host, error_name: err.name, error_message: err.message }));
    return { ok: false, step: 'dns' };
  }

  return await new Promise((resolve) => {
    const socket = net.connect({ host, port }, () => {
      console.log(JSON.stringify({ tag: 'conn_test', step: 'tcp', host, port, cost: Date.now() - t0 }));
      socket.end();
      resolve({ ok: true, step: 'tcp' });
    });
    socket.setTimeout(5000, () => {
      console.log(JSON.stringify({ tag: 'conn_test', step: 'tcp', error_name: 'Timeout', host, port, cost: Date.now() - t0 }));
      socket.destroy();
      resolve({ ok: false, step: 'tcp' });
    });
    socket.on('error', (err) => {
      console.log(JSON.stringify({ tag: 'conn_test', step: 'tcp', error_name: err.code || err.name, error_message: err.message, cost: Date.now() - t0 }));
      resolve({ ok: false, step: 'tcp' });
    });
  });
};

结果判读比较直接:DNS 步骤报 ENOTFOUND 或 EAI_AGAIN,说明域名解析在这个环境里没有成功;TCP 步骤报 ECONNREFUSED、ETIMEDOUT、EHOSTUNREACH、ENETUNREACH 这类错误,说明连接阶段就失败了,此时反复调大函数超时没有意义。需要注意的是,TCP 能连通不代表 HTTPS 一定可用,如果 TCP 成功但业务请求仍在 TLS 或证书阶段失败,应继续查看请求错误里的具体信息。另外公网访问能力与函数所处的网络配置有关,例如是否配置了固定出口、是否与私有网络绑定,这些需要结合当前函数配置在控制台确认。

加上一次重试与降级返回再观察

分段日志显示连接和读取都正常、只是偶发超时,多半属于抖动型失败;每次都稳定失败在同一阶段,则更可能是配置或网络问题。为了区分两者,可以在单次调用内加有限次数重试,并在全部失败后返回统一错误结构,让调用方拿到的是可识别的结果而不是函数整体超时:

async function callWithRetry(url, { retries = 1, timeoutMs = 8000 } = {}) {
  const errors = [];
  for (let i = 0; i <= retries; i++) {
    const t0 = Date.now();
    try {
      const resp = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
      await resp.text();
      console.log(JSON.stringify({ tag: 'ext_retry', attempt: i + 1, ok: true, status: resp.status, cost: Date.now() - t0 }));
      return { ok: true, status: resp.status, attempt: i + 1 };
    } catch (err) {
      errors.push({ attempt: i + 1, error_name: err.name, error_message: err.message, cost: Date.now() - t0 });
      console.log(JSON.stringify({ tag: 'ext_retry', attempt: i + 1, ok: false, error_name: err.name, error_message: err.message, cost: Date.now() - t0 }));
    }
  }
  return {
    ok: false,
    code: 'UPSTREAM_UNAVAILABLE',
    attempts: retries + 1,
    errors
  };
}

exports.main = async () => {
  const result = await callWithRetry(process.env.TARGET_URL, { retries: 1, timeoutMs: 8000 });
  if (!result.ok) {
    // 降级:返回统一错误结构,而不是把异常抛到函数超时
    return { success: false, code: result.code, attempts: result.attempts, requestId: process.env.REQUEST_ID || null };
  }
  return { success: true, status: result.status, attempt: result.attempt };
};

日志里通过 attempt 字段区分首次失败和重试成功:同一 requestId 下先出现 attempt: 1, ok: false,紧接着出现 attempt: 2, ok: true,就属于抖动型超时,重点应放在重试次数和请求级超时的配合上;两次都失败且错误类型一致,则回到连通性和外部服务状态排查。重试次数建议保持很小,因为重试会成倍占用函数执行时间,在函数超时余量本就不足时,重试反而更容易把函数拖到被强制终止。降级返回的错误结构要稳定,包含成功标志、错误码和尝试次数,方便调用方决定是提示用户、走缓存数据还是稍后再试。