前言
当一个 Javascript 程序需要在浏览器端存储数据时,你有以下几个选择:
- Cookie:通常用于 HTTP 请求,并且有 64 kb 的大小限制。
- LocalStorage:存储 key-value 格式的键值对,通常有 5MB 的限制。
- WebSQL:并不是 HTML5 标准,已被废弃。
- FileSystem & FileWriter API:兼容性极差,目前只有 Chrome 浏览器支持。
- IndexedDB:是一个 NOSQL 数据库,可以异步操作,支持事务,可存储 JSON 数据并且用索引迭代,兼容性好。
很明显,只有 IndexedDB 适用于做大量的数据存储。但是直接使用 IndexedDB 也会碰到几个问题:
-
IndexedDB API 基于事务,偏向底层,操作繁琐,需要简化封装。
-
IndexedDB 性能瓶颈主要在哪儿?
-
IndexedDB 在 浏览器多 tab 页的情况下可能会对同一条数据记录进行多次操作。
本篇文章将结合笔者的实践经验,就以上问题来进行相关探索。
Log 日志存储场景
有这样一个场景,客户端产生大量的日志并存放若干日志。在发生某些错误时(或者长连接得到服务器的指令时)可拉取本地全部日志内容并发请求上报。
如图所示:
这是一个很好的设计到了 IndexedDB CRUD 场景的操作,在这里,我们只关注 IndexedDB 存储这部分。有关于 IndexedDB 的基础概念,如仓库 IDBObjectStore、索引 IDBIndex、游标 IDBCursor、事务 IDBTransaction,限于篇幅请参照 IndexedDB-MDN。
创建数据库
我们知道 IndexedDB 是事务驱动的,打开一个数据库 db_test,创建 store log,并以 time 为索引。
调用语句如下:
增删改操作
当日志插入一条数据,我们需要提交一个事务,事务里对 store 进行 add 操作。
由于每次的增删改查都需要打开一个 transaction,这样的调用不免显得繁琐,我们需要一些步骤来简化,提供 ES6 promise 形式的 API。
调用代码如下:
查询
查询有很多种情况,常见的 ORM 里提供范围查询和索引查询两种方法,范围查询中还可以分页查询。在 IndexedDB 中我们简化为 getByIndex。
查询需要使用到 IDBCursor 游标和 IDBIndex 索引。
查询当然还有更多可能,比如查询一张表全部的数据,或者是 count 获取这张表的记录数量等,留待读者们自行扩展。
优化
我们需要将 Model 和 Database 拆开来,上文 createDB 的时候做一些改进,类似 ORM 一样提供映射,以及基础的增删改查方法。
调用如下:
当然这只是一个很简陋的模型,它还有一些不足。比如查询时,开发者调用时不需要接触 IDBKeyRange,类似是 sequelize 风格的,映射为 time: { gt: new Date().getTime() },用 gt 来替代 IDBKeyRange.lowerbound。\
批量操作
值得一提的,IndexedDB 的操作性能和提交给它的事务多少有着紧密的关系,推荐尽可能使用批量插入。
批量操作,可以采取事件委托来避免产生许多的 request 的 onsuccess、onerror 事件。
性能探索
IndexedDB 的插入耗时与提交给它的事务数量有显著的关联。我们设置一组对照实验:\
- 提交 1000 个事务,每个事务插入 1 条数据。
- 提交 1 个事务,事务中插入 1000 条数据。
测试代码如下:
减少事务提交非常重要,以至于需要有大量存入的操作时,都推荐日志在内存中尽可能合并下,再批量写入。
值得一提的是,body 在上面的对照实验中只写入了个位数的字符,假设每次写 5000 个字符,批量写入的时间也只是从 250ms 提升到 300ms,提升的并不明显。
让我们再来对比一组情况,我们会提交 1 个事务,插入 1000 条数据,在 0 到 500 万存量数据间进行测试,我们得到以下数据:
上文数据表明波动并不大,给出结论在 500w 的数据范围内,插入耗时没有明显的提升。当然查询取决的因素更多,其耗时留待读者们自行验证。
多 tab 操作相同数据的情况
对于 IndexedDB 来说,它只负责接收一个又一个的事务进行处理,而不管这些事务是从哪个 tab 页提交来的,就可能会产生多个 tab 页的 JS 程序往数据库里试图操作同一条数据的情况。
拿我们的 db 来举例,若我们修改创建 store 时的索引 time 为:
同时打开 3 个 tab,每个 tab 都是每 20ms 往数据库里写入一份数据,大概率会出现 error,解决这个问题的理想方法是 SharedWorker API, SharedWorker 类似于 WebWorker,不同点在于 SharedWorker 可以在多个上下文之间共享。我们可以在 SharedWorker 中创建数据库,所有浏览器的 tab 都可以向 Worker 请求数据,而不是自己建立数据库连接。
遗憾的是 SharedWorker API 在 Safari 中无法支持,没有 polyfill。作为取代,我们可以使用 BroadcastChannel API,他可以在多 tab 间通信,选举出一个 leader,允许 leader 拥有写入数据库的能力,而其他 tab 只能读不能写。
下面是一个 leader 选举过程的简单代码,参照自 broadcast-channel。
调用代码如下:
效果如 broadcast-channel 这样:
总结
在浏览器中离线存放大量数据,我们目前只能使用 IndexedDB,使用 IndexedDB 会碰到几个问题:
- IndexedDB API 基于事务,偏向底层,操作繁琐,需要做个封装。
- IndexedDB 性能最大的瓶颈在于事务数量,使用时注意减少事务的提交。
- IndexedDB 并不在意事务是从哪个 tab 页提交,浏览器多 tab 页的情况下可能会对同一条数据记录进行多次操作,可以选举一个 leader 才允许写入,规避这个问题。
本仓库使用代码见 github:github.com/everlose/in…
近期活动推荐
前端开发作为当下热门技术之一,受到不少开发者和学习者的关注。网易智企联合 CCF YOCSEF 武汉共同打造了《前端有话说系列公开课》,为广大开发者提供学习与交流的机会。本次前端有话说系列公开课能够满足开发者从基础入门到企业实践再到未来就业三个板块的不同需求,深入浅出帮助开发者了解和掌握前端知识。本次系列公开课的安排如下,欢迎大家报名参加: