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 的差异
| 维度 | Datomic | Xtdb |
|---|---|---|
| 存储 | transactor + SQL/内存 | 对象存储 + 索引 |
| 许可 | 商业(free 版受限) | 开源 |
| 查询 API | d/q 与 d/pull | xt/q 与 xt/entity |
| 时间旅行 | as-of/since/history | xt/db 带 valid-time |
| 部署 | transactor 单点 | 查询节点可水平扩展 |
心法:两者的心智模型一致,差异在「部署与许可」——如果你认同「数据即事实」,Datomic 给你成熟生态,Xtdb 给你对象存储上的弹性与开源自由。选型更多是运维与成本问题,而非查询能力问题。
9. 与 SQL 和 ORM 的对比
9.1 查询模型
| 维度 | SQL | Datalog |
|---|---|---|
| 连接 | 显式 JOIN ON | 变量同名即连接 |
| 递归 | 递归 CTE | 规则天然递归 |
| 返回 | 扁平行 | 可嵌套(pull) |
| 历史 | 需自建 | 原生 |
| schema | 迁移 DDL | 属性可演进 |
9.2 选型表
| 场景 | 推荐 |
|---|---|
| 强事务与复杂报表 | SQL |
| 图与关系遍历、递归 | Datalog |
| 需要审计与历史 | Datomic 或 Xtdb |
| 团队只熟悉 SQL | SQL(学习成本低) |
| 高写入吞吐、简单 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 |
| 递归 | 规则内自引用 |
| Xtdb | xt/submit-tx 与 xt/q |
一句话记忆:Datalog/Datomic = 数据即事实(EAVT 五元组、只增不改)→ query 找实体、pull 取子图 → 规则支持递归(图遍历利器)→ as-of/since/history 让时间成为一等公民 → :db/fn 把原子性下推 transactor、唯一属性天然 upsert → Xtdb 用对象存储实现同一心智 → 关系复杂或需历史或需递归时降维打击,简单 CRUD 与极致吞吐时不划算——它换掉的是「覆盖式更新」的世界观,换来的是可回溯、可推理的数据层。
延伸阅读
- Clojure 数据库访问 — JDBC 与关系库访问
- Clojure 事件溯源与 CQRS — 事实日志与事件模型
- Clojure 数据操作 — 集合与数据变换基础
- Clojure 数据管道与流处理 — 数据处理流水线
- Clojure 函数式设计模式 — 不可变数据建模
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。