Datalog 查询与 Datomic/Xtdb:数据即事实、pull、时间旅行与架构

深入 Clojure 生态的 Datalog 与 Datomic/Xtdb 实践:数据即事实(EAVT)模型、查询与拉取(pull)语法、规则与递归、时间旅行与 as-of 历史查询、事务与数据库函数、Xtdb 的对象存储架构,以及与 SQL/ORM 的对比选型,帮你掌握一套不可变、可回溯、面向事实的数据层。

Datomic 把数据库从「可变的表」变成「只增不减的事实集合」——你不再 UPDATE 一行,而是追加一个事实;历史不是靠审计表模拟,而是数据模型的原生属性。查询语言是 Datalog,拉取(pull)则像「按图取子图」。本文从 EAVT 模型讲到 Xtdb 架构,覆盖查询、拉取、规则与递归、时间旅行、事务与数据库函数,以及与 SQL/ORM 的对比,帮你掌握这套不可变、可回溯的数据层。

1. 数据即事实

1.1 EAVT 模型

Datomic 把每条数据存成五元组(Datom):

[实体ID  属性  值  事务ID  是否添加]
[42     :user/name  "Alice"  13194139534312  true]

索引 EAVT 即 Entity-Attribute-Value-Transaction:

索引排序适合
EAVT实体到属性取某实体的全部属性
AEVT属性到实体按属性反查实体
AVET属性到值到实体唯一值查找
VAET值到属性反向引用查询

1.2 不可变与累积

;; 传统 UPDATE:覆盖,旧值消失
;; Datomic:追加,旧值仍可查
;; 「修改名字」= 追加一条新 datom(值变了),旧 datom 仍在历史里

心智:数据库不是「当前状态的容器」,而是「所有事实的日志」——当前状态只是「截至某时刻的事实集合」。理解这一点,时间旅行就自然了。

1.3 与关系模型的心智差异

维度关系模型Datomic
存储行与表datom 五元组
修改UPDATE 覆盖追加新事实
历史需审计表原生支持
关联JOIN实体引用导航
schema表结构 DDL属性定义可演进

心法:关系模型问「现在是什么」,Datomic 问「发生过什么」——前者优化当前状态读写,后者把「时间」当成一等公民。

2. 起步与连接

2.1 依赖

;; deps.edn
{:deps {com.datomic/peer        {:mvn/version "1.0.7277"}
        com.datomic/datomic-free {:mvn/version "0.9.5697"}}}

2.2 连接与建库

(require '[datomic.api :as d])

;; 内存数据库(开发用)
(def uri "datomic:mem://app")
(d/create-database uri)
(def conn (d/connect uri))

;; 定义 schema:属性要显式声明
(def schema
  [{:db/ident       :user/name
    :db/valueType   :db.type/string
    :db/cardinality :db.cardinality/one
    :db/unique      :db.unique/identity}
   {:db/ident       :user/email
    :db/valueType   :db.type/string
    :db/cardinality :db.cardinality/one
    :db/unique      :db.unique/identity}
   {:db/ident       :user/age
    :db/valueType   :db.type/long
    :db/cardinality :db.cardinality/one}])

@(d/transact conn schema)

2.3 三种形态

datomic:mem://      内存(测试与原型)
datomic:dev://      本地 transactor + 存储
datomic:sql://      关系库后端(SQL 存储)
Xtdb                对象存储或内存,开源替代

心法:开发用 mem,生产看运维成本——Datomic 的强项是「查询与时间」,代价是 transactor 的部署复杂度;预算敏感或想自托管时,Xtdb 是同一套 Datalog 心智的开源选择。

3. Datalog 查询基础

3.1 查询即数据

Datalog 查询本身是数据(vector),可被构造、组合、传递:

;; 查询结构:[:find ... :in ... :where ...]
(d/q '[:find ?name
       :where [?u :user/name ?name]]
     (d/db conn))

3.2 基础查询

;; 找名字为 Alice 的实体
(d/q '[:find ?e
       :where [?e :user/name "Alice"]]
     (d/db conn))

;; 多条件(隐式 JOIN:同一个 ?e)
(d/q '[:find ?name ?age
       :where [?e :user/name ?name]
              [?e :user/age  ?age]]
     (d/db conn))

3.3 输入参数

;; $ = 数据库,?n = 绑定参数
(d/q '[:find ?name
       :in $ ?n
       :where [?e :user/name ?name]
              [?e :user/age ?n]]
     (d/db conn) 30)

;; 集合输入(批量)
(d/q '[:find ?name
       :in $ [?n ...]
       :where [?e :user/age ?n]
              [?e :user/name ?name]]
     (d/db conn) [30 40 50])

3.4 聚合与 find 规格

;; 计数
(d/q '[:find (count ?e) .
       :where [?e :user/name]] (d/db conn))

;; 分组聚合
(d/q '[:find ?age (count ?e)
       :where [?e :user/age ?age]]
     (d/db conn))

;; 返回 map(带键)
(d/q '[:find ?name ?age
       :keys name age
       :where [?e :user/name ?name]
              [?e :user/age  ?age]]
     (d/db conn))
;; => ({:name "Alice" :age 30} ...)

心法:Datalog 的 JOIN 是「变量同名即连接」——不像 SQL 要写 ON,两个 clause 共享 ?e 就自动关联。这让查询读起来像「描述满足条件的事实集合」,而不是「拼装连接」。

4. 拉取

4.1 pull 语法

pull 用于「取一个实体的子图」,比 query 更适合读取结构化对象:

(d/pull (d/db conn)
        '[:user/name :user/age]
        [:user/name "Alice"])
;; => {:user/name "Alice" :user/age 30}

4.2 模式化 pull

;; 嵌套:取用户及其朋友的名字
(d/pull (d/db conn)
        '[:user/name
          {:user/friends [:user/name]}]
        eid)

;; 通配与递归
(d/pull db '[*] eid)                               ;; 全部属性
(d/pull db '[:user/name {:user/friends ...}] eid)  ;; 递归展开

4.3 pull 与 query 混用

;; query 定位实体,pull 取详情
(d/q '[:find [(pull ?e [:user/name :user/age]) ...]
       :where [?e :user/age ?age]
              [(> ?age 30)]]
     (d/db conn))

心法:query 找「哪些实体」,pull 取「实体长什么样」——把两者组合,就是「先筛后取」,比一堆 JOIN 更直观,也天然返回嵌套结构,直接喂给 API。

5. 规则与递归

5.1 规则定义

规则把可复用的查询逻辑抽出来:

;; 规则:[规则名 [?参数...] [子句...]]
(def rules
  '[[(adult? ?e)
     [?e :user/age ?age]
     [(>= ?age 18)]]])

(d/q '[:find ?name
       :in $ %
       :where [?e :user/name ?name]
              (adult? ?e)]
     (d/db conn) rules)

5.2 递归规则

Datalog 的强项之一是递归——组织层级、图可达性:

;; 传递闭包:?a 是 ?b 的祖先(直接或间接)
(def rules
  '[[(ancestor ?a ?b)
     [?a :org/child ?b]]
    [(ancestor ?a ?b)
     [?a :org/child ?x]
     (ancestor ?x ?b)]])

(d/q '[:find ?desc
       :in $ % ?root
       :where (ancestor ?root ?desc)]
     (d/db conn) rules [:org/name "总部"])

5.3 规则的作用域

规则可见性:
  同一 :where 里可调用多条规则
  规则可互相引用(递归)
  规则只在该查询内可见,不污染全局

心法:递归是 Datalog 相对 SQL 的核心优势——SQL 的递归 CTE 语法繁琐且限制多,Datalog 的规则天然支持互相递归,图遍历(组织树、依赖关系、可达性)写起来几乎像自然语言。

6. 时间旅行与 as-of

6.1 三种时间视角

;; 当前
(d/db conn)

;; as-of:截至某时刻(含)
(d/as-of (d/db conn) #inst "2024-01-01")

;; since:某时刻之后(不含)
(d/since (d/db conn) #inst "2024-01-01")

;; history:全部历史(含删除)
(d/history (d/db conn))

6.2 历史查询

;; 查「用户改名前的旧名」
(def db (d/db conn))
(def old-db (d/as-of db t))

(d/q '[:find ?name
       :in $ ?e
       :where [?e :user/name ?name]]
     old-db eid)

6.3 审计与回滚

时间旅行的生产用途:
  审计     —— 这条记录上周是什么值
  调试     —— bug 发生时的数据快照
  对比     —— since 查这段时间新增了哪些事实
  回滚思路 —— 用历史状态构造补偿事务

心法:「时间旅行」不是附加功能,而是不可变模型的自然结果——因为从不覆盖,所以任何历史时刻的数据都还在。审计、调试、合规查询不需要额外的影子表,一条 as-of 搞定。

7. 事务与数据库函数

7.1 事务形态

;; 事务是「一组事实的追加」,用 map 描述
@(d/transact conn
  [{:user/email "a@x.com" :user/name "Alice" :user/age 30}])

;; 引用已有实体(唯一键 upsert)
@(d/transact conn
  [{:user/email "a@x.com" :user/age 31}])

7.2 数据库函数

数据库函数在 transactor 端原子执行,适合「读-改-写」和校验:

;; 定义函数
@(d/transact conn
  [{:db/ident :inc-age
    :db/fn (d/function
             '{:lang "clojure"
               :params [db eid n]
               :requires [[:user/age]]
               :code (let [age (d/q '[:find ?a . :in $ ?e
                                      :where [?e :user/age ?a]]
                                    db eid)]
                       [{:db/id eid :user/age (+ age n)}])})}])

;; 调用
@(d/transact conn [[:inc-age eid 1]])

7.3 幂等与 upsert

;; 带 :db.unique/identity 的属性 = 天然 upsert
;; 同一 email 重复 transact 不会新建实体,而是更新
@(d/transact conn [{:user/email "a@x.com" :user/name "Alice2"}])
;; 仍只有一个 a@x.com 实体

心法:数据库函数把「原子性」下推到 transactor——并发下的计数器、余额扣减、状态机转换,用 :db/fn 避免「读-改-写」竞态;配合唯一属性 upsert,幂等写入几乎免费。

8. Xtdb 架构与迁移

8.1 对象存储模型

Xtdb 用「文档 + 不可变索引」实现同一套 Datalog 心智,存储落在对象存储(S3 等):

写入  ->  事务日志(Kafka 或 S3)
      ->  索引器(Indexer)定期把文档合并成索引段
      ->  查询节点(Query Engine)读索引段服务查询

8.2 事务与查询

(require '[xtdb.api :as xt])

(def node (xt/start-node {}))
(xt/submit-tx node [[::xt/put {:xt/id :user/1 :name "Alice" :age 30}]])
(xt/sync node)

;; 查询(Datalog 语法,与 Datomic 高度相似)
(xt/q node '{:find [?name]
             :where [[?e :name ?name]]})
;; => #{["Alice"]}

8.3 Datomic 与 Xtdb 的差异

维度DatomicXtdb
存储transactor + SQL/内存对象存储 + 索引
许可商业(free 版受限)开源
查询 APId/q 与 d/pullxt/q 与 xt/entity
时间旅行as-of/since/historyxt/db 带 valid-time
部署transactor 单点查询节点可水平扩展

心法:两者的心智模型一致,差异在「部署与许可」——如果你认同「数据即事实」,Datomic 给你成熟生态,Xtdb 给你对象存储上的弹性与开源自由。选型更多是运维与成本问题,而非查询能力问题。

9. 与 SQL 和 ORM 的对比

9.1 查询模型

维度SQLDatalog
连接显式 JOIN ON变量同名即连接
递归递归 CTE规则天然递归
返回扁平行可嵌套(pull)
历史需自建原生
schema迁移 DDL属性可演进

9.2 选型表

场景推荐
强事务与复杂报表SQL
图与关系遍历、递归Datalog
需要审计与历史Datomic 或 Xtdb
团队只熟悉 SQLSQL(学习成本低)
高写入吞吐、简单 KV关系库或专用 KV

9.3 何时不该用

  • 团队没有时间学习 Datalog 心智,且业务就是简单 CRUD。
  • 需要极致的写入吞吐或超大规模聚合(transactor 可能是瓶颈)。
  • 强依赖某个关系库的扩展生态(如 PostGIS)。

心法:Datalog 不是 SQL 的替代品,而是「关系 + 时间 + 图」三者的统一表达——它在「关系复杂、需要历史、需要递归」的场景里降维打击,在「简单 CRUD、极致吞吐」里反而不划算。

10. 速查表与一句话记忆

需求写法
连接库(d/connect uri)
事务@(d/transact conn tx-data)
查询(d/q '[:find ... :where ...] db)
拉取(d/pull db pattern eid)
规则:in $ % 加 (rule ?a ?b)
时间旅行d/as-of 与 d/since 与 d/history
数据库函数:db/fn
幂等:db.unique/identity
递归规则内自引用
Xtdbxt/submit-tx 与 xt/q

一句话记忆:Datalog/Datomic = 数据即事实(EAVT 五元组、只增不改)→ query 找实体、pull 取子图 → 规则支持递归(图遍历利器)→ as-of/since/history 让时间成为一等公民 → :db/fn 把原子性下推 transactor、唯一属性天然 upsert → Xtdb 用对象存储实现同一心智 → 关系复杂或需历史或需递归时降维打击,简单 CRUD 与极致吞吐时不划算——它换掉的是「覆盖式更新」的世界观,换来的是可回溯、可推理的数据层。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「clojure」更多文章

  1. Clojure 静态检查与格式化工具链:clj-kondo、cljfmt、zprint 与 CI
  2. Clojure 认证授权与安全实践:Ring 安全链、JWT、密码哈希与审计
  3. Clojure 日志、指标与可观测性:Timbre、OpenTelemetry 与采样