indexdb 的三两事
indexdb 在浏览器上的支持已经有很久了,它的特点是:
- 只暴露了异步 API
- 只支持事务模型操作
- 存储基本没有上限(有些浏览器是 500M 左右)
但是,一般情况下,很少人直接使用这些接口,因为它有些难以理解,有些说明其实更加神奇,比如这个官方示例:
const trans1 = db.transaction("foo", "readwrite");
const trans2 = db.transaction("foo", "readwrite");
const objectStore2 = trans2.objectStore("foo");
const objectStore1 = trans1.objectStore("foo");
objectStore2.put("2", "key");
objectStore1.put("1", "key");
官方的说法是:After the code is executed the object store should contain the value “2”, since trans2
should run after trans1
.
可以简单理解:transaction 提交的顺序与创建顺序有关。再精简一下就是:别同时存在多个 transaction,因为把握不住。
indexdb 在 rust 生态的三两事
indexdb 在 web-sys 内,其实是非常难用的,所以,社区有几个封装库,比如 indexed-db/idb,如果你仔细查看这两个库,就会发现一个问题:由于 rust future 在 browser 上的调度与 js 原生的存在一定的差异,大部分 indexdb rust bind 在 js 多线程调度下,transction commit 会出现非预期行为,而这一点是很难修复的,目前只有 indexed-db 这个库修复了 indexdb 在 webworker 环境下使用的问题。
indexdb transaction 开启还有一个 option 选项,叫做 durability
,在 rust 的 web-sys 库里面这个属性被标记为 web_sys_unstable_apis
,必须通过 fork 才能开启,因为它的开启需要使用环境变量 RUSTFLAGS
才能打开,而这个环境变量在 cargo 1.57 被禁止在 build.rs 中修改/读取。而这个属性可以修改 indexdb transaciton commit 的行为,让它变得严格或者宽松,让数据落盘可靠一些,默认选项属于中间值,当前,如果需要修改这个选项,需要手动写 js 进行人工 ffi 了。
综合上面的问题,我们可以得出一个最佳实践:
- 尽量不要同时存在多个 transaction
- 尽量让 indexdb 只在一个线程执行操作
- 如果 rust 无法做到一些操作,建议手动写 js,然后用 ffi 绑定到 rust 实现需求
indexdb 在 lightclient 的适配
lightclient native 使用的是 rocksdb,它的接口是同步的,indexdb 接口必然是异步的,那么如何融合这两者的 API 让它协调合作呢,同时还需要让 indexdb 满足上面说的一些限制。一般来讲,方案有以下三种:
- 使用 BtreeMap 作为内存实现,在开启/关闭的时候,做全数据刷入和读出操作
- 在 db 层做统一封装,对外只暴露异步接口
- 对 indexdb 进行封装,让它暴露同步接口
第一个方案没什么好说的,我们重点来说说后两者。
统一异步接口
就个人而言,这种代码改动量相对来说可以接受,并且 rocksdb 本来就是在异步的网络环境中直接使用同步接口,现在只是套上一个异步接口的壳而已,并不费劲,示例代码如下:
原先的代码是这样的,这是 rocksdb 的 iterator 模式:
pub fn is_filter_scripts_empty(&self) -> bool {
let key_prefix = Key::Meta(FILTER_SCRIPTS_KEY).into_vec();
let mode = IteratorMode::From(key_prefix.as_ref(), Direction::Forward);
self.db
.iterator(mode)
.take_while(|(key, _value)| key.starts_with(&key_prefix))
.next()
.is_none()
}
之后暴露的接口变成这样:
pub async fn is_filter_scripts_empty_async(&self) -> bool {
self.is_filter_scripts_empty()
}
fn is_filter_scripts_empty(&self) -> bool {
let key_prefix = Key::Meta(FILTER_SCRIPTS_KEY).into_vec();
let mode = IteratorMode::From(key_prefix.as_ref(), Direction::Forward);
self.db
.iterator(mode)
.take_while(|(key, _value)| key.starts_with(&key_prefix))
.next()
.is_none()
}
外部只能调用这个 async 壳,本质上一切都没有变,而因为 rust future 的特性,只有在返回 pending 的时候,future 才会有 yield 点,同时才会有后续 waker 的 wake 操作;如果一个 future 只执行顺序操作,没有 yield 点,它的执行就是正常的顺序执行,也就是同步代码的逻辑,整体的行为也不会发生变化。这就达成了 db 层统一异步接口的目的。
indexdb 同步接口
上面说过,indexdb 只有异步接口,要将它封装成同步接口,要考虑几个问题:
- 任意同步都会卡死浏览器事件主循环,即势必启用 webworker,将 lightclient 放在另一个线程执行
- indexdb 本身要异步,lightclient 要同步,这两个程序需要两个 webworker 线程
- 两个 webworker 线程需要做一个同步 channel 进行通信,重点是 web 端 atomic 的使用
这样让 indexdb 在一个线程接任务异步执行,完成后将结果返回 lightclient 线程;lightclient 线程在发起 db 操作之后,直接 atomic.wait
等待,通过多线程的方式,将异步操作同步话,完成对 indexdb 的同步封装,缺点就是比较费线程,要需要考虑同步 channel 的实现正确性问题。
主线程与 lightclient 线程的通信必须是异步的,这就可以直接用 webworker 的 onMessage
和 postMessage
接口搞定,当然,如果激进一点,可以将暴露在用户的 js function 中直接打开 indexdb,但我无法确定多线程同时使用 indexdb transction 会不会出问题,尤其是之前说了,多个 transaction 同时存在的时候,提交会出现非预期行为。
最后
之前说的 branch 实现,是用了 db 接口全异步化的方式去实现的,所有 native rpc 都实现成了 js function 供用户调用,而 indexdb 同步接口封装的尝试是另一个同时去尝试做的,目前实现了一个 poc,但基本验证了方案的可行性,最终 production 的项目到底选哪种方案,目前暂不可知,不过可以肯定的是,两种路线都没有什么大问题。
顺便一说,lightclient 有接收用户 transaction 验证并发送给全节点的功能,native 下,验证功能是 ckb-vm 用汇编实现的,而在 wasm 中,这个功能是由纯 rust 实现的解释器,中间会有细微的差异,这也是难点之一,因为如果 cycle 验证不一致,wasm 实现的 lightclient 就会被全节点拉黑。同时,因为 wasm 下不支持线程操作,交易验证流程也与全节点有很大的差别,简单说,调度系统被完全禁用了。
再顺便一说,tentacle 对 js websocket 的封装,是做了一个大胆的 unsafe impl 的,因为以 tentacle 的封装,用户无法拿到 raw websocket ptr,在 tentacle 的调度下,这个 ptr 只能在当前线程工作,这意味着这种实现看似允许 ptr 在线程间移动,实际上完全无法实现,避开了大量的修改。社区也有类似的库,比如 send_wrapper,比我的想法还激进一些,它的是可以移动但读必须在原线程,而我的是完全无法移动。