自动化 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 更容易出现
同一份代码在两个项目中的表现不同,并不能说明代码不存在异步问题。通常需要检查以下差异:
-
PROD 返回的数据更多
DEV 可能只有少量交易。虽然函数在写入完成前就返回了,但少量请求可能碰巧执行完毕。PROD 有数百条记录,函数提前结束造成的影响会明显得多。
-
网络和数据库延迟不同
第三方 API 的响应时间、数据库负载和实例冷启动都可能波动。异步操作没有被等待时,结果就可能表现为偶尔成功。
-
错误被捕获后没有继续抛出
下面的写法会让执行结果仍然显示为成功:
try { await saveTransactions(); } catch (error) { console.error(error); // 没有 throw }记录错误后应继续抛出:
} catch (error) { console.error(error); throw error; } -
项目配置不同
需要核对 PROD 中的以下配置:
- Realtime Database 实例和
databaseURL - 部署时选择的 Firebase project
- 第三方 API 地址、令牌和环境变量
- 数据库区域
- 服务账号权限
- API 返回的数据结构
- 交易 ID 是否有效、重复或为空
- 数据是否意外写入了另一个数据库实例
- Realtime Database 实例和
使用 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 响应和实际资源消耗。
备注:内容仅供参考。
