
在开发Cent之前,困扰我的一大问题就是如何处理数据同步,似乎在没有后端数据库的情况下,这几乎是不可能的,不过Github让这一切成为了可能。
考虑这样一个场景,你需要实现一个Web App,它和核心数据是一份数组,核心的功能是对这份数组的增删改查。对于一个纯本地应用来说,这很容易,使用indexdedDB就能实现,并且有很多第三方库用来简化数据库操作,例如Dexie.js,IDB等等。但是当你需要将其改造为在线应用,问题就开始复杂起来:同一个用户在多个设备上登录,并且分别进行了不同的操作,那么最终得到的结果要如何保证一致呢?
最简单的方法是,将所有操作直接同步写到云端数据库,所有的修改都基于云端数据库,确保用户在每个设备上看到的都是最新的,问题解决。
然而现实情况是,出于网络或者各种原因,用户的操作并不能总是及时地更新到云端,这就导致了延迟修改,冲突就会发生,就像git一样,修改同一个文件总是伴随着心惊胆战,必须要有一个人跳出来承担解决冲突的重任。
避免冲突
那么有没有办法避免冲突呢?当然有的,冲突的重灾区,在线文档应用已经总结出了好几种一致性算法,用于避免冲突,例如OT协同算法等,这些算法的核心思想就是通过记录"行为"代替记录"数据",从而避免数据不一致,它基于这样一条很简单的公理,纯函数的运行结果总是一致的,那么只要将用户的所有操作转换为"纯函数",不产生任何副作用,那么只要重新运行这些操作,最终就能得到一致的结果。通过记录增量数据而不直接改动原始数据,避免冲突就成为了可能。
完整的OT算法十分复杂,对于大多数应用,比如Cent来说,其实用不到这么复杂的算法,只需要根据其解决冲突的原理做一些改动,就可以快速实现无冲突的数据结构。
假设App核心的数组元素结构如下所示:
css
type Item = {
id: string;
time: number;
amount: number;
categoryId: string
}
其中id是必需要素,它指定了每个元素的唯一标识符,需要确保每个元素都拥有独一无二的id,这在分布式应用里是最基本的要求,这也很简单,通过uuid算法库就可以随意生成唯一ID。
然后我们就可以开始实现OT数据结构了,最理想的结构是,将用户的每一个操作,都记录为一个update,例如用户新增了一笔记账,就可以保存为下面的数据:
yaml
const record = {
id: newRecordId,
action: 'add',
time: now,
content: {
id: newId,
time: now,
categoryId: 'Food',
amount: 1000,
}
}
修改和删除也依次类推,通过不同的action进行区分,最终得到一份数组,将这些数据的操作"回放"一遍,就能得到实际的结果,相当于将用户的行为"重放"。这样的好处是,只需要不断地向数据库里追加数据,而不需要关心之前的数据究竟是怎样的,即使用户在不同的设备上触发了不同的改动,最终依旧能合并成一份数据,并且所有设备上计算出的结果都会是一致的。
用一个简单的流程来描述下多个设备同时编辑的情况
css
初始云端数据:[add 1, add 2, delete 1] 实际数据: [1,2]
设备A操作:[add 3, add 4] 实际数据:[1,2,3,4]
设备B操作:[add 5, delete 2] 实际数据:[1,5]
用户在不同设备上进行了不同的操作,在未同步前,不同设备中显示的数据是不一样的,这很正常,现在来看同步后的结果:
css
初始云端数据:[add 1, add 2, delete 1] 实际数据: [1,2]
// 假设设备A先上传
云端数据:[add 1, add 2, delete 1, add 3, add 4] 实际数据: [2,3,4]
// 设备B再上传
云端数据:[add 1, add 2, delete 1, add 3, add 4, add 5, delete 2] 实际数据: [3,4,5]
设备A数据:[3,4,5]
设备B数据:[3,4,5]
// 假设设备B先上传
云端数据:[add 1, add 2, delete 1, add 5, delete 2] 实际数据: [5]
// 设备A再上传
云端数据:[add 1, add 2, delete 1, add 5, delete 2, add 3, add 4] 实际数据: [5,3,4]
设备A数据:[5,3,4]
设备B数据:[5,3,4]
可以看到,虽然AB设备上进行了不同的操作,但是最终得到的云端数据是完全一致的,并且无论是A设备先上传改动,还是B设备先上传改动,最终结果都不会改变,只是最终顺序不太一致,这可以接受,因为最终所有的数据都会根据内置的时间戳进行排序,通过排序后得到的数组内容是完全一致的。
如果继续优化,将每个Item的key作为操作最小单位,例如 'update Item1 key time to xxx'这样的数据结构,可以顾及到更细粒度的操作行为,例如在A设备上改了一笔账单的备注,在另一个设备上改了同一笔账单的金额,最终结果将会同时保留最新的金额和备注,但是考虑到这样的场景较少,Cent只是采用了将一笔账单单独作为记录的最小单位,也足够完成无冲突同步。
当然,并非所有的数据都属于数组的一部分,例如账本的元数据,用户的个人设置等等这些简单的json文件,Cent通过一个特殊 meta action 用于覆盖这一部分的更新,meta数据的更新是简单的覆盖,因此会存在被覆盖的问题,但是meta文件较小,同步也很快,所以不会像账单数据那样急需无冲突数据结构。
scala
export type BaseItem = {
id: string;
[key: string]: any;
};
export type Update<T extends BaseItem> = {
type: "update";
timestamp: number;
id: string;
value: T;
metaValue?: undefined;
overlap?: number;
};
export type Delete<T extends BaseItem> = {
type: "delete";
timestamp: number;
id: string;
value: T["id"];
metaValue?: undefined;
overlap?: number;
};
export type MetaUpdate = {
type: "meta";
timestamp: number;
id: string;
value?: undefined;
metaValue: any;
overlap?: number;
};
export type FullAction<T extends BaseItem> = Update<T> | Delete<T> | MetaUpdate;
export type Action<T extends BaseItem> = Omit<
FullAction<T>,
"timestamp" | "id"
> & {
timestamp?: number;
};
export type Full<T extends BaseItem> = T & {
__delete_at?: number;
__create_at: number;
__update_at: number;
};
与Github结合
无冲突的数据结构使得延迟同步成为了可能,这就意味着在同步服务上我们可以有更多选择,它让我们不用再考虑延迟问题,只要一个服务能够实现上传和下载,无论速度有多慢,都可以成为一个同步端点,最为常见的就是各种WebDAV,云盘等,以及,Github。
Github提供了非常完善的API服务,可以允许用户自由地控制自己的仓库内容,包括但不限于创建仓库,修改文件等等,并且完全支持跨域访问,对于纯前端Web来说简直是大善人,只需要一个token就可以将整个Github仓库作为在线文件系统使用,还能享受Git的历史记录功能,同时囊括了一系列授权登录服务,简直不要太爽。
不过这并不意味着Github API完全符合我们的需求,虽然它提供了简单直接的读取和写入接口,但是对于账单数据来说,存放在一个单独文件中,频繁地读取和写入还是有点太不雅观了,更何况大陆地区的Github访问速度一直时好时坏,虽然说不在意传输速度,但是也不能完全没有用户体验吧,因此,Cent做了一点小小的努力,用于优化Github同步体验。
延迟同步
首先是上传文件,Cent使用延迟同步,当短时间内多次修改账单时,所有的操作会被合并为最终的一次提交,减少Github API的调用。这部分代码通过Scheduler实现
增量更新
同时,Cent会将账单拆分,按照长度将账单分为不同文件,这就意味着每次更新都只需要上传和下载一份文件,其他的文件只要哈希值不同,则无需修改,Cent内部已经实现了哈希值校验,使得拉取云端数据时间大幅度减少。
附件上传
Github支持上传任意文件,这就使得附件上传也成为了可能,Cent内部也进行了处理,在上传到Github之前,Cent会优先读取本地indexedDB中的文件,同步完成后再切换成云端的文件地址。不过Github有文件大小限制和仓库大小限制,因此不推荐上传大体积的照片。
不止Github
Cent将上述的所有功能都尽可能地做了解耦,例如无冲突数据结构,实现为StashBucket,支持传入不同的Storage实现,不仅限于indexedDB。Github核心同步代码则实现为Gitray,一样支持自定义的Storage。这些解耦使得Cent后续可以轻松接入不同的同步端点,例如网盘,WebDAV等等,事实上,Cent的离线模式正是通过创建一个空的同步端点实现的,这使得Cent的可拓展性大大增强。
结尾
将Github作为数据库,目前其实已经有很多第三方实现,它们都利用了Github强大的开放API,让低成本数据协同变得越来越简单,Cent也是其中的一员。我最早开始想到要将Github作为"网盘",是从Urodele开始的。正是在开发Urodele时,我想到了将Github自身作为图床,从而摆脱不稳定并且可能收费的第三方图床服务,用来作为博客图片的展示,因此熟悉了相关的API,于是才有了在改造旧oncent的过程中,将Github直接作为数据库的实现,通过Github,直接完美解决了多人协作这个困扰我多年的问题。
目前来说,Github确实是最佳选择,它用户多,API完善,还自带多人协作,解决方案接近完美,后续,我也会继续调研其他同步方案,让Cent的使用体验更上一层。毕竟Cent的目标,就是要成为免费记账软件里功能最多的,功能多的记账软件里最便宜的APP。
什么,为什么不直接用数据库,那当然是因为,能白嫖为什么要付钱呢。