【问题标题】:Cloud Function for Firebase timeout after 60 seconds while running a bunch of Firebase queries运行一堆 Firebase 查询 60 秒后 Cloud Function for Firebase 超时
【发布时间】:2017-08-30 15:12:41
【问题描述】:

我正在将 Firebase 用于群组协作应用程序(如 Whatsapp),并且我正在使用云函数来确定哪些电话联系人也在使用我的应用程序(再次类似于 Whatsapp)。 Cloud Function 运行良好,直到我开始在 Functions Log 中看到以下日志以进行某些调用。

Function execution took 60023 ms, finished with status: 'timeout'

我做了一些调试,发现对于这个特定的用户,他的手机通讯录上有很多联系人,因此很明显,找出哪些联系人正在使用该应用程序所需的工作也增加了,以至于花了60多秒。下面是云函数的代码

      // contactsData is an array of contacts on the user's phone
      // Each contact can contain one more phone numbers which are
      // present in the phoneNumbers array. So, essentially, we need
      // to query over all the phone numbers in the user's contact book
      contactsData.forEach((contact) => {
        contact.phoneNumbers.forEach((phoneNumber) => {
          // Find if user with this phoneNumber is using the app
          // Check against mobileNumber and mobileNumberWithCC
          promises.push(ref.child('users').orderByChild("mobileNumber").
            equalTo(phoneNumber.number).once("value").then(usersSnapshot => {
              // usersSnapshot should contain just one entry assuming
              // that the phoneNumber will be unique to the user
              if(!usersSnapshot.exists()) {
                return null
              }
              var user = null
              usersSnapshot.forEach(userSnapshot => {
                user = userSnapshot.val()
              })
              return {
                name: contact.name,
                mobileNumber: phoneNumber.number,
                id: user.id
              }
            }))
          promises.push(ref.child('users').orderByChild("mobileNumberWithCC").
            equalTo(phoneNumber.number).once("value").then(usersSnapshot => {
              // usersSnapshot should contain just one entry assuming
              // that the phoneNumber will be unique to the user
              if(!usersSnapshot.exists()) {
                return null
              }
              var user = null
              usersSnapshot.forEach(userSnapshot => {
                user = userSnapshot.val()
              })
              return {
                name: contact.name,
                mobileNumber: phoneNumber.number,
                id: user.id
              }
            }))
        });
      });
      return Promise.all(promises)
    }).then(allContacts => {
      // allContacts is an array of nulls and contacts using the app
      // Get rid of null and any duplicate entries in the returned array
      currentContacts = arrayCompact(allContacts)

      // Create contactsObj which will the user's contacts that are using the app
      currentContacts.forEach(contact => {
        contactsObj[contact.id] = contact
      })
      // Return the currently present contacts
      return ref.child('userInfos').child(uid).child('contacts').once('value')
    }).then((contactsSnapshot) => {
      if(contactsSnapshot.exists()) {
        contactsSnapshot.forEach((contactSnapshot) => {
          previousContacts.push(contactSnapshot.val())
        })
      }
      // Update the contacts on firease asap after reading the previous contacts
      ref.child('userInfos').child(uid).child('contacts').set(contactsObj)

      // Figure out the new, deleted and renamed contacts
      newContacts = arrayDifferenceWith(currentContacts, previousContacts, 
        (obj1, obj2) => (obj1.id === obj2.id))
      deletedContacts = arrayDifferenceWith(previousContacts, currentContacts,
        (obj1, obj2) => (obj1.id === obj2.id))
      renamedContacts = arrayIntersectionWith(currentContacts, previousContacts,
        (obj1, obj2) => (obj1.id === obj2.id && obj1.name !== obj2.name))
      // Create the deletedContactsObj to store on firebase
      deletedContacts.forEach((deletedContact) => {
        deletedContactsObj[deletedContact.id] = deletedContact
      })
      // Get the deleted contacts
      return ref.child('userInfos').child(uid).child('deletedContacts').once('value')
    }).then((deletedContactsSnapshot) => {
      if(deletedContactsSnapshot.exists()) {
        deletedContactsSnapshot.forEach((deletedContactSnapshot) => {
          previouslyDeletedContacts.push(deletedContactSnapshot.val())
        })
      }
      // Contacts that were previously deleted but now added again
      restoredContacts = arrayIntersectionWith(newContacts, previouslyDeletedContacts,
        (obj1, obj2) => (obj1.id === obj2.id))
      // Removed the restored contacts from the deletedContacts
      restoredContacts.forEach((restoredContact) => {
        deletedContactsObj[restoredContact.id] = null
      })
      // Update groups using any of the deleted, new or renamed contacts
      return ContactsHelper.processContactsData(uid, deletedContacts, newContacts, renamedContacts)
    }).then(() => {
      // Set after retrieving the previously deletedContacts
      return ref.child('userInfos').child(uid).child('deletedContacts').update(deletedContactsObj)
    })

以下是一些示例数据

// This is a sample contactsData
[
  {
    "phoneNumbers": [
      {
        "number": "12324312321",
        "label": "home"
      },
      {
        "number": "2322412132",
        "label": "work"
      }
    ],
    "givenName": "blah5",
    "familyName": "",
    "middleName": ""
  },
  {
    "phoneNumbers": [
      {
        "number": "1231221221",
        "label": "mobile"
      }
    ],
    "givenName": "blah3",
    "familyName": "blah4",
    "middleName": ""
  },
  {
    "phoneNumbers": [
      {
        "number": "1234567890",
        "label": "mobile"
      }
    ],
    "givenName": "blah1",
    "familyName": "blah2",
    "middleName": ""
  }
]



// This is how users are stored on Firebase. This could a lot of users
  "users": {
    "id1" : {
      "countryCode" : "91",
      "id" : "id1",
      "mobileNumber" : "1231211232",
      "mobileNumberWithCC" : "911231211232",
      "name" : "Varun"
    },
    "id2" : {
      "countryCode" : "1",
      "id" : "id2",
      "mobileNumber" : "2342112133",
      "mobileNumberWithCC" : "12342112133",
      "name" : "Ashish"
    },
    "id3" : {
      "countryCode" : "1",
      "id" : "id3",
      "mobileNumber" : "123213421",
      "mobileNumberWithCC" : "1123213421",
      "name" : "Pradeep Singh"
    }
  }

在这种特殊情况下,contactsData 包含 1046 条目,其中一些条目有两个 phoneNumbers。因此,假设我需要检查总共有 1500 电话号码。我正在为数据库中的用户创建查询以与mobileNumbermobileNumberWithCC 进行比较。因此,在 Promise 完成之前,该函数将进行总共 3000 查询,我猜测完成所有这些查询需要 60 多秒,因此 Cloud Function 超时。

我的几个问题是:

  1. 是否所有这些查询都需要超过 60 秒?鉴于它在 Firebase 基础架构中运行,我希望它能够更快地完成。
  2. 有没有办法增加函数的超时限制?我目前正在使用 Blaze 计划。

我还将感谢上述功能的任何替代实施建议,以缓解问题。谢谢!

【问题讨论】:

  • 我很困惑。您正在查询所有这些数据,但您没有对它做任何事情。我所看到的是你正在从 then() 返回一个对象。通常你应该从 then() 返回另一个 promise 实际上在链式 then() 中使用它。
  • @DougStevenson 为了简单起见,我跳过了那段代码。基本上,上面的代码将返回一个正在使用我的应用程序的联系人数组。我将这些联系人上传到应用程序读取的 Firebase,以使用应用程序填充联系人列表。我还执行其他操作,例如找出自上次同步以来所有已删除、恢复和重命名的联系人,然后更新在应用程序中使用此类联系人的组。我已经稍微更新了代码以反映这一点。根据我的记录,函数在 Promise.all 调用完成之前超时。
  • 好吧,在任何情况下查询数千个项目似乎都是多余的,尤其是如果您只打算使用一个项目。您应该找到一种方法来构建数据和查询,以便在开始时只返回您需要的少数项目。
  • @DougStevenson 我不确定你为什么认为我会使用单个项目。我对所有这些一千个查询的结果感兴趣。例如。假设我有 100,000 个用户使用我的应用程序,我存储在数据库的 users 分支中,并且每个用户存储的信息非常少。现在一个用户启动了这个应用程序,他在这个通讯录中有 1000 个联系人,我需要弄清楚这 1000 个联系人中有哪些正在使用这个应用程序。所以,我至少要运行 1000 个查询。如果所有这 1000 个联系人都在使用该应用程序,我将使用所有这些查询的结果。
  • 在这段代码中:usersSnapshot.forEach(userSnapshot => { user = userSnapshot.val() }) 您正在迭代 usersSnapshot 中的所有内容,但只记住该集合中的单个用户,无论最后出现什么。如果您添加一些关于您在每个阶段要完成的任务的 cmets 以及一些数据样本,也许会有所帮助,因为这很难想象您在这里构建的所有内容。

标签: javascript firebase firebase-realtime-database google-cloud-functions


【解决方案1】:

问题

您遇到的性能问题来自查询 ref.child('users').orderByChild("mobileNumber").equalTo(phon‌​eNumber.number).once‌​("value"),您在另一个 forEach() 内调用该查询 forEach()

要分解此查询,您实际上是在要求数据库迭代通过/users 的子级,将键mobileNumberphon‌​eNumber.number 进行比较,如果它们匹配,则返回一个值.但是,您不仅为mobileNumbermobileNumberWithCC 调用它,而且在forEach()每次迭代 上调用它。因此,这意味着您正在查看X 的用户数量、Y 的电话号码数量、Z 的联系人数量,因此最多执行X*Y*Z 内部数据库操作。这显然很费力,因此您的查询需要超过 60 秒的时间来处理。

潜在修复

我建议在您的数据库上实现一个索引,称为/phoneNumbers/phoneNumbers 中的每个键都将命名为 n###########c###########,并包含与该电话号码关联的用户 ID“数组”。

这个结构看起来类似于:

"phoneNumbers": {
  "n1234567890": { // without CC, be warned of overlap
    "userId1": true,
    "userId3": true
  },
  "c011234567890": { // with CC for US
    "userId1": true
  },
  "c611234567890": { // with CC for AU
    "userId3": true
  },
  ...
}

注意事项:

为什么电话号码以n###########c###########的格式存储?

这是因为 Firebase 将数字键视为数组的索引。这对这个用例没有意义,所以我们在开头添加n/c 来抑制这种行为。

为什么同时使用n###########c###########

如果所有条目都仅使用 n 前缀,则 11 位电话号码可能会与添加了国家/地区代码的 10 位电话号码重叠。因此,我们将n 用于普通电话号码,将c 用于包含国家/地区代码的号码。

为什么你说/phoneNumbers 的每个键都包含一个“数组”的用户ID?

这是因为您应该避免在 Firebase 数据库(以及一般的数组)中使用数字索引数组。假设两个独立的进程想要通过删除用户 ID 来更新 /phoneNumbers/n1234567890。如果一个要删除位置1的ID,另一个要删除位置2的ID;他们最终会删除位置 1 和 3 的 ID。这可以通过将用户 ID 存储为键来解决,而不是通过 ID 而不是位置来添加/删除它。

实施

由于您已经在使用 Cloud Functions,因此实现这样的索引相对简单。这段代码可以很容易地适应任何类型的基于用户数据的自动生成的索引。

// Initialize functions and admin.
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);

/**
 * Listens to operations on the children of `/users` and updates the `/phoneNumbers` index appropriately.
 */
exports.handleNewUser = functions.database.ref('/users/{userId}')
  .onWrite(event => {
    var deltaSnapshot = event.data,
        userId = event.params.userId,
        tasks = []; // for returned promises

    if (!deltaSnapshot.exists()) {
      // This user has been deleted.
      var previousData = deltaSnapshot.previous.val();
      if (previousData.number) {
        tasks.push(removeUserFromPhoneNumber(userId, previousData.number, false));
      }
      if (previousData.numberWithCC) {
        tasks.push(removeUserFromPhoneNumber(userId, previousData.numberWithCC, true));
      }
      // Handle other cleanup tasks.
      return Promise.all(tasks).then(() => {
        console.log('User "' + userId + '" deleted successfully.');
      });
    }

    var currentData = deltaSnapshot.val();

    if (deltaSnapshot.previous.exists()) {
      // This is an update to existing data.
      var previousData = deltaSnapshot.previous.val();

      if (currentData.number != previousData.number) { // Phone number changed.
        tasks.push(removeUserFromPhoneNumber(userId, previousData.number, false));
        tasks.push(addUserToPhoneNumber(userId, currentData.number, false));
      }
      if (currentData.numberWithCC != previousData.numberWithCC) { // Phone number changed.
        tasks.push(removeUserFromPhoneNumber(userId, previousData.numberWithCC, true));
        tasks.push(addUserToPhoneNumber(userId, currentData.numberWithCC, true));
      }
      // Handle other tasks related to update.
      return Promise.all(tasks).then(() => {
        console.log('User "' + userId + '" updated successfully.');
      });
    }

    // If here, this is a new user.
    tasks.push(addUserToPhoneNumber(userId, currentData.number, false));
    tasks.push(addUserToPhoneNumber(userId, currentData.numberWithCC, true));
    // Handle other tasks related to addition of new user.
    return Promise.all(tasks).then(() => {
      console.log('User "' + userId + '" created successfully.');
    });
  );

/* Phone Number Index Helper Functions */

/**
 * Returns an array of user IDs linked to the specified phone number.
 * @param {String} number - the phone number
 * @param {Boolean} withCountryCode - true, if the phone number includes a country code
 * @return {Promise} - a promise returning an array of user IDs, may be empty.
 */
function lookupUsersByPhoneNumber(number, withCountryCode) {
  // Error out before corrupting data.
  if (!number) return Promise.reject(new TypeError('number cannot be falsy.');
  return lookupIdsByIndex('phoneNumbers', (withCountryCode ? 'c' : 'n') + number);
}

/**
 * Adds the user ID under the specified phone number's index.
 * @param {String} userId - the user ID
 * @param {String} number - the phone number
 * @param {Boolean} withCountryCode - true, if the phone number includes a country code
 * @return {Promise} - the promise returned by transaction()
 */
function addUserToPhoneNumber(userId, number, withCountryCode) {
  // Error out before corrupting data.
  if (!number) return Promise.reject(new TypeError('number cannot be falsy.');
  return addIdToIndex(userId, 'phoneNumbers', (withCountryCode ? 'c' : 'n') + number)
}

/**
 * Removes the user ID under the specified phone number's index.
 * @param {String} userId - the user ID
 * @param {String} number - the phone number
 * @param {Boolean} withCountryCode - true, if the phone number includes a country code
 * @return {Promise} - the promise returned by transaction()
 */
function removeUserFromPhoneNumber(userId, number, withCountryCode) {
  // Error out before corrupting data.
  if (!number) return Promise.reject(new TypeError('number cannot be falsy.');
  return removeIdFromIndex(userId, 'phoneNumbers', (withCountryCode ? 'c' : 'n') + number)
}

/* General Firebase Index CRUD APIs */
/* Credit: @samthecodingman */

/**
 * Returns an array of IDs linked to the specified key in the given index.
 * @param {String} indexName - the index name
 * @param {String} keyName - the key name
 * @return {Promise} - the promise returned by transaction()
 */
function lookupIdsByIndex(indexName, keyName) {
  // Error out before corrupting data.
  if (!indexName) return Promise.reject(new TypeError('indexName cannot be falsy.');
  if (!keyName) return Promise.reject(new TypeError('keyName cannot be falsy.');
  return admin.database().ref(indexName).child(keyName).once("value")
  .then(snapshot => {
    if (!snapshot.exists()) return []; // Use empty array for 'no data'
    var idsObject = snapshot.val();
    if (idsObject == null) return [];
    return Object.keys(idsObject); // return array of IDs
  });
}

/**
 * Adds the ID to the index under the named key.
 * @param {String} id - the entry ID
 * @param {String} indexName - the index name
 * @param {String} keyName - the key name
 * @return {Promise} - the promise returned by transaction()
 */
function addIdToIndex(id, indexName, keyName) {
  // Error out before corrupting data.
  if (!id) return Promise.reject(new TypeError('id cannot be falsy.');
  if (!indexName) return Promise.reject(new TypeError('indexName cannot be falsy.');
  if (!keyName) return Promise.reject(new TypeError('keyName cannot be falsy.');
  return admin.database().ref(indexName).child(keyName)
  .transaction(function(idsObject) {
    idsObject = idsObject || {}; // Create data if it doesn't exist.
    if (idsObject.hasOwnProperty(id)) return; // No update needed.
    idsObject[id] = true; // Add ID.
    return idsObject;
  });
}

/**
 * Removes the ID from the index under the named key.
 * @param {String} id - the entry ID
 * @param {String} indexName - the index name
 * @param {String} keyName - the key name
 * @return {Promise} - the promise returned by transaction()
 */
function removeIdFromIndex(id, indexName, keyName) {
  // Error out before corrupting data.
  if (!id) return Promise.reject(new TypeError('id cannot be falsy.');
  if (!indexName) return Promise.reject(new TypeError('indexName cannot be falsy.');
  if (!keyName) return Promise.reject(new TypeError('keyName cannot be falsy.');
  return admin.database().ref(indexName).child(keyName)
  .transaction(function(idsObject) {
    if (idsObject === null) return; // No data to update.
    if (!idsObject.hasOwnProperty(id)) return; // No update needed.
    delete idsObject[id]; // Remove ID.
    if (Object.keys(idsObject).length === 0) return null; // Delete entire entry.
    return idsObject;
  });
}

上述 sn-p 中的 handleNewUser 函数不会捕获错误。它只会让 Firebase 处理它们(默认情况下 FB 只会记录错误)。我建议您根据需要实施适当的回退(就像您应该使用任何云功能一样)。

关于您问题中的源代码,它会变成类似于:

contactsData.forEach((contact) => {
  contact.phoneNumbers.forEach((phoneNumber) => {
    var tasks = [];
    tasks.push(lookupUsersByPhoneNumber(phoneNumber.number, false)); // Lookup without CC
    tasks.push(lookupUsersByPhoneNumber(phoneNumber.number, true)); // Lookup with CC
    Promise.all(tasks).then(taskResults => {
      var i = 0;
      // Elements of taskResults are arrays of strings from the lookup functions.
      // Flatten and dedupe strings arrays
      var userIds = taskResults.reduce((arr, results) => {
        for (i=0;i<results.length;i++) {
          if (results[i] !== null && ~arr.indexOf(results[i])) {
            arr.push(results[i]); // Add if not already added.
          }              
        }
        return arr;
      }, []);

      // Build 'contacts' array (Doesn't need a database lookup!)
      return userIds.map(uid => ({
        name: contact.name,
        phone: phoneNumber.number,
        id: uid
      }));
    }).then(currentContacts => {
      currentContacts.forEach(contact => {
        contactsObj[contact.id] = contact
      });

      // do original code from question here.
      // I'm not 100% on what it does, so I'll leave it to you.
      // It currently uses an array which is a bad implementation (see notes above). Use PUSH to update the contacts rather than deleting and readding them constantly.
    });
  });
});

关于安全/隐私的说明

出于隐私原因,我强烈建议将 /phoneNumbers 的读写访问权限仅限于云功能服务人员。这可能还需要根据权限问题将部分程序逻辑移至服务器。

为此,请替换:

admin.initializeApp(functions.config().firebase);

与:

admin.initializeApp(Object.assign({}, functions.config().firebase, {
  databaseAuthVariableOverride: {
    uid: "cloudfunc-service-worker" // change as desired
  }
});

要启用它,您需要按如下方式配置 Firebase 数据库规则:

"rules": {
    "phoneNumbers": {
      ".read": "'cloudfunc-service-worker' === auth.uid",
      ".write": "'cloudfunc-service-worker' === auth.uid"
    }
  }

【讨论】:

  • 感谢@samthecodingman 的扩展回答。我将更详细地讨论答案以完全理解它并发布 cmet 如果我有的话。
【解决方案2】:

如果您无法避免查询如此多的数据,您可以使用左侧的 Functions 产品为您的项目更改 Cloud Console 中的函数超时。目前,您必须在每次新部署时重置超时。

【讨论】:

  • 感谢您的回答。这将暂时解决我的问题。我真的很期待您对我如何为这个工作流构建和查询我的数据的任何 cmets。我的工作流程非常简单。我有一个在 Firebase 中使用我的应用的用户列表。每个用户都包含一个手机号码。对于给定的 mobileNumbers 数组,我想找出其中哪些 mobileNumbers 正在使用该应用程序。再次感谢您的 cmets 和回复。我现在可以使用云功能而不是 firebase-queue worker 发布我的应用程序的新版本 :)
  • 所以,它原来是超时和分配给函数的内存的组合。最初,我只是将时间更改为 120 秒,但它仍然超时。然后,我将分配的内存从 256 MB 增加到 512 MB,然后函数在 64 秒内完成。不确定我是否应该进一步增加分配的内存,以及它将如何影响完成该功能所需的成本和时间,但我会运行一些测试。谢谢!
  • 根据定价页面,当您使用 Blaze 计划时,您肯定会为时间和内存的组合付费。
  • 是的,这就是我的想法,如果我只是将分配给函数的内存增加到 1 GB,那么它也会影响相同函数的调用定价100 毫秒,即 95% 的调用。我认为增加的内存不会影响按比例完成这些调用所花费的时间。我将对此进行一些测试并尝试提出正确的组合。
猜你喜欢
  • 1970-01-01
  • 2018-01-03
  • 2018-04-19
  • 2017-08-26
  • 2020-04-29
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-05-02
相关资源
最近更新 更多