现在开始将Github作为数据库

在开发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。

什么,为什么不直接用数据库,那当然是因为,能白嫖为什么要付钱呢。

相关推荐
折哥的程序人生 · 物流技术专研14 小时前
Java面试85题图解版 · 特别篇:2026后端高频面试题复盘(算法底层逻辑+高并发架构设计全解析,附Java实战代码)
java·网络·数据库·算法·面试
GoGeekBaird14 小时前
从 Prompt Engineering 到 Loop Engineering,我觉得 AI 开发这事儿终于开始变味了
后端·github
问心无愧051315 小时前
ctf show web入门160 161
前端·笔记
李小白6615 小时前
第四天-WEB服务器基本原理,IIS服务
运维·服务器·前端
humcomm15 小时前
AI编程时代新前端职位
前端·ai编程
好家伙VCC16 小时前
Web Components主题热切换方案揭秘
java·前端
想吃火锅100516 小时前
【leetcode】14.最长公共前缀js
算法·leetcode·职场和发展
甲维斯16 小时前
Kimi版超级玛丽效果“惊人”,配额不足5厘米!
前端·人工智能
aosky16 小时前
一台电脑配置多个 SSH Key 对应不同的 GitHub 账号
运维·ssh·github
hboot16 小时前
AI工程师第一课 - Python
前端·后端·python