Eidos 计算字段设计实现

2023-11-06#Eidos#设计#sqlite-wasm

多维表格无法回避的性能问题

多维表格简单易用的特性并不是凭空而来的,其背后的代价是数据量级和性能的衰退。在数据量级上,数据库 > Excel > 多维表格。换句话说,同等量级的数据,多维表的性能最差,业界标杆 airtable 单表行数限制是 100,000。更不用提 Notion 这种不是专门做表格的,性能瓶颈可能都不在计算上,而是停留在 UI 上。Untitled 283 行的数据,已经可以让 Notion 的体验下降一大截。

我对 Notion 保守的上限估计是 10,000 行,或许大多数用户根本累计不到这么多数据。我曾经也天真地以为对于个人而言 Notion 够用,大部分生活场景的数据是1k的数量级,Notion 应该能承担的住。几十篇文章、几十部游戏、几百部电影、几百本书、几千首歌。但是当我看到日益增长的 GitHub star repo,我开始意识到随着年岁的增长,个人数据的量级也会逐步增加,就像每年更替的手机存储一样,越来越大。

对于企业而言,对数据量的需求是一个不可回避的问题。我也能想象出企业客户向 Notion 反馈表格卡顿的问题,然后销售给了一堆治标不治本的方案。如果就像多维表格宣传的那样,一套表格模板替换掉一个 SaaS App。可曾想 SaaS App 依赖的关系型数据库在云原生技术的加持下可以无限扩容,但是对多维表格而言是否能无限扩容?这个答案是否定的,不然 airtable 也不会定个 100k 的行数上限。

Eidos 是服务于个人的。个人可能不会像企业级对数据量的需求那么大,但我们也追求最优的性能实现。我们希望 Eidos 是可以承载大量数据的,希望你可以在 Eidos 中可以做自己的单词本、归档存储成千上万条推文、几十上百 G 的微信聊天记录等等。争取把每个 SaaS 服务都送走,最后把用户送走,数据导到 U 盘上,放进你的骨灰盒里。

性能瓶颈在计算

不管底层实现如何,现在的多维表格定位更像是一个易用的关系型数据库。多维表格是关系型数据库之上的一层抽象。

其性能瓶颈和数据库一样,在于计算。随着计算字段的增多,引用关系的增加,计算复杂度增加,需要计算的数据变多,其性能是逐步下降的。

什么是计算字段

计算的定义比较宽泛,在多维表格的情景下。一般包含三种

  1. 计算公式 formula
  2. 维持关系的 link/relation
  3. 汇总计算的 rollup

一个简单的例子,存储用户的生日,计算用户的年龄。通常的做法生日被视为一个实体字段,存储在表中,一般是不变的。年龄被视为一个计算字段,查询时计算。age = thisyear - birthyear ,age 是一个计算字段。

你有很多的朋友,有个朋友叫张三。你和张三之间连接是朋友关系,可以用 link/relation 表示。

你想找出你年纪最大的朋友,首先计算的是你朋友们的 age,然后是找出最大的 age 。你家有一头牛,隔壁王五家有2头牛,加起来是3头,挨家挨户把牛的数量加起来就是整个村子拥有的牛的数量。这种是汇总(aggregate) 计算

如你说见,计算你年龄最大的朋友这件事情已经综合涉及到了 formula、link、rollup 这些计算字段。

如何获得更好的计算性能

贴近底层实现以获得更好的性能。

站在应用开发的思维上,通常是数据库只作为简单的存储,把复杂的计算逻辑放在应用层做,那么性能就会大打折扣。每一层抽象带来优势的同时都会引起性能损耗,你可以理解为存在中间商赚差价。

但是如果站在数据库的角度来看,一些特性可能一条 sql 就解决了,并不需要太多额外的工作。接下来我会以 formula 的实现为例子,阐述两种不同的实现方案。

Formula 的简单实现思路

计算放在应用层

为了实现 age = thisyear - birthyear 的解析,首先你需要定义一套表达式的语法,然后就是编译原理里面的那些 tokenizer parser interpreter 之类的。从数据库读取原始数据到内存中,解释器带上数据执行表达式得到结果。这里的性能损耗是你需要把数据读取到应用内存里,倘若再加上一个 rollup 统计全部朋友的年龄之和,你需要读取全部的数据。不管用何种方式,和数据库交互的时间、内存开销是性能瓶颈所在。

简单代码概述如下


// 渲染
const allFriends  = await friend.findall()
return allFriends.map(friend=>{
	return {
		...friend,
		age: yourEval(`year(now()) - year(friend.date_of_birth)`, context={friend})		
	}
})

// 求和
const allFriends  = await friend.findall()
return allFriends.reduce((prev,friend)=>{
		const age = yourEval(`year(now()) - year(friend.date_of_birth)`, context={friend})
		return prev+age
},0)

按照现在流行的云计算架构,数据库是一个单独的服务。于是你的数据经历了,从硬盘到内存,通过网络传输到计算服务,计算服务再把网络的数据 load 到内存计算,兜兜转转一圈你得到了一个数字。

计算放在数据库层

sql 本身就支持计算字段,不用再实现一套表达式语法,也不用先查询出用户年龄,然后再计算汇总。直接一条 sql 语句就搞定了,简单直接。应用向数据库服务发起了一个 query 请求,然后数据库返回了一个数字。

SELECT strftime('%Y', 'now') - strftime('%Y', date_of_birth) AS age FROM users WHERE user_id = <user_id>;

唯一的门槛是易用性,不是所有人都会 sql,去记那些公式函数。但是大家都有数学基础,会简单的加减乘除。因此一个显而易见的方案是,封装一些简单易用的函数给终端用户。换到多维表格中来,如果我们提供类似 Notion 公式的实现,我们可以这样

year(now()) - year(props('date_of_birth'))

这种方案的思路是实现一套简单的语法解析,把用户输入的公式转化为 sql 查询。以此享受数据库的底层计算查询优化,减少不必要的开销。

strftime 是 sql 中的通用函数,需要记一些参数,year 则是我们为普通用户准备简单易用函数。没有什么是一个函数不能解决的,如果有就再加一个函数。

在 sqlite-wasm 中实现用户自定义函数

sqlite-wasm 向开发者暴露了自定义函数的接口,只需要如下声明自己定义的函数,再调用 API createFunction,即可在 sql 语句中调用执行。

export const twice = {
  name: "twice",
  xFunc: function (pCx, arg) {
    return arg + arg
  },
  opt: {
    deterministic: true,
  },
}

export const today = {
  name: "today",
  xFunc: function (pCx) {
    return new Date().toISOString().slice(0, 10)
  },
  opt: {
    deterministic: false,
  },
}

// 注册 UDF
this.db.createFunction(twice)
this.db.createFunction(today)

  1. twice 是一个 pure 函数,不管执行多少次,相同输入的结果总是一样的。即 deterministic 为 true
  2. today 则是一个依赖系统时间的函数,每天执行的结果都不一样,即 deterministic 为 false

对于怎么把 year(now()) - year(props('date_of_birth')) 转化为 sql,已经有很多成熟的方案,并不是本文的重点。相信你已经知道 year 函数该怎么实现了,于是“朋友表”某个包含姓名(text)、生日(date)、年龄(formula)三个字段的视图,可以转化为如下的 sql 查询

SELECT name, date_of_birth, year(now()) - year(date_of_birth) as age FROM friends;

通过 UDF(user-defined-function) 我们可以无限扩展函数,以达到用户的需求,在保证安全的前提下,也可以把 UDF 开放给用户,这样他们就可以自己写函数,打造自己独一无二的 space。

如上所示,相比在应用层做计算,在 sqlite 这套计算框架下,我们可以通过 UDF 轻松的实现 formula 这个多维表格抽象出来的功能,并且拥有较好的性能。你可以理解为实现相同的抽象层,但是其代价却是不一样的。一个是高层本的抽象、一个是低层本的抽象。

SQL 之上

sql 是经过学术论证和几十年商业检验的数据查询方案,历久弥新。虽然各个数据库引擎的实现支持不太一样,但是基本的语法是一致的,而且有丰富的文档沉淀,GPT 从中学到了很多。一个简单的 prompt 就能让 GPT 返回想要的 sql 语句,通过自然语言和表格数据对话也很方便实现。

除了在表格中使用 sql,Eidos 也把 sql 的能力暴露给了文档。你可以在文档中使用 /sql 插入 sql 查询的 block。

你也可以写一些复杂的查询,达到倒计时的效果。结合 AI 的玩法可以进一步降低入门门槛,即使你不懂 sql 也没关系。

这里只是抛砖引玉的玩法,进一步我们可以直接通过自然语言插入这些计算值。

Eidos 的数据全部存储在 sqlite 中,因此你可以做到任意的查询嵌入。在文档里面嵌入表格单元格、汇总计算、多少个待办事项等等。