Datomic 是一种新型数据库,它被设计为一系列简单服务的组合。它力求在传统的关系型数据库管理系统的能力,以及新一代冗余分布式存储系统的灵活的可伸缩性之间建立一种平衡。
动机
Datomic 力图完成以下几个目标:
- 提供可靠的信息模型,而避免原地更新(update-in-place)
- 充分利用冗余的可伸缩存储系统
- 提供 ACID 事务和一致性
- 在应用中支持声明性数据编程
Datomic 将数据库视为一个 _ 信息系统 _,每个信息都是一组事实,而事实就是指已经发生的事。由于人类不能够改变过去,因此它意味着数据库将 _ 累积 _ 事实,而不是原地更新结果。虽然过去可以遗忘,但却是不能改变的。因此,如果某些人“修改了”他们的地址,Datomic 会存储他们拥有新地址这个事实,而非替换掉老的事实(它只是在这个时间点被简单的回收了)。这个不变性(immutability)带来了很多重要的架构优势和机会。在上一篇文章中,我介绍了Datomic 的架构。这篇文章将会专注于信息模型与一些编程经验。
传统数据库(也包括许多新来者)专注于“现在”——即当前正确的事实集,但是由于这种做法造成它们丢失了信息。所有的业务都是在历史信息中寻找增长的价值,没有什么理由不去保留这些信息。这不仅仅是保留相关的历史数据,例如采用备份与日志方法的问题,而是保留它将能够支持各种决策流程。对于某个业务实体来说,了解你当前的地址以将某将些东西寄送给你确实是必需的,但是类似于“哪些客户经常性的搬家,他们通常来处哪里?”这样的问题对他们的市场或者产品开发团队来说也非常有趣。类似的还有供应商的产品价格历史等等,他们可不愿意为了查找数据而被迫去恢复备份文件,或者重新将每条日志都扫描一遍。
为什么是否保留活动的历史记录会成为一道是非题呢,考虑一下这个问题会十分有趣。毕竟,在计算机出现之前,人类就学会了保留各种累积数据。并且随着时间推移,进一步产生了“会计绝不能使用橡皮檫”这样的见解。我猜测早期的计算机系统只是单纯的不具有保留历史数据的能力(或者无法承受这种数据量)。但这种猜测在如今需要重新思考一下了,毕竟在过去的25 年间计算机的存储能力得到了百万倍的提升。开发者们还会因为无法将他们的代码库保存在一张软盘上,而拒绝使用类似Git 这样的版本控制系统吗?
数据库之所以称为数据库,主要原因是来自于它所对数据的处理能力。否则的话它就只不过是个存储系统而已。这种能力通常是来自于数据组织(比如通过索引)以及利用该组织的查询系统的一个综合体。开发者们对这样的能力非常感兴趣。随后容量更大的分布式冗余_ 存储系统_ 也开始得到应用,但这些系统的功能性则有所削弱。Datomic 的目标是建立于这些存储系统之上,以利用它们的可伸缩性,将组织化的信息存储其中,并且将那些失去的功能重新交还给开发人员使用。
结构与展现
每个数据库在其模型的底部都有一个基础单元,例如关系、行或者文档。对Datomic 来说,这个单元就是具有原子性的事实,我们将其称为一个 Datom。
一个 Datom 包含以下组件:
- 实体
- 属性
- 值
- 事务(数据库时间)
- 新增 / 回收
这种展现形式与 RDF 命题的“主题 / 谓词 / 对象”数据模型非常相似,但由于 RDF 命题缺少了回收这一概念以及适当的展现形式,它的功能并不足以展现历史信息。而 Datomic 是面向商业信息系统的,它采用了封闭世界假定(closed world assumption)理论,以避免了语义网(semantic web)所面临的全局统一命名、开放世界(open world)以及共享语义等方面的挑战。一个Datom 是对一个事实的最小化而充分的展现。
将一个具有原子性的单元放置在模型的最下方确保了对新内容(novelty)(例如事务)的展现的大小和新事实本身的大小一样。否则,每次修改新内容的部分信息都必须重新提交整个文档,或者是为了避免重新提交而导致使用脆弱的增量结构(delta scheme)。
Datom 构成了一种单一的、平滑的、全局的关系,除此之外 Datomic 就没有别的结构化组件了。这一点非常重要,因为你的模型中结构化组件越多,你的应用程序就会显得越僵硬。比方说,在一个典型的关系型数据库中,每个关系都必须被命名,并且为了查找数据必须了解这些名称。更糟的是,为了创建类似于多对多这样的关系模型,必须人为地建立联合表(join table),而这些联合表的名称也必须为应用程序所了解。为了将应用程序与物理结构的决策进行分离,必须投入极大的精力去创建一系列的逻辑视图,但这些视图不仅数量众多并且用途单一。而文档存储的结构就更加僵硬,因为它已经硬编码在你的应用程序中的每个角落里了,只有极少数(如果有的话)类似于视图的工具可以为该结构稍稍提供一些间接抽象。
Schema
所有的数据库都有 schema,唯一的区别就是它们在多大程度上支持(或者要求)schema 的明确性。在 Datomic 中,必须在使用 schema 前预先定义属性。
属性本身就是实体,只是包含了以下一些特定的属性:
- 名称
- 值的数据类型
- 基数(属性可以为多值)
- 一致性
- 索引的特性
- 自然组件(你的双足可以认为是你的组件,但你的母亲就不是了)
- 文档
应用到实体上的属性并没有什么限制,因此实体是开放且分散的。属性可以在多个实体间共享,并且可以使用命名空间以避免冲突。以下是一个属性的例子:
:person/name {:db/ident :person/name, :db/valueType :db.type/string, :db/cardinality :db.cardinality/one, :db/doc "A person's name"}
与 Datomic 其它的各种交互形式一样,schema 也是由数据展现的,以上的 schema 是以 edn 格式展现的某个 map 对象。在 Datomic 中不存在数据定义语言(DDL)。
通过 Datom 这种简单的基元形式,以及分散的、(有可能)支持多值的属性,使用者就可以展现类似行(row)的元组(tuple)、类似结构化文档的实体、以及类似列存储的多列与图形数据等等了。
事务
Datomic 中的事务以其最基本的形式存在,它仅仅是原子性地提交至数据库并被接受的一系列断言与撤销。一个基本的事务就是一个 Datom 的列表:
[[:db/add entity-id attribute value] [:db/add entity-id attribute value]...]
再声明一次,Datomic 中所有的交互形式都是由数据展现的,以上是以 _edn_ 格式展现的一个嵌套列表,每个内部列表都按顺序表现一个 Datom。
[op entity attribute value]
如果你打算提交同一个实体的多个事实,你可以用一个 map 对象来代替:
[{:db/id entity-id, attribute value, attribute value} ...]
虽然在这篇文章中必须以文本的方式展现这些 Datom,但对于 Datomic 的设计来说有一点非常重要,即事务其实就是你的编程语言中创建的一些基本数据结构(例如 j.u.Lists、j.u.Maps 以及 array 等等)。Datomic 的主要表现形式既不是字符串也不是数据操作语言(DML),而是数据。
注意到在这里并没有在 Datom 中指定事务的部分,而是会由解释器提供该信息。也就是说,事务本身就是实体,并且事务能够断言它本身的事实,例如关于事务源头、外部执行时间、发起的进程等元数据信息。
当然,并非每个事务都可以表现为断言或撤消那么简单,它的实现需要由某种“后来者获胜”的竞态冲突方案进行处理。Datomic 也支持数据库函数的概念,这些函数是由普通的编程语言(例如 Java 或 Clojure)所写,并安装至数据库中的(当然,是以数据形式通过事务提交的)。一旦安装后,数据库函数调用就可以成为某个事务的一部分了:
[[:db/add entity-id attribute value] [:my/giveRaise sally-id 100] ...]
当数据库函数作为某个事务的一部分出现的时候,它就会被视为一个事务函数,在调用时会传递给它一个额外的参数作为它的第一个参数,该参数是 _ 数据库本身的内部事务值 _。函数还可以发起查询等操作。事务函数必需返回事务数据,它本身返回的任何数据都会替换成事务中的数据。这一过程将不断重复,直到所有的事务函数都返回简单的新增 / 撤消为至。在之前描述的事务中,giveRaise 函数会查找 Sally 的当前工资,它找到了具体的数字 45000,随后返回一个新值的断言,用以下形式返回事务数据:
[[:db/add entity-id attribute value] [:db/add sally-id :employee/salary 45100] ...]
由于 _:employee/salary_ 的基数为 1,新增了 Sally 的工资的这一事实会显式地撤消之前的相关事实。由于事务函数在事务内是以原子方式顺序进行的,可以用这些函数来进行人为的、无冲突的转换。你可以在这篇文档中了解数据库函数的更多信息。
连接与数据库值
在写操作方面的过程显得很平常。你使用一个URI 获取一个数据库连接,该URI 包含了如何连接该数据库,以及如果通过该数据库连接到当前使用的转换器的信息。可以通过调用该连接上的_transact_ 方法发起一个事务,并按以上描述的方式传递事务数据。
在读操作方面的过程就完全不同了。在传统的数据库中,读与查询都是连接对象的某个方法。你通过连接传递某个查询,它会到达当前数据库状态上下文所在的服务器,取决于该服务器上所使用的查询语言的限制,它可能会与其它操作竞争资源与同步性,包括写操作在内。
与传统数据库的方式相反,在Datomic 中,在连接对象上唯一的读操作就是db() 方法,而且它实际上不会到达服务器。相反,连接对象会持续地从数据库中获取信息,等到获取到了足够的信息时,它就能够以一个不可变对象的形式立即返回数据库中的值信息,并让你的应用程序对该对象进行处理。因此,对数据的所有调用和查询都发生在本地(底层的引擎在需要时会自行到数据库端获取数据,无需人为干预)。请注意,并非每个应用服务器结点都会保存整个数据库的信息,它们只会保存最近发生的新内容以及指向之前的数据在数据库中的指针。另外也不会发生任何“快照”操作。虽然对应用程序与查询引擎来说,数据库似乎始终可用,但实际的实现是非常轻量级的,它们仅仅引用了一些持久化数据结构的指针,包括内存中和数据库中的数据。大量的缓存功能都由底层负责实现。
查询
在Datomic 中,查询并不是连接对象上的某个方法,甚至不是数据库中的某个方法。相反,查询是一个独立的方法,它接受一个或多个数据源作为参数。这些数据源可以是数据库的值或者普通的数据集合,或者是这两者任意的组合形式。这种方式的一大好处就是查询操作的运行可以从数据库上下文中解脱出来。
Datomic 的客户端类库中包含了一个基于 Datalog 的查询引擎,Datalog 是一个基于逻辑的声明式查询语言,它使用了模式匹配的方式,这一点非常适合于查询 Datom 以及内存中的集合数据。
该查询的基本形式如下:
{:find [variables...] :where [clauses...]}
或者也可以为列表使用这种替代(更容易键入)的形式:
[:find variables... :where clauses...]
再次声明,以上仅仅只是以文本形式展现了你可以以编程方式创建的数据结构而已,查询是数据而不是字符串,当然如果你传入了字符串,也能够接受它并随后转换为数据。
如果你的数据库中包含了以下 Datom(在这里 sally、fred 和 ethel 代表了他们各自的实体的 Id):
[[sally :age 21] [fred :age 42] [ethel :age 42] [fred :likes pizza] [sally :likes opera] [ethel :likes sushi]]
我们就会以这种方式创建一个查询:
;;who is 42? [:find ?e :where [?e :age 42]]
并且获得以下结果:
[[fred], [ethel]]
_:where_ 语句是部分匹配的。对于数据库源来说,每个匹配的 Datom 会展现为元组的方式
[entity attribute value transaction].
你可以从返回值的右方去除任意一部分(在本例中就是 transaction)。以?开头的符号代表变量,返回结果会包含每个匹配该变量的源元组所对应的值元组。
查询的联合是隐式的,并且只会在你使用某个变量超过一次时才会发生:
;;which 42-year-olds like what? [:find ?e ?x :where [?e :age 42] [?e :likes ?x]
它将返回:
[[fred pizza], [ethel sushi]]
查询的 API 是一个名为 p 的方法:
Peer.q(query, inputs...);
这里的 inputs 可以是数据库、集合或者是标量值等等。查询还可以(递归式地)应用各种规则,并且调用你的应用程序中的代码。你可以在这篇文档中了解关于查询的更多内容。
最终代码如下:
//connect Connection conn = Peer.connect("a-db-URI"); //grab the current value of the database Database db = conn.db(); //a string for now, because Java doesn't have collection literals String query = "[:find ?e :where [?e :likes pizza]]"; //who likes pizza? Collection result = Peer.q(query, db);
相同的查询,不同的基准时间
当我们利用了 db 方法能够包含所有历史信息的特性之后,事实就开始变得有趣了:
//who liked pizza last week? Peer.q(query, db.asOf(lastTuesday));
对数据库执行 _asOf_ 方法,并指定一个时间值或是事务,会返回一个该数据库在过去某个时间点上的视图。请注意,在这里我们既没有调用连接对象的方法,也没有改变查询本身。如果你曾经自己实现过时间戳,你就会了解对某段暂时保留数据的查询与对当前数据的查询往往是完全不同的。与之类似功能的相关方法还有 _since_。
//what if we added everyone from Brooklyn? Peer.q(query, db.with(everyoneFromBrooklyn));
_with_ 方法会接受事务数据,并返回数据库的一个本地值,其中包含了新增的数据。这里不会将事务传递给连接对象,因此你可以在提交事务之前进行推测查询、如果查询以及检查事务数据。另外还有一个 _filter_ 方法可以根据某些谓词返回过滤后的数据。在这里我们依然没有用到连接对象、db 或者 query。
如果我们想测试查询,但又不想搭建一个数据库该怎么办呢?我们可以提供一些简单的数据,而沿用同样的方式进行查询:
//test the query without a database Peer.q(query, aCollectionOfListsWithTestData);
query 还是保持不变,但确实已经运行了, 不同之处就是模拟了一个数据库的连接对象。
目前为止,所有的查询技术都是针对过去或未来的某个特定时间点进行的,但许多有趣的分析都需要查询跨时间的数据:
//who has ever liked pizza? Peer.q(query, db.history());
_history_ 方法会返回所有时间里的全部 Datom,并且可以和 _asOf_ 等方法结合使用。你可以直接使用这个方法,不过跨时间的查询经常会有些不同之处,比如经常需要聚合数据等等。
查询可以接受多个数据源,因此可以轻易地跨数据库执行,或者使用某个数据库的不同视图。能够将集合传递给查询的功能就类似于 Steroids 上的参数化语句
不同的查询(或者关系),相同的基准时间
数据库值是不可变的,因此你可以随意进行非事务性的多步骤计算,而不必担心数据被更改了。与之类似,数据库的基础点也可以被获取并传递给另一个进程,该进程就可以在相同的状态下获取某个数据库值了。因此分布在不同进程上或不同时间上的查询也能够在相同的基础上工作了。
直接索引访问
最后,数据库值为从(不可变的)索引中迭代式地访问底层的已排序 Datom 提供了一个高性能的 API。可以根据这个基础功能创建各种不同的查询方式。比方说,使用这个 API,你就可以通过 Clojure 中类似 Prolog 功能的 _core.log_ 类库来查询 Datomic 数据库了。
结论
我希望本文使你对 Datomic 的信息模型的本质以及它的部分细节有了一个直观的感受。将数据库看作一个值是一种非常不同但又强大的方式,我想我们都在不断探索着各种可能性的存在!你可以从 Datomic 的文档中了解更多的知识。
关于作者
Richy Hickey是 Clojure 的作者以及 Datomic 的设计者,他作为一名软件开发者在各个领域已经有着超过 25 年的工作经验了。Rich 曾经参与过调度系统、广播自动化、音频分析与识别、数据库设计、收益管理、民意调查系统以及机器学习等多方面的内容,并基于多种语言进行开发。
查看英文原文: The Datomic Information Model
活动推荐:
2023年9月3-5日,「QCon全球软件开发大会·北京站」 将在北京•富力万丽酒店举办。此次大会以「启航·AIGC软件工程变革」为主题,策划了大前端融合提效、大模型应用落地、面向 AI 的存储、AIGC 浪潮下的研发效能提升、LLMOps、异构算力、微服务架构治理、业务安全技术、构建未来软件的编程语言、FinOps 等近30个精彩专题。咨询购票可联系票务经理 18514549229(微信同手机号)。
评论