PAYATHON 2026

自动化 cloud function 为何仅偶尔执行数据库更新?

支付老李

结论

最可能的原因不是 timeout 或 memory,而是函数在所有异步数据库写入完成之前就返回了。

日志中的:

Function execution took 1556ms, finished with status: 'ok'

只说明 Cloud Functions 认为入口函数已经成功结束,不代表它启动的 axios 请求或 Realtime Database 写入也全部完成了。如果写入 Promise 没有被 return 或 await,运行环境可能会在后台操作完成前冻结或回收实例。

PROD 的数据量更大,网络延迟也更不稳定,所以这个异步问题更容易出现。DEV 数据较少时,后台写入可能碰巧在函数结束前完成,因此看起来一直正常。

常见错误

没有返回 axios 的 Promise

exports.syncTransactions = functions.pubsub
  .schedule('50 23 * * *')
  .timeZone('America/New_York')
  .onRun(() => {
    axios.get(REPORT_URL).then(async (response) => {
      await processTransactions(response.data);
    });

    // 此处函数已经返回 undefined
  });

Cloud Functions 不会等待 .then() 中的操作,因为入口函数没有返回对应的 Promise。

使用 forEach 执行异步写入

transactions.forEach(async (transaction) => {
  await admin
    .database()
    .ref(`history/${transaction.id}`)
    .update(transaction);
});

forEach 不会等待异步回调。即使回调内部使用了 await,外层函数仍可能提前结束。

下面的写法也有同样的问题:

transactions.map(async (transaction) => {
  await saveTransaction(transaction);
});

map() 返回的是 Promise 数组。如果没有对它执行 await Promise.all(...),这些写入仍然不会被等待。

推荐实现

将入口函数声明为 async,并等待 HTTP 请求和数据库写入全部完成:

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const axios = require('axios');

admin.initializeApp();

exports.syncTransactions = functions.pubsub
  .schedule('50 23 * * *')
  .timeZone('America/New_York')
  .onRun(async () => {
    try {
      const response = await axios.get(REPORT_URL, {
        timeout: 30000,
        headers: {
          Authorization: `Bearer ${API_TOKEN}`
        }
      });

      const transactions = response.data.transactions;

      if (!Array.isArray(transactions)) {
        throw new Error('Transaction API returned an invalid payload');
      }

      const writes = transactions.map((transaction) => {
        const transactionId = String(transaction.id);

        const historyRecord = {
          accountNumber: transaction.accountNumber,
          name: transaction.name,
          address: transaction.address,
          amount: transaction.amount,
          processedAt: admin.database.ServerValue.TIMESTAMP
        };

        return admin
          .database()
          .ref(`history/${transactionId}`)
          .update(historyRecord);
      });

      await Promise.all(writes);

      console.log('Transaction synchronization completed', {
        received: transactions.length,
        written: writes.length
      });

      return null;
    } catch (error) {
      console.error('Transaction synchronization failed', {
        message: error.message,
        stack: error.stack,
        responseStatus: error.response && error.response.status
      });

      throw error;
    }
  });

这段实现需要注意以下几点:

  • 使用 await axios.get(...) 等待 HTTP 请求
  • 每次数据库写入都要返回 Promise
  • 使用 await Promise.all(writes) 等待全部写入
  • 失败时重新抛出异常,避免函数被错误地标记为成功
  • 只在所有写入完成后输出完成日志

更适合数百笔交易的写法

如果每笔交易只写入不同路径,可以使用 Realtime Database 的 multi-location update。这通常比逐条发送写入请求更高效,而且同一次 update() 是原子操作。

exports.syncTransactions = functions.pubsub
  .schedule('50 23 * * *')
  .timeZone('America/New_York')
  .onRun(async () => {
    const response = await axios.get(REPORT_URL, {
      timeout: 30000
    });

    const transactions = response.data.transactions;

    if (!Array.isArray(transactions)) {
      throw new Error('Transaction API returned an invalid payload');
    }

    const updates = {};

    for (const transaction of transactions) {
      const transactionId = String(transaction.id);

      updates[`history/${transactionId}`] = {
        accountNumber: transaction.accountNumber,
        name: transaction.name,
        address: transaction.address,
        amount: transaction.amount,
        processedAt: admin.database.ServerValue.TIMESTAMP
      };
    }

    await admin.database().ref().update(updates);

    console.log('Transaction synchronization completed', {
      received: transactions.length,
      written: Object.keys(updates).length
    });

    return null;
  });

如果 payload 很大,不要无限增加单次 update() 的内容。可以按固定数量分批,并依次等待每批写入完成:

async function writeInBatches(transactions, batchSize = 200) {
  for (let offset = 0; offset < transactions.length; offset += batchSize) {
    const batch = transactions.slice(offset, offset + batchSize);
    const updates = {};

    for (const transaction of batch) {
      const transactionId = String(transaction.id);

      updates[`history/${transactionId}`] = {
        accountNumber: transaction.accountNumber,
        amount: transaction.amount,
        processedAt: admin.database.ServerValue.TIMESTAMP
      };
    }

    await admin.database().ref().update(updates);

    console.log('Batch completed', {
      offset,
      count: batch.length
    });
  }
}

为什么 PROD 更容易出现

同一份代码在两个项目中的表现不同,并不能说明代码不存在异步问题。通常需要检查以下差异:

  1. PROD 返回的数据更多

    DEV 可能只有少量交易。虽然函数在写入完成前就返回了,但少量请求可能碰巧执行完毕。PROD 有数百条记录,函数提前结束造成的影响会明显得多。

  2. 网络和数据库延迟不同

    第三方 API 的响应时间、数据库负载和实例冷启动都可能波动。异步操作没有被等待时,结果就可能表现为偶尔成功。

  3. 错误被捕获后没有继续抛出

    下面的写法会让执行结果仍然显示为成功:

    try {
      await saveTransactions();
    } catch (error) {
      console.error(error);
      // 没有 throw
    }

    记录错误后应继续抛出:

    } catch (error) {
      console.error(error);
      throw error;
    }
  4. 项目配置不同

    需要核对 PROD 中的以下配置:

    • Realtime Database 实例和 databaseURL
    • 部署时选择的 Firebase project
    • 第三方 API 地址、令牌和环境变量
    • 数据库区域
    • 服务账号权限
    • API 返回的数据结构
    • 交易 ID 是否有效、重复或为空
    • 数据是否意外写入了另一个数据库实例

使用 Admin SDK 时,Realtime Database Security Rules 通常不是主要限制,因为 Admin SDK 通过服务账号权限访问数据库。不过,服务账号、IAM 配置以及数据库实例指向仍需检查。

timeout 和 memory 是否需要调整

可以调整,但不应把它们当作首要修复手段。

如果函数超时,日志通常会出现 deadline exceeded、timeout 或执行被终止,而不是在约 1.5 秒后以 status: 'ok' 结束。如果内存不足,日志通常会包含进程退出、实例崩溃或内存限制相关信息。

先确认函数已经等待所有操作完成,再根据实际运行时间设置资源。例如,旧版 Firebase Functions API 可以这样配置:

exports.syncTransactions = functions
  .runWith({
    timeoutSeconds: 300,
    memory: '512MB'
  })
  .pubsub
  .schedule('50 23 * * *')
  .timeZone('America/New_York')
  .onRun(async () => {
    // 必须等待全部异步操作
    return null;
  });

具体支持哪些 timeout 和 memory 取值,取决于 Cloud Functions 代际、Firebase Functions SDK 版本和部署配置,不能只根据当前现象判断。提高资源也无法解决 Promise 没有返回或异步写入没有被等待的问题。

另外,axios 的 timeout 和 Cloud Function 的 timeout 并不是同一个概念:

await axios.get(REPORT_URL, {
  timeout: 30000
});

前者限制单次 HTTP 请求的等待时间,后者限制整次函数执行时间。

排查方法

可以为每次同步生成唯一的运行 ID,并记录各阶段处理的数量:

const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;

console.log('Sync started', { runId });

const response = await axios.get(REPORT_URL, {
  timeout: 30000
});

console.log('API response received', {
  runId,
  status: response.status,
  transactionCount: response.data.transactions?.length
});

await writeTransactions(response.data.transactions);

console.log('Database writes completed', {
  runId,
  transactionCount: response.data.transactions.length
});

排查时重点确认:

  • 是否出现了 “API response received”
  • API 实际返回了多少笔交易
  • 是否出现了 “Database writes completed”
  • 计划写入数和实际写入数是否一致
  • 是否有单条数据转换异常
  • 是否出现 axios 的 4xx、5xx 或超时错误
  • 部署日志中的项目 ID 和数据库 URL 是否正确
  • 第三方 API 是否可能在 11
    PM 尚未完成当日账单生成

不要在日志中记录完整姓名、地址、卡号、令牌或完整 API payload。只记录交易数量、脱敏后的 ID、HTTP 状态码和错误类型即可。

还需要注意的事项

定时任务可能因重试、人工触发或基础设施行为而执行多次,因此同步逻辑应尽量具备幂等性。使用第三方提供的稳定 transaction ID 作为数据库键,比每次调用 push() 更安全:

updates[`history/${transaction.id}`] = historyRecord;

如果使用 push(),同一批交易再次处理时可能产生重复记录。

此外,EST 固定为 UTC-5,而美国东部时间存在夏令时。如果任务需要在当地晚上 11

执行,应使用:

.timeZone('America/New_York')

Node.js 12.16.2 已经非常老。升级运行时和 Firebase SDK 可以获得安全更新及更好的诊断能力,但升级本身不会自动解决 Promise 没有被等待的问题。应先修正异步控制流,再检查 PROD 的项目配置、第三方 API 响应和实际资源消耗。

备注:内容仅供参考。