IndexedDB为何慢,有什么替代方案?

当您有一个Web应用程序,需要在客户端存储数据,无论是为了使其脱机可用,仅用于缓存目的还是其他原因。

对于浏览器端数据存储,您有一些选择:

  • Cookies随每个HTTP请求发送,因此您不能在其中存储多个字符串。
  • WebSQL已弃用,因为它从未是一个真正的标准,将其转变为标准将会很困难。
  • LocalStorage是一个同步API,覆盖了异步IO访问。存储和读取数据可以完全阻塞JavaScript进程,因此您不能在其中存储超过少量简单的键值对。
  • FileSystem API可用于存储纯二进制文件,但目前仅在Chrome中受支持。
  • IndexedDB是一个带索引的键-对象数据库。它可以存储JSON数据并遍历其索引。它得到了广泛支持并且很稳定。

2023年4月更新
自2023年初以来,所有现代浏览器都支持File System Access API,该API允许以更好的性能在浏览器中持久存储数据。

IndexedDB数据库

很明显,唯一可行的方法是使用IndexedDB。您开始开发您的应用程序,一切都很顺利。但是,一旦您的应用程序变得更大、更复杂或者只是处理更多的数据,您可能会注意到一些问题。IndexedDB很慢。不是像廉价服务器上的数据库那样慢,甚至更慢!插入几百个文档可能需要几秒钟。这段时间可能对于快速页面加载至关重要。甚至通过互联网将数据发送到后端可能比将其存储在IndexedDB数据库中更快。

事务与吞吐量

所以在我们开始抱怨之前,让我们分析一下到底是什么慢。当您在Nolans Browser Database Comparison上运行测试时,您会发现将1k个文档插入IndexedDB大约需要80毫秒,即每个文档0.08毫秒。这并不算慢。它相当快,而且您很可能不想在客户端同时存储那么多文档。但这里的关键是所有这些文档都在单个事务中写入。

使用比较工具,并将其更改为每个文档写入一个事务。结果出来了。使用每次写入一个事务插入1k个文档,大约需要2秒。有趣的是,如果我们将文档大小增加100倍,存储它们的时间仍然大致相同。这清楚地表明,影响IndexedDB性能的限制因素是事务处理,而不是数据吞吐量。

IndexedDB事务吞吐量

要解决IndexedDB性能问题,您必须确保尽可能少地使用数据传输/事务。有时这很容易,例如,与其遍历文档列表并调用单个插入,但大多数时候不是这样。用户点击周围,数据从后端复制,另一个浏览器选项卡写入数据。所有这些事情都可能在随机时间发生,您无法在单个事务中处理所有这些数据。

另一个解决方案是根本不关心性能。等待浏览器供应商将优化IndexedDB,一切都会变得很快。IndexedDB在2013年很慢,今天仍然很慢。如果这种趋势继续下去,几年后它仍然会很慢。等待不是一个选项。Chromium开发人员发表了一项声明,专注于优化读取性能,而不是写入性能。

转到WebSQL(即使已弃用)也不是一个选项,因为正如比较工具所示,它的事务速度更慢。

因此,您需要一种方法来使IndexedDB更快。以下我将列出一些可以进行的性能优化,以便在IndexedDB中进行更快的读写。

提示:您可以在此存储库中复制所有性能测试。在所有测试中,我们使用了一个包含40000个人类文档的数据集,年龄在1到100之间随机。

批处理游标

IndexedDB 2.0引入了一些新方法,可以用来提高性能。使用getAll()方法,可以创建一个比旧的openCursor()更快的替代方法,从IndexedDB存储中读取数据时可以提高性能。

假设我们想从存储中查询所有年龄大于25岁的用户文档。为了实现一个快速的批处理游标,只需要调用getAll()而不是getAllKeys(),我们首先需要创建一个包含主键ID的年龄索引作为最后一个字段。

1
2
3
4
5
6
7
myIndexedDBObjectStore.createIndex(
'age-index',
[
'age',
'id'
]
);

这是必需的,因为年龄字段不是唯一的,并且我们需要一种方法来检查上一批返回的最后一个文档,以便在下一次调用getAll()中从那里继续。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const maxAge = 25;
let result = [];
const tx

: IDBTransaction = db.transaction([storeName], 'readonly', TRANSACTION_SETTINGS);
const store = tx.objectStore(storeName);
const index = store.index('age-index');
let lastDoc;
let done = false;
/**
* Run the batched cursor until all results are retrieved
* or the end of the index is reached.
*/
while (done === false) {
await new Promise((res, rej) => {
const range = IDBKeyRange.bound(
/**
* If we have a previous document as checkpoint,
* we have to continue from it's age and id values.
*/
[
lastDoc ? lastDoc.age : -Infinity,
lastDoc ? lastDoc.id : -Infinity,
],
[
maxAge + 0.00000001,
String.fromCharCode(65535)
],
true,
false
);
const openCursorRequest = index.getAll(range, batchSize);
openCursorRequest.onerror = err => rej(err);
openCursorRequest.onsuccess = e => {
const subResult: TestDocument[] = e.target.result;
lastDoc = lastOfArray(subResult);
if (subResult.length === 0) {
done = true;
} else {
result = result.concat(subResult);
}
res();
};
});
}
console.dir(result);

如性能测试结果所示,使用批处理游标可以带来巨大的改进。有趣的是,选择一个较高的批处理大小很重要。当您知道需要给定IDBKeyRange的所有结果时,您根本不应设置批处理大小,而是直接通过getAll()查询所有文档。

IndexedDB分片

分片是一种在服务器端数据库中通常使用的技术,其中数据库被水平分区。与将所有文档存储在一个表/集合中不同,文档被分割成所谓的分片,每个分片都存储在一个表/集合中。在服务器端架构中执行此操作可以将负载分散到多个物理服务器之间,从而提高可扩展性。

当您在浏览器中使用IndexedDB时,当然无法在客户端和其他服务器之间分配负载。但您仍然可以从分片中受益。将文档在多个IndexedDB存储中进行水平分区已经显示出在写入和读取操作中有很大的性能改进,而只会稍微增加初始页面加载时间。

正如性能测试结果所示,分片应始终由IDBObjectStore而不是数据库来执行。使用10个存储分片并行运行整个数据集的批处理游标比在单个存储上运行快约28%。初始化时间从9毫秒增加到了17毫秒。通过批处理迭代索引获取四分之一的数据集时,分片比查询单个存储时的速度快43%。

不足之处在于,当必须跨分片运行时,按其ID获取10k个文档会变慢。此外,重新组合来自不同分片的结果以生成所需的查询结果可能需要很大的工作量。当执行没有限制的查询时,分片方法可能会导致数据加载的巨大开销。

自定义索引

索引显著改善了IndexedDB的查询性能。当您搜索其子集时,不必从存储中获取所有数据,而是可以遍历索引并在找到所有相关数据时停止遍历。

例如,要查询所有年龄大于25岁的用户文档,您将创建一个年龄+ID索引。为了能够在索引上运行批处理游标,我们始终需要将我们的主键(ID)作为最后一个索引字段。

与其这样做,您可以使用自定义索引来提高性能。自定义索引在写入时会运行在每个文档上添加的辅助字段ageIdCustomIndex上。现在我们的索引仅包含单个字符串字段而不是两个(年龄-数字和ID-字符串)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 在插入文档时添加ageIdCustomIndex字段。
const idMaxLength = 20; // 必须知道以构建自定义索引
docData.ageIdCustomIndex = docData.age + docData.id.padStart(idMaxLength, ' ');
store.put(docData);
// ...

// 正常索引
myIndexedDBObjectStore.createIndex(
'age-index',
[
'age',
'id'
]
);

// 自定义索引
myIndexedDBObjectStore.createIndex(
'age-index-custom',
[
'ageIdCustomIndex'
]
);

为了在索引上进行迭代,您还需要使用自定义制作的keyrange,具体取决于批处理游标的最后检查点。因此,必须知道id的最大长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 正常索引的keyrange
const range = IDBKeyRange.bound(
[25, ''],
[Infinity, Infinity],
true,
false
);

// 自定义索引的keyrange
const range = IDBKeyRange.bound(
// 将两个值组合成一个字符串
25 + ''.padStart(idMaxLength, ' '),
Infinity,
true,
false
);

正如所示,使用自定义索引可以进一步提高批处理游标的性能约10%。

使用自定义索引的另一个重大好处是,您还可以在其中编码布尔值,这在普通IndexedDB索引中无法实现。

不约束持久性

基于Chromium的浏览器允许在创建IndexedDB事务时将持久性设置为relaxed 。这将以更不安全的持久性模式运行事务,从而可以提高性能。

用户代理可能认为事务已成功提交,只要所有未解决的更改已写入操作系统,而无需后续验证。

如此所示,使用放宽持久性模式可以略微提高性能。当需要运行许多小型事务时,可以测量到最佳性能改进。较少且较大的事务并不会受益那么多。

显式事务提交

通过显式提交事务,可以实现另一种轻微的性能改进。与等待浏览器提交打开的事务不同,我们调用commit()方法显式关闭它。

1
2
3
4
// .commit() 不是所有浏览器都可用,因此首先检查它是否存在。
if (transaction.commit) {
transaction.commit()
}

这种技术的改进是微不足道的,但正如这些测试所示,是可以观察到的。

优先使用内存

为了防止事务处理并解决性能问题,我们需要停止将IndexedDB用作数据库。相反,所有数据在初始页面加载时加载到内存中。在这里,所有读取和写入都发生在大约快100倍的内存中。只有在发生写入后的一段时间后,内存状态才会被持久化到IndexedDB中,使用单个写入事务。在这种情况下,IndexedDB被用作文件系统,而不是数据库。

已经有一些库可以做到这一点:

  • 带有IndexedDB适配器的LokiJS
  • Absurd-SQL
  • 带有empscripten文件系统API的SQL.js
  • DuckDB Wasm
内存:持久性

不直接使用IndexedDB的一个缺点是您的数据并不始终持久。当JavaScript进程退出而没有持久到IndexedDB时,数据可能会丢失。为了防止这种情况发生,我们必须确保将内存状态写入磁盘。一个要点是尽可能快地进行持久化。例如,LokiJS具有增量indexeddb适配器,它只将新写入保存到磁盘,而不是持久化整个状态。另一个要点是在正确的时间点运行持久化。

  • 当数据库处于空闲状态且未运行写入或查询时。在此期间,如果有任何新写入出现,我们可以持久化状态。
  • 当窗口触发beforeunload事件时,我们可以假设JavaScript进程随时退出,并且我们必须持久化状态。在beforeunload之后有几秒钟的时间,足以存储所有新更改。这已被证明相当可靠。

唯一可能发生的丢失事件是当浏览器意外退出时,例如当浏览器崩溃或计算机的电源关闭时。

内存中:多标签支持

Web应用程序与“普通”应用程序之间的一个重大区别是,您的用户可以同时在多个浏览器标签中使用应用程序。但是,当您将所有数据库状态保存在内存中并且只定期将其写入磁盘时,多个浏览器标签可能会彼此覆盖,并且您可能会丢失数据。当您依赖客户端 - 服务器复制时,这可能不是问题,因为丢失的数据可能已经与后端和其他标签复制。但是,当客户端处于脱机状态时,这种方法将不起作用。

解决该问题的理想方法是使用SharedWorkerSharedWorker类似于一个WebWorker,它运行自己的JavaScript进程,只是SharedWorker在多个上下文之间共享。您可以在SharedWorker中创建数据库,然后所有浏览器标签都可以向Worker请求数据,而不是拥有自己的数据库。但遗憾的是,SharedWorker API并不适用于所有浏览器。Safari放弃了对其的支持,而InternetExplorerAndroid Chrome从未采用它。此外,它无法进行polyfill。更新:苹果在Safari 142中重新添加了SharedWorkers。

相反,我们可以使用BroadcastChannel API在标签之间进行通信,这样无论打开多少个标签,始终显示的标签生效。

缺点是需要一些时间来加载初始页面(约150毫秒)。当JavaScript进程完全被阻塞时可能会失败。当发生这种情况时,一个很好的方法是重新加载浏览器标签以重新启动选举过程。