Cozo 数据库文档#

欢迎来到 Cozo 数据库的文档。Cozo 是一个使用 Datalog 做为查询语言的功能丰富的事务型关系型数据库。Cozo 专注于图数据与图算法,可进行历史穿梭查询,且性能强劲。

无需在电脑上安装任何软件,现在就打开 入门教程 开始学习吧!

如需要具体安装说明以及针对特定语言的API,请参考 Cozo 的 GitHub 页面 或者 Gitee 页面

入门教程#

本教程将介绍Cozo数据库的基本功能。

建议一边学习一边在自己的环境中执行查询语句,也可以做一些修改,以加深印象。你可以用下面这些方式来执行查询:

  • 在浏览器中直接运行:请在新窗口中打开 Cozo 网页版国内镜像)然后跟着教程做。

  • 你可以从 发布页面国内镜像)下载名为 cozo-* 的可执行文件(请选择与你的操作系统以及 CPU 匹配的版本),解压缩,改名为 cozo,然后在终端中执行命令 cozo repl 以开启命令行模式。在执行中如果可以粘贴多行代码。如果要手动输入多行代码,则先输入空格,再输入回车,以进入多行模式。

  • 如果你熟悉Python数据科学栈,也可以先 安装 PyCozo ,然后在Jupyter中运行 此文件

  • 还有其他 很多方法国内镜像),不过上面列出的几种方法最简单。

下面这个单元格是用来初始化Jupyter的:如果你使用的是其它方式,请忽略。

[1]:
%load_ext pycozo.ipyext_direct

基础知识#

Cozo是一个关系型数据库。以下是经典的“你好,世界”查询:

[2]:
?[] <- [['hello', 'world', 'Cozo!']]
[2]:
  _0 _1 _2
0 hello world Cozo!

在这个查询中,我们将一个由列表的列表所表示的临时性表传递给了数据库,而数据库将其作为结果返回。

可以传递更多行,或者列:

[3]:
?[] <- [[1, 2, 3], ['a', 'b', 'c']]
[3]:
  _0 _1 _2
0 1 2 3
1 a b c

这个例子展示了如何输入数字、字符串、布尔值以及空值:

[4]:
?[] <- [[1.5, 2.5, 3, 4, 5.5],
        ['aA', 'bB', 'cC', 'dD', 'eE'],
        [true, false, null, -1.4e-2, "A string with double quotes"]]
[4]:
  _0 _1 _2 _3 _4
0 True False None -0.014000 A string with double quotes
1 1.500000 2.500000 3 4 5.500000
2 aA bB cC dD eE

可以看出,Cozo中的输入表达式与JavaScript中的类似。Cozo保证对双引号中的字符串的解析方式与JSON中字符串的解析方式完全相同。Cozo的查询结果以JSON格式传递给程序,但它们在屏幕上的显示方式可能不同于JSON,比如在Python中布尔运算会按照Python的类型显示为大写而不是JSON中的小写。

在这个例子中,行返回的顺序与输入的顺序不一样。这是因为在Cozo中,表永远是以树的形式存储的,而树中所存数据是有序的。树在存储数据时每个键只能有一个值,因此Cozo所返回的结果也不会包含重复行:

[5]:
?[] <- [[1], [2], [1], [2], [1]]
[5]:
  _0
0 1
1 2

也就是说,Cozo中的表遵循 集合 语义,会自动去重。相比之下,SQL通常遵循 词袋 语义。(数据库通常通过给每条记录赋一个唯一的内部键来实现词袋语义。在Cozo中如果需要重复行,则需要显性地这么做。)

Cozo采用集合语义的原因是集合语义在处理表之间的递归时要方便得多。Cozo作为一个以图数据为基础的数据库,通常需要处理大量的递归。

表达式#

以下例子展示了表达式和注释的使用:

[6]:
?[] <- [[
            1 + 2, # 加法
            3 / 4, # 减法
            5 == 6, # 等于
            7 > 8, # 大于
            true || false, # 逻辑或
            false && true, # 逻辑与
            lowercase('HELLO'), # 函数
            rand_float(), # 不需要参数的函数
            union([1, 2, 3], [3, 4, 5], [5, 6, 7]), # 参数数量不定的函数
        ]]
[6]:
  _0 _1 _2 _3 _4 _5 _6 _7 _8
0 3 0.750000 False False True False hello 0.342518 [1, 2, 3, 4, 5, 6, 7]

可以在 这里 查阅所有的函数。函数的语法与类C语言的函数类似。

规则与表#

之前的例子均以 ?[] <- 开头。包含 <- 这种形式的 规则 叫做 常量规则? 之后 <- 之前的部分是规则的 头部<- 之后的部分是规则的 正文

每个规则均有名。名为 ? 的规则是一个特殊的规则:其结果会作为整个查询的结果返回。

在规则头部中我们可给出返回的每列 绑定 变量:

[7]:
?[first, second, third] <- [[1, 2, 3], ['a', 'b', 'c']]
[7]:
  first second third
0 1 2 3
1 a b c

常量规则的绑定变量的数量必须与实际数据相匹配,否则将会报错:

[8]:
?[first, second] <- [[1, 2, 3], ['a', 'b', 'c']]
[8]:
parser::fixed_rule_head_arity_mismatch

  × Fixed rule head arity mismatch
   ╭────
 1 │ ?[first, second] <- [[1, 2, 3], ['a', 'b', 'c']]
   · ─────────────────────────────────────────────────
   ╰────
  help: Expected arity: 3, number of arguments given: 2

规则的正文中可以 应用 其它规则:

[9]:
rule[first, second, third] <- [[1, 2, 3], ['a', 'b', 'c']]
?[a, b, c] := rule[a, b, c]
[9]:
  a b c
0 1 2 3
1 a b c

在第一行中,我们定义了一个名为 rule 的常量规则。名为 ? 规则是一个 内联规则 ,用连接符号 := 来表示。 在它的正文中,它应用了之前定义的固定规则:给出规则的名称与三个 新的绑定 ,即变量 abc

通过内联规则,可以指定返回哪些列,以及返回的列的顺序:

[10]:
rule[first, second, third] <- [[1, 2, 3], ['a', 'b', 'c']]
?[c, b] := rule[a, b, c]
[10]:
  c b
0 3 2
1 c b

内联规则的正文由 原子 组成。在以上例子中,我们以唯一的一个 规则应用原子 作为规则的正文。如果有多个原子,则用逗号连接它们:

[11]:
?[c, b] := rule[a, b, c], is_num(a)
rule[first, second, third] <- [[1, 2, 3], ['a', 'b', 'c']]
[11]:
  c b
0 3 2

这里第二个原子是一个 表达式 is_num(a)。表达式原子的作用是过滤结果:只有表达式求值为“真”的行会被返回。如果一个查询包含多个规则,他们之间的顺序不重要。

在规则应用原子中,参数也可以直接绑定为常量:

[12]:
rule[first, second, third] <- [[1, 2, 3], ['a', 'b', 'c']]
?[c, b] := rule['a', b, c]
[12]:
  c b
0 c b

归一运算符 = 可用来引入新的绑定:

[13]:
rule[first, second, third] <- [[1, 2, 3], ['a', 'b', 'c']]
?[c, b, d] := rule[a, b, c], is_num(a), d = a + b + 2*c
[13]:
  c b d
0 3 2 9

如果在规则正文中包含多个规则应用,返回结果为所有绑定的组合:

[14]:
r1[] <- [[1, 'a'], [2, 'b']]
r2[] <- [[2, 'B'], [3, 'C']]

?[l1, l2] := r1[a, l1],
             r2[b, l2]
[14]:
  l1 l2
0 a B
1 a C
2 b B
3 b C

因为这里两个规则应用中所有的绑定都不同,所以结果给出了关系代数中的笛卡尔乘积。如果绑定被重复使用,就会引发 隐式归一,也就是关系代数中的内连:

[15]:
r1[] <- [[1, 'a'], [2, 'b']]
r2[] <- [[2, 'B'], [3, 'C']]

?[l1, l2] := r1[a, l1],
             r2[a, l2] # reused `a`
[15]:
  l1 l2
0 b B

算符 = 表示与单一值的归一。如果要与列表中所有的值依次进行归一,则可用 in 算符:

[16]:
?[x, y] := x in [1, 2, 3], y in ['x', 'y']
[16]:
  x y
0 1 x
1 1 y
2 2 x
3 2 y
4 3 x
5 3 y

内联规则的头部不需要使用正文中出现的所有变量,但是头部中的任何变量都必须在主体中至少出现一次,这叫做 安全规则

[17]:
r1[] <- [[1, 'a'], [2, 'b']]
r2[] <- [[2, 'B'], [3, 'C']]

?[l1, l2, x] := r1[a, l1],
                r2[a, l2]
[17]:
eval::unbound_symb_in_head

  × Symbol 'x' in rule head is unbound
   ╭─[3:1]
 3 │
 4 │ ?[l1, l2, x] := r1[a, l1],
   · 
 5 │                 r2[a, l2]
   ╰────
  help: Note that symbols occurring only in negated positions are not considered bound

存储表#

Cozo中,持久化的表叫做 存储表,创建起来很简单:

[18]:
r1[] <- [[1, 'a'], [2, 'b']]
r2[] <- [[2, 'B'], [3, 'C']]

?[l1, l2] := r1[a, l1],
             r2[a, l2]

:create stored {l1, l2}
[18]:
  status
0 OK

在这个例子中,:create 查询选项声明将返回结果存储在一个名为 stored 的存储表中,包含列 l1l2

注意:给表起名时不要以下划线 _ 开头。如果你使用了下划线开头的表名,程序并不会报错,但是你会发现表并没有被存储:实际上你创建的是 临时表 而非存储表。 这里 有详细的说明。

如果只是想创建存储表而不添加任何数据,可以省略查询,只保留 :create 选项。

可以通过运行一个 系统操作符 来验证目前系统中已经有刚刚创建的存储表:

[19]:
::relations
[19]:
  name arity access_level n_keys n_non_keys n_put_triggers n_rm_triggers n_replace_triggers
0 stored 2 normal 2 0 0 0 0

也可以列出存储表所包含的列:

[20]:
::columns stored
[20]:
  column is_key index type has_default
0 l1 True 0 Any? False
1 l2 True 1 Any? False

应用一个存储表时,在其名字前加一个 * 符号,以和内联规则的应用区分:

[21]:
?[a, b] := *stored[a, b]
[21]:
  a b
0 b B

与内联表不同,存储表的列有固定的名称。利用这一点,我们可以不必每次都列出所有的列名,而是有选择地通过名称引用列:

[22]:
?[a, b] := *stored{l2: b, l1: a}
[22]:
  a b
0 b B

如果列名与欲绑定的变量同名,我们可以使用简略的写法:

[23]:
?[l2] := *stored{l2}
[23]:
  l2
0 B

使用 :put 加入数据至存储表中:

[24]:
?[l1, l2] <- [['e', 'E']]

:put stored {l1, l2}
[24]:
  status
0 OK
[25]:
?[l1, l2] := *stored[l1, l2]
[25]:
  l1 l2
0 b B
1 e E

使用 :rm 删除数据:

[26]:
?[l1, l2] <- [['e', 'E']]

:rm stored {l1, l2}
[26]:
  status
0 OK
[27]:
?[l1, l2] := *stored[l1, l2]
[27]:
  l1 l2
0 b B

使用 ::remove (注意:两个冒号)删除整个存储表:

[28]:
::remove stored
[28]:
  status
0 OK
[29]:
::relations
[29]:
  name arity access_level n_keys n_non_keys n_put_triggers n_rm_triggers n_replace_triggers

到目前为止,所有存储表的数据都存在了其键中。我们可以指示Cozo只将一部分列数据作为键,从而声明数据的 函数性依赖

[30]:
?[a, b, c] <- [[1, 'a', 'A'],
               [2, 'b', 'B'],
               [3, 'c', 'C'],
               [4, 'd', 'D']]

:create fd {a, b => c}
[30]:
  status
0 OK
[31]:
?[a, b, c] := *fd[a, b, c]
[31]:
  a b c
0 1 a A
1 2 b B
2 3 c C
3 4 d D

在这个表中,插入一条包含已有键的数据,该行的值会被更新:

[32]:
?[a, b, c] <- [[3, 'c', 'CCCCCCC']]

:put fd {a, b => c}
[32]:
  status
0 OK
[33]:
?[a, b, c] := *fd[a, b, c]
[33]:
  a b c
0 1 a A
1 2 b B
2 3 c CCCCCCC
3 4 d D

以下命令返回的 is_key 列显示了存储表中每列是否为键:

[34]:
::columns fd
[34]:
  column is_key index type has_default
0 a True 0 Any? False
1 b True 1 Any? False
2 c False 2 Any? False

我们也可以看到,每列都有其声明的类型与默认值,而且每个存储表可以有关联的 触发器此章节 将详述这些问题。

在进入下一小节前,我们先删除之前创建的存储表:

[35]:
::remove fd
[35]:
  status
0 OK

#

下面我们将一张 (也叫 网络)存入一个存储表:

[36]:
?[loving, loved] <- [['alice', 'eve'],
                     ['bob', 'alice'],
                     ['eve', 'alice'],
                     ['eve', 'bob'],
                     ['eve', 'charlie'],
                     ['charlie', 'eve'],
                     ['david', 'george'],
                     ['george', 'george']]

:replace love {loving, loved}
[36]:
  status
0 OK

这个图表述了一段复杂的恋爱关系:Alice爱Eve,Bob爱Alice;没人爱David,但David爱George,而George只爱自己,等等。创建此存储表时我们使用了 :replace 而不是 :create,其区别在于,如果已经存在名为 love 的存储表,则将其现有数据抹去,再加入新的数据。

有了这张图,我们就可以研究一些恋爱中的竞争关系:

[37]:
?[loved_by_b_e] := *love['eve', loved_by_b_e],
                   *love['bob', loved_by_b_e]
[37]:
  loved_by_b_e
0 alice

到目前为止所有规则的正文都只包含了原子间的 结合(类似于“与关系”)。通过 or 算符,我们也可以表达原子之间的 析取(类似于“或”关系):

[38]:
?[loved_by_b_e] := *love['eve', loved_by_b_e] or *love['bob', loved_by_b_e],
                   loved_by_b_e != 'bob',
                   loved_by_b_e != 'eve'
[38]:
  loved_by_b_e
0 alice
1 charlie

表达析取关系的另一种方式是给一条规则赋予多段不同的正文:

[39]:
?[loved_by_b_e] := *love['eve', loved_by_b_e],
                   loved_by_b_e != 'bob',
                   loved_by_b_e != 'eve'
?[loved_by_b_e] := *love['bob', loved_by_b_e],
                   loved_by_b_e != 'bob',
                   loved_by_b_e != 'eve'
[39]:
  loved_by_b_e
0 alice
1 charlie

当同一内联规则有多条正文时,这一规则下的所有头部都必须兼容。只有内联规则可以有多条正文,常量规则不行。

否定#

首先是用 ! 算符对表达式进行否定:

[40]:
?[loved] := *love[person, loved], !ends_with(person, 'e')
[40]:
  loved
0 alice
1 george

我们也可以否定一条规则应用,但是不用算符 !,而使用 not

[41]:
?[loved_by_e_not_b] := *love['eve', loved_by_e_not_b],
                       not *love['bob', loved_by_e_not_b]
[41]:
  loved_by_e_not_b
0 bob
1 charlie

在Cozo中有两个层次的逻辑运算,一层作用于表达式,另层作用于原子:

  • 原子:,and 为“与”,or 为“或”,not 为“非”

  • 表达式:&& 为“与”,|| 为“或”,! 为“非”

,and 的区别是他们的运算优先级:and 的优先级比 or 高,而 or 的优先级比 , 高。

在对原子进行否定时,必须保证 安全

[42]:
?[not_loved_by_b] := not *love['bob', not_loved_by_b]
[42]:
eval::unbound_symb_in_head

  × Symbol 'not_loved_by_b' in rule head is unbound
   ╭────
 1 │ ?[not_loved_by_b] := not *love['bob', not_loved_by_b]
   ·   ──────────────
   ╰────
  help: Note that symbols occurring only in negated positions are not considered bound

这个查询因为不安全而被禁止。不安全的原因是,此查询的结果是一个无穷大的集合。例如,根据现有的数据,Bob没有明确表现出过对“钱”的兴趣,因此“钱”就应该包含在结果中。

为了保证此查询能够返回有限的结果,我们必须明确地给出一个 封闭的世界

[43]:
the_population[p] := *love[p, _a]
the_population[p] := *love[_a, p]

?[not_loved_by_b] := the_population[not_loved_by_b],
                     not *love['bob', not_loved_by_b]
[43]:
  not_loved_by_b
0 bob
1 charlie
2 david
3 eve
4 george

递归#

内联规则可以应用其他规则,并且可以有多条正文。将此两者结合起来,便有了递归:

[44]:
alice_love_chain[person] := *love['alice', person]
alice_love_chain[person] := alice_love_chain[in_person],
                            *love[in_person, person]

?[chained] := alice_love_chain[chained]
[44]:
  chained
0 alice
1 bob
2 charlie
3 eve

实际上,只需要能够应用其他规则,而不需要多条正文,便可以递归。但只有单条正文的递归没什么实际作用:

[45]:
alice_love_chain[person] := alice_love_chain[in_person],
                            *love[in_person, person]

?[chained] := alice_love_chain[chained]
[45]:
  chained

与“否定”一节中的情况类似,如果没有办法从给定的事实中推导出一条事实,那么这条事实就被认为是“假”的。多条正文的作用在于给出一开始便为“真”的一些事实,以启动递归程序。

聚合#

聚合 通常用于计算统计数据。在Cozo中,在内联规则的头部以函数的形式来表达聚合计算:

[46]:
?[person, count(loved_by)] := *love[loved_by, person]
[46]:
  person count(loved_by)
0 alice 2
1 bob 1
2 charlie 1
3 eve 2
4 george 2

Cozo支持诸如求和 sum、平均数 mean 等常见的聚合。在后面我们将看到,将聚合表达在规则的头部而不是正文中,使得查询语言的表达力更加强大。

完整的聚合列表 在此

查询选项#

在上面我们已经见过了诸如 :create:put:rm 等操作存储表的查询选项。另一些查询选项可以改变返回值的各种方面,比如只返回一行:

[47]:
?[loving, loved] := *love{ loving, loved }

:limit 1
[47]:
  loving loved
0 alice eve

在下面这个例子,我们要求返回值以 love 列正向排序,在顺序相同时再以 loving 列反向排序,然后省略结果里面的第一行:

[48]:
?[loving, loved] := *love{ loving, loved }

:order -loved, loving
:offset 1
[48]:
  loving loved
0 george george
1 alice eve
2 charlie eve
3 eve charlie
4 eve bob
5 bob alice
6 eve alice

:order 选项子句中的变量前面加上 - 表示降序,+ 或者不写表示升序。

这一章 介绍了所有的查询选项。

固定规则#

用于常量规则的 <- 语法是语法糖,去糖后的写法是:

[49]:
?[] <~ Constant(data: [['hello', 'world', 'Cozo!']])
[49]:
  _0 _1 _2
0 hello world Cozo!

这是一个名为 Constant固定规则,其需要一个名为 data 的选项。“摇尾巴算符” <~ 表示固定规则。

固定规则除了选项外,也接受表作为参数,表可以是存储表也可以是内联表。固定规则对这些表进行计算并输出结果。上面我们介绍的固定规则 Constant 接受 0 个表作为参数。

如果在浏览器中使用Cozo,则 Constant 是唯一可以使用的固定规则。其它平台的Cozo包含了图算法模块,其中所有的图算法都是作为固定规则来实现的。比如,我们可以使用PageRank算法来找出谁在上面的爱情大三角中最受欢迎:

[50]:
?[person, page_rank] <~ PageRank(*love[])

:order -page_rank
[50]:
  person page_rank
0 george 0.280443
1 eve 0.275376
2 alice 0.191205
3 bob 0.103023
4 charlie 0.103023
5 david 0.025000

在这个例子中,我们将存储表 *love 作为参数。如上所述,在浏览器中运行此查询将会报错。

所有的固定规则 见此

[51]:
::remove love
[51]:
  status
0 OK

历史穿梭#

一般来说,当我们 :put 键值至一个表中时,旧的值会被覆盖,而我们 :rm 键时,该键值就被删除。如果这种操作的历史记录本身就具有价值,那我们就应当将其保存下来:参见 这个小故事历史穿梭 就是 Cozo 对于这种场景的解决方案:存储表中的数据不单单是当前所有的事实,也同时包括所有事实完整的历史(对于还在不停产生的数据来说,到目前为止所有的历史)。

当然确实并不是所有项目都需要这种功能。Cozo 奉行“零成本、零脑力开销的抽象”的理念,也就是说,如果你不使用某个功能(抽象),那你第一不需要承担这个功能所带来的性能损失,第二你也不用费脑力理解这个功能的任何东西。所以如果你不需要历史穿梭,可以直接跳到下一节继续阅读。

从简单的例子开始:存储国家元首的表。首先我们创建这个表:

[52]:
:create hos {state: String, year: Validity => hos: String}
[52]:
  status
0 OK

这里 hos 是“国家元首”的简写。与之前我们创建的表相比,这个表唯一特殊的地方是它年份 year 这一列的类型为 Validity(有效性)。有效性看作是一个包含两个元素的数组,其中第一个元素是一个整数,记录该行所表示事实的“时间戳”;第二个元素是一个布尔值,若为真,则表示该事实是从这个时间开始 有效 的断言,若为假,则表示前一个同键事实到此时间时不再有效。注意,这里的“时间戳”的具体意义由用户来决定,你可以将时间戳记录为秒,记录为年,甚至只是一个没有具体间隔的递增的数列。在这个存储表中,为了简单起见,我们的时间以年份记录。

先插入一些数据:

[53]:
?[state, year, hos] <- [['US', [2001, true], 'Bush'],
                        ['US', [2005, true], 'Bush'],
                        ['US', [2009, true], 'Obama'],
                        ['US', [2013, true], 'Obama'],
                        ['US', [2017, true], 'Trump'],
                        ['US', [2021, true], 'Biden']]

:put hos {state, year => hos}
[53]:
  status
0 OK

上面插入的 2005 的数据其实即使省略了,历史穿梭的查询结果也一样,因为其值与前一条相同。这个存储表可以当做一个普通的存储表使用:

[54]:
?[state, year, hos] := *hos{state, year, hos}
[54]:
  state year hos
0 US [2021, True] Biden
1 US [2017, True] Trump
2 US [2013, True] Obama
3 US [2009, True] Obama
4 US [2005, True] Bush
5 US [2001, True] Bush

以上我们看出,有效性类型并不是一个真正的数组,因为它是按时间降序排列的。

如果一个存储表的最后一个键列具有显性的 Validity 类型,那这个表就支持历史穿梭查询。比如,我们忘了 2019 年美国元首是谁。那就查查:

[55]:
?[hos, year] := *hos{state: 'US', year, hos @ 2019}
[55]:
  hos year
0 Trump [2017, True]

这也能忘?结果中除了名字之外,还返回了行本身的有效性。

2099 年的时候,美国元首是谁呢?

[56]:
?[hos, year] := *hos{state: 'US', year, hos @ 2099}
[56]:
  hos year
0 Biden [2021, True]

这……可能不太对。其实 2025 年之后是谁,我们目前都 不知道。我们可以显性地表示我们这种无知:

[57]:
?[state, year, hos] <- [['US', [2025, false], '']]

:put hos {state, year => hos}
[57]:
  status
0 OK

这种对于无知的表示对应于普通存储表中的 删除 操作。如果你想要你的存储表不可变,则永远只在其中 插入 数据,永远也不要 删除覆盖 数据。由于有效性是键的一部分,所以只要两个键的有效性不同,它们之间就不构成覆盖。

我们再执行一次上一个查询:

[58]:
?[hos, year] := *hos{state: 'US', year, hos @ 2099}
[58]:
  hos year

由于我们明确地表示了我们的无知,数据库现在也说“不知道”。

历史穿梭是作用于每一个存储表的应用的:我们可以在同一查询中对于同一个存储表给出不同的穿梭时间戳:

[59]:
?[hos2018, hos2010] := *hos{state: 'US', hos: hos2018 @ 2018},
                       *hos{state: 'US', hos: hos2010 @ 2010}
[59]:
  hos2018 hos2010
0 Trump Obama

我们已经说过,有效性中的时间戳如何解释由用户来定。而 Cozo 中有一些其他的功能,基于对这个时间戳的一种默认的解释。让我们先创建一个存储表,用来记录每个人的心情:

[60]:
:create mood {name: String, at: Validity => mood: String}
[60]:
  status
0 OK

记录自己 当前 的心情:

[61]:
?[name, at, mood] <- [['me', 'ASSERT', 'curious']]
:put mood {name, at => mood}
[61]:
  status
0 OK

注意这里我们给出的有效性不是一个二元数组,而是字符串 ASSERT,这意味着我们要求系统应用 当前的 时间戳,且有效性中的布尔值应为真。什么是“当前”呢?

[62]:
?[name, at, mood] := *mood{name, at, mood}
[62]:
  name at mood
0 me [1672047587447466, True] curious

这个挺大的整数代表的意义是以 微秒(百万分之一秒)记录的 UNIX 时间戳。我们可以把它转化成更易读的字符串形式:

[63]:
?[name, time, mood] := *mood{name, at, mood},
                       time = format_timestamp(at)
[63]:
  name time mood
0 me 2022-12-26T09:39:47.447+00:00 curious

想知道 现在 的事实,我们可以用字符串 NOW 作为有效性的时间戳:

[64]:
?[name, time, mood] := *mood{name, at, mood @ 'NOW'},
                       time = format_timestamp(at)
[64]:
  name time mood
0 me 2022-12-26T09:39:47.447+00:00 curious

我们同样也可以在插入数据时手动给出未来的时间戳。由于以微秒计的时间戳不易读,系统同时也接受表示时间的字符串作为有效性:

[65]:
?[name, at, mood] <- [['me', '2030-01-01T00:00:00.000+00:00', 'hopeful']]
:put mood {name, at => mood}
[65]:
  status
0 OK

未来的事实不改变当前的查询:

[66]:
?[name, time, mood] := *mood{name, at, mood @ 'NOW'},
                       time = format_timestamp(at)
[66]:
  name time mood
0 me 2022-12-26T09:39:47.447+00:00 curious

除了 NOW 之外,我们也可以使用字符串 END 来作为查询时有效性的声明,其意义为我们想知道世界末日时的事实:

[67]:
?[name, time, mood] := *mood{name, at, mood @ 'END'},
                       time = format_timestamp(at)
[67]:
  name time mood
0 me 2030-01-01T00:00:00+00:00 hopeful

当我们想声明我们 从现在开始 对未来的无知时,可以使用 RETRACT 字符串:

[68]:
?[name, at, mood] <- [['me', 'RETRACT', '']]
:put mood {name, at => mood}
[68]:
  status
0 OK

从某个时刻开始的无知,也可以用表示时间的字符串来声明:只需要在字符串前添加 ~ 符号:

[69]:
?[name, at, mood] <- [['me', '~9999-01-01T00:00:00.000+00:00', 'who cares']]
:put mood {name, at => mood}
[69]:
  status
0 OK

同时,作为一个普通的存储表,我们可以一次性查看历史的全局:

[70]:
?[name, time, is_assert, mood] := *mood{name, at, mood},
                                  time = format_timestamp(at),
                                  is_assert = to_bool(at)
[70]:
  name time is_assert mood
0 me 2022-12-26T09:39:47.447+00:00 True curious
1 me 2022-12-26T09:40:19.999+00:00 False
2 me 2030-01-01T00:00:00+00:00 True hopeful
3 me 9999-01-01T00:00:00+00:00 False who cares

是不是觉得历史穿梭还挺好使的?而且,由于这个功能是在底层实现的,这些查询运行起来比直接拿规则拼出来的等价查询 快得多

此章节 中有更多的关于历史穿梭的技术性论述。

[71]:
::remove mood, hos
[71]:
  status
0 OK

延伸案例:航空路线数据集#

我们已经对 Cozo 的使用有了一个基本的了解。接下来我们会研究一个小型的真实数据集,这个数据集是一个图,有大约 3700 个节点和 57000 条边。

这个数据集以及以下的不少具体例子,都改编自 《实用Gremlin》 一书。Gremlin 是一种命令式的图查询语言,与声明式的 Datalog 有很大不同。

我们先创建所需的存储表:

[52]:
{:create airport {
    code: String
    =>
    icao: String,
    desc: String,
    region: String,
    runways: Int,
    longest: Float,
    elev: Float,
    country: String,
    city: String,
    lat: Float,
    lon: Float
}}
{:create country {
    code: String
    =>
    desc: String
}}
{:create continent {
    code: String
    =>
    desc: String
}}
{:create contain { entity: String, contained: String }}
{:create route { fr: String, to: String => dist: Float }}
[52]:
  status
0 OK

这里我们一次性执行了多条查询,每条查询都包含在一对花括号中。这多条查询构成了一个 事务:同一个事务中的所有查询对存储层的写入是 原子化 的,要么全部成功,要么全部失败,没有中间状态。

以下命令仅限于使用 Jupyter 的情况:它下载一个包含了所有数据的 JSON 文件并将其导入数据库。注释的行干的也是类似的事情,只是使用了本地文件而不是网络文件。如果你是在浏览器中学习此教程,浏览器界面上有按钮可以让你导入网络或本地的文件。

[53]:
%cozo_import_remote_file 'https://raw.githubusercontent.com/cozodb/cozo/dev/cozo-core/tests/air-routes.json'
# %cozo_import_local_file '../../cozo/cozo-core/tests/air-routes.json'

如果你是在 cozo repl 的命令行中执行查询的,则应当使用以下命令:

%import https://raw.githubusercontent.com/cozodb/cozo/dev/cozo-core/tests/air-routes.json

以上的网址可以替换为本地文件的路径,或国内镜像的地址(见此)。其他环境中也有达成同样事情的不同手段:请参阅各个环境自己的文档。

当然,这个 JSON 文件是我们事先特地准备好的。一般来说原始的数据文件不长这样。在本教程的末尾,我们会介绍如何直接导入原始文件。

现在我们先确认存储表都成功创建了:

[54]:
::relations
[54]:
  name arity access_level n_keys n_non_keys n_put_triggers n_rm_triggers n_replace_triggers
0 airport 11 normal 1 10 0 0 0
1 contain 2 normal 2 0 0 0 0
2 continent 2 normal 1 1 0 0 0
3 country 2 normal 1 1 0 0 0
4 route 3 normal 2 1 0 0 0

为了防止误操作,让我们把这些表都锁成只读状态:

[55]:
::access_level read_only airport, contain, continent, country, route
[55]:
  status
0 OK

更多关于状态的信息,参见 这一章

我们先看看关于机场的存储表:

[56]:
?[code, city, desc, region, runways, lat, lon] := *airport{code, city, desc, region, runways, lat, lon}

:limit 5
[56]:
  code city desc region runways lat lon
0 AAA Anaa Anaa Airport PF-U-A 1 -17.352600 -145.509995
1 AAE Annabah Annaba Airport DZ-36 2 36.822201 7.809170
2 AAL Aalborg Aalborg Airport DK-81 2 57.092759 9.849243
3 AAN Al Ain Al Ain International Airport AE-AZ 1 24.261700 55.609200
4 AAQ Anapa Anapa Airport RU-KDA 1 45.002102 37.347301

哪些机场跑道最多呢?

[57]:
?[code, city, desc, region, runways, lat, lon] := *airport{code, city, desc, region, runways, lat, lon}

:order -runways
:limit 10
[57]:
  code city desc region runways lat lon
0 DFW Dallas Dallas/Fort Worth International Airport US-TX 7 32.896801 -97.038002
1 ORD Chicago Chicago O'Hare International Airport US-IL 7 41.978600 -87.904800
2 AMS Amsterdam Amsterdam Airport Schiphol NL-NH 6 52.308601 4.763890
3 BOS Boston Boston Logan US-MA 6 42.364300 -71.005203
4 DEN Denver Denver International Airport US-CO 6 39.861698 -104.672997
5 DTW Detroit Detroit Metropolitan, Wayne County US-MI 6 42.212399 -83.353401
6 ATL Atlanta Hartsfield - Jackson Atlanta International Airport US-GA 5 33.636700 -84.428101
7 GIS Gisborne Gisborne Airport NZ-GIS 5 -38.663300 177.977997
8 HLZ Hamilton Hamilton International Airport NZ-WKO 5 -37.866699 175.332001
9 IAH Houston George Bush Intercontinental US-TX 5 29.984400 -95.341400

数据集里面一共有多少个机场?

[58]:
?[count(code)] := *airport{code}
[58]:
  count(code)
0 3504

通过以下查询我们可以看出,机场代码的首字母分布并不是均匀的:

[59]:
?[count(initial), initial] := *airport{code}, initial = first(chars(code))

:order initial
[59]:
  count(initial) initial
0 212 A
1 235 B
2 214 C
3 116 D
4 95 E
5 76 F
6 135 G
7 129 H
8 112 I
9 80 J
10 197 K
11 184 L
12 228 M
13 111 N
14 89 O
15 203 P
16 7 Q
17 121 R
18 245 S
19 205 T
20 77 U
21 86 V
22 59 W
23 28 X
24 211 Y
25 49 Z

以下是关于机场跑道数量的一些统计:

[60]:
?[count(r), count_unique(r), sum(r), min(r), max(r), mean(r), std_dev(r)] :=
    *airport{runways: r}
[60]:
  count(r) count_unique(r) sum(r) min(r) max(r) mean(r) std_dev(r)
0 3504 7 4980.000000 1 7 1.421233 0.743083

关联“国家”表之后,我们可以找出哪些国家一个机场都没有:

[61]:
?[desc] := *country{code, desc}, not *airport{country: code}
[61]:
  desc
0 Andorra
1 Liechtenstein
2 Monaco
3 Pitcairn
4 San Marino

“航线”表单独看起来信息量并不大:

[62]:
?[fr, to, dist] := *route{fr, to, dist}

:limit 10
[62]:
  fr to dist
0 AAA FAC 48.000000
1 AAA MKP 133.000000
2 AAA PPT 270.000000
3 AAA RAR 968.000000
4 AAE ALG 254.000000
5 AAE CDG 882.000000
6 AAE IST 1161.000000
7 AAE LYS 631.000000
8 AAE MRS 477.000000
9 AAE ORN 477.000000

这个表有三列,分别记录了航线的始发机场、到达机场,以及此航线的实际距离(英里)。这个表记录了图数据里关于边的信息,所以只有在图查询中其中蕴含的内容才能显现出来。

哪些机场没有任何航线:

[63]:
?[code, desc] := *airport{code, desc}, not *route{fr: code}, not *route{to: code}
[63]:
  code desc
0 AFW Fort Worth Alliance Airport
1 APA Centennial Airport
2 APK Apataki Airport
3 BID Block Island State Airport
4 BVS Breves Airport
5 BWU Sydney Bankstown Airport
6 CRC Santa Ana Airport
7 CVT Coventry Airport
8 EKA Murray Field
9 GYZ Gruyere Airport
10 HFN Hornafjordur Airport
11 HZK Husavik Airport
12 ILG New Castle Airport
13 INT Smith Reynolds Airport
14 ISL Ataturk International Airport
15 KGG Kédougou Airport
16 NBW Leeward Point Field
17 NFO Mata'aho Airport
18 PSY Stanley Airport
19 RIG Rio Grande Airport
20 SFD San Fernando De Apure Airport
21 SFH San Felipe International Airport
22 SXF Berlin-Schönefeld International Airport *Closed*
23 TUA Teniente Coronel Luis a Mantilla Airport
24 TWB Toowoomba Airport
25 TXL Berlin, Tegel International Airport *Closed*
26 VCV Southern California Logistics Airport
27 YEI Bursa Yenişehir Airport

航线最多的机场:

[64]:
route_count[fr, count(fr)] := *route{fr}
?[code, n] := route_count[code, n]

:sort -n
:limit 5
[64]:
  code n
0 FRA 310
1 IST 309
2 CDG 293
3 AMS 283
4 MUC 270

从欧盟到美国一共有多少条航线?

[65]:
routes[unique(r)] := *contain['EU', fr],
                     *route{fr, to},
                     *airport{code: to, country: 'US'},
                     r = [fr, to]
?[n] := routes[rs], n = length(rs)
[65]:
  n
0 435

美国有多少个机场,有来自欧盟的航线?

[66]:
?[count_unique(to)] := *contain['EU', fr],
                       *route{fr, to},
                       *airport{code: to, country: 'US'}

[66]:
  count_unique(to)
0 45

英国伦敦每个机场都有多少条航线?

[67]:
?[code, count(code)] := *airport{code, city: 'London', region: 'GB-ENG'}, *route{fr: code}
[67]:
  code count(code)
0 LCY 51
1 LGW 232
2 LHR 221
3 LTN 130
4 STN 211

这里我们必须指明 GB-ENG 区域,因为世界上不止一个城市叫做伦敦。

从伦敦起飞,转机两次,能到多少个不同的机场?

[68]:
lon_uk_airports[code] := *airport{code, city: 'London', region: 'GB-ENG'}
one_hop[to] := lon_uk_airports[fr], *route{fr, to}, not lon_uk_airports[to];
?[count_unique(a3)] := one_hop[a2], *route{fr: a2, to: a3}, not lon_uk_airports[a3];
[68]:
  count_unique(a3)
0 2353

不转机,从伦敦盖特威克机场能飞到的最远的城市(不是机场)有哪些?

[69]:
?[city, dist] := *route{fr: 'LGW', to, dist},
                 *airport{code: to, city}
:order -dist
:limit 10
[69]:
  city dist
0 Buenos Aires 6908.000000
1 Singapore 6751.000000
2 Langkawi 6299.000000
3 Duong Dong 6264.000000
4 Taipei 6080.000000
5 Port Louis 6053.000000
6 Rayong 6008.000000
7 Cape Town 5987.000000
8 Hong Kong 5982.000000
9 Shanghai 5745.000000

在格林威治子午线经度 0.1 之内的机场有哪些?

[70]:
?[code, desc, lon, lat] := *airport{lon, lat, code, desc}, lon > -0.1, lon < 0.1
[70]:
  code desc lon lat
0 CDT Castellon De La Plana Airport 0.026111 39.999199
1 LCY London City Airport 0.055278 51.505278
2 LDE Tarbes-Lourdes-Pyrénées Airport -0.006439 43.178699
3 LEH Le Havre Octeville Airport 0.088056 49.533901

以伦敦希思罗机场为中心画一个方块,里面包含的机场有哪些?

[71]:
h_box[lon, lat] := *airport{code: 'LHR', lon, lat}
?[code, desc] := h_box[lhr_lon, lhr_lat], *airport{code, lon, lat, desc},
                 abs(lhr_lon - lon) < 1, abs(lhr_lat - lat) < 1
[71]:
  code desc
0 LCY London City Airport
1 LGW London Gatwick
2 LHR London Heathrow
3 LTN London Luton Airport
4 SOU Southampton Airport
5 STN London Stansted Airport

下面我们来做点球面几何:SFONRT 这两个机场,与地球中心所构成的角度是多少?

[72]:
?[deg_diff] := *airport{code: 'SFO', lat: a_lat, lon: a_lon},
               *airport{code: 'NRT', lat: b_lat, lon: b_lon},
               deg_diff = rad_to_deg(haversine_deg_input(a_lat, a_lon, b_lat, b_lon))
[72]:
  deg_diff
0 73.992112

之前我们提到过,在 Cozo 中,聚合与递归的结合使其查询语句有了更加强大的表达力。下面这个例子就展示了这种表达力。

我们需要找出两个机场之间的最短路线距离。最朴素的方法是列举这两个机场之间的所有路线,然后对结果应用最小聚合。然而两个机场之间一般来说有无穷多条路线,因为我们可以绕着圈儿飞,造成航线上的死循环。

所以这种朴素的方法不可行。那我们试试递归式思考:如果我们已经知道了所有机场之间的所有最短路线,我们就可以推导出一个最短路线满足的方程:a 与 b 之间的最短路线,要么是一条直飞航线的距离,要么是从 a 到某个 c 之间的最短路线的距离与从 c 到 d 的直飞航线的距离之和。这样以来,我们需要考虑的 a 与 b 之间的情况数量就小得多,而且是有限的。我们对这些情况来做最小聚合。

把上面这个思路直接写出来,就可以得到以下计算 LHR 机场与 YPO 机场之间最短路线的查询:

[73]:
shortest[b, min(dist)] := *route{fr: 'LHR', to: b, dist}
                          # 从 'LHR' 出发,列举直航航线到达的机场 b

shortest[b, min(dist)] := shortest[c, d1], # 已知的到达 c 的最短路径
                          *route{fr: c, to: b, dist: d2},  # 从 c 到 d 的直航航线
                          dist = d1 + d2 # 新航线的距离

?[dist] := shortest['YPO', dist] # 从所有答案中提取关于 YPO 的答案
                                 # 为什么这个例子选 YPO?因为它是最难到达的机场之一
[73]:
  dist
0 4147.000000

这里有一点需要注意:如果用下面的方法来写这个查询(不要尝试运行):

shortest[a, b, min(dist)] := *route{fr: a, to: b, dist}
shortest[a, b, min(dist)] := shortest[a, c, d1],
                          *route{fr: c, to: b, dist: d2},
                          dist = d1 + d2

?[dist] := shortest['LHR', 'YPO', dist]

这个查询从语义上来说和之前的是等价的,但是跑起来就像进了死循环一样长时间不返回结果。为什么?

在这个新的查询中,内联规则 shortest 包含了所有机场间的最短路线。如果这个规则不含聚合操作,则 Cozo 在编译查询语句时会发现其实不需要计算所有的中间结果就能得出答案,然后使用一种叫做“魔法集重写”的方法优化掉这些中间结果(详见 此章)。但是这个优化不能用于包含聚合操作的查询。因此,系统会尝试计算一千三百多万个机场组合之间的最短距离,显然无法很快返回结果。

如果聚合加递归的查询迟迟不返回结果,我们可以粗略估算一下与聚合递归规则相关的中间结果大概有多大。如果像这个例子似的特别大,那我们就需要通过手动改写查询来避免它。

图算法巡礼#

现在我们用图算法来研究这个图。如前所述,浏览器中运行的 Cozo 并没有图算法模块,下面的例子,需要使用原生的 Cozo 实现(例如 Python 的)来运行。

寻路是最常用的图算法,因此 Cozo 有几个寻路的固定规则:

[74]:
starting[] <- [['LHR']]
goal[] <- [['YPO']]
?[starting, goal, distance, path] <~ ShortestPathDijkstra(*route[], starting[], goal[])
[74]:
  starting goal distance path
0 LHR YPO 4147.000000 ['LHR', 'YUL', 'YVO', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']

这比直接拿内联规则写不但更简单,效率也高不少,而且直接就给出了具体的最短路径。

以下算法计算最短的 10 条路径:

[75]:
starting[] <- [['LHR']]
goal[] <- [['YPO']]
?[starting, goal, distance, path] <~ KShortestPathYen(*route[], starting[], goal[], k: 10)
[75]:
  starting goal distance path
0 LHR YPO 4147.000000 ['LHR', 'YUL', 'YVO', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']
1 LHR YPO 4150.000000 ['LHR', 'DUB', 'YUL', 'YVO', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']
2 LHR YPO 4164.000000 ['LHR', 'YUL', 'YMT', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']
3 LHR YPO 4167.000000 ['LHR', 'DUB', 'YUL', 'YMT', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']
4 LHR YPO 4187.000000 ['LHR', 'MAN', 'DUB', 'YUL', 'YVO', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']
5 LHR YPO 4202.000000 ['LHR', 'IOM', 'DUB', 'YUL', 'YVO', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']
6 LHR YPO 4204.000000 ['LHR', 'MAN', 'DUB', 'YUL', 'YMT', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']
7 LHR YPO 4209.000000 ['LHR', 'YUL', 'YMT', 'YNS', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']
8 LHR YPO 4211.000000 ['LHR', 'MAN', 'IOM', 'DUB', 'YUL', 'YVO', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']
9 LHR YPO 4212.000000 ['LHR', 'DUB', 'YUL', 'YMT', 'YNS', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']

上面这些算法都要求先把整个图编译成内存中的数据结构再进行运算。如果你的图很大,顶点很多,你也能给出一个相对靠谱的距离下限,下面这个 A* 算法可能会更快:

[76]:
code_lat_lon[code, lat, lon] := *airport{code, lat, lon}
starting[code, lat, lon] := code = 'LHR', *airport{code, lat, lon};
goal[code, lat, lon] := code = 'YPO', *airport{code, lat, lon};
?[] <~ ShortestPathAStar(*route[],
                         code_lat_lon[node, lat1, lon1],
                         starting[],
                         goal[goal, lat2, lon2],
                         heuristic: haversine_deg_input(lat1, lon1, lat2, lon2) * 3963);
[76]:
  _0 _1 _2 _3
0 LHR YPO 4147.000000 ['LHR', 'YUL', 'YVO', 'YKQ', 'YMO', 'YFA', 'ZKE', 'YAT', 'YPO']

使用 A* 算法需要更多的设置:我们得提取机场的经纬度,并用两个机场的经纬度算出这两个机场之间的球面距离作为飞行距离的下限。上面的数字 3963(英里)是地球的半径。

下面我们用 PageRank 来找出最重要的机场:

[77]:
rank[code, score] <~ PageRank(*route[a, b])
?[code, desc, score] := rank[code, score], *airport{code, desc}

:limit 10;
:order -score
[77]:
  code desc score
0 IST Istanbul International Airport 0.004889
1 DFW Dallas/Fort Worth International Airport 0.004696
2 ORD Chicago O'Hare International Airport 0.004452
3 DEN Denver International Airport 0.004252
4 PEK Beijing Capital International Airport 0.004044
5 FRA Frankfurt am Main 0.004027
6 ATL Hartsfield - Jackson Atlanta International Airport 0.004022
7 DXB Dubai International Airport 0.004002
8 CDG Paris Charles de Gaulle 0.003998
9 DME Moscow, Domodedovo International Airport 0.003817

下面的查询计算的是中心度。中心度的算法复杂度高,计算需要一些时间:

[78]:
centrality[code, score] <~ BetweennessCentrality(*route[a, b])
?[code, desc, score] := centrality[code, score], *airport{code, desc}

:limit 10;
:order -score
[78]:
  code desc score
0 ANC Anchorage Ted Stevens 1074868.250000
1 KEF Reykjavik, Keflavik International Airport 928450.437500
2 HEL Helsinki Ventaa 581588.500000
3 PEK Beijing Capital International Airport 532021.062500
4 DEL Indira Gandhi International Airport 472979.968750
5 IST Istanbul International Airport 457882.156250
6 PKC Yelizovo Airport 408571.000000
7 MSP Minneapolis-St.Paul International Airport 396433.250000
8 LAX Los Angeles International Airport 393309.968750
9 DEN Denver International Airport 374339.531250

中心度高的机场,如果停止运营,将会对整个航线网络造成最大的影响。这个指标很重要,然而我们也可以看到,对于稍微大一点的数据集,计算这个指标就比较慢了。

使用社区检测,我们可以将原始的图折叠成一个 超级图 并将其存为存储表:

[79]:
community[detailed_cluster, code] <~ CommunityDetectionLouvain(*route[a, b])
?[code, cluster, detailed_cluster] := community[detailed_cluster, code], cluster = first(detailed_cluster)

:replace community {code => cluster, detailed_cluster}
[79]:
  status
0 OK

我们可以查询某个具体的机场包含在哪个超级节点中。以下例子表明,和预想的一样,伦敦盖特威克机场的超级节点主要由英国和其他欧洲城市的机场组成:

[80]:
community[code] := *community{code: 'LGW', cluster}, *community{code, cluster}
?[country, count(code)] :=
    community[code],
    *airport{code, desc, country: country_code},
    *country{code: country_code, desc: country},

:order -count(code)
:limit 5
[80]:
  country count(code)
0 United Kingdom 54
1 France 50
2 Norway 49
3 Spain 40
4 Greece 38

肯尼迪机场的超级节点则主要包括美国的机场:

[81]:
community[code] := *community{code: 'JFK', cluster}, *community{code, cluster}
?[country, count(code)] :=
    community[code],
    *airport{code, desc, country: country_code},
    *country{code: country_code, desc: country},

:order -count(code)
:limit 5
[81]:
  country count(code)
0 United States 444
1 Canada 111
2 Brazil 108
3 Mexico 57
4 Colombia 50

当然这个结果并不是由地理划分的。比如法兰克福机场在德国:

[82]:
?[desc, country_desc] := *airport{code: 'FRA', desc, country: country_code}, *country{code: country_code, desc: country_desc}
[82]:
  desc country_desc
0 Frankfurt am Main Germany

但这是其超级节点:

[83]:
community[code] := *community{code: 'FRA', cluster}, *community{code, cluster}
?[country, count(code)] :=
    community[code],
    *airport{code, desc, country: country_code},
    *country{code: country_code, desc: country},

:order -count(code)
:limit 5
[83]:
  country count(code)
0 United States 444
1 Canada 111
2 Brazil 108
3 Mexico 57
4 Colombia 50

德国甚至不在前五位里。实际上法兰克福机场和肯尼迪机场的超级节点是同一个。在这里,重要的是机场间的航线关系,而不是纯粹的地理。更好的例子是下面这个:

[84]:
community[code] := *community{code: 'SIN', cluster}, *community{code, cluster}
?[country, count(code)] :=
    community[code],
    *airport{code, desc, country: country_code},
    *country{code: country_code, desc: country},

:order -count(code)
:limit 5
[84]:
  country count(code)
0 China 216
1 Australia 125
2 Indonesia 68
3 Japan 65
4 Philippines 40

看起来 SIN 好像是个中国的机场,但是:

[85]:
?[desc, country_desc] := *airport{code: 'SIN', desc, country: country_code}, *country{code: country_code, desc: country_desc}
[85]:
  desc country_desc
0 Singapore, Changi International Airport Singapore

实际上它是新加坡樟宜机场。

接下来我们把整个航线关系折叠到超级节点上:

[86]:
?[fr_cluster, to_cluster, count(dist), sum(dist)] := *route{fr, to, dist},
                                                     *community{code: fr, cluster: fr_cluster},
                                                     *community{code: to, cluster: to_cluster}
:replace super_route {fr_cluster, to_cluster => n_routes=count(dist), total_distance=sum(dist)}
[86]:
  status
0 OK

我们可以看到,在这个折叠后的关系中,“对角线”关系 fr_cluster == to_cluster,不管是航线数量还是总距离,值都是最大的:

[87]:
?[fr_cluster, to_cluster, n_routes, total_distance] := *super_route{fr_cluster, to_cluster, n_routes, total_distance}, fr_cluster < 2
[87]:
  fr_cluster to_cluster n_routes total_distance
0 0 0 9041 8933554.000000
1 0 1 434 1695379.000000
2 0 2 228 761661.000000
3 0 3 530 1681865.000000
4 0 4 163 391892.000000
5 0 8 3 300.000000
6 0 11 2 283.000000
7 0 19 1 238.000000
8 0 21 2 705.000000
9 0 22 1 975.000000
10 1 0 434 1696858.000000
11 1 1 4474 5142452.000000
12 1 2 1160 1492734.000000
13 1 3 526 1724591.000000
14 1 4 223 361986.000000
15 1 9 1 808.000000

折叠后的超级图很小,所以所有图算法都可以瞬间返回结果:

[88]:
?[cluster, score] <~ PageRank(*super_route[])
:order -score
:limit 5
[88]:
  cluster score
0 3 0.173309
1 0 0.093072
2 1 0.053465
3 2 0.053389
4 4 0.044654

对这些结果,我们可以试着给出解释。例如,对于以上结果一个朴素的解释是,北美仍然是世界上最发达的地区,然后东亚第二名,南亚第三名,欧洲第四名(三、四名之间差距很小)。

“硬派”地导入数据#

之前我们导入了专门制作的 JSON 文件数据。接下来我们介绍如何使用 Cozo 直接导入 CSV 格式的原始数据。

我们先把之前的表解锁,然后删掉:

[89]:
::access_level normal airport, contain, continent, country, route
[89]:
  status
0 OK
[92]:
::remove airport, contain, continent, country, route, community, super_route
[92]:
  status
0 OK

接下来我们定义一些变量,变量里的值是需要导入的文件的位置(注释掉的行内容是本地文件):

[94]:
%cozo_set AIR_ROUTES_NODES_URL 'https://raw.githubusercontent.com/cozodb/cozo/dev/cozo-core/tests/air-routes-latest-nodes.csv'
%cozo_set AIR_ROUTES_EDGES_URL 'https://raw.githubusercontent.com/cozodb/cozo/dev/cozo-core/tests/air-routes-latest-edges.csv'
# %cozo_set AIR_ROUTES_NODES_URL 'file://./../../cozo/cozo-core/tests/air-routes-latest-nodes.csv'
# %cozo_set AIR_ROUTES_EDGES_URL 'file://./../../cozo/cozo-core/tests/air-routes-latest-edges.csv'

首先我们导入机场:

[95]:
res[idx, label, typ, code, icao, desc, region, runways, longest, elev, country, city, lat, lon] <~
    CsvReader(types: ['Int', 'Any', 'Any', 'Any', 'Any', 'Any', 'Any', 'Int?', 'Float?', 'Float?', 'Any', 'Any', 'Float?', 'Float?'],
              url: $AIR_ROUTES_NODES_URL,
              has_headers: true)

?[code, icao, desc, region, runways, longest, elev, country, city, lat, lon] :=
    res[idx, label, typ, code, icao, desc, region, runways, longest, elev, country, city, lat, lon],
    label == 'airport'

:replace airport {
    code: String
    =>
    icao: String,
    desc: String,
    region: String,
    runways: Int,
    longest: Float,
    elev: Float,
    country: String,
    city: String,
    lat: Float,
    lon: Float
}
[95]:
  status
0 OK

工具规则 CsvReader 的作用是下载 CSV 文件,并根据设置将其内容解析为一个表。我们储存表的时候给出了所有列的类型,并将机场代码作为主键。

接下来导入国家:

[96]:
res[idx, label, typ, code, icao, desc] <~
    CsvReader(types: ['Int', 'Any', 'Any', 'Any', 'Any', 'Any'],
              url: $AIR_ROUTES_NODES_URL,
              has_headers: true)
?[code, desc] :=
    res[idx, label, typ, code, icao, desc],
    label == 'country'

:replace country {
    code: String
    =>
    desc: String
}
[96]:
  status
0 OK

大陆:

[97]:
res[idx, label, typ, code, icao, desc] <~
    CsvReader(types: ['Int', 'Any', 'Any', 'Any', 'Any', 'Any'],
              url: $AIR_ROUTES_NODES_URL,
              has_headers: true)
?[idx, code, desc] :=
    res[idx, label, typ, code, icao, desc],
    label == 'continent'

:replace continent {
    code: String
    =>
    desc: String
}
[97]:
  status
0 OK

由于原始数据中每个机场的数字 ID 不是我们想要的(我们直接拿机场代码作为主键),而原始数据的航线通过这些数字表示,我们先建立一个主键转换表:

[98]:
res[idx, label, typ, code] <~
    CsvReader(types: ['Int', 'Any', 'Any', 'Any'],
              url: $AIR_ROUTES_NODES_URL,
              has_headers: true)
?[idx, code] :=
    res[idx, label, typ, code],

:replace idx2code { idx => code }
[98]:
  status
0 OK

接下来的表储存地理上的包含关系:

[99]:
res[] <~
    CsvReader(types: ['Int', 'Int', 'Int', 'String'],
              url: $AIR_ROUTES_EDGES_URL,
              has_headers: true)
?[entity, contained] :=
    res[idx, fr_i, to_i, typ],
    typ == 'contains',
    *idx2code[fr_i, entity],
    *idx2code[to_i, contained]


:replace contain { entity: String, contained: String }
[99]:
  status
0 OK

最后,我们使用转换表导入航线(一共大概 6 万多条航线):

[100]:
res[] <~
    CsvReader(types: ['Int', 'Int', 'Int', 'String', 'Float?'],
              url: $AIR_ROUTES_EDGES_URL,
              has_headers: true)
?[fr, to, dist] :=
    res[idx, fr_i, to_i, typ, dist],
    typ == 'route',
    *idx2code[fr_i, fr],
    *idx2code[to_i, to]

:replace route { fr: String, to: String => dist: Float }
[100]:
  status
0 OK

导入完毕,转换表可以删了:

[101]:
::remove idx2code
[101]:
  status
0 OK

最后,让我们确认一下确实所有表都导入了:

[102]:
::relations
[102]:
  name arity access_level n_keys n_non_keys n_put_triggers n_rm_triggers n_replace_triggers
0 airport 11 normal 1 10 0 0 0
1 contain 2 normal 2 0 0 0 0
2 continent 2 normal 1 1 0 0 0
3 country 2 normal 1 1 0 0 0
4 route 3 normal 2 1 0 0 0

教程至此结束!

查询#

Cozo 数据库的查询语言名为 CozoScript,是 Datalog 的一种方言。

CozoScript 中,查询由一个或多个 规则 组成。每个规则都有名字,代表一个 关系表 (以下简称表),即行列数据的集合。名称为 ? 的规则是查询的 入口,而它所代表的表是查询的结果。每个规则都由头部与正文组成。头部的绑定对应于表中的列,而正文则声明应该如何计算表的内容。

Cozo 中的表(不管是不是存储表)都遵守 集合语义 :即使一个规则多次产生同一行数据,同样的数据最终的结果只包含一次。

CozoScript 中有两种规则:

  • 内联规则 ,使用 := 来连接头部和正文。正文中通过原子的组合来声明结果的计算方法。

  • 固定规则 ,使用 <~ 来连接头部和正文。固定规则中计算结果的算法是提前预设的,正文中只需要给出参数和选项。

使用 <- 来连接头部和正文的 常量规则 其实是语法糖。比如:

const_rule[a, b, c] <- [[1, 2, 3], [4, 5, 6]]

与下面查询意思一致:

const_rule[a, b, c] <~ Constant(data: [[1, 2, 3], [4, 5, 6]])

内联规则#

一个内联规则的例子:

hc_rule[a, e] := rule_a['constant_string', b], rule_b[b, d, a, e]

内联规则的正文由多个 原子 组成,原子间以逗号连接,其意义为这些原子的 合取

原子#

原子分为多种。上例中:

rule_a['constant_string', b]

是一个 规则应用 原子:名为 rule_a 的规则必须定义在同一个查询中,且其必须接受对应数量的绑定(这里是 2)。这个规则所代表的表中的每行数据都会与方括号中给出的绑定进行 归一 :这里每行的第一列都与给出的常量字符串归一,如果这行所包含的第一列不等于这个字符串,则个归一会失败,而这行则被舍弃而不出现在结果中;而第二列则与一个 变量 a 归一。由于这个变量在这里是 的(第一次出现),这个归一永远不会失败,且在接下来的语句中这行里这列的值就成为了这个变量的值,这个变量也就成了 已绑定 的变量。当已绑定的变量再次被归一时,只有当其当前值与新的归一值相等时,归一才会成功。因此,对多个规则应用使用同样的变量进行归一,就相当于关系代数中对表的内联操作。

对于 存储表 的规则应用,语法如下:

*stored_relation[bind1, bind2]

这里星号表明这是一个存储表。当使用方括号时,我们必须给出与这个表所含列同样多的绑定。如果有些列不需要被绑定,可以使用特殊的变量 _ :这个变量不参与任何归一。

存储表的列都有其名字,所以归一时可以通过名字来指定列:

*stored_relation{col1: bind1, col2: bind2}

如果使用此形式的归一,不需要的列可以不出现,顺序也不重要。如果需要绑定的列与变量名相同,可以简便地写做: *stored_relation{col1} ,意思与 *stored_relation{col1: col1} 一样。

另一种原子是 表达式,例如:

a > b + 1

这里 ab 不能是新的,必须在同一规则内其他地方被绑定(但绑定的地方不一定需要在这个表达式之前)。作为原子的表达式的结果必须是一个布尔值,当值为真的时候当前行被保留,为假的时候舍弃。因此原子表达式是用来过滤行的。

原子也可以是 显性归一 的表达式:

a = b + c + d

等号左侧必须是一个变量,该变量与右侧的表达式归一。

备注

显性归一与等于算符 == 不同:等于算符的左侧可以是任何表达式。当然,当显性归一的左侧变量已被绑定时,这两种写法语义上是等价的。

显性归一也可以将变量与一个数组中的元素依次归一:

a in [x, y, z]

如果 in 右侧的表达式的结果不是一个数组,查询会报错。

头部#

如上所述,原子对应于关系代数中的表、表的投影或过滤,而逗号连接起来的原子则表示关系表的内联,其中表的列可以绑定为常量,也可以绑定为变量。最简单的规则 头部 就是一个变量列表,这个列表声明了正文中出现的所有变量中哪些需要被输出为结果,以及它们作为输出列的顺序。

头部变量列表中的所有变量都必须在正文中被绑定(安全规则)。不需要的变量可以不出现在头部列表中。

多重正文和原子的析取#

一个内联规则(仅内联规则)可以有多条不同的正文,但这些正文所对应的头部的变量数目必须相同。当有多条正文时,这个规则的结果集合为每条正文所计算的集合的 析取 (即多个集合中行的的“或”操作)。

有时候使用 or 算符来表达析取可以使查询更加简洁:

rule1[a, b] := rule2[a] or rule3[a], rule4[a, b]

另外,算符 and 也可以用来表达合取,与逗号一样。但是算符 and 的算符优先级比 or 高, or 又比逗号高。

否定#

在一个原子前使用 not 算符来表示对其的 否定

not rule1[a, b]

当否定规则或存储表的应用时,至少有一个绑定变量必须是在正文中未绑定的部位被绑定过的(这也是一条 安全规则)。如果否定应用中的其它变量没有出现在未绑定的部位,那这些变量也不会成为绑定的,也就是说他们不能出现在头部中:否定的原子不会产生新的绑定。

使用 not 对表达式进行的否定,其语义与使用 ! 对这个表达式进行的否定相同,其结果是一个新的表达式,作为过滤器使用。

对显性归一的否定只有在其左边变量已经被绑定时才被允许。在这种情况下,整个归一被转换成等价的布尔表达式,然后对这个表达式求非。

递归#

内联规则中可以出现对其它规则的应用,包括对自己的应用,且多个内联规则间可以相互应用:这就是 递归。这里有一个例外:名为 ? 的规则不能被应用,自我应用也不行。

递归应用不可以被否定( 安全规则 ): r[a] := not r[a] 是非法的。

警告

CozoScript 允许显性归一,而显性归一可以生成无穷大的表。一个简单的例子是:

r[a] := a = 0
r[a] := r[b], a = b + 1
?[a] := r[a]

编译器从原理上无法检测一个查询是否一定会生成无穷表,而禁止这种可能性则会同时禁止很多其实结果有限的查询。因此用户有责任保证返回表都是有限的。如果你不小心进行了一个无穷大的查询,你可以杀掉其进程:具体方法见 系统操作 章节。或者你可以在提交查询时就给一个时间限制。

聚合#

在 CozoScript 中,聚合表示为在内联规则的头部对变量应用 聚合算符

?[department, count(employee)] := *personnel{department, employee}

这里我们应用了常见的计数 count 算符。头部中没有聚合算符的变量都被视为 分组变量 ,以它们作为键进行聚合。如果你没有指定任何分组变量,那么产生的表仅会包含一行。

在应用聚合算符时,使用的是词袋语义而非集合语义,原因是如果不这样,那下面的查询:

?[count(employee)] := *personnel{employee}

当存储表中有数据时会返回 1,没有时会返回 0。这与一般人理解的“计数”算符不一样。而在此处使用词袋语义则没有这个问题。

如果一个内联规则有多条正文,那每条正文所对应的头部都必须在相同的位置应用了相同的聚合算符。

Cozo 允许同时应用聚合算符与自递归,前提是聚合算符必须是 半晶格算符 (详见 此章):

shortest_distance[destination, min(distance)] :=
    route{source: 'A', destination, distance}

shortest_distance[destination, min(distance)] :=
    shortest_distance[existing_node, prev_distance], # recursion
    route{source: existing_node, distance: route_distance},
    distance = prev_distance + route_distance

?[destination, min_distance] :=
    shortest_distance[destination, min_distance]

这里 shortest_distance 的自递归含有半晶格算符 min

这种半晶格算符与自递归的组合同时要求所有的聚合算符都出现在头部列表的末尾部分。在上例中,如果将头部写为 shortest_distance[min(distance), destination],则编译器会报错,因为这时编译器不会讲 min 作为半晶格算符来考虑,因而会禁止其在递归中的应用。

固定规则#

固定规则的正文由规则名称及参数和选项的列表组成,比如:

?[] <~ PageRank(*route[], theta: 0.5)

上例中,存储表 *route 是唯一的参数。只有存储表与规则所代表的表才能作为参数。

不同的固定规则对参数有不同的要求,具体而言必须查阅相应的 文档 才能了解如何使用。

在固定规则中,参数的绑定通常被省略。如果提供绑定,则这些绑定的意义也是根据不同的规则而不懂的。例如在 DFS 算法中,绑定就有其特殊的作用。

在上例中, theta 是算法的一个选项,而这个算法的API要求其为值是常数的表达式。不同的算法要求不同的选项,而有些选项有默认值,可以省略。

每个固定规则都会输出固定数量的列。因此,固定规则头部的绑定可以省略。但如果不省略,那数量就必须对得上。

查询选项#

每个查询都可以有其 查询选项

?[name] := *personnel{name}

:limit 10
:offset 20

上例中, :limit:offset 是查询选项,对于使用过 SQL 的人来说意思很明显。所有的查询选项都以单个冒号 : 开头。 查询选项可以放在规则前,也可以放在后面,甚至夹在中间。

有一些查询选项的作用是对数据库进行读写,这部分的选项将在 另一章节 中详述。接下来我们介绍与读写无关的选项。

:limit <N>

限制最大返回行数为 <N> 。可能的话,在查询生成了足够数量的返回行数后执行便会停止(早停法)。

:offset <N>

在返回时跳过前 <N> 行。

:timeout <N>

如果查询不在 <N> 秒内完成,则强行终止。秒数可以是一个表达式,因此可以指定随机的秒数。

:sleep <N>

查询完成后等待 <N> 秒再提交事务并返回。秒数可以是一个表达式,因此可以指定随机的秒数。用处是用来进行复杂逻辑中穿插事务的测试。

:sort <SORT_ARG> (, <SORT_ARG>)*

对结果进行排序。如果 :limit:offset 选项存在,则 :sort 执行后它们才会被执行。 <SORT_ARG> 用来指定排序的列,列名与 ? 规则头部中的绑定相同,如需要多个以逗号隔开。可以在列名前添加 + 用来表示升序排列(和不加效果一样),添加 - 用来表示降序排列,例如 :sort -count(employee), dept_name 先以计数进行降序排列,当计数相同时按照名字顺序升序排列。

警告

只有在内联规则中才能应用聚合算符。因此在上例中, ? 规则的头部必须包含聚合 count(employee) ,单有 employee 是不行的。

:order <SORT_ARG> (, <SORT_ARG>)*

:sort 的别名。

:assert none

如果查询返回结果非空,则报错。主要在事务与触发器中使用。

:assert some

如果查询结果为空,则报错。主要在事务与触发器中使用。如果不需要返回所有行,你可以同时加上 :limit 1 选项,这也可能会触发早停法使查询变得更快。

存储表与事务#

Cozo 使用 存储表 来存数据。

存储表#

查询存储表时,使用类如 *relation[...]*relation{...} 的原子应用( 上一章 中已详述)。以下查询选项可用来操作存储表的读写:

:create <NAME> <SPEC>

创建一个名为 <NAME> 的新存储表,使用 <SPEC> 作为其列定义。若已有同名的存储表存在,则报错。若同时也给出了 ? 入口规则,则该规则中的数据会被在创建表时插入。这是唯一一个可以省略入口规则的查询选项。

:replace <NAME> <SPEC>

功能与 :create 类似,也是创建存储表,其区别在于如果已有重名的表存在,则重名的表(包括其中数据)会被删除,然后新表会被建立。若重名表有关联的触发器,则这些触发器会被关联到新表上,即使新表的列定义不同(这可能会使执行触发器时报错,需要手动调整)。使用 :replace 时入口规则不可省略。

:put <NAME> <SPEC>

将入口查询的表中各行插入名为 <NAME> 的存储表。若存储表中已有同键的数据,则会被新插入的数据覆盖。

:rm <NAME> <SPEC>

将入口查询表中所有键从名为 <NAME> 的存储表中删除。在 <SPEC> 中,只需要声明键的列,不需要值的列。需要删除的键即使在表中不存在也不会报错。

:ensure <NAME> <SPEC>

检查入口查询表中的所有键值都在名为 <NAME> 的存储表中存在,且当前事务从开始到提交这段时间内没有其他事务更改过这些键值。主要用来保证一些查询的读写一致性。

:ensure_not <NAME> <SPEC>

检查入口查询表中的所有键值都不存在于名为 <NAME> 的存储表中,且当前事务从开始到提交这段时间内没有其他事务更改过这些键。主要用来保证一些查询的读写一致性。

如果需要删除存储表,需要使用系统操作 ::remove;如果需要修改存储表的名称,使用系统操作 :rename。在 系统操作 一章中有论述。

创建与覆盖#

以上各选项中的列定义 <SPEC> 格式都相同,但语义略有差异。以下我们先介绍其在 :create:replace 中的语义。

列定义最外层是花括号 {} ,其中每列的定义由逗号隔开,如下例:

?[address, company_name, department_name, head_count] <- $input_data

:create dept_info {
    company_name: String,
    department_name: String,
    =>
    head_count: Int,
    address: String,
}

=> 符号之前的列组成存储表的 ,之后的组成 。如果所有列都是键,则符号 => 可省略。列的顺序,尤其是键列的顺序,是很重要的:数据按照键列的字典排序顺序存在数据库的存储中。

在以上例子中,我们对每一列都声明了类型。如果存入的行中数据的类型与声明的类型不同,系统会先尝试进行类型转换,如果不成功,则报错。如果省略类型声明,则默认的类型为 Any? ,可存入任何数据。举例来说,上面的例子将所有类型省略,我们就得到:

?[address, company_name, department_name, head_count] <- $input_data

:create dept_info { company_name, department_name => head_count, address }

在例子中,入口的绑定变量与列名相同(虽然顺序不同)。如果不同,我们可以指定每列对应的入口绑定:

?[a, b, count(c)] <- $input_data

:create dept_info {
    company_name = a,
    department_name = b,
    =>
    head_count = count(c),
    address: String = b
}

如果入口绑定的变量含有聚合操作算符,则必须显性地指定对应关系,因为诸如 count(c) 的入口绑定不是合法的列名。另外在上例 address 列中,我们也可以看到如何同时声明类型和绑定对应。

也可以使用 default 给列声明默认值:

?[a, b] <- $input_data

:create dept_info {
    company_name = a,
    department_name = b,
    =>
    head_count default 0,
    address default ''
}

默认值可以是一个表达式,这个表达式会对插入的每行重新执行。因此如果默认值是一个生成随机 UUID 的表达式,那每个插入的行都会得到一个不一样的 UUID。

增删改及约束#

使用 :put:remove:ensure:ensure_not 时,当某列在表创建时有默认值或这些列可为空的情况下,这些列可以在列定义中省略,而插入或删除的列值为默认值或空值。在这些操作中声明新的默认值没有任何效果。

在使用 :put:ensure 时,给出的列定义,加上默认值,必须足够生成所有的键值列。

在使用 :rm:ensure_not 时,给出的列定义,加上默认值,必须足够生成所有的键列(值列不需要)。

连锁查询#

每个提交给数据库的查询文本都在独立的 事务 中执行。当需要保证多个操作能够原子的执行时,可以将多个查询放在同一个查询文本中,这时以花括号 {} 将每个查询包裹起来。每个查询都可以有自己独立的查询选项。执行时,提交的多个查询按照顺序依次执行,直到最后一个查询成功完成,或某个查询报错。整个查询文本的返回结果是最后一个查询的结果。

你可以使用 :assert (some|none):ensure:ensure_not 这些查询选项来表述事务提交时必须满足的约束条件。

在下例中,我们同时提交了三个查询,这三个查询要么全部成功并将修改写入数据库,要么某个失败而数据库不写入任何数据,且保证在查询提交时有一行数据存在于存储表中:

{
    ?[a, b] <- [[1, 'one'], [3, 'three']]
    :put rel {a => b}
}
{
    ?[a] <- [[2]]
    :rm rel {a}
}
{
    ?[a, b] <- [[4, 'four']]
    :ensure rel {a => b}
}

查询事务开始执行时,数据库会对所有数据进行快照,任何对数据库的读行为都只会从快照及当前的更改中获取数据。这意味着在查询中查到的数据要么在查询开始前就已经提交至数据库,要么是当前查询文本修改过的数据,不会查到事务开始后其它事务写入的数据。当前数据提交时,如果多个事务提交了互相矛盾的数据,则会报错。如果写入存储表时激活了这些表的触发器,这些触发器也会在同一个事务中执行。

实际上连锁查询功能本身是由一个迷你语言实现的,这个迷你语言里面的 表达式 就是一个个花括号隔开的完成的查询,上面的例子都是由一连串的表达式组成的。这个迷你语言里还有其他语句:

  • %if <cond> %then ... (%else ...) %end 选择性地执行分支。另外还有以 %if_not 开头的否定形式。 <cond> 可以是一个表达式,也可以是一个临时表,不管是哪种,总之结果是一个表。如果这个表的第一行的最后一列在执行 to_bool 函数后为真值,则这个表被认为是真值,如果这个表为空,或其第一行最后一列在执行 to_bool 后为假值,则这个表被认为是假值。

  • %loop ... %end 用来循环。在循环中你可以使用 %break%continue 。你可以在 %loop 前面加上 %mark <marker>,然后使用 %break <marker>%continue marker 来跨层级次跳跃。

  • %return <表达式或临时表或空> 立即返回结果。

  • %debug <临时表> 打印临时表内容到标准输出。

  • %ignore_error <表达式> 执行表达式,忽略所有错误。

  • %swap <临时表> <另一个临时表> 交换两个临时表。

上面所说的 临时表 是什么呢?临时表只在事务执行的时候存在,只能被当前事务读写(所以在单个查询中使用临时表没有意义)。创建和修改临时表的方式与存储表相同,除了表的名字以下划线 _ 开头以外。临时表可以说是连锁查询迷你语言中的 变量

让我们举几个例子:

{:create _test {a}}

%loop
    %if { len[count(x)] := *_test[x]; ?[x] := len[z], x = z >= 10 }
        %then %return _test
    %end
    { ?[a] := a = rand_uuid_v1(); :put _test {a} }
%end

这里返回的表含有十行随机数据。注意这里生成随机数据 必须 使用内联规则。如果使用常量规则,则生成的“随机数”每次都一样:常量规则的正文只会被执行一次,因此会造成此查询死循环。

第二个:

{?[a] <- [[1], [2], [3]]; :replace _test {a}}

%loop
    { ?[a] := *_test[a]; :limit 1; :rm _test {a} }
    %debug _test

    %if_not _test
    %then %break
    %end
%end

%return _test

返回的表为空(非常牵强的从表中删除行的方式)。

最后:

{?[a] <- [[1], [2], [3]]; :replace _test {a}}
{?[a] <- []; :replace _test2 {a}}
%swap _test _test2
%return _test

返回的表也为空,因为两个表被交换了。

这个迷你语言的主要目的是为了可以快速写一些简单的循环算法。当然,因为 Cozo 的 Datalog 本来就是图灵完备的,不用这个迷你语言也可以写任何算法,但是可以不代表好写,也不代表能跑得快。比如说,你可以尝试用基本的查询语言来写佩奇指数算法,你会发现需要使用大量的递归和聚合。但是如果用了这个迷你语言,写起来就很简单。

多语句事务#

Cozo 的部分库(目前包括 Rust、Python、及 NodeJS 的库)以及独立程序同时支持多语句事务:在执行查询时,首先开启一个事务,然后对这个事务进行查询以及修改,最后提交或回滚事务。多语句事务比连锁查询更加灵活,但是使用方法根据不同语言是不同的。请查阅各个语言自己的 Cozo 文档来确定具体如何使用此功能。

索引#

版本号 0.5 之后的 Cozo 支持为存储表建立关联的索引。在 Cozo 中,索引就是存储表中列的不同排列。如果我们有以下存储表:

:create r {a => b}

但我们经常需要执行查询 ?[a] := *r{a, b: $value} 。如果不使用索引,则查询需要扫描整个表。我们用下列语句建立索引:

::index create r:idx {b, a}

注意在建立索引时 不要 声明函数式依赖(这个例子中的索引也没有函数式依赖)。

Cozo 中的索引就是只读的存储表,因此可以直接查询:

?[a] := *r:idx {a, b: $value}

在此例子中,之前的查询 ?[a] := *r{a, b: $value} 实际上也会被改写成与上面显性的索引查询相同的执行方案(你可以使用 ::explain 语句来确定这一点)。但是一般来说,Cozo 在决定是否使用索引来改写查询时极端保守:只要有任何可能性使用了索引反而导致性能下降,则 Cozo 不会使用索引。目前来说,只有当使用索引可以避免扫描整个表时 Cozo 才会自动使用索引。这种策略保证了添加索引在任何情况下都不会降低性能。如果你知道一些查询使用索引会更快,但是数据库没有自动改写,你只需要手动使用索引即可。这比使用各种技巧来说服数据库不要使用某个不该使用的索引简单得多。

删除索引:

::index drop r:idx

在 Cozo 中,创建索引时不需要使用所有的列。但是当所声明的列不包括所有的键列时,系统会自动补齐,也就是说,如果你的表是

:create r {a, b => c, d, e}

而你要求建立如下索引:

::index create r:i {d, b}

则数据库实际上会建立的索引是:

::index create r:i {d, b, a}

你可以查看数据库到底建立了哪些列: ::columns r:i

索引可以作为固定规则的输入表。如果索引的最后一列的类型为 Validity,则也可以对索引进行历史穿梭查询。

触发器#

Cozo 支持在存储表上绑定触发器:使用 ::set_triggers 系统操作来设置一个存储表的触发器:

::set_triggers <REL_NAME>

on put { <QUERY> }
on rm { <QUERY> }
on replace { <QUERY> }
on put { <QUERY> } # 可以设置任意数量任意种类的触发器

这里面 <QUERY> 可以是任何查询。

on put 后面的触发器会在数据插入或覆盖后触发: :put:create:replace 均可触发。在触发器中,有两个隐藏的内联表 _new[]_old[] 可以在查询中使用,分别包含新插入的行,以及被覆盖的行的旧值。

on rm 触发器会在行被删除时触发:即 :rm 查询选项可触发。隐藏内联表 _new[]_old[] 分别包含删除的键(即使此键在存储表中不存在),以及确实被删除的行的键值。

on replace 触发器会在执行 :replace 查询选项时触发。此触发器触发后才会触发任何 on put 触发器。

在设置触发器的 ::set_triggers 系统命令中,所有触发器必须同时一起给出,每次执行此命令会覆盖所设计存储表之前所有的触发器。执行 ::set_triggers <REL_NAME> 命令但不给出任何触发器会删除存储表关联的所有的触发器。

下面给出一个使用触发器来手动建立索引的例子。假设我们有如下原始存储表:

:create rel {a => b}

手动的索引表:

:create rel.rev {b, a}

我们用以下触发器来保证索引表的同步性:

::set_triggers rel

on put {
    ?[a, b] := _new[a, b]

    :put rel.rev{ b, a }
}
on rm {
    ?[a, b] := _old[a, b]

    :rm rel.rev{ b, a }
}

现在索引表就建好了,在查询中我们可以使用 *rel.rev{..} 来取代 *rel{..} ,以执行对索引的查询。

注意,与自动索引不同,有部分导入数据的 API 执行时不会激活触发器。另外,如果你使用触发器来手动同步索引,你需要手动导入索引建立之前的历史数据。

警告

触发器 不会 激活其它的触发器,也就是说如果一个触发器修改了某个表,而那个表有其它的触发器,则后面这些触发器不会被运行。这个处理方式与早期的版本中的处理方式不同。我们修改了之前的处理方式,因为触发器连锁反应造成的问题比解决的问题更多。

历史穿梭查询#

在数据库中进行“历史穿梭”意味着需要对数据的所有更改进行记录,并允许提取某个历史时刻的数据快照用来进行查询。从一定意义上来说,可以历史穿梭的数据库也是一个不可变的数据库:因为所有的变更其实都是数据的插入,而永远不会有数据被删除。我们写了一个 小故事 简单介绍了不可变数据的价值。

在 Cozo 中,如果一个存储表的键列的最后一列类型为 Validity (有效性),则此表支持历史穿梭查询。一个有效性包含两部分:一部分是一个时间戳,由一个整数表示,另一部分表明这行数据是插入行为还是删除行为,用布尔值表示,例如 [42, true] 就是一个有效性。有效性在排序时先按照时间戳倒排,再按照行为布尔值倒排,因此我们有:

[1, true] < [1, false] < [-1, true]

除了最后的有效性之外完全相同的键,组成了这个键的 历史 ,其语义如下:每行数据代表一个 事实 ,这个事实的行为值若为真,则从这个事实的时间戳开始(包含时间戳的时刻本身)此事实是 有效 的;而如果这个事实的行为值为假,则前一个有效的事实的有效性截止于这个事实的时间戳(不包含时间戳的时刻本身)。行为值为假的行仅仅表明前一事实有效性的终结,其值列数据没有意义。

我们在应用原子中加入有效性声明来进行历史穿梭查询,比如:

?[name] := *rel{id: $id, name, @ 789}

符号 @ 之后的部分就是有效性声明,必须是一个编译时的常量,也就是说可以是一个表达式,但是表达式里面不能含有变量。带有有效性声明的原子应用对存储表的查询,相当于对仅包含在声明时间戳的时刻有效的数据的表的查询。

两行数据的键列,可以仅仅在行为布尔值这一部分不同,而其它部分都相同。这种情况等价于行为布尔值为假的那行数据不存在,因为那行数据的代表了半开区间的“开”的部分,然后紧接着就是下一个半开区间的“闭”的部分。这种情况有其实际用处:我们可以在声明数据(行为值为真)后紧接着在未来的时间戳撤回此数据(行为值为假)来表示未来的时间戳之后我们不知道情况到底如何。如果在将来某个时刻我们有了更多信息可以预测更久,我们直接再插入一条与最后数据时间戳相同而行为值为真的数据即可,不需要删除那条撤回数据。

可以使用函数 to_bool 来提取有效性中的布尔行为值, 用函数 to_int 来提取代表时间戳的整数。

有效性中的时间戳的具体含义由用户来决定。如果用户决定其含义为以微秒计算的 UNIX 时间戳,则下列功能可用:

  • 插入与撤回事实时,如果需要以当前时间作为时间戳,则可以分别以字符串 ASSERTRETRACT 来代表。使用字符串来代表当前时间的一个好处是同一个事务中所有由此机制生成的时间戳都保证是一样的。同时,存储表列的默认值可以使用这些字符串,这使得插入数据时可以省略显式声明有效性。

  • 插入数据时可以以 RFC 3339 格式的时间字符串来代表行为值为真的有效性。如果在字符串前添加 ~ 符号,则代表行为值为假的有效性。

  • 在应用原子的有效性声明中,可以以 NOW 字符串来代表当前的时间戳,且此时间戳与 ASSERTRETRACT 在同一事务中生成的时间戳保证相同。除了 NOW 之外,有效性声明的时间戳也可以是字符串 END ,这代表“历史的终结”的时间戳。

  • 函数 format_timestamp 可以将有效性转换为可读的字符串(RFC 3339 格式)。

历史穿梭查询的应用之一是预先写好整个历史的剧本,然后在应用中永远都用 NOW 来作为有效性声明。这样一来,用户在使用时就会有亲历了历史发展的错觉,而实际上他们的经历背后,任何主动性、自由性都一点儿也没有,都是 拉普拉斯妖 在操作而已。

系统操作#

所有的系统操作都由两个冒号 :: 开始。下面我们介绍各个系统操作及所需参数。

解释查询#

::explain { <QUERY> }

<QUERY> 是一单个完整的查询。可以含有查询选项,但是由于查询本身不会被执行,选项不起任何作用。返回的不是查询的结果,而是查询的执行计划。返回格式目前仍没有固定的标准,不过在阅读 此章 之后应该基本能理解。

对存储表的操作#

::relations

列出所有存储表。

::columns <REL_NAME>

列出存储表 <REL_NAME> 的所有列。

::remove <REL_NAME> (, <REL_NAME>)*

删除存储表。如果要一次性删除多个,则用逗号连接其名称。

::rename <OLD_NAME> -> <NEW_NAME> (, <OLD_NAME> -> <NEW_NAME>)*

改变存储表的名称:由 <OLD_NAME> 改为 <NEW_NAME> 。可以同时改多个表的名称,也是用逗号隔开。

::index ...

操作索引表,详见 存储表与事务

::show_triggers <REL_NAME>

列出存储表 <REL_NAME> 关联的触发器。

::set_triggers <REL_NAME> ...

为存储表 <REL_NAME> 设置触发器, 存储表与事务 中有详细说明。

::access_level <ACCESS_LEVEL> <REL_NAME> (, <REL_NAME>)*

设置存储表 <REL_NAME> 的安全级(也叫锁表)。可用的级别如下:

  • normal :任何操作都被允许;

  • protected 禁止 ::remove:replace

  • read_only 在上面的基础上禁止一切对行的写入及删除操作;

  • hidden 在上面的基础上禁止读操作(系统操作 ::relations 等属于元数据操作,不属于读操作,所以仍可以执行)。

这些安全级别主要是为了防止程序员无心的错误造成数据的损坏与丢失,并不是为了防止非法数据访问。

进程#

::running

列出所有正在运行的查询以及其 ID。

::kill <ID>

强行终止某个查询。ID 可通过 ::running 命令获得。

维护#

::compact

要求存储引擎执行数据紧缩操作。紧缩后可能可以使数据文件所占空间减小,访问变快。

数据类型#

运行时类型#

Cozo 中的值可以用以下 运行时类型 来分类:

  • Null 空值

  • Bool 布尔值

  • Number 数字

  • String 字符串

  • Bytes 字节数组

  • Uuid UUID

  • List 数组

  • Validity 有效性

Number 可以是 Float (双浮点数)或 Int (64 位带符号整数)。如果需要的话,Cozo 会自动将 Int 类型的值转换为 Float 类型。

List (数组)可以包含任何类型的值,包括其他数组。

可以对 Cozo 中的数值进行全排序,不同运行时类型的值排序顺序根据上表,即: null 小于 true 小于 []

在相同的类型中,排序逻辑如下:

  • false < true

  • -1 == -1.0 < 0 == 0.0 < 0.5 == 0.5 < 1 == 1.0

  • 数组遵循其元素的字典顺序排序;

  • 字节数组遵循字节的字典顺序排序;

  • 字符串遵循其 UTF-8 表示字节的字典顺序排序;

  • UUID 的排序比较特殊:时间戳相近的 UUIDv1 会排在相近的位置。这可以使使用 UUIDv1 作为键的数据的局部性,增强性能;

  • 有效性的存在意义是为了实现历史穿梭查询,其排序顺序在 历史穿梭查询 一章中介绍。

警告

1 == 1.0 的值为真,但是 11.0 是不同的值,所以作为键时,同一个存储表可以同时包含这两个键。在类如 JavaScript 之类的语言中嵌入式地使用 Cozo 时,这尤其容易让人困惑,因为在 JavaScript 中所有的数字都会被转换为浮点数。因此,也因为其它很多原因,我们不推荐用浮点数作为键(但也不禁止)。

字面表达式#

空值为 null,布尔值 false 为假, true 为真。

输入数字时,可以使用常见的十进制,也可以添加 0x-0x 前缀来使用 16 进值,添加 0o-0o 前缀来使用 8 进制,添加 0b-0b 前缀来使用二进制。浮点数的小数点后如果是 0,则可以省略 0,也可以使用常见的科学计数法来表示。所有数字在表示时都可以在任何位置添加额外的下划线 _ 使数字更易读,例如 299_792_458 米每秒是真空中的光速。

字符串的字面表达式与 JSON 中相同,都以 "" 作为分隔符,转义写法也一模一样。另外也可以用单引号 '' 作为分隔符:除了单双引号之外其他所有转义方式都不变。另外,也可以使用所谓的“原始字符串”:

___"I'm a raw string"___

原始字符串以任意个下划线加上双引号开始,以双引号加上相同数量的下划线结束,其间所有字符都以字面意思来解释,不经过转义。只要下划线足够多,任何字符串都可以不经过转义表示为原始字符串。

字节数组与 UUID 没有字面表达式,必须使用函数才能创建它们。在满足键列的类型约束时,数据库会自动调用 decode_base64to_uuid 函数。

数组以方括号 [] 作为分隔符,其元素以逗号隔开。最后一个元素之后可以有一个多余的逗号。

列类型#

列类型的 基本类型 如下:

  • Int 整数

  • Float 浮点数

  • Bool 布尔值

  • String 字符串

  • Bytes 字节数组

  • Uuid UUID

  • Validity 有效性

注意:空值不在上表中。如果在基本类型后添加问号,则类型为 可空 的类型:值要么满足类型约束,要么为空。

有两种复合类型: 同质数组 以方括号加内含元素的类型来表示,如 [Int] 。也可以指定数组的长度,如 [Int; 10]异质数组 以圆括号加上内含元素的列表来表示,如 (Int, Float, String) 。异质数组的长度只能是固定的。

特殊类型 Any 可接受任何非空的值,而 Any? 则接受所有值。这两种特殊类型都可以作为复合类型中元素的类型。

查询的执行#

一般来说,数据库具体如何执行查询被认为是具体实现的细节,用户不需要了解,理由是这样数据库会有更多的有优化查询计划的空间。然而在实际应用中的情况并非如此理想化:由于在复杂查询(尤其是多表连接查询)时数据库“优化”后的计划常常造成性能上的问题,用户常常需要通过各种方式限制数据库的这种自由度来使数据库按照用户的意图来执行查询,这个过程艰辛且令人痛苦。在图数据库中这个问题尤其突出,因为图查询包含大量多表连接。

对此问题,Cozo 采取了实用的态度,保证执行查询时的执行计划是 确定性的 ,即根据查询的文本本身就能断定查询如何被执行。这也意味着用户常常需要对数据本身有一定的了解,特别是需要知道应该如何写查询文本才能生成想要的查询执行计划。大家会发现,在查询文本复杂时,这种确定性,这种能够明确地操纵查询计划的能力,实际上会省去很多时间与精力。

析取范式#

查询文本编译的第一个步骤是将所有内联规则中由原子组成的逻辑表达式转化为 析取范式 :所有的否定都直接作用于底层的原子上,然后整个逻辑短句由有限个合取式的析取组成,这些合取式则只包含底层原子或底层原子的否定式。而后顶层析取式中的每个合取短句都成为独立的同一个内联规则下的正文。需要注意的是,转化后的查询文本可能会违反安全规则,即使文本本身看起来似乎没问题,比如:

::

rule[a, b] := rule1[a] or rule2[b]

在改写后这一条正文变成了两条正文,而在这两条正文中都未全部绑定包含头部所需要的所有变量,因此此查询是不安全的。

规则的分层#

下一步是将所有规则分为多个 层次 。首先将规则作为一个图中的节点,而当一条规则的正文中应用了其它的规则时,就画一条从这条规则到被应用规则的边。这些边中,满足以下任一条件的被标注为 断层边

  • 规则应用的原子处于被否定的位置;

  • 被应用的规则不是当前规则本身,且被应用的规则含有任何聚合算符;

  • 被应用的规则就是当前规则本身,被应用的规则含有聚合算符且该聚合算符不能做为半晶格算符执行;

  • 规则或被应用的规则是固定规则;

接下来算出这个图中的 强连通分量 。如果某一个强连通分量中含有断层边,则此查询被认为无法分层,执行报错。

如果没有报错,则对强连通分量组成的图进行拓扑排序。排序后这些强连通分量会被进一步合并为 ,这个合并满足以下条件:每层中所含有的规则间没有断层边,且每层中的规则只有到当前或之前层中规则的边,没有到之后层中规则的边。

通过这个步骤我们得到的层,在查询中会按照顺序依次执行。具体查询文本的分层可以通过 ::explain 系统操作来展示。

魔法集改写#

接下来,层中的规则会做为一个整体来进行 魔法集改写 。这个改写的目的是省去一些不必要的中间结果。例如在以下查询中

::

reachable[a, b] := link[a, n] reachable[a, b] := reachable[a, c], link[c, b] ?[r] := reachable[‘A’, r]

如果不进行改写就直接执行,完整的 reachable 表会被生成,而当我们返回最终结果是实际上仅用到了这个表中非常小的一部分,即 a == 'A' 的那部分。而改写后的规则则只会计算这需要的一部分,并保证输出的最终结果与改写前完全相同。改写的结果同样可以通过 ::explain 查询。

任何带有聚合算符的规则都不会被改写。

半朴素求值#

现在每层内要么含有单个的固定规则,要么含有改写过的若干个内联规则。单个的固定规则会按照其实现算法执行,而内联规则的集,当我们已经知道如何执行每条规则的正文时,从宏观上来说,则会依照半朴素求值算法执行。朴素求值算法是一个自下而上的算法:它以所有已知的事实出发,按照所给规则推导隐含的事实。由于规则间的递归关系,这个推导过程需要执行不定的次数,直到没有新事实被推导出为止,而半朴素求值法则在朴素求值法的基础上做了一些改进,通过代数性质省去了大量的推导过程。

备注

另一类求值法是自上而下的求值法:由需要证明的事实而不是给出的事实出发,逐步构建证明的步骤。自下而上求值法相比自上而下求值法而言具有许多优势,尤其是在一定情况下能保证算法不陷入死循环(自上而下不能)。其劣势在于自下而上法经常会生成不需要的事实,而魔法集改写的引入正好完美消除了这个劣势。

对原子排序#

现在我们来介绍内联规则的正文部分如何被执行。首先编译器会对原子进行重新排序,然后原子依次被执行。由于我们已经将正文都转写为了析取范式,现在每个原子只能是以下情况之一:

  • 显性归一;

  • 存储或内联表的应用;

  • 表达式;

  • 存储或内联表应用的否定。

上面给出的前两种情况可以生成新的绑定,而后两种不行。在重新排序时,前两种情况的原子原地不动,而后两种情况的原子会被安排在其所包含所有变量都已经绑定了的尽量早的位置。在重新排序之后,前两种情况就变成了关系代数的表的连接,用来连接的列就是其包含的已绑定的变量;而后两种情况就变成了关系代数中的过滤。改写的意义在于,过滤会被尽量早地执行,使得中间结果尽量地少。

在编写查询文本时,首要的目的就是减少中间结果的数量。一个几乎只有好处没有坏处的策略是,在连接多个表的时候,将能过滤掉更多行的表写在前面。

对原子求值#

接下来介绍能够生成新绑定的原子的求值方法。

在显性归一种,算符右边的表达式会被执行(此时其所含所有变量都已有绑定),然后结果会被连接到当前的表中,相当于函数式语言中的 map-cat 函数。

表的应用都与单个的表相关,而这些表都是以其键做字典排序来存储的树。因此,一个表的应用的算法复杂度取决于其已绑定的变量是否构成表键列的 前缀 。比如说,在下例中的应用

a_rule['A', 'B', c]

变量 c 未被绑定。这个查询中前两列已被绑定,且构成了前缀,所以系统可以直接在树上找到前缀对应的位置,因此此查询复杂度低,速度快。与此相对的,以下查询中

a_rule[a, 'B', 'C']

如果变量 a 未绑定,则系统必须遍历所有数据才能找出满足条件的行,因此复杂度高,速度慢。

在了解存储表应用的具体执行必须先了解存储表的结构。系统操作 ::explain 也可以显示一个查询中是否含有对某个表的遍历操作。

对正文求值时,行是以流式的方式生成的,也就是说前一个原子生成了一行后,就立刻开始连接下一个原子,而不等前一个原子生成其所有行。

早停法#

指定了查询选项 :limit 以后,在执行入口规则 ? 时系统会启用一个计数器来记录已经生成了多少满足查询条件的行,而由于流式的正文执行,当足够的行被生成后查询就会立刻停止。这就是 早停法 。此法仅对没有同时指定 :order 的查询中的内联规则奏效。

查询技巧#

空值的问题#

Cozo 的类型检查很严格:下面的查询

?[a] := *rel[a, b], b > 0

b 为空值时会报错,因为只能对相同类型的值做大小比较。解决方法是使空值代表某一个具体的非空值:

?[a] := *rel[a, b], (b ~ -1) > 0

这里的 ~ 是“聚凝”算符。括号其实不是必须的,但是可以让这个查询更易读一些。

你也可以显式检查空值:

?[a] := *rel[a, b], if(is_null(b), false, b > 0)

你也可以使用 cond 结构。

如何连接表#

假设我们有如下存储表:

:create friend {fr, to}

我们想知道 Alice 的朋友的朋友的朋友的朋友的朋友们都是谁。我们可以这么写:

?[who] := *friends{fr: 'Alice', to: f1},
          *friends{fr: f1, to: f2},
          *friends{fr: f2, to: f3},
          *friends{fr: f3, to: f4},
          *friends{fr: f4, to: who}

也可以这么写:

f1[who] := *friends{fr: 'Alice', to: who}
f2[who] := f1[fr], *friends{fr, to: who}
f3[who] := f2[fr], *friends{fr, to: who}
f4[who] := f3[fr], *friends{fr, to: who}
?[who] := f4[fr], *friends{fr, to: who}

这两种写法的返回值是相同的。但是,在真实的社交网络数据中,第二种写法比第一种写法快得多(指数级别地快),其原因在于 Cozo 的表遵循集合语义,因此第二种写法中每层朋友都被去重了。与此相对的,第一种写法只有在返回时朋友们才会被去重,而这其中生成的中间结果就太多了。实际上,即使每个规则的返回值中没有重复,第二种写法也会更快,因为 Cozo 的各个规则在语义资源允许的情况下是并行计算的。

因此,我们在写查询语句的时候,应该尽量将查询分为多个小规则。这不但让查询更易读,也会让查询运行得更快(这一点和其他一些数据库正好相反)。当然,上面的例子其实用递归查询更好:

f_n[who, min(layer)] := *friends{fr: 'Alice', to: who}, layer = 1
f_n[who, min(layer)] := f_n[fr, last_layer], *friends{fr, to: who}, layer = last_layer + 1, layer <= 5
?[who] := f_n[who, 5]

这里我们用了一个表达式原子 layer <= 5 来保证返回结果不是无穷大集。

第一种写法也有其作用,比如:

?[who] := *friends{fr: 'Alice', to: f1},
          *friends{fr: f1, to: f2},
          *friends{fr: f2, to: f3},
          *friends{fr: f3, to: f4},
          *friends{fr: f4, to: who}
:limit 1

因为我们要求数据库至多返回一行结果,“早停法”的机能会被激活。在这种情况下,这样写会比拆成多个规则稍微快那么一点。

另外,在聚合数据时:

?[count(who)] := *friends{fr: 'Alice', to: f1},
                 *friends{fr: f1, to: f2},
                 *friends{fr: f2, to: f3},
                 *friends{fr: f3, to: f4},
                 *friends{fr: f4, to: who}

合起来写与拆成多个规则写返回的结果是不同的。对于这个具体的查询,如果你想知道的是 Alice 到她第五层朋友们不同 路径 的数目,那只有上面这种写法是对的。另外,虽然不同路径的数量很大,以上查询执行时使用的内存非常小,因为 Cozo 在这里执行流式计算,中间结果不需要存在任何表中。

函数与算符#

函数是表达式的一种。Cozo 中除了读取当前时间的函数和以 rand_ 开头的函数之外,其它所有的函数都是纯函数:给出同样的参数,其值相同。

不是函数#

首先我们来介绍一些不是函数的构造。函数的求值规则是先对其参数求值,然后将参数的值传入函数的正文,得到一个值作为结果。以下这些构造不是函数,因为其不遵守这个规则。

首先,以下构造不返回值:

  • var = expr 显性地将 varexpr 归一。注意与算符形式的函数 expr1 == expr2 的区别。

  • not clause 将原子 clause 否定。注意与算符形式的函数 !expr 以及函数 negate(expr) 的区别。

  • clause1 or clause2 将两个原子进行析取。注意与函数 or(expr1, expr2) 的区别。

  • clause1 and clause2 将两个原子进行合取。注意与函数 and(expr1, expr2) 的区别。

  • clause1, clause2 将两个原子进行合取。

以上最后三项中, or 的优先级最高, and 次之, , 最低。 and, 的唯一区别就是他们的优先级。

以下构造返回值,但是不是函数:

  • if(a, b, c) 首先对 a 求值,若为真,则对 b 求值并返回其结果,否则对 c 求值并返回其结果。 a 的求值结果必须是布尔值。

  • if(a, b)if(a, b, null) 等价。

  • cond(a1, b1, a2, b2, ...) 首先对 a1 求值,若其值为真,则对 b1 求值并返回其结果,否则对 a2b2 进行相同操作,直到有结果返回。此构造必须给出偶数个参数,且所有的 a 都必须求值为布尔值。若所有的 a 的值都为假,则返回空值。若想返回非空的默认值,则可以在最后一对参数的第一个中使用真值作为参数。

函数的算符表达#

为了书写简便,一些函数有算符表示。以下是二元算符:

  • a && b 等价于 and(a, b)

  • a || b 等价于 or(a, b)

  • a ^ b 等价于 pow(a, b)

  • a ++ b 等价于 concat(a, b)

  • a + b 等价于 add(a, b)

  • a - b 等价于 sub(a, b)

  • a * b 等价于 mul(a, b)

  • a / b 等价于 div(a, b)

  • a % b 等价于 mod(a, b)

  • a >= b 等价于 ge(a, b)

  • a <= b 等价于 le(a, b)

  • a > b 等价于 gt(a, b)

  • a < b 等价于 le(a, b)

  • a == b 等价于 eq(a, b)

  • a != b 等价于 neq(a, b)

  • a ~ b 等价于 coalesce(a, b)

二元算符的优先级如下(以行记,前面的行中所包括的算符优先级高,后面的低;同一行中所有算符有相机相同):

  • ~

  • ^

  • *, /

  • +, -, ++

  • %

  • ==, !=

  • >=, <=, >, <

  • &&

  • ||

^ 之外,所有二元算符都向左关联,即 a / b / c(a / b) / c^ 则向右关联: a ^ b ^ c 等价于 a ^ (b ^ c)

一元算符如下:

  • -a 等价于 minus(a)

  • !a 等价于 negate(a)

在需要改变算符顺序时,可以使用括号。括号优先级最高,其次是一元算符,最后是二元算符。

相等与比较#

eq(x, y)#

相等。算符形式为 x == y 。两个参数如果类型不同,则结果为假值。

neq(x, y)#

不等。算符形式为 x != y 。两个参数如果类型不同,则结果为真值。

gt(x, y)#

大于。算符形式为 x > y

ge(x, y)#

大于等于。算符形式为 x >= y

lt(x, y)#

小于。算符形式为 x < y

le(x, y)#

小于等于。算符形式为 x <= y

备注

大小比较的两个参数必须隶属于同类型,否则会报错。在 Cozo 中,整数与浮点数的运行时类型相同,都是 Number

max(x, ...)#

返回参数中的最大值。所有参数都必须是数字。

min(x, ...)#

返回参数中的最小值。所有参数都必须是数字。

布尔函数#

and(...)#

接受任意个参数的合取。二元形式等价于 x && y

or(...)#

接受任意个参数的析取。二元形式等价于 x || y

negate(x)#

否定。等价于 !x

assert(x, ...)#

x 为真则返回真,否则抛出异常。给出多个参数时其它参数会包含在异常中,可以作为错误信息。

数学函数#

add(...)#

多参数形式的加法。二元形式等价于 x + y

sub(x, y)#

减法,等价于 x - y

mul(...)#

多参数形式的乘法。二元形式等价于 x * y

div(x, y)#

除法,等价于 x / y

minus(x)#

求负,等价于 -x

pow(x, y)#

xy 次方。等价于 x ^ y 。返回浮点数,即使参数都是整数。

mod(x, y)#

xy 求模(余数)。参数可以是浮点数。返回的值的符号与 x 相同。等价于 x % y

abs(x)#

绝对值。

signum(x)#

返回 10-1 中与所传参数符号一样的数,比如 signum(to_float('NEG_INFINITY')) == -1signum(0.0) == 0 ,但是 signum(-0.0) == -1 。如果参数为 NAN 则返回 NAN

floor(x)#

向下求整。

ceil(x)#

向上求整。

round(x)#

四舍五入。当遇到点五时,取离 0 远的值,如 round(0.5) == 1.0round(-0.5) == -1.0round(1.4) == 1.0

exp(x)#

指数函数,以自然对数 e 为底。

exp2(x)#

指数函数,以 2 为底。即使参数是整数也返回浮点数。

ln(x)#

对数函数,以自然对数为底。

log2(x)#

对数函数,以 2 为底。

log10(x)#

对数函数,以 10 为底。

sin(x)#

正弦函数。

cos(x)#

余弦函数。

tan(x)#

正切函数。

asin(x)#

正弦函数的反函数。

acos(x)#

余弦函数的反函数。

atan(x)#

正切函数的反函数。

atan2(x, y)#

正切函数的反函数,同时传入两个参数,对这两个参数的比做反正切,并使用这两个参数的符号来决定返回值的象限。

sinh(x)#

双曲正弦函数。

cosh(x)#

双曲余弦函数。

tanh(x)#

双曲正切函数。

asinh(x)#

双曲正弦函数的反函数。

acosh(x)#

双曲余弦函数的反函数。

atanh(x)#

双曲正切函数的反函数。

deg_to_rad(x)#

将角度转换为弧度。

rad_to_deg(x)#

将弧度转换为角度。

haversine(a_lat, a_lon, b_lat, b_lon)#

给出球面上两点的两对经纬度,使用 半正矢公式 来计算他们之间的夹角。经纬度都以弧度给出。由于地图上的经纬度通常以角度给出,下一个函数更常用一些。

haversine_deg_input(a_lat, a_lon, b_lat, b_lon)#

与上面的函数的唯一区别是经纬度参数以角度而不是弧度给出。返回的值仍然是弧度而不是角度。

计算球面表面两点的球面距离时,将返回值乘以球的半径。比如地球的半径为 6371 公里,或 3959 英里,或 3440 海里。

备注

由于地球并不是精确的球体,所以用此函数来计算距离时会有一定的误差,误差在百分之一之内。

字符串函数#

length(str)#

返回字符串中含有的 Unicode 字符的数量。参数也可以是数组。

警告

length(str) 返回的不是字符串的字节长度,且两个等价的 Unicode 字符串可能规范化形式不同,而导致它们的长度不同。遇到这种情况时建议使用先对字符串使用 unicode_normalize 函数来保证统一的规范化形式,然后再使用 length 函数。

concat(x, ...)#

串联字符串。二元形式等价于 x ++ y 。参数也可以都是数组。

str_includes(x, y)#

如果字符串 x 包含 字符串 y 的内容,则返回真,否则返回假。

lowercase(x)#

将字符串转换为小写。支持 Unicode。

uppercase(x)#

将字符串转换为大写。支持 Unicode。

trim(x)#

删除字符串两头的空白字符。空白字符由 Unicode 标准定义。

trim_start(x)#

删除字符串开头的空白字符。空白字符由 Unicode 标准定义。

trim_end(x)#

删除字符串结尾的空白字符。空白字符由 Unicode 标准定义。

starts_with(x, y)#

检查字符串 x 是否以 y 为前缀。

小技巧

使用 starts_with(var, str) 而不是等价的正则表达式可以帮助系统更好的优化查询:在一定情况下系统可以使用范围扫描而不是全局扫描。

ends_with(x, y)#

检查字符串 x 是否以 y 结尾。

unicode_normalize(str, norm)#

对字符串 str 进行 Unicode 规范化。规范化种类 norm 可以是 'nfc''nfd''nfkc''nfkd'

chars(str)#

返回字符串中所含的 Unicode 字符。

from_substrings(list)#

将一个字符串的数组组合成一个字符串。可以说是 chars 的逆函数。

警告

由于 Unicode 的复杂性,Cozo 中的字符串不能以整数作为索引来查询特定位置的字符。如果查询时需要此功能,则需要先使用 chars 将其转化为数组。

数组函数#

list(x, ...)#

将参数组成一个数组。 list(1, 2, 3) 等价于 [1, 2, 3]

is_in(el, list)#

测试元素是否在数组中。

first(l)#

提取数组中的第一个元素。空数组返回空值。

last(l)#

提取数组中的最后一个元素。空数组返回空值。

get(l, n)#

返回数组中索引为 n 的元素,索引为整数,从 0 开始。若索引在范围之外则报错。

maybe_get(l, n)#

返回数组中索引为 n 的元素,索引为整数,从 0 开始。若索引在范围之外则返回空值。

length(list)#

返回数组的长度。也可以对字节数组及字符串使用。

slice(l, start, end)#

从索引值 start 开始(含)到索引值 end 为止(不含),取参数数组的子数组。索引值可以为负数,意义为从数组结尾开始计算的索引。例: slice([1, 2, 3, 4], 1, 3) == [2, 3]slice([1, 2, 3, 4], 1, -1) == [2, 3]

concat(x, ...)#

将参数数组组成一个数组。二元形式等价于 x ++ y 。参数也可以是字符串。

prepend(l, x)#

将元素 x 插入 l 的最前端。

append(l, x)#

将元素 x 插入 l 的最后端。

reverse(l)#

倒转数组。

sorted(l)#

对数组进行排序,返回排序后的结果。

chunks(l, n)#

将数组切为长度为 n 的多个数组,最后一个数组可能长度不够,例: chunks([1, 2, 3, 4, 5], 2) == [[1, 2], [3, 4], [5]]

chunks_exact(l, n)#

将数组切为长度为 n 的多个数组,如果最后一个数组长度不够则舍弃之,例: chunks([1, 2, 3, 4, 5], 2) == [[1, 2], [3, 4]]

windows(l, n)#

返回数组中长度为 n 的滑动窗口,例: windows([1, 2, 3, 4, 5], 3) == [[1, 2, 3], [2, 3, 4], [3, 4, 5]]

union(x, y, ...)#

返回给定参数(每个参数都代表一个集合)的联合。

intersection(x, y, ...)#

返回给定参数(每个参数都代表一个集合)的交叉。

difference(x, y, ...)#

返回第一个参数对其它参数(每个参数都代表一个集合)的差异。

二进制函数#

length(bytes)#

返回字节数组的长度。也接受字符串及数组为参数。

bit_and(x, y)#

返回两个字节数组比特级别的与。两个字节数组长度必须一致。

bit_or(x, y)#

返回两个字节数组比特级别的或。两个字节数组长度必须一致。

bit_not(x)#

返回字节数组比特级别的非。

bit_xor(x, y)#

返回两个字节数组比特级别的排他或。两个字节数组长度必须一致。

pack_bits([...])#

将一个包含布尔值的数组转换为一个字节数组。若参数中的数组长度不能被 8 整除,则以假值补足再转换。

unpack_bits(x)#

将字节数组转换为布尔值的数组。

encode_base64(b)#

将字节数组使用 Base64 编码为字符串。

备注

对列进行类型转化时,若列的类型为字节数组,则会自动套用此函数。

decode_base64(str)#

尝试将字节使用 Base64 编码解码为字节数组。

类型检查与转换函数#

coalesce(x, ...)#

聚凝算符,即返回第一个非空的值。若所有值都为空则返回空。二元形式等价于 x ~ y

to_string(x)#

将参数转换为字符串。如参数本身就是字符串,则不做变更,否则使用 JSON 的字符串表示形式。

to_float(x)#

将参数转换为浮点数。不管参数是什么,此函数都不会抛出异常,当无法转换时会返回特殊的浮点数 NAN。以下是一些可转换的特殊字符串:

  • INF 转换为正无穷大;

  • NEG_INF 转换为负无穷大;

  • NAN 转换为 NAN (两个 NAN 不相等:若要检查值是否为 NAN,需要使用 is_nan 函数);

  • PI 转换为圆周率(3.14159…);

  • E 转换为自然对数的底(欧拉常数之一,2.71828…)。

空值与假值转换为 0.0 ,真值转换为 1.0

to_int(x)#

将参数转换为整数。当参数为有效性时,提取有效性中的整数时间戳。

to_unity(x)#

将参数转换为 01 :空值、假值、 00.0""[] 、空字节数组转换为 0 ,其余都转换为 1

to_bool(x)#

将参数转换为布尔值。以下转换为假值,其他所有值转换为真值:

  • null

  • false

  • 00.0

  • "" 空字符串

  • 空字节数组

  • 空 UUID (所有字节都为 0)

  • [] 空数组

  • 所有行为值为假的有效性

to_uuid(x)#

将参数转换为 UUID。如果参数不是 UUID 或合法的 UUID 字符串表示,则报错。

uuid_timestamp(x)#

从 UUID v1 中提取时间戳的浮点数,以秒为单位。如果 UUID 版本不是 1,则返回空值。若参数不是 UUID 则报错。

is_null(x)#

测试参数是否为空值。

is_int(x)#

测试参数是否为整数。

is_float(x)#

测试参数是否为浮点数。

is_finite(x)#

测试参数是否为有限的数字。

is_infinite(x)#

测试参数是否为无穷的浮点数。

is_nan(x)#

测试参数是否是特殊的浮点数 NAN

is_num(x)#

测试参数是否为数字。

is_bytes(x)#

测试参数是否为字节数组。

is_list(x)#

测试参数是否为数组。

is_string(x)#

测试参数是否为字符串。

is_uuid(x)#

测试参数是否为 UUID。

随机函数#

rand_float()#

返回在闭区间 [0, 1] 内均匀采样的浮点数。

rand_bernoulli(p)#

返回随机的布尔值,以几率 p 返回真值。

rand_int(lower, upper)#

返回所给闭区间内的随机整数,均匀采样。

rand_choose(list)#

随机返回数组中的一个元素,随机采样。若数组为空则返回空值。

rand_uuid_v1()#

生成一个随机的 UUID v1(包含当前时间戳)。在浏览器中的时间戳精度比原生程序的低很多。

rand_uuid_v4()#

生成一个随机的 UUID v4。

正则表达式函数#

regex_matches(x, reg)#

测试字符串能否被正则表达式匹配。

regex_replace(x, reg, y)#

将字符串 x 中被正则表达式匹配上的第一处替换为 y

regex_replace_all(x, reg, y)#

将字符串 x 中被正则表达式匹配上的所有地方都替换为 y

regex_extract(x, reg)#

将字符串中所有被正则表达式匹配上的地方放在一个数组中返回。

regex_extract_first(x, reg)#

返回字符串中被正则表达式匹配上的第一处。如果没有匹配则返回空值。

正则表达式语法#

单个字符:

.             除了换行之外的任何字符
\d            数字 (\p{Nd})
\D            非数字
\pN           单个字母表示的 Unicode 字符类
\p{Greek}     Unicode 字符类
\PN           单个字母表示的 Unicode 字符类的补集
\P{Greek}     Unicode 字符类的补集

字符集:

[xyz]         单个字符 x 或 y 或 z
[^xyz]        除了 x 、 y 、 z 以外的所有单个字符
[a-z]         在 a-z 范围内的单个字符
[[:alpha:]]   ASCII 字符类([A-Za-z])
[[:^alpha:]]  ASCII 字符类的补集([^A-Za-z])
[x[^xyz]]     包含潜逃的字符类
[a-y&&xyz]    交集(匹配 x 或 y)
[0-9&&[^4]]   使用交集与补集来做差异
[0-9--4]      差异(匹配 0-9,但是 4 除外)
[a-g~~b-h]    对称差异(仅匹配 a 与 h)
[\[\]]        字符集中的转义(匹配 [ 或 ])

组合:

xy    串联(x 后面紧接着 y)
x|y   交替(x 或者 y,都可以的时候优先 x)

重复:

x*        零或多个 x(贪婪匹配)
x+        一或多个 x(贪婪匹配)
x?        零或一个 x(贪婪匹配)
x*?       零或多个 x(惰性匹配)
x+?       一或多个 x(惰性匹配)
x??       零或一个 x(惰性匹配)
x{n,m}    至少 n 个,至多 m 个 x(贪婪匹配)
x{n,}     至少 n 个 x(贪婪匹配)
x{n}      正好 n 个 x(贪婪匹配)
x{n,m}?   至少 n 个,至多 m 个 x(惰性匹配)
x{n,}?    至少 n 个 x(惰性匹配)
x{n}?     正好 n 个 x(惰性匹配)

空匹配:

^     文本起始处
$     文本结束处
\A    仅文本起始处
\z    仅文本结束处
\b    Unicode 词语边界(以 \w 开始,以 \W、\A 或 \z 结束)
\B    不是 Unicode 词语边界

时间戳函数#

now()#

返回当前的 UNIX 时间戳(以秒计,浮点数)。浏览器中的精度比原生程序的低得多。

format_timestamp(ts, tz?)#

将浮点数 UNIX 时间戳 ts (以秒计)根据 RFC 3339 标准转换为字符串。若 ts 为有效性,则使用其中以微秒计的整数时间戳。

可选的第二个参数指定字符串显示的市区,格式为 UNIX 系统中的格式。

parse_timestamp(str)#

根据 RFC 3339 标准将字符串转换为浮点数时间戳(以秒计)。

聚合#

Cozo 中的聚合可以理解为作用于一连串值然后返回单个值(聚合值)的函数。

Cozo 中的聚合分为两类, 普通聚合半晶格聚合 。所有的半晶格聚合都同时也是普通聚合,但是底层的实现方式不一样。只有半晶格聚合才能用在自递归的规则中。

半晶格聚合满足额外的代数性质:

幂等性

对单个值 a 进行聚合,值一定为 a 本身;

交换律

ab 的聚合等于 ba 的聚合;

结合律

聚合时怎么打括号得到的结果都一样。

在含有半晶格聚合的自递归规则中,理论上来说正文中的变量还需要满足一些额外的安全限制。常见的使用方式里这些限制都是被满足的,但随意使用归一来根据递归结果生成新的值可能会违反这些限制:Cozo 不会(也无法)检查这些有问题的查询。

半晶格聚合#

min(x)#

最小值。

max(x)#

最大值。

and(var)#

所有值的逻辑与。

or(var)#

所有值的逻辑或。

union(var)#

所有值(每一个都是数组)的并集。

intersection(var)#

所有值(每一个都是数组)的交集。

choice(var)#

从给出的值中抽取一个非空值作为聚合值。若所有值都为空,则聚合值为空。

min_cost([data, cost])#

聚合值为 cost 最小的 data

shortest(var)#

聚合值为长度最短的那个数组。

bit_and(var)#

比特级别的“与”聚合值。被聚合的值必须都是字节数组。

bit_or(var)#

比特级别的“或”聚合值。被聚合的值必须都是字节数组。

普通聚合#

count(var)#

计数。

count_unique(var)#

计数,重复值只计算一次。

collect(var)#

将所有值聚合为一个数组。

unique(var)#

将所有值聚合为一个数组,重复值只保留一份。

group_count(var)#

对被聚合值按照值进行计数,列:值依次为 'a''b''c''c''a''c' ,聚合值为 [['a', 2], ['b', 1], ['c', 3]]

bit_xor(var)#

比特级别的“排他或”聚合。值必须都为字节数组。

latest_by([data, time])#

聚合值为 time 最大的 data 。与 min_cost 类似,但是 min_cost 中数组里第二个值只能是数字,这里可以是任何类型,且 min_cost 用的是最小,这里是最大。

smallest_by([data, cost])#

聚合值为 cost 最小的 data 。与 min_cost 类似,但是 min_cost 中数组里第二个值只能是数字,这里可以是任何类型。当 cost 为空值时,直接舍弃此数组。

choice_rand(var)#

从值中随机均匀取样一个值作为聚合值。

备注

此聚合不是半晶格聚合,因为如果不保存状态,则无法保证均匀采样,而目前的半晶格聚合实现都是无状态的。

统计聚合#

mean(x)#

平均数。

sum(x)#

求和。

product(x)#

求积。

variance(x)#

(样本)方差。

std_dev(x)#

(样本)标准差。

工具与算法#

固定规则包括工具与算法两种。

只有在编译时启用了 graph-algo 选项时,以下叙述的算法规则才可用。目前除了浏览器 WASM 之外的所有预编译发布都启用了此选项。

如果你使用的是 Rust、Python 或 NodeJS 的 Cozo 库,或 Cozo 的独立可执行文件,你可以自定义规定规则。具体方法请参考相应的文档。

工具#

Constant(data: [...])#

将传入的数组作为表输出。常量规则 ?[] <- ... 其实是 ?[] <~ Constant(data: ...) 的语法糖。

参数:

data – 一个元素为数组的数组,用来表示所需的表。

ReorderSort(rel[...], out: [...], sort_by: [...], descending: false, break_ties: false, skip: 0, take: 0)#

将传入的规则 rel 所代表的表排序,然后提取其一些列输出为新的表。

参数:
  • out (required) – 一个由表达式元素组成的数组,每个元素会依次成为输出表的一列。表达式中的变量会绑定为 rel 参数中给出的变量。

  • sort_by – 一个由表达式组成的数组,使用这些表达式生成的值来对 rel 中的行进行排序。表达式中的变量会绑定为 rel 参数中给出的变量。

  • descending – 是否以倒序排序。默认否。

  • break_ties – 排序时同序的行是否给出不同的行号,即两行序列相同时,行号是 12 还是 11 。默认否,即给出相同的行号。

  • skip – 输出时跳过多少行。默认为 0.

  • take – 最多输出多少行。0 表示不做限制。默认为不限制。

返回:

返回表的列除了 out 中声明的列之外,还会在前面添加序号列。序号从 1 开始。

小技巧

此工具与 :order:limit:offset 查询选项功能有一定重合,但是这个工具可以运用于任意规则,而查询选项只能运用于入口规则。在应用于入口规则时,建议使用查询选项。

CsvReader(url: ..., types: [...], delimiter: ',', prepend_index: false, has_headers: true)#

从文件或 GET HTTP 请求获取 CSV 格式的数据,并将其转化为表。

浏览器中的 WASM 实现中没有此工具。另外,如果编译时没有打开 requests 选项,则不能从网络读取数据。

参数:
  • url (required) – CSV 文件的 URL。本地文件请使用 file://<文件路径> 格式的 URL。

  • types (required) – 元素为字符串的数组,每个字符串表示一列的类型。与数据库中其它应用类型的地方不同,如果某一列类型为可空类型,而对文件中的数据应用对应的类型转换失败时,此工具会输出空值而不是报错。这么设计的原因是常见的生成 CSV 的程序输出的坏值太多了,如果碰到坏值就报错,那么这个工具其实就没多大作用了。

  • delimiter – CSV 文件中的分隔符。

  • prepend_index – 若为真,则输出的第一列为行号。

  • has_headers – CSV 文件的第一行是否是文件头。若为真,则程序直接跳过第一行,不会对其做任何解析。

JsonReader(url: ..., fields: [...], json_lines: true, null_if_absent: false, prepend_index: false)#

从文件或 GET HTTP 请求获取 JSON 格式的数据,并将其转化为表。

浏览器中的 WASM 实现中没有此工具。另外,如果编译时没有打开 requests 选项,则不能从网络读取数据。

参数:
  • url (required) – JSON 文件的 URL。本地文件请使用 file://<文件路径> 格式的 URL。

  • fields (required) – 元素为字符串的数组,系统会将 JSON 对象中这些名称的字段提取为列。

  • json_lines – 若真,则文件应每行包含一个 JSON 对象,否则文件应包含一个大数组,数组中包含多个对象。

  • null_if_absent – 若真,则所需的字段不存在时默认使用空值,若假,则遇到这种情况时报错。

  • prepend_index – 若真,则输出的第一列为行号。

连通性算法#

ConnectedComponents(edges[from, to])#

根据边算出顶点的 连通分量

返回:

第一列为顶点,第二列为其所在连通分量的编号。

StronglyConnectedComponent(edges[from, to])#

根据边算出顶点的 强连通分量

返回:

第一列为顶点,第二列为其所在连通分量的编号。

SCC(...)#

Algo.StronglyConnectedComponent

MinimumSpanningForestKruskal(edges[from, to, weight?])#

在给出的边上运行 克鲁斯卡尔算法 来求 最小生成树 。边的权重可为负。

返回:

第一、二列表示一个边,第三列是从树根到第二列顶点的距离。具体哪个顶点会被选为根是不固定的。如果有多个根,则表明图不是连通的。

MinimumSpanningTreePrim(edges[from, to, weight?], starting?[idx])#

在给出的边上运行 普里姆算法 来求 最小生成树starting 应为一个只有一行、一列的表,其值会作为树根。只有与树根连接的顶点才会被返回。若没有给出 starting ,则在图不连通时不一定会返回哪个分量。

返回:

第一、二列表示一个边,第三列是从树根到第二列顶点的距离。

TopSort(edges[from, to])#

对所给出的边中的顶点进行 拓扑排序 。给出的边必须组成一个连通的图。

返回:

第一列为排序后的序号,第二列为顶点。

寻路算法#

ShortestPathBFS(edges[from, to], starting[start_idx], goals[goal_idx])#

在所给出的边上进行宽度优先搜索,来找出 starting 中的顶点与 goals 中的顶点的最短路径。给出的边是有向图的边,每个边的权重都为 1。若有多条最短路径,则返回任意一条。这是最简单的寻路算法:下面有更多的应用更广的寻路算法。

返回:

第一列为起点,第二列为终点,第三列为最短路径。

ShortestPathDijkstra(edges[from, to, weight?], starting[idx], goals[idx], undirected: false, keep_ties: false)#

在给出的边上运行 戴克斯特拉算法 ,以找出 starting 中的节点与 goals 中的节点的最短路径。若给出了权重,则权重必须非负。

参数:
  • undirected – 若真,则给出的边为无向图的边,否则为有向图的边。默认为有向图的边。

  • keep_ties – 当有多条最短路径时,是否返回所有的最短路径。默认为否,也就是仅返回其中某一条。

返回:

第一列为起点,第二列为终点,第三列为最短路径的总权重,第四列为最短路径。

KShortestPathYen(edges[from, to, weight?], starting[idx], goals[idx], k: expr, undirected: false)#

在给出的边上运行 Yen 算法来找出连接每对 starting 中的顶点与 goals 中的顶点的最短的 k 条路径。

参数:
  • k (required) – 每对顶点返回多少条路径。

  • undirected – 若真,则给出的边为无向图的边,否则为有向图的边。默认为有向图的边。

返回:

第一列为起点,第二列为终点,第三列为最短路径的总权重,第四列为最短路径。

BreadthFirstSearch(edges[from, to], nodes[idx, ...], starting?[idx], condition: expr, limit: 1)#

在所给的边上运行宽度优先搜索,从 starting 中的顶点开始搜索。若 starting 未给出,则默认为边中所包含的所有顶点(计算量可能会非常大)。

参数:
  • condition (required) – 表示停止搜索条件的表达式。表达式中的变量绑定为 nodes 参数给出的变量。表达式的值应为布尔值,当值为真时表示找到了所需结果。

  • limit – 找到多少个所需结果后停止搜索。默认为 1。

返回:

第一列为起点,第二列为终点,第三列为找到的路径。

BFS(...)#

Algo.BreadthFirstSearch

DepthFirstSearch(edges[from, to], nodes[idx, ...], starting?[idx], condition: expr, limit: 1)#

在所给的边上运行深度优先搜索,从 starting 中的顶点开始搜索。若 starting 未给出,则默认为边中所包含的所有顶点(计算量可能会非常大)。

参数:
  • condition (required) – 表示停止搜索条件的表达式。表达式中的变量绑定为 nodes 参数给出的变量。表达式的值应为布尔值,当值为真时表示找到了所需结果。

  • limit – 找到多少个所需结果后停止搜索。默认为 1。

返回:

第一列为起点,第二列为终点,第三列为找到的路径。

DFS(...)#

Algo.DepthFirstSearch

ShortestPathAStar(edges[from, to, weight], nodes[idx, ...], starting[idx], goals[idx], heuristic: expr)#

在给出的边上执行 A* 算法 ,以找出 starting 中每个顶点到 goals 中每个顶点的最短路径。给出的边 edges 必须是有向图的边,且每条边都有非负的权重值。

参数:

heuristic (required) – 启发式的表达式。表达式中的变量将会绑定为 goalsnodes 参数中给出的变量。启发式求值后应得到一个数值,这个数值应是当前顶点到当前终点的最短路径权重的一个下限。若启发式求值后的数值不是下限,则算法可能会返回错误的结果。

返回:

第一列为起点,第二列为终点,第三列为最短路径的总权重,第四列为最短路径。

小技巧

A* 算法的性能受启发式的影响极大,好的启发式会大大提速算法。由于边的权重非负, 0 是一个合法的启发式,但在这种情况下实际上应该使用戴克斯特拉算法。

很多好的启发式实际上是由顶点所在空间的距离函数(度量)所决定的测地线长度,比如说球面上两个点之间的测度线, 在这时 Func.Math.haversine_deg_input 函数可以用来表示启发式(但注意半径的单位一定要和数据中的单位匹配)。另一个例子是曼哈顿网格空间中的最短距离。

虽然给出不满足条件的启发式可能会得出错误的结果,但误差的上限决定于启发式对于实际最短距离高估的值。如果这个值不大的话,在一些场景下错误的结果也是可以用的。

社区发现算法#

ClusteringCoefficients(edges[from, to, weight?])#

根据所给出的边,计算其中所含每个顶点的聚合系数。

返回:

第一列为顶点,第二列为聚合系数,第三列为包含此顶点的三角形的数量,第四列为包含该顶点的边的数量。

CommunityDetectionLouvain(edges[from, to, weight?], undirected: false, max_iter: 10, delta: 0.0001, keep_depth?: depth)#

在给出的边上运行 Louvain 算法找出社区结构。

参数:
  • undirected – 图是否是无向图。默认为否,即有向图。

  • max_iter – 在算法的每个纪元内运行的最大迭代次数。默认为 10。

  • delta – 模块性变更多少以上才认为其代表了有效的社区效应。

  • keep_depth – 返回多少层社区。默认返回所有层级。

返回:

第一列是表示社区的一个数组,第二列是包含在这个社区中的一个顶点。数组的长度小于等于要求输出的社区层数,其结构表示社区与子社区的内部结构。

LabelPropagation(edges[from, to, weight?], undirected: false, max_iter: 10)#

在所给出的边上运行 标签传播算法 来找出社区。

参数:
  • undirected – 图是否是无向图。默认为否,即有向图。

  • max_iter – 最大迭代次数。默认为 10。

返回:

第一列是社区的标签(整数),第二列是这个社区包含的一个顶点。

中心度量算法#

DegreeCentrality(edges[from, to])#

使用给出的边计算其中所含顶点的度中心性。这个没有任何复杂计算,因此非常快,在拿到新图想要了解其结构时第一个运行的应该就是此算法。

返回:

第一列是顶点,第二列是包含此顶点的边的数量,第三列是由此顶点出发的边的数量,第四列是到达此顶点的边的数量。

PageRank(edges[from, to, weight?], undirected: false, theta: 0.85, epsilon: 0.0001, iterations: 10)#

在所给的边上运行 佩奇排名 算法。

参数:
  • undirected – 图是否是无向图。默认为否,即有向图。

  • theta – 0 与 1 之间的一个小数,表示显性给出的边在(假设的)所有边中的比例。默认为 0.8。为 1 时表明严格意义上网络中不存在未发现、未给出的隐藏边。

  • epsilon – 迭代中最小变化阈值。Pagerank 值的提升如果低于这个阈值,则认为迭代已经收敛。默认为 0.05。

  • iterations – 最大迭代次数。若提前收敛,则在此次数到达前返回结果。默认为 20。

返回:

第一列为顶点,第二列为佩奇排名的值。

ClosenessCentrality(edges[from, to, weight?], undirected: false)#

使用给出的边计算其中所含顶点的临近中心性。边可以同时给出权重。

参数:

undirected – 图是否是无向图。默认为否,即有向图。

返回:

第一列为顶点,第二列为临近中心性。

BetweennessCentrality(edges[from, to, weight?], undirected: false)#

使用给出的边计算其中所含顶点的介中心性。边可以同时给出权重。

参数:

undirected – 图是否是无向图。默认为否,即有向图。

返回:

第一列为顶点,第二列为介中心性。

警告

此算法复杂度非常高,因此无法在大中型网络上运行。对于大中型网络,建议先使用社区发现算法将其聚合为中小型的超级网络,再运行此算法。

杂项#

RandomWalk(edges[from, to, ...], nodes[idx, ...], starting[idx], steps: 10, weight?: expr, iterations: 1)#

在给出的边上进行随机游走,起点为 starting 中的顶点。

参数:
  • steps (required) – 游走步数。若走入了死胡同,则实际返回的步数可能会比这个值短。

  • weight – 表达式,其中变量绑定为 nodesedges 参数中给出的变量,每次执行时分别代表了当前所在顶点与下一步可选的边。应返回非负浮点数,代表这条边的权重,权重越大选择这条边的几率越大。若未给出此选项,则每步都对所有可选边均匀抽样。

  • iterations – 由 starting 中的每个顶点开始随机游走的次数。

返回:

第一列是序号,第二列是开始的节点,第三列是游走的路线(表示为包含节点的一个数组)。

CozoScript 之外#

Cozo 数据库中的大多数功能都皆由 CozoScript API 使用。但由于 CozoScript API 必须返回固定的格式,一些功能并不适合这种表达方式。还有另一些功能虽然可以如此表达,但是其作用与一般的查询关系不大。这些功能都必须经由单独的 API 访问。

在不同的语言环境中,调用这些 API 的方式略有差异,具体请参考每个语言实现自己的文档。以下我们简述每个 API 的基本作用(下例中使用的是 Python API 的格式)。

export_relations(self, relations)#

导出一个或多个存储表。数据统一性:一次性所有导出的表都仅含有同一快照中的数据。

参数:

relations – 含有需要导出的表的名称的数组。

返回:

一个字典,键为表名,值为表中所有数据。

import_relations(self, data)#

导入数据。导入在一个事务中执行,所以要么所有的导入数据都成功写入,要么任何数据都不被写入。若导入时其他进程(导入或查询)造成了被导入的数据冲突,则报错,导入失败。

导入的存储表必须已经存在于数据库中,且导入的数据必须满足存储表声明的列结构。

这个 API 不但可以用来插入数据,也可以用来删除数据。删除数据时,在存储表的前面加负号,比如删除 "rel_a" 中的数据,则使用 "-rel_a" 作为表名。插入数据时不管当前表中有无当前键,都不报错,而执行覆盖。删除数据时,即使当前表中不含当前键,也不报错。删除数据时只需要给出键,不需要值。

警告

此 API 不会激活触发器。

参数:

data – 一个字典,键为表名,值与 export_relations 返回的表内容相同,例如: {"rel_a": {"headers": ["x", "y"], "rows": [[1, 2], [3, 4]]}, "rel_b": {"headers": ["z"], "rows": []}}

backup(self, path)#

备份数据库至指定路径。备份的数据保证是满足一致性的数据快照。

这个 API 对所有数据进行备份:你无法选择性的备份指定的表。另外,这个 API 比 export_relations 效率高,同时用的资源也更少。

此 API 仅在编译时打开了 storage-sqlite 功能的情况下才可用。除了浏览器 WASM 之外的所有预编译发布都打开了此功能。备份文件实际上就是一个完整的 SQLite 存储引擎的文件,因此可以直接打开查询。若要长期储存,建议将此文件压缩。

参数:

path – 备份路径。若远程操作数据库,则此为远程机器上的路径。

restore(self, path)#

从备份中恢复数据至当前空数据库。

此 API 恢复所有数据:你无法选择性的只恢复指定的表。

参数:

path – 备份路径。远程操作数据库时此 API 无法使用。

import_from_backup(self, path, relations)#

从备份中抽取表中数据并写入当前数据库中的同名表。

从语义上来说,此 API 与 import_relations 类似,不同点在于数据来源于备份而不是直接传入,且此 API 无法删除数据。另外此 API 占用资源也比 import_relations 小。

警告

此 API 不会激活任何触发器。

参数:
  • path – 备份路径。若远程操作数据库,则此为远程机器上的路径。

  • relations – 存储表名称组成的数组。这些存储表必须预先存在于当前数据库中。

回调#

Cozo 支持对指定的存储表设置回调函数以在表被更改时获取更新。目前此功能仅可在 Rust、Python 及 NodeJS 库,以及独立的可运行程序中使用。具体使用方法请参阅它们自己的安装使用文档。

一些笔记#

以下是一些有关 Cozo 各个方面的笔记。

Cozo 的应用场景#

Cozo 是一个通用型的数据库,因此在一般 PostgreSQL 与 SQLite 的应用场景中都是可以使用的。不过,Cozo 的出现本来就是为了弥补传统数据库在一些特定场景下的短板,以下我们一一加以介绍。

互联关系#

第一个场景是你有一大堆表,而这些表里的数据内容是互相关联的。使用时,你需要将这些表中的内容作为一个复杂的网状结构来查询。

一个具体的例子是为用户在做不同操作时进行授权的系统。在此例中,需要考虑用户在组织结构中的职位所具有的权限、操作涉及的各个组织对于操作人员的具体要求,也需要考虑授权操作本身在不同组织中不同的规则。

这种查询如果用传统的数据库来写,一般结果是一坨多层嵌套的表关连,并包含一大堆的公共表表达式(CTE)。这种查询不但难写、难读、出了问题难以找到根源,而且随着表的增多(尤其是 20 张表以上的时候),具体查询的执行由于查询计划优化器的存在反而变得不可控,常常由于糟糕的查询计划使查询超时。

在 Cozo 中,查询是以一个个 Horn 规则组成的,这些规则将复杂的查询拆解为易于理解的最小部分,再由其组合来得到最终结构。另外,Cozo 中所有查询执行计划都是确定性的,即完全可以根据查询文本本身知道查询的具体执行方案,这也使优化查询性能的工作变得简单得多。

纯粹的网络(图)#

第二个场景是你的表很少,可能就一张,但这一张表实际上代表了一个网络结构(图)。

在入门教程中我们接触的航线数据集就是这样的:航线表本身结构非常简单,仅仅包含起点和终点的机场代码以及航线距离。

在传统的数据库中,当你拿到新的数据时,一般通过执行各种聚合计算来从统计上了解数据的内涵,比如各个值的分布,各个列的关联性,等等。

在 Cozo 中做这些聚合计算当然没有问题,然而我们也看到了,如果表本身背后是网络结构,这些计算得到的结果并没有太大意义。更有意义的信息隐含与网络结构中,如哪些节点是高连通性的“超级节点”,连通方式本身所决定的网络中的隐形社区,等等。这些计算一般的数据库做起来非常吃力,而在 Cozo 中就是输入一个命令的事儿。

隐藏的结构#

第三个场景是你的数据中隐含着一些重要的结构,而这些结构只有在特定的尺度下才会展现出来。

具体的例子是基本所有的存在于真实世界的网络结构,如社交网络。社交网络隐含着非常丰富的多层组织关系,而企业与企业间的关系、社群与社群间的关系,甚至是国家与国家间的关系,只有在明确地提取了这些高层次的组织之后才能开始研究。

在传统的数据库中,对这类数据能做的也就是多层嵌套的聚合计算及其分析,而这些分析都需要原始数据中已经有一些明确的标签。例如如果社交数据中记录了每个人的性别与其籍贯,那就可以以这两个维度提取一些统计数字出来。然而大多数类似的标签在数据中是缺失的,且有些属性本身就是隐形的,不可能在原始数据中存在。

在 Cozo 中,图算法在此可以大放异彩。比如,社区发现的算法可以在没有任何标签的情况下单凭网络结构就将网络中的多层级结构提取出来,而这多层结构每一层之间都可以抽取为其所在尺度的超级网络,对这些超级网络进行建模和分析得到的不再是简单的统计数字,而是对整个网络中生态的全方位、立体的浓缩体现。通过这种分析,诸如隐藏的犯罪集团、间谍网络、企业间的合谋传统都无可藏匿。另外一个优势是,超级网络由于已经将单个个体合并为组织进行了抽象,所以网络大小比原始的网络小得多,而很多复杂的图算法只能在较小的网络上才能得到结果。

知识图谱的叠加#

第四个场景是你的数据是实时更新的线上数据,而你想通过适当的知识图谱来更好地理解这些数据。

具体的例子是一个含有产品、买家、库存、订单等表单的销售数据库,与此对应的知识图谱是诸如产品背后的原料、厂家、用户辨识等数据,以及关于各个用户所属社群的背景知识,如学生、上班族、主妇等对不同产品的需求、偏好等。这些知识图谱完全是独立于线上数据的,有些也是多年的科研分析得出来的泛用结论。

这种将知识图谱叠加在实时数据上的查询本质上是图查询,完全不适合在传统的数据库中进行操作。对此,一般的做法是将数据定时导入专用的图计算平台,只在平台上进行图分析。

对于 Cozo 来说,这种对于数据的割裂与人为造成的非实时性完全是可以避免的,同时导入、整理不同的知识图谱这件事情本身也是几行代码就能搞定的事情。由于简单,所以在 Cozo 中用户会更多地使用图分析以及指数图谱来熟悉这些数据,而由于熟悉,才能更好地内化数据背后的逻辑,在现实问题中做出更好的决策。

Cozo 哪儿都能跑#

当我们发布 Cozo 的 0.1 版本时,Cozo 已经可以在 Python、 NodeJS、Java、Rust 以及 C 语言中嵌入式地运行,也可以作为一个独立的服务运行。发布之后,很多人询问 Cozo 是否能在移动设备上运行。

Cozo 的 0.1 版本使用 RocksDB 作为存储引擎,而这成了在移动设备上运行的阻碍:在多个移动平台上编译 RocksDB 依赖并不是那么简单的事情。我们一开始选择 RocksDB,是因为看中了它的高性能和高并发支持,但是在移动设备上这两点都没那么重要,尤其是并发:基本上数据库同时只有一个用户访问使用。

所以,为了支持移动端,我们将代码中所有关于存储引擎的部分抽象成了一个接口,然后面对这个接口又写了几个其它的存储引擎实现。所以现在,Cozo 支持以下不同的引擎:

  • 基于内存的非持久性存储引擎

  • 基于 SQLite 的存储引擎

  • 基于 RocksDB 的存储引擎

  • 基于 Sled 的存储引擎

  • 基于 TiKV 的分布式存储引擎

这些引擎各有优劣:

  • 如果你只是想将 Cozo 作为一个计算平台应用,那纯内存的引擎非常合适。由于其设置简单,启动快,也非常适合用来写测试。其劣势也很明显:不对数据做任何持久化,且在并发方面只支持读,所有写操作实际上是线性单线程的。

  • SQLite 存储引擎占用资源极小,在各种平台上的编译非常简单,其读取数据的速度也相当快。SQLite 的另一个优势是其定义了一个稳定的单文件存储格式,所以我们在 Cozo 中直接也将这个格式用为了所有引擎的备份格式。这意味着所有数据库备份都是直接可用的数据库,从备份中读取数据不需要经过恢复备份的过程。如果将 SQLite 文件进行压缩,得到的文件大小是与数据源文件的压缩一个数量级的,因此也适合长期保存。其劣势在于其写入速度并不理想,尤其是并发环境下的写入速度相当慢。但这在移动端上一般不是问题。

  • RocksDB 存储引擎不论读写都飞快,且可以支持相当高的并发,同时耗费的资源也很合理。另外 RocksDB 的存储本身就自带压缩,因此作为线上数据库,其占用的存储资源最小。劣势我们已经说过了,在移动端和其它的一些较为小众的平台上不太好编译。

  • 写 Sled 的存储引擎基本上是练手。如果非要说这个引擎的好处的话,那就是它是纯 Rust 实现的,编译起来简单,但这点对于除了 Rust 原教旨主义者之外并没有多大吸引力。尤其是 Sled 在各方面的性能并不比 SQLite 优秀,而占用存储空间还比它大。

  • TiKV 存储引擎是一个分布式引擎,所有读写操作都要通过网络交换数据,所以是所有引擎中最慢的(比最快的慢 10 到 100 倍左右)。其优势也就是“分布式存储”这一点了,需要的用户可以自己尝试。当然,比最快的存储慢 10 到 100 倍,听起来很差,但实际上和市面上的某些图数据库比起来也没那么糟糕。

存储引擎的抽象,使得 Cozo 现在可以运行在更多不同的平台与设备上,尤其是 iOS、安卓、浏览器(WASM)平台。我们也借此机会让 Cozo 可以在更多编程语言中直接嵌入式地使用:新加入了对 Swift 和 Go 语言的支持。另外,即使你最爱的语言现在没有官方的嵌入式支持,你也可以通过服务器-客户端的形式来调用 Cozo,而如果你想吃螃蟹的话,给你的语言写一个 Cozo 的原生适配其实也不难(如果遇到了苦难,欢迎联系我们,我们会尽可能地提供帮助)。

性能那些事儿#

商业化的数据库喜欢通过性能测试搞大新闻,动不动就是比竞争对手快多少多少倍,当然竞争对手也有类似的性能测试。我们不干这事。第一,我们作为一个开源软件,这种空洞的新闻意义不大;第二,这种新闻除了“信我,用我”式的传教之外,并不能给用户带来什么真正的好处。我们希望,我们的性能测试对用户而言有一定的实用性,能让用户自己回答以下问题:

  • 在我的应用场景中,使用 Cozo 时都有哪些选择?这些选择的优劣都是什么?

  • 用我手上现有的机器跑我需要的查询,大概能达到什么样的性能?

准备工作#

我们所有的性能测试,都是拿不同设置的 Cozo 互相比较。我们用来跑测试的机器有下面这两台:

  • 一台 Mac Mini(2020 年产)

  • 一台 Linux 服务器(2017 年产)

Mac Mini 的操作系统为 MacOS 13.0.1,使用苹果 M1 处理器,4 个性能核,四个功效核,16GB 内存,存储是一个挺快的 NVMe SSD。它所代表的性能是不太旧的台式机、笔记本的性能。Linux 服务器的操作系统为 Ubuntu 22.04.1 LTS,使用量块 Intel Xeon E5-2678v3 处理器,共 24 个物理核,64GB 内存,存储是我们见过最慢的 SSD。如果你的服务器比较旧,或者你租用了云服务商溢价的高端云主机,那么性能应该和这台机器有类似之处。自己精心配制的新的服务器应该比我们这台快得多。

测试时我们嵌入式地通过 Rust 来调用 Cozo(我们直接用了 Rust 内置的性能测试框架)。由于是嵌入式地使用,我们通过调整分配给查询任务的线程数来测试某项工作的性能与所用处理器数量之间的关系。当然,使用 Rust 嵌入式地调用 Cozo 意味着测试时几乎没有什么额外性能损耗:如果通过 Python 等来调用,则额外性能开销还是比较明显的。尽管如此,在差不多的硬件条件下,不管什么语言环境中的性能都应该是同一个数量级的,否则就应该是 bug,如果发现了请告诉我们。

众多图数据库都喜欢用 斯洛文尼亚 Pokec 社交网络 的数据来做性能测试(比如 这个 ,还有 这个 )。我们会用这个数据集的几个不同的子集来测试:

  • “微型集”:1 万个顶点,12 多万条边;

  • “小型集”: 10 万个顶点,176 多万条边;

  • “中型集”:163 多万个顶点,3062 多万条边(这是数据的全集)。

数据的分割与 这个测试 里面的实际上是一样的,但是他们的“小型集”在我们这里叫“微型集”,他们的“中型集”在我们这里叫做“小型集”,他们的“大型集”在我们这里叫做“中型集”。我们这么命名的原因是,“大数据时代”喊了这么多年了,将一个区区 3000 多万条边的数据集叫做“大数据集”,实在是有些自欺欺人。当然我们也希望能在真正的 大数据集 上测试:不过先等我们钱包更鼓点吧。对于一般的应用来说,我嫩测试的“中型集”也不算小了。

数据库的结构由下列 CozoScript 建立:

{:create user {uid: Int => cmpl_pct: Int, gender: String?, age: Int?}}
{:create friends {fr: Int, to: Int}}
{:create friends.rev {to: Int, fr: Int}}

如果你还不太了解 CozoScript 的语法:第一个表名为 user ,主键是个整数,除了主键之外还有三列;第二个表名为 friends ,有两个整数列组成的组合主键,数据表示人之间的关系;第三个表为 friends.rev ,存储内容与第二个表相同,但是列的顺序相反,可以作为反向索引使用(Cozo 对索引的使用都是显性的,详见文档)。

我们会测试以下三种不同的存储引擎:

  • 基于内存(Mem)的非持久性存储引擎

  • 基于 SQLite 的存储引擎

  • 基于 RocksDB 的存储引擎

Cozo 中的所有查询都满足快照隔离:即当并发读写时,所有的读写操作都只能读取当前事务开始之前已经写入的数据,加上当前事务中自己写入的数据,且如果两个写操作对相同的行做出了修改,则至少有一个操作会失败,这个操作不会有任何修改被写入数据库。在测试时如果遇到这种失败,我们会重试至成功为止。目前 Cozo 只支持这一种一致性:没有办法放松一致性来进一步提高性能。

在测试中,所有的写操作只有在数据持久化之后才会返回:我们不搞“打一枪就跑,存没存进去不知道”的小动作来抬高性能。

我们只测试 Cozo 的“开箱即用”配置。实际上,可以通过配置 RocksDB 存储引擎来进一步优化性能,但是这种优化过于复杂,对于一般的用户来说意义也有限。

完整的测试结果在 这个表里 ,你可以下载下来自己分析。

数据载入#

批量载入#

第一个测试是:完全导入数据需要花多久(要不要等上好几天)。在这个测试中,我们从文本文件中读取原数据,再以 300 行一组,单线程分批插入数据。

在微型集上,结果如下:

平台

引擎

耗时(秒)

Mac Mini

Mem

0.265788

Mac Mini

RocksDB

0.686022

Mac Mini

SQLite

6.383260

服务器

Mem

0.494136

服务器

RocksDB

1.285214

服务器

SQLite

27.971535

在小型集上,结果如下:

平台

引擎

耗时 (秒)

Mac Mini

Mem

5.429186

Mac Mini

RocksDB

11.752198

Mac Mini

SQLite

146.848621

服务器

Mem

8.741262

服务器

RocksDB

19.261249

服务器

SQLite

432.705856

在中型集上,结果如下:

平台

引擎

耗时 (秒)

Mac Mini

Mem

155.894422

Mac Mini

RocksDB

212.731813

服务器

Mem

219.956052

服务器

RocksDB

348.638331

因为我们很没耐心,所以我们根本没测 SQLite 引擎在中型集上需要多久。根据上面所现的规律,我们可以给出一个大概的估计:用 SQLite 引擎导入中型集,如果用的是 Mac Mini,则大概要 45 分钟,如果是服务器则要两个多小时。这让 SQLite 看起来很差,但是实际上是其他两个引擎表现太好了:我们之前在往其它某个图数据中导入相同数据的时候用了半天的时间,即使我们再不会用,这速度也太离谱了。

实际上如果你非要使用 SQLite 引擎,也有导入数据快得多的方法,后面会介绍。

如果用的是 RocksDB 引擎,则几分钟之内几千万行的数据就被搞定了。我们觉得这挺快了。

我们可以在数据集之间纵向比较性能:不再看总耗时,而看每秒能导入多少行。注意每个边算作两行,因为我们需要往两个表中插入同一条边:

Batch import

我们可以看出 RocksDB 的性能表现非常好,尤其是在数据集变大时:大数据集对于单位时间内处理行数的影响很小,且比起完全不往硬盘上写内容的 Mem 引擎来,也只慢了三倍左右。

有些人可能会说,这么测对 SQLite 引擎不公平,因为如果允许调参数、改变插入方式的话,SQLite 的数值不会这么低。但是我们的原则就是测试“开箱即用”的性能,而且 RocksDB 参数 的种类更多,性能上升空间更大。

在整个导入过程中,占用了多少系统内存呢?以下我们给出操作系统记录的进程的峰值内存使用量:

Batch import mem

实际上数据库本身用的内存比图中显示的还少:测试框架本身就会占用大概 50MB 内存,即使什么也不干。所以实际上 SQLite 引擎使用的内存接近于 0,而 RocksDB 则确实用了一些内存作为缓存。图中没有 SQLite 对于中数据集的数据,原因之前已经说过,等太久,没耐心了。

纯内存存储引擎的内存峰值如下:

Batch import mem for mem

我们将这个结果单独列出来,因为实际上这就是整个数据集在内存中大概的大小。我们可以看出苹果平台在内存使用上似乎更节省一些。在下面的测试中,当我们展示内存相关的结果时,我们不会展示纯内存引擎相关的数据,因为纯内存引擎的内存使用基本完全取决于存了多少数据,也就是说数据都和这张图差不多。如果你想看更详细的数据, 文件 里面有。

备份#

Cozo 的备份文件就是一个 SQLite 的引擎文件。生成速度如何呢?

Backup

在 Mac Mini 上,不管什么引擎都可以达到每秒一百万行以上的性能,对于大多数应用来说这大概足够快了。而在服务器上,由于其 SSD 实在比较糟糕,所以性能与 Mac Mini 差距较大,但是速度也是可接受的。

之前我们说过向 SQLite 导入数据有更快的方法,现在你知道了:现导入其他的引擎,再导出成备份,而备份文件本身就可以被 SQLite 引擎直接使用。

内存峰值:

Backup memory

没惊也没喜。我们之前说过,测试框架大概就会用 50MB 内存,所以应该把大概这个数除去。

从备份恢复#

从备份恢复,大概速度如何呢?

Restore

在所有的测试中,这是唯一一个 RocksDB 比其它两个引擎慢的测试,大概只能达到每秒 40 万行。恢复到 SQLite 很快,但是实际上可以更快:我们前面已经说过了,直接把文件拷贝一份就好(或者如果你不打算写入数据的话,连拷贝都不用,直接用)。

内存峰值:

Restore memory

如所料。

事务性查询(OLTP)#

事务性查询(OLTP)指的是对数据简单的读或写,影响的行数小,单个查询返回速度快。

单点读#

我们来测试能想到的最简单的查询:给出一个随机的 ID,返回整条数据:

?[cmpl_pct, gender, age] := *user{uid: $id, cmpl_pct, gender, age}

我们用来反映性能的指标是每秒执行次数(QPS):

Single vertex read QPS

我们可以看出,对于单点查询来说,数据集本身多大对性能影响不大,而线程数量与性能的相关基本是线性的,直到用完处理器所有的性能核为止(这里 Mac Mini 才分性能核与功效核,而其功效核在这里可以看出基本没什么用,甚至有反作用)。基本来说,在充分利用资源的情况下,每秒 10 万次查询对什么机器问题都不大。

峰值内存:

Single vertex read mem

Single vertex read mem#

只有到了中型集的时候,RocksDB 才开始积极使用内存,其他情况下都是能省则省。

单点写#

最简单的写操作,写入一个新的端点:

?[uid, cmpl_pct, gender, age] <- [[$id, 0, null, null]] :put user {uid => cmpl_pct, gender, age}

以下只有 RocksDB 我们才展示多线程的性能,因为其他的引擎实际上都是用锁把写入变成单线程的:

Single vertex write QPS

RocksDB 这里的性能超群:Mac Mini 和服务器上都达到了每秒 10 万次以上。注意如果线程数量超过了性能核的数量,在 Mac Mini 上对性能会有不少的负面影响,所以尽量避免这种情况出现。另外,SQLite 的柱子根本看不见,是不是?我们用对数轴再来看看:

Single vertex write QPS zoom

RocksDB 每秒能处理 10 万次,结果 SQLite 在服务器上处理 100 次都困难?这整整慢了 1000 倍都不止!我们知道,SQLite 的写事务本来就慢得很,而由于我们测的就是单点写,这里每次操作都必须作为 SQLite 的写事务来执行,没有别的办法。所以我们只能建议如果你需要大量的并行写入,别用 SQLite。

内存峰值:

Single vertex write mem

成绩不错。即使是对中型集进行写入,RocksDB 用的内存也不超过 500MB。

单点改#

这里我们更改指定行中一列的值:

?[uid, cmpl_pct, age, gender] := uid = $id, *user{uid, age, gender}, cmpl_pct = $n
:put user {uid => cmpl_pct, age, gender}

性能如下:

Single vertex update QPS

改比写慢,但是慢不过一倍。用 RocksDB 引擎的话,每秒 5 万次仍然很轻松。内存峰值与写操作差不多:

Single vertex update mem
混合查询#

当然,在实际应用中,读、写、改一般是同时来的。这里我们就不展示具体数据了,基本结论是 RocksDB 引擎对读写改的性能都差不多,不同配比对其影响不大,但写和改只要一多 SQLite 就跑不动了。详情看 文件

你可能会疑惑:上面的测试基本上说明 SQLite 的写性能不好,那干嘛还要用它?原因是在移动端的应用场景下,这样的性能其实也能用,尤其是当批量而不是单点写入的时候。另外 SQLite 真的是一比特多余的内存都不用。

分析型查询(OLAP)#

一般来说,分析型查询(OLAP)一次影响很多行数据,也可能会对这些数据进行复杂的计算,返回的行数也可能很多。所有的图查询都可以认为是分析型查询。

对于分析型查询来说,最重要的指标是延迟,即查询提交后(平均)多久会返回结果。

层层都有朋友#

最简单的图查询就是找出某个人所有朋友的朋友都有谁。在这个查询中,第一层朋友的结果必须缓存起来(一般是缓存在内存里)。以下查询我们只给出对于中数据集的结果(160 万个节点,3200 万条边)。如果对更小的数据集做测试,则延迟低得多。文件里有详情。

首先我们来看“二段跳”的结果:朋友的朋友

?[to] := *friends{fr: $id, to: a}, *friends{fr: a, to}

平均下来,每个人有大概几百个这种二阶友。性能:

Friends 2 latency

RocksDB 性能一如既往地好。当硬盘足够快的时候,它甚至比纯内存存储还快。由于这是只读查询,所以 SQLite 性能也不错。多线程操作会提高延迟,但是不多。

峰值内存: Friends 2 mem

与前面一样,SQLite 一点儿内存都不多用,多线程的时候例外。RocksDB 用的内存也不多。

接下来看三阶友的查询:

l1[to] := *friends{fr: $id, to}
l2[to] := l1[fr], *friends{fr, to}
?[to] := l2[fr], *friends{fr, to}

三阶友的数量的方差极大,平均下来每个人有几千个三阶友,但是某些人的三阶友达到了几万个或更多。延迟如下:

Friends 3 latency

各个不同设置之间的的性能比与二阶差不多,但是整体上延迟时二阶的 20 倍。这也是大约返回数量的比。

内存峰值:

Friends 3 mem

因为返回数据时,所有的数据都必须在内存中,所以在所有配置下内存使用都比之前多。但是即使在开了 24 线程的服务器上,所用内存也不到 1GB。

下面我们看四阶友:

l1[to] := *friends{fr: $id, to}
l2[to] := l1[fr], *friends{fr, to}
l3[to] := l2[fr], *friends{fr, to}
?[to] := l3[fr], *friends{fr, to}

返回行数的方差现在大得惊人:孤僻的人只有几千个四阶友,但社牛们的四阶友差不多是整体人群的一半多!

Friends 4 latency

对于社牛来说,几秒就能数出自己几十上百万个四阶友,不错了。

内存峰值直接与返回集合大小挂钩:

Friends 4 mem

再往上剩阶意义不大了,因为根据“六度空间”理论,到了六阶的时候基本上返回值永远是全集。实际上在五阶的时候返回全集的概率就不小了。

聚合查询#

对于数据库来说,聚合查询与图查询不同,是另一种挑战:内存中需要保存的数据很少(如果是计数只需要保存一个值),但是必须扫描表中所有行才能得出结果。我们只给出对于中型集(160 万行)的结果。

首先,我们查询不同年龄都有多少人:

?[age, count(uid)] := *user{uid, age}
Aggregation group latency

这实际上测试的是单核 CPU 性能与数据读取性能。不论什么设置,1 秒内能扫一百多万的表。

由于内存中不需要存额外的数据,峰值内存很小:

Aggregation group mem

接下来我们在上面查询中加入一个规律:

?[age, count(age)] := *user{age}, age ~ 0 >= 18

过滤需要花费一些计算时间,但和之前的性能是同一个数量级的: Aggregation filter latency

峰值内存和之前雷同:

Aggregation filter mem

不论同时做多少个聚合操作,结果都差不多,比如:

?[min(uid), max(uid), mean(uid)] := *user{uid, age}

延迟: Aggregation stats latency

峰值内存: Aggregation stats mem

佩奇指数#

最后让我们看看内置算法的性能:佩奇指数算法

?[] <~ PageRank(*friends[])

我们会展示不同大小数据集的结果。首先是微型集(1 万个顶点,12 多万条边):

Pagerank tiny latency

一眨眼就算完了。峰值内存:

Pagerank tiny mem

用得很少。接下来是小型集(10 万个顶点,170 万条边):

Pagerank small latency

一秒的上下一倍之内。峰值内存:

Pagerank small mem

实际上所用内存基本就是把整个图使用特殊格式存在内存里占用的量(比存储格式用得少)。

全数据集(160 多万个顶点,3200 多万条边):

Pagerank medium latency

不管什么设置,半分钟都跑完了。实际上世界上最快的佩奇指数实现大概也不会快太多。峰值内存:

Pagerank medium mem

这个量级的图用了差不多 1GB 内存,比较合理。

结论#

我们可以看出,Cozo 性能强劲,同时资源占用少。Cozo 又基本哪儿都能跑。所以你还有什么不用的理由呢?我们期待你使用后的反馈。

数据库的时光之梦#

你做了个网站。用户在你的网站上唯一能做的事情就是记录自己的心情。你看,老王昨天心情是“期待”,今天变成了“空虚”。这个网站的后端是用 Cozo 数据库这么实现的:

:create status {uid: String => mood: String}

翻译成 Postgres 的 SQL 的话,是这样:

create table status (
    uid text primary key,
    mood text not null
)

网站的主页也特简单:就是所有人心情的一个大列表。你这么生成这个列表:

?[uid, mood] := *status{uid, mood}
select uid, mood from status

用户记录心情时,后端执行:

?[uid, mood] <- $input
:put status {uid => mood}
update status 
set mood = $1 
where uid = $2

简单,对吧。有一天,一群自称搞研究研究的人找你,想买你的数据做疫情期间的人群焦虑分析。机智如你,当然知道他们没安好心,所以你直接把他们轰走了。然后你就开始扇自己嘴巴子:“怎么就把网站最值钱的东西丢了呢?怎么就没记录历史呢?怎么想的呢?”

单是懊悔也没用,所以你找未来的你借了个时光机,回去给过去的你提个醒。“简单!”,过去的你不以为然:

:create status {uid: String, ts default now() => mood: String}
create table status (
    uid text not null,
    ts timestamptz not null default now(),
    mood text not null,
    primary key (uid, ts)
)

这么一来,用户也没法删除他们的账号了,他们只能不停地更新心情。“真好”,你说。

修改后主页大列表这么生成:

?[uid, mood] := *status{uid, mood, ts}, ts == now()
select uid, mood from status
where ts = now()

完了,网站崩了,主页永远都是空的!

问题在于,查询条件 ts == now() 永远都不可能为真,因为“当下”是虚幻的,所有的心情都只存在于“过去”。

折腾了半天之后,你终于写出了以下查询:

candidates[uid, max(ts)] := *status{uid, ts}
?[uid, mood] := candidates[uid, ts], *status{uid, ts, mood}
with candidates(uid, ts) as (
    select uid, max(ts) from status
    group by uid
)
select status.uid, status.mood from status
inner join candidates on status.uid = candidates.uid and status.ts = candidates.ts

先找出每个用户最后心情的时间戳,再根据时间戳把他们最后的心情取出来。绕了个圈子。

有了这些改动,“时光穿梭”的查询也就很自然了:

candidates[uid, max(ts)] := *status{uid, ts}, ts < $date_ts
?[uid, mood] := candidates[uid, ts], *status{uid, ts, mood}
with candidates(uid, ts) as (
    select uid, max(ts) from status
    where ts < $1
    group by uid
)
select status.uid, status.mood from status
inner join candidates on status.uid = candidates.uid and status.ts = candidates.ts

沉重的历史#

网站成了爆款,“研究人员”们也很满意。但是随着日子一天天过去,网站也越发慢了起来,逐渐让人无法忍受。

不应该啊?目前你的网站仅限于某校内网访问,而学校里连流浪猫都算上也不过一万个用户而已。你仔细看了看数据,发现大多数用户更新心情起来都废寝忘食,有些简直可以说是颇具奥林匹克精神:三个月不到的时间转换过万次以上心情的大有人在。而生成主页的查询

candidates[uid, max(ts)] := *status{uid, ts}
?[uid, mood] := candidates[uid, ts], *status{uid, ts, mood}
with candidates(uid, ts) as (
    select uid, max(ts) from status
    group by uid
)
select status.uid, status.mood from status
inner join candidates on status.uid = candidates.uid and status.ts = candidates.ts

必须扫描所有数据才能生成列表。一万个用户,按照平均每个用户一千次心情来算,那就每次访问主页都要扫描一千万条数据。而你正在做将网站推广到整个互联网的计划。这样下去,生成主页过不了多久就会变成每次扫描成百上千亿条数据。

索引梦#

你的投资人急了,逼你做“大厂”式的优化:不再实时计算主页,而是提前批量算好,隔一段时间更新一次。作为一个(前)黑客,你最烦的词就是“大厂”,抵触情绪强烈。你搞金融的哥们儿建议你试试时序数据库:“那么多股指的实时更新都能搞定,你这点儿数据毛毛雨啦!”但是你试了一下还是不行:股指是一起以固定间隔更新并存储的,人的心情可不是。要不,任何人更新情绪时,就强行给所有人的情绪再存一次?云服务商听了这个主意特别兴奋,立刻就给你发了他们时序数据库产品的折扣报价单。投资人觉得也不错,因为这样一来你公司立刻就真的有“大数据”了,但是担心融资速度可能赶不上账单的速度。你掐指算了算账单大概会有多少,遂放弃了这个念头。

投资人还是纠缠不休,凌晨三点了还要跟你视频会议。你心中燃起邪火,拔了网线,恍恍惚惚地睡去了。

梦中你悠悠荡荡,到了一处。但见朱栏玉砌,绿树清溪,真是人迹不逢,飞尘罕到。你在梦中欢喜,想道:“这个地方儿有趣!我若能在这里过一生,强如天天被投资人入肉呢。”正在胡思乱想,却猛地发现你原来是个木头人,躯体不受意识控制,嘎吱嘎吱驶入一处。但见此处布满大橱,架子上齐齐整整司存着簿册:原来数据库中的树在梦中幻象成形了!你意识到你的任务是生成几天前的心情表,只见你腐朽的躯体机械地将簿册一一拿下,展开,记录,再放回原处:

“林〇〇,壬寅年腊月初九午时三刻,太早。”

“林〇〇,壬寅年腊月初九戌时整,太早。”

“林〇〇,壬寅年腊月初九亥时一刻,仍太早。”

……

“林〇〇,壬寅年腊月初九亥时五刻,仍太早。”

“林〇〇,壬寅年腊月初十子时六刻。过了需要的时间了,那么上一条就是需要的心情记录。”(记录的是“爆竹”。)

“林〇〇,壬寅年腊月初十子时七刻。不用看这条了。”

“林〇〇,壬寅年腊月初十丑时一刻。不用看这条了。”

……

“林〇〇,壬寅年腊月十一午时。干嘛还在看这人的……”

……

“薛〇〇,壬寅年九月初六卯时……”

你发现这木头身子着实蠢。首先,应当倒着查:这样就不需要在过了需要的时间去返回去看前一本(特别是前一本还不一定是同一个人的,操作起来很乱)。然后,如果这真是存储树的幻象,就不能直接跳到想要的地方吗?非要一本一本看吗?

正想着,忽然狂风乱作,大橱尽倒,簿册遍地。顷刻风停,本子又像有了魂似的,自己飞回重新立好的大橱架内。你发现躯体不知道什么时候也变了,不再是木头,成了硅胶,也灵敏得多了:现在你想去架子哪个位置,立刻就能蹦着过去了,而架子上面每个人的簿册,也按照时间倒着放了。

虽然活儿需要重头干,但是这回快多了:

“林〇〇,壬寅年腊月十一午时。太晚。直接快进到壬寅年腊月初十子时。”

“林〇〇,壬寅年腊月初九亥时五刻。就是这条,记下来。然后快进到林〇〇盘古开天时候的记录。”

“薛〇〇,壬寅年腊月初九亥时一刻。这条也该记下来。想起来,薛〇〇之后就没记录过心情了?”

……

你从梦中惊坐起,汗下如雨。随后赶紧摸到电脑边,将梦中所见以代码形式记录了下来。

回到正事#

多年后,你那个小破站的铁蹄踏平了寰宇,人们也再无从知晓网站出现前的生活是如何的。所有这些,靠得仅仅是下面这个存储表:

:create status {uid: String, ts: Validity default 'ASSERT' => mood: String}

查询目前心情:

?[uid, mood] := *status{uid, mood @ 'NOW'}

以及查询过往心情的代码:

?[uid, mood] := *status{uid, mood @ '2019-12-31T23:59:59Z'}

当然,这些查询不能靠 Postgres 了,所以也没有 SQL 的翻译。

你的故事,或者说 Cozo 0.4 版本中添加的 历史穿梭查询 功能的故事,到此结束。在入门教程中也有一些新的内容,用来熟悉此功能的基本使用。

附册:正事是索引的性能#

正经的数据库需要注重性能,而 Cozo 是正经的数据库。下面我们来做一些性能测试:就用 之前 的 Mac Mini:操作系统是 MacOS 13.0.1,用的苹果 M1 处理器,有 4 个性能核与 4 个功效核,16GB 内存,以及挺快的 NVMe SSD 存储。为了测试不过于繁复,我们只测 RocksDB 存储引擎。

首先,生成一堆存储表,每个表中都有一万个用户的数据。第一种表叫“无历史”表:只存储当前的数据。第二种表叫做“扫历史”表:存储方式就是幻境中刮风前的方式,而这个表有多个版本,分别对应每个用户不同的历史心情数量的情况。第三种表叫做“跳历史”表,用的是幻境中刮风后的存储方式。

测试时我们查询的时间戳是随机生成的,以下结果是多次测试的平均值。

首先我们来看历史存储对性能的影响:对于点查询,以下是对各表查询时每秒能完成的查询次数(QPS):

种类

单用户心情数量

QPS

性能比

无历史

1

143956

100.00%

扫历史

1

106182

73.76%

扫历史

10

92335

64.14%

扫历史

100

42665

29.64%

扫历史

1000

7154

4.97%

跳历史

1

125913

87.47%

跳历史

10

124959

86.80%

跳历史

100

100947

70.12%

跳历史

1000

102193

70.99%

当单用户心情数达到一千时,扫历史的性能就比无历史差了 20 倍。而跳历史则一直能保持无历史 70% 的性能。

实际上,如果点查询足够简单(比如只需要查一条信息,也同时知道所有需要的键的时候),可以将查询改写成较为巧妙的形式,以使扫历史和跳历史在这种情况下性能类似。在上面测试中我们特地没有这么做,因为这种优化不是什么情况下都做得到的。

接下啦我们看看聚合计算的性能:在聚合计算中,我们必须遍历所有的用户。在这个测试中我们关注的是延迟:

种类

单用户心情数量

延迟(毫秒)

减速比

无历史

1

2.38

1.00

扫历史

1

8.90

3.74

扫历史

10

55.52

23.35

扫历史

100

541.01

227.52

扫历史

1000

5391.75

2267.53

跳历史

1

9.60

4.04

跳历史

10

9.99

4.20

跳历史

100

39.34

16.55

跳历史

1000

31.41

13.21

随着数据不断增多,扫历史的性能越来越糟糕:无历史表只需要 2 毫秒,而扫历史单用户仅仅存了 1000 条历史数据就需要整整 5 秒。跳历史就没这个问题,多得多的历史数据也能很好支持。注意上表中显示跳历史存 1000 条数据时性能比存了 100 条数据还好一些。这并不是测量的误差:我们多种方式测了很多次,结果都一样。这可能是因为当数据到达一定量后 RocksDB 存储使用的逻辑不一样,不过我们也没深究。

当然,无论怎么存历史,比起不存来,在所有情况下都要慢至少三倍。这是时光穿梭的最小开销。这也是为什么在 Cozo 中,不是什么样的存储表都自动支持历史穿梭查询。Cozo 的理念是“零成本、零脑力开销的抽象”:当你不需要一个功能的时候,第一你不必为不用的功能损失性能,第二你也不用费劲去理解与此功能相关的任何知识就也能使用其他所有功能。

有些人宣称数据从根本上来说是不可变的,因此最好的数据存储形式也应该是不可变的。我们不这么理解。“不可变性”与“历史穿梭”都仅仅是工具,在适合的时候才应该使用。进一步来说,我们都仅仅是簿册中早已写好的谶语的展开吗?希望不是,而现代物理理论,不管是从波函数的塌陷方面来理解,还是从混沌边缘的生命方面来理解,对此问题都已经给出了明确的否定的答案。因此,所谓“不可变性”本身才是虚幻的,或者说是一种柏拉图式的梦,我们依托这种梦来理解世界。这本身并没问题,因为人只有将自己创造出来的模型映射到现实,才能理解这个世界,即使模型本身是虚幻的。我们应该坚持的,是不要成为虚幻的奴隶,不要被其束缚。

Cozo 0.5:距离 1.0 只有一半了!#

Cozo 数据库开源已经三个月了,今天迎来了 0.5 版本,我们一开始所设想的功能,在这个版本里也都实现了:

  • 自定义的固定规则(0.5 版本新功能)

  • 表被修改时执行回调函数(0.5 版本新功能)

  • 多命令事务(0.5 版本新功能)

  • 索引(0.5 版本新功能)

  • 命令式迷你语言(0.5 版本新功能)

  • 历史穿梭查询(0.4 版本新功能)

  • 可替换的存储引擎(0.2 版本新功能)

另外,0.5 版本带来的一些底层修改使数据库性能更加强劲:

  • 半朴素算法现在会对每条规则并行执行(所以现在把查询拆成小块不但有利于可读性,而且还能跑得更快)

  • 表达式求值不再解释执行,而是在一个基于栈的虚拟机上执行(对含有大量过滤的查询能提高几个百分点的性能——主要是因为不再需要在堆上分配内存了)

从现在起,到 1.0 版本为止,Cozo 的开发重点将不再是开发新功能,而是放在以下方面:

请多给我们提意见,让我们可以把 Cozo 做得更好!

索引#