用于大型文档和内部数组的竞争友好型数据库架构
Contention-friendly database architecture for large documents and inner arrays
上下文
我有一个数据库,其中包含使用此模式的文档集合(缩短模式,因为某些数据与我的问题无关):
{
title: string;
order: number;
...
...
...
modificationsHistory: HistoryEntry[];
items: ListRow[];
finalItems: ListRow[];
...
...
...
}
这些文档很容易达到 100 或 200 kB,具体取决于它们包含的项目和 finalItems 的数量。同样重要的是,尽可能快地更新它们,尽可能减少带宽使用。
这是在 Web 应用程序上下文中,使用 Angular 9 和 @angular/fire
6.0.0。
问题
当最终用户在对象的 item
数组中编辑一个项目时,就像只编辑一个 属性,反映在数据库内部需要我发送整个对象,因为 firestore 的 update
方法不支持字段路径中的数组索引,唯一可以对数组进行的操作是添加或删除元素 as described inside documentation.
但是,通过发送整个文档来更新 items
数组的一个元素会给没有良好连接的任何人带来糟糕的性能,我的很多用户就是这种情况。
第二个问题是,在我的情况下,将所有内容都实时保存在一个文档中会使协作变得困难,因为其中一些元素可以由多个用户同时编辑,这会产生两个问题:
- 如果在同一秒内进行两次更新,由于文档争用过多,某些写入操作可能会失败。
- 更新不是原子的,因为我们一次发送整个文档,因为它不使用事务来避免更多地使用带宽。
我已经尝试过的解决方案
子集
描述
这是一个非常简单的解决方案:为 items
、finalItems
和 modificationsHistory
数组创建一个子集合,使它们易于编辑,因为它们现在有自己的 ID,所以很容易联系他们更新他们。
为什么它不起作用
有一个包含 10 finalItems
、30 items
和 50 个条目的列表 modificationsHistory
意味着我需要总共打开 4 个侦听器才能完全侦听一个元素.考虑到用户可以同时打开许多这些元素,收听几十个文档会造成同样糟糕的性能情况,在完整用户案例中可能更糟。
这也意味着如果我想更新一个有 100 个项目的大元素并且我想更新其中的一半,那么每个项目都会花费我一个写操作,更不用说更新所需的读操作量了检查权限等,每次写入可能 3 次,因此 150 次读取 + 50 次写入只是为了更新数组中的 50 项。
云函数更新文档
const {
applyPatch
} = require('fast-json-patch');
function applyOffsets(data, entries) {
entries.forEach(customEntry => {
const explodedPath = customEntry.path.split('/');
explodedPath.shift();
let pointer = data;
for (let fragment of explodedPath.slice(0, -1)) {
pointer = pointer[fragment];
}
pointer[explodedPath[explodedPath.length - 1]] += customEntry.offset;
});
return data;
}
exports.updateList = functions.runWith(runtimeOpts).https.onCall((data, context) => {
const listRef = firestore.collection('lists').doc(data.uid);
return firestore.runTransaction(transaction => {
return transaction.get(listRef).then(listDoc => {
const list = listDoc.data();
try {
const [standard, custom] = JSON.parse(data.diff).reduce((acc, entry) => {
if (entry.custom) {
acc[1].push(entry);
} else {
acc[0].push(entry);
}
return acc;
}, [
[],
[]
]);
applyPatch(list, standard);
applyOffsets(list, custom);
transaction.set(listRef, list);
} catch (e) {
console.log(data.diff);
}
});
});
});
描述
使用 diff 库,我在以前的文档和新更新的文档之间进行了比较,并将此 diff 发送到使用事务 API.
操作更新的 GCF
这种方法的好处在于,由于事务发生在 GCF 内部,它非常快并且不会消耗太多带宽,而且更新只需要发送差异,不再需要发送整个文档。
为什么它不起作用
实际上,云功能真的很慢,有些更新需要超过 2 秒才能完成,它们也可能因争用而失败,而 firestore 连接器不知道,因此在这种情况下无法确保数据完整性.
如果我发现其他东西可以尝试,我将进行相应的编辑以添加更多解决方案
问题
我觉得我错过了一些东西,比如 firestore 是否有一些我根本不知道的东西可以解决我的用例,但我不知道它是什么,也许是我以前测试过的解决方案实施不当,或者我错过了一些重要的事情。我错过了什么?甚至有可能实现我想做的事情吗?我愿意接受数据重塑、查询更改等任何事情,因为它主要用于学习目的。
你的 diff-approach 似乎大部分是明智的,细节放在一边。
您应该内联存储 items
,但将 modificationsHistory
延迟到子集合中。对于整个根文档,记录 modificationsHistory
的哪些元素已经合并(通过时间戳应该足够了),以及所有尚未合并的元素,您必须在每个客户端上单独重新申请,使用上述时间戳进行查询。
modificationsHistory
中的每个条目不应描述单个差异,而应尽可能描述一组差异。
将 modificationsHistory
集合的更改批量应用到 items
,通过 GCF 延迟。您可以任意推迟此操作,并且您可能希望排除仅在最后几秒执行的修改,以说明 Firestore 中未建立的一致性。这样就没有争用的风险。
modificationsHistory
集合的清理必须进一步推迟,直到您可以确定没有客户端仍然可以访问根文档的旧版本。特别是如果你考虑到客户端在监听器被触发时并不严格要求更新根文档。
如果modificationsHistory
由于最终一致性约束以意想不到的方式发生变化,您可能需要在客户端重建补丁堆栈。例如。如果你在补丁集中有一个总顺序,如果集合意外地突然包含 "older" 个客户端之前未知的补丁,你需要从基础图像重新应用补丁堆栈。
总而言之,您应该能够避免频繁更新,并将其仅限于插入 modificationsHistory
子集合。带宽要求不超过一次获取整个文档的成本,加上流式传输尚未应用的补丁集合。预计不会发生争用。
您可以调整客户端可以忽略对根文档的硬更新的时间长度,以及在提交新差异之前可以在客户端批处理多少更改。后者也是关于另一个客户端最初必须获取多少文档的权衡,关于每个查询的最大文档限制。
如果您需要其他可能存在争用的信息,例如当前打开特定文档的用户列表,那么这些信息也应该进入子集合。
如果看到其他用户更改的延迟最终被证明是不可接受的,您可以选择一个额外的、具有实时能力的数据通道来分发特定文档上的补丁。 ActiveMQ 或其他一些消息代理在专用资源上运行,运行 独立于 FireStore。
您应该能够通过使用地图而不是数组来存储数据来减少更新文档所需的带宽。这将允许您仅发送正在使用其密钥更新的项目。
我不知道这对您的改变有多大影响,但听起来比其他选项更省力。
你说你的文档单个达到200kb也不是不可能。请记住,Firestore 将文档大小限制为 1mb。如果您计划提供超出此范围的支持文档,则需要找到一种方法来分割数据。
关于您的争用问题...您可能会考虑使用一种系统来“锁定”文档并防止它在其他用户尝试保存时接收更新。您可以使用使用 websockets 或 Firebase FCM 构建的简单消息系统来执行此操作。客户端将订阅文档的频道,并在他们尝试更新时发布。其他客户随后会收到文档正在更新的通知,必须等待才能保存自己的更改。
另外,我不知道 modificationsHistory 的内容是什么样的,但在我看来,这像是您可能保留在子集合中的数据类型。
在您尝试过的解决方案中,子集合对我来说似乎是最具扩展性的。您可以研究不使用 onSnapshot 侦听器的可能性,而是创建自己的事件系统来通知客户端更改。我想它可以像我上面提到的“锁定”系统一样工作。客户端在更新属于文档的项目时发送事件。订阅该文档频道的其他客户将知道检查数据库中的最新版本。
上下文
我有一个数据库,其中包含使用此模式的文档集合(缩短模式,因为某些数据与我的问题无关):
{
title: string;
order: number;
...
...
...
modificationsHistory: HistoryEntry[];
items: ListRow[];
finalItems: ListRow[];
...
...
...
}
这些文档很容易达到 100 或 200 kB,具体取决于它们包含的项目和 finalItems 的数量。同样重要的是,尽可能快地更新它们,尽可能减少带宽使用。
这是在 Web 应用程序上下文中,使用 Angular 9 和 @angular/fire
6.0.0。
问题
当最终用户在对象的 item
数组中编辑一个项目时,就像只编辑一个 属性,反映在数据库内部需要我发送整个对象,因为 firestore 的 update
方法不支持字段路径中的数组索引,唯一可以对数组进行的操作是添加或删除元素 as described inside documentation.
但是,通过发送整个文档来更新 items
数组的一个元素会给没有良好连接的任何人带来糟糕的性能,我的很多用户就是这种情况。
第二个问题是,在我的情况下,将所有内容都实时保存在一个文档中会使协作变得困难,因为其中一些元素可以由多个用户同时编辑,这会产生两个问题:
- 如果在同一秒内进行两次更新,由于文档争用过多,某些写入操作可能会失败。
- 更新不是原子的,因为我们一次发送整个文档,因为它不使用事务来避免更多地使用带宽。
我已经尝试过的解决方案
子集
描述
这是一个非常简单的解决方案:为 items
、finalItems
和 modificationsHistory
数组创建一个子集合,使它们易于编辑,因为它们现在有自己的 ID,所以很容易联系他们更新他们。
为什么它不起作用
有一个包含 10 finalItems
、30 items
和 50 个条目的列表 modificationsHistory
意味着我需要总共打开 4 个侦听器才能完全侦听一个元素.考虑到用户可以同时打开许多这些元素,收听几十个文档会造成同样糟糕的性能情况,在完整用户案例中可能更糟。
这也意味着如果我想更新一个有 100 个项目的大元素并且我想更新其中的一半,那么每个项目都会花费我一个写操作,更不用说更新所需的读操作量了检查权限等,每次写入可能 3 次,因此 150 次读取 + 50 次写入只是为了更新数组中的 50 项。
云函数更新文档
const {
applyPatch
} = require('fast-json-patch');
function applyOffsets(data, entries) {
entries.forEach(customEntry => {
const explodedPath = customEntry.path.split('/');
explodedPath.shift();
let pointer = data;
for (let fragment of explodedPath.slice(0, -1)) {
pointer = pointer[fragment];
}
pointer[explodedPath[explodedPath.length - 1]] += customEntry.offset;
});
return data;
}
exports.updateList = functions.runWith(runtimeOpts).https.onCall((data, context) => {
const listRef = firestore.collection('lists').doc(data.uid);
return firestore.runTransaction(transaction => {
return transaction.get(listRef).then(listDoc => {
const list = listDoc.data();
try {
const [standard, custom] = JSON.parse(data.diff).reduce((acc, entry) => {
if (entry.custom) {
acc[1].push(entry);
} else {
acc[0].push(entry);
}
return acc;
}, [
[],
[]
]);
applyPatch(list, standard);
applyOffsets(list, custom);
transaction.set(listRef, list);
} catch (e) {
console.log(data.diff);
}
});
});
});
描述
使用 diff 库,我在以前的文档和新更新的文档之间进行了比较,并将此 diff 发送到使用事务 API.
操作更新的 GCF这种方法的好处在于,由于事务发生在 GCF 内部,它非常快并且不会消耗太多带宽,而且更新只需要发送差异,不再需要发送整个文档。
为什么它不起作用
实际上,云功能真的很慢,有些更新需要超过 2 秒才能完成,它们也可能因争用而失败,而 firestore 连接器不知道,因此在这种情况下无法确保数据完整性.
如果我发现其他东西可以尝试,我将进行相应的编辑以添加更多解决方案
问题
我觉得我错过了一些东西,比如 firestore 是否有一些我根本不知道的东西可以解决我的用例,但我不知道它是什么,也许是我以前测试过的解决方案实施不当,或者我错过了一些重要的事情。我错过了什么?甚至有可能实现我想做的事情吗?我愿意接受数据重塑、查询更改等任何事情,因为它主要用于学习目的。
你的 diff-approach 似乎大部分是明智的,细节放在一边。
您应该内联存储 items
,但将 modificationsHistory
延迟到子集合中。对于整个根文档,记录 modificationsHistory
的哪些元素已经合并(通过时间戳应该足够了),以及所有尚未合并的元素,您必须在每个客户端上单独重新申请,使用上述时间戳进行查询。
modificationsHistory
中的每个条目不应描述单个差异,而应尽可能描述一组差异。
将 modificationsHistory
集合的更改批量应用到 items
,通过 GCF 延迟。您可以任意推迟此操作,并且您可能希望排除仅在最后几秒执行的修改,以说明 Firestore 中未建立的一致性。这样就没有争用的风险。
modificationsHistory
集合的清理必须进一步推迟,直到您可以确定没有客户端仍然可以访问根文档的旧版本。特别是如果你考虑到客户端在监听器被触发时并不严格要求更新根文档。
如果modificationsHistory
由于最终一致性约束以意想不到的方式发生变化,您可能需要在客户端重建补丁堆栈。例如。如果你在补丁集中有一个总顺序,如果集合意外地突然包含 "older" 个客户端之前未知的补丁,你需要从基础图像重新应用补丁堆栈。
总而言之,您应该能够避免频繁更新,并将其仅限于插入 modificationsHistory
子集合。带宽要求不超过一次获取整个文档的成本,加上流式传输尚未应用的补丁集合。预计不会发生争用。
您可以调整客户端可以忽略对根文档的硬更新的时间长度,以及在提交新差异之前可以在客户端批处理多少更改。后者也是关于另一个客户端最初必须获取多少文档的权衡,关于每个查询的最大文档限制。
如果您需要其他可能存在争用的信息,例如当前打开特定文档的用户列表,那么这些信息也应该进入子集合。
如果看到其他用户更改的延迟最终被证明是不可接受的,您可以选择一个额外的、具有实时能力的数据通道来分发特定文档上的补丁。 ActiveMQ 或其他一些消息代理在专用资源上运行,运行 独立于 FireStore。
您应该能够通过使用地图而不是数组来存储数据来减少更新文档所需的带宽。这将允许您仅发送正在使用其密钥更新的项目。
我不知道这对您的改变有多大影响,但听起来比其他选项更省力。
你说你的文档单个达到200kb也不是不可能。请记住,Firestore 将文档大小限制为 1mb。如果您计划提供超出此范围的支持文档,则需要找到一种方法来分割数据。
关于您的争用问题...您可能会考虑使用一种系统来“锁定”文档并防止它在其他用户尝试保存时接收更新。您可以使用使用 websockets 或 Firebase FCM 构建的简单消息系统来执行此操作。客户端将订阅文档的频道,并在他们尝试更新时发布。其他客户随后会收到文档正在更新的通知,必须等待才能保存自己的更改。
另外,我不知道 modificationsHistory 的内容是什么样的,但在我看来,这像是您可能保留在子集合中的数据类型。
在您尝试过的解决方案中,子集合对我来说似乎是最具扩展性的。您可以研究不使用 onSnapshot 侦听器的可能性,而是创建自己的事件系统来通知客户端更改。我想它可以像我上面提到的“锁定”系统一样工作。客户端在更新属于文档的项目时发送事件。订阅该文档频道的其他客户将知道检查数据库中的最新版本。