数据库访问是后端服务的核心能力。Clojure 生态经过多年演进,形成了以 next.jdbc(数据访问核心)、HoneySQL(数据驱动 SQL 生成)、HikariCP(连接池)、Migratus(迁移)为核心的现代数据库栈。它们与函数式理念完美契合:SQL 即数据、连接即资源、迁移即版本管理。本文将从连接建立到生产调优,构建完整的数据库访问体系。
如果你需要回顾 Web 层如何与这些库集成,可参考 Clojure 现代 Web 全栈开发;数据库本身的原理与调优可参考 PostgreSQL 专题。
1. next.jdbc 基础
next.jdbc 是 Clojure 社区现代数据库访问的事实标准(前身 clojure.java.jdbc 已基本被取代)。它围绕 DataSource 与 可参数化 SQL 向量 两个核心抽象展开。
1.1 数据源配置与连接
(require '[next.jdbc :as jdbc])
;; 方式一:数据库连接 map(每次新建连接,仅适合脚本/测试)
(def db {:dbtype "postgresql"
:dbname "myapp"
:host "localhost"
:port 5432
:user "app"
:password "secret"})
;; 方式二:通过 get-datasource 构建 DataSource(推荐,交给连接池)
(def ds (jdbc/get-datasource db))
;; 方式三:直接用 JDBC URL
(def ds2 (jdbc/get-datasource
{:jdbcUrl "jdbc:postgresql://localhost:5432/myapp"
:username "app"
:password "secret"}))
| 配置项 | 说明 |
|---|---|
:dbtype | 数据库类型,自动推导驱动(postgresql/mysql/h2/sqlite…) |
:jdbcUrl | 完整 JDBC URL,优先于 dbtype |
:maximum-pool-size | 连接池最大连接数(交给 HikariCP 时生效) |
:connection-init-sql | 连接建立时执行的初始化 SQL |
next.jdbc 的 API 统一接收「连接对象」,它可以是 map、DataSource 或已建立的 Connection——同一个函数无需区分,这就是其易用性的来源。
1.2 参数化查询与执行
;; execute!:返回所有行(向量)
(jdbc/execute! ds ["SELECT * FROM users WHERE active = ?" true])
;; => [{:users/id 1, :users/name "Alice", :users/active true} ...]
;; execute-one!:只返回第一行(没有则返回 nil)
(jdbc/execute-one! ds ["SELECT * FROM users WHERE id = ?" 42])
;; => {:users/id 42, :users/name "Alice"}
;; 写操作:返回受影响行数(默认)或生成键
(jdbc/execute! ds
["INSERT INTO users (name, email) VALUES (?, ?)"
"Bob" "bob@example.com"]
{:return-keys true}) ; 返回生成的自增主键
;; => [{:generated_keys [101]}]
参数化 SQL 是安全底线:永远不要用 str 拼接用户输入。? 占位符由 JDBC 驱动转义,天然免疫 SQL 注入。
1.3 结果集构建器:列名与类型映射
next.jdbc 的结果集构建器(builder-fn)控制行如何从 ResultSet 转为 Clojure map:
(require '[next.jdbc.result-set :as rs])
;; 默认:qualified maps(带表名前缀 :users/id)
(jdbc/execute! ds ["SELECT id, name FROM users"])
;; => [{:users/id 1, :users/name "Alice"}]
;; 无表名前缀
(jdbc/execute! ds ["SELECT id, name FROM users"]
{:builder-fn rs/as-unqualified-maps})
;; => [{:id 1, :name "Alice"}]
;; 自定义列名处理(如转成小写关键字)
(jdbc/execute! ds ["SELECT ID, NAME FROM users"]
{:builder-fn rs/as-maps
:column-fn keyword
:label-fn clojure.string/lower-case})
;; 数组/JSONB 列自动解析为 Clojure 数据结构(配合 :builder-fn rs/as-arrays? 等)
| builder-fn | 行为 |
|---|---|
rs/as-maps | 带限定名的 map |
rs/as-unqualified-maps | 无表名前缀 |
rs/as-arrays | 返回数组(批量读取更高效) |
rs/as-modified-maps | 结合 :label-fn/:column-fn 自定义 |
rs/data-source 系列 | 一次一行的流式读取 |
1.4 流式读取大结果集
对于百万行数据,一次性 execute! 会把所有行加载进内存。用 plan 逐行处理:
;; plan 返回可逐步消费的 reducible
(jdbc/plan ds ["SELECT * FROM events"]
(fn [row] (process-event! row)))
配合 transduce/reduce 可以做到常量内存遍历:
(transduce
(comp (map :events/amount) (filter pos?))
+
0
(jdbc/plan ds ["SELECT amount FROM events"]))
2. 事务处理
2.1 with-transaction 基本用法
(jdbc/with-transaction [tx ds]
(jdbc/execute! tx ["UPDATE accounts SET balance = balance - ? WHERE id = ?" 100 1])
(jdbc/execute! tx ["UPDATE accounts SET balance = balance + ? WHERE id = ?" 100 2]))
with-transaction 保证:块内所有操作要么全部提交,要么全部回滚。任一语句抛异常都会触发回滚,异常会继续向上传播。
2.2 事务隔离级别与 savepoint
(jdbc/with-transaction [tx ds {:isolation :serializable
:read-only false}]
...)
;; 显式提交/回滚
(jdbc/with-transaction [tx ds]
(try
(jdbc/execute! tx ["INSERT ..."])
(jdbc/commit tx)
(catch Exception e
(jdbc/rollback tx))))
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
:read-uncommitted | 可能 | 可能 | 可能 |
:read-committed(默认) | 否 | 可能 | 可能 |
:repeatable-read | 否 | 否 | 可能 |
:serializable | 否 | 否 | 否 |
2.3 事务传播与嵌套
with-transaction 检测到已存在事务连接时会复用外层事务(连接透传),因此嵌套使用安全:
(jdbc/with-transaction [tx ds]
(create-order! tx order) ; 内部 if-let 又开启 with-transaction
(update-inventory! tx sku qty))
;; 内层操作合并到外层事务,统一提交/回滚
注意:内层事务不能独立回滚外层已执行的操作——事务边界由最外层
with-transaction决定。需要局部回滚点用 savepoint:
(jdbc/with-transaction [tx ds]
(jdbc/execute! tx ["INSERT INTO audit ..."])
(let [sp (jdbc/set-savepoint! tx)]
(try
(jdbc/execute! tx ["INSERT INTO orders ..."])
(catch Exception _
(jdbc/release-savepoint! tx sp))))) ; 回滚到 savepoint
3. HoneySQL:数据驱动 SQL 生成
HoneySQL 用 Clojure 数据结构描述 SQL,天然支持动态拼接与复用,且把 SQL 注入风险降到最低。
3.1 DSL 基础
(require '[honey.sql :as sql]
'[honey.sql.helpers :as h])
(sql/format {:select [:id :name]
:from [:users]
:where [:= :active true]})
;; => ["SELECT id, name FROM users WHERE active = ?" true]
;; 链式 helpers 写法
(sql/format
(-> (h/select :id :name)
(h/from :users)
(h/where [:= :active true])
(h/order-by [:id :desc])
(h/limit 10)))
;; => ["SELECT id, name FROM users WHERE active = ? ORDER BY id DESC LIMIT 10" true]
| DSL 表达式 | 生成 SQL | 说明 |
|---|---|---|
[:= :a 1] | a = ? | 比较操作符向量 |
[:in :id [1 2 3]] | id IN (?,?,?) | in 自动展开 |
[:like :name "%foo%"] | name LIKE ? | 模糊匹配 |
[:between :age 18 65] | age BETWEEN ? AND ? | 范围 |
[:raw "NOW()"] | NOW() | 原始 SQL 片段 |
[:default] | DEFAULT | 默认值 |
{:select [[[:count :*] :cnt]]} | SELECT COUNT(*) AS cnt | 别名 |
3.2 动态查询构建
HoneySQL 最强的场景是按条件动态拼接——条件性查询不再需要字符串拼接:
(defn build-user-query [{:keys [name role active? min-id]}]
(cond-> (h/select :*)
(h/from :users)
name (h/where [:ilike :name (str "%" name "%")])
role (h/where [:= :role role])
(some? active?) (h/where [:= :active active?])
min-id (h/where [:> :id min-id])
:always (h/order-by [:id :desc])))
;; 不同参数生成不同 SQL
(sql/format (build-user-query {:role "admin" :active? true}))
;; => ["SELECT * FROM users WHERE role = ? AND active = ? ORDER BY id DESC" "admin" true]
(sql/format (build-user-query {:name "alice"}))
;; => ["SELECT * FROM users WHERE name ILIKE ? ORDER BY id DESC" "%alice%"]
cond-> 让每个可选条件独立成行,可读性远超命令式 if 拼接。同样的模式可以组合 h/order-by、h/limit、h/offset 实现分页。
3.3 写操作与批量
;; INSERT
(sql/format (h/insert-into :users
(h/values [{:name "Alice" :email "a@example.com"}
{:name "Bob" :email "b@example.com"}])))
;; => ["INSERT INTO users (name, email) VALUES (?, ?),(?, ?)" "Alice" "a@example.com" "Bob" "b@example.com"]
;; UPDATE
(sql/format (-> (h/update :users)
(h/set {:name "Alicia"})
(h/where [:= :id 42])))
;; => ["UPDATE users SET name = ? WHERE id = ?" "Alicia" 42]
;; DELETE
(sql/format (-> (h/delete-from :users)
(h/where [:and [:< :age 18] [:= :active false]])))
;; => ["DELETE FROM users WHERE age < ? AND active = ?" 18 false]
;; RETURNING(PostgreSQL)
(sql/format (-> (h/insert-into :users)
(h/values [{:name "Carol"}])
(h/returning :*)))
;; => ["INSERT INTO users (name) VALUES (?) RETURNING *" "Carol"]
3.4 与 next.jdbc 集成
HoneySQL 产出 [sql params] 向量,正好是 next.jdbc 的输入格式:
(require '[next.jdbc :as jdbc]
'[honey.sql :as sql]
'[honey.sql.helpers :as h])
(defn find-users [ds filters]
(jdbc/execute! ds
(sql/format (build-user-query filters))
{:builder-fn next.jdbc.result-set/as-unqualified-maps}))
(defn create-user! [ds user]
(jdbc/execute-one! ds
(sql/format (-> (h/insert-into :users)
(h/values [user])
(h/returning :*)))
{:return-keys true}))
(defn batch-insert! [ds users]
(jdbc/execute! ds
(sql/format (h/insert-into :users (h/values users)))))
这套「HoneySQL 生成 + next.jdbc 执行」的组合,让 SQL 与 Clojure 数据模型无缝衔接,且全程参数化。
4. HugSQL:SQL 文件驱动的数据访问
HugSQL 反其道而行:把 SQL 写进 .sql 文件,由宏在编译期生成对应的 Clojure 函数。适合 SQL 复杂、希望与代码分离的团队。
4.1 基本用法
-- resources/sql/users.sql
-- :name find-user-by-id :? :1
-- :doc 根据 ID 查询用户
SELECT * FROM users WHERE id = :id;
-- :name find-active-users :? :*
SELECT * FROM users WHERE active = :active ORDER BY id;
-- :name insert-user! :insert :returning
INSERT INTO users (name, email)
VALUES (:name, :email)
RETURNING id, name, email;
-- :name update-user! :! :n
UPDATE users SET name = :name WHERE id = :id;
(require '[hugsql.core :as hugsql])
;; 生成命名空间内函数(snake_case 转 kebab-case)
(hugsql/def-db-fns "sql/users.sql")
;; => find-user-by-id, find-active-users, insert-user!, update-user!
;; 使用(传入连接对象)
(find-user-by-id ds {:id 42})
(find-active-users ds {:active true})
(insert-user! ds {:name "Dave" :email "d@example.com"})
| 参数标记 | 含义 |
|---|---|
:? :1 | 返回单行 |
:? :* | 返回多行 |
:insert :returning | 插入并返回生成键 |
:! :n | 写操作,返回影响行数 |
:! :1 / :! :* | 写操作返回行 |
4.2 动态 SQL
HugSQL 用 --~ 引入 Clojure 表达式做条件拼接:
-- :name search-users :? :*
-- :doc 动态条件搜索
SELECT * FROM users
WHERE 1 = 1
--~ (when (:name params) " AND name ILIKE :name")
--~ (when (:role params) " AND role = :role")
ORDER BY id;
(search-users ds {:name "ali" :role "admin"})
4.3 HugSQL vs HoneySQL 选型
| 维度 | HoneySQL | HugSQL |
|---|---|---|
| SQL 位置 | Clojure 数据结构内 | .sql 文件 |
| 动态拼接 | cond-> + helper | SQL 文件内嵌 Clojure |
| DBA 协作 | 需要懂 Clojure | DBA 可直接编辑 SQL 文件 |
| 类型安全 | 编译期检查 DSL | 运行期校验 |
| 适合场景 | 全 Clojure 团队 | 有专职 DBA / SQL 高度复杂 |
两者可以共存:HoneySQL 处理常规 CRUD,HugSQL 托管复杂报表 SQL。
5. 连接池:HikariCP
每次请求都新建数据库连接开销极大(建 TCP、鉴权、初始化 session)。生产环境必须使用连接池。Clojure 通过 hikari-cp 封装 HikariCP(Java 生态最快的连接池)。
5.1 基础配置与调优
(require '[hikari-cp.core :as hikari])
(defonce datasource
(hikari/make-datasource
{:jdbc-url "jdbc:postgresql://localhost:5432/myapp"
:username "app"
:password "secret"
;; 核心调优参数
:maximum-pool-size 20
:minimum-idle 5
:connection-timeout 30000 ; 获取连接超时(ms)
:idle-timeout 600000
:max-lifetime 1800000
:initialization-fail-timeout 30000
:connection-test-query "SELECT 1"}))
;; 生命周期管理:应用关闭时销毁
;; (hikari/close-datasource datasource)
5.2 池大小估算
HikariCP 作者的核心建议:池大小 ≠ 越大越好。经验公式:
connections = ((core_count * 2) + effective_spindle_count)
对现代 SSD + PostgreSQL,常见做法:
| 场景 | 推荐池大小 | 理由 |
|---|---|---|
| Web API(低延迟) | CPU 核数 × 2 | 连接大多快速借还 |
| 批处理/ETL(长事务) | 核数 ~ 核数+2 | 避免长事务占满池 |
| 只读报表 | 4~8 | 查询短,复用高 |
| 混合负载 | 10~20 | 留有余量 |
池过小会导致 connection-timeout 排队;池过大则浪费内存与数据库线程。观察 active/idle 连接曲线再微调。
5.3 监控与健康检查
(defn pool-metrics []
(let [pool (:datasource hikari/*datasource*)] ; 假设已构建
{:active-connections (.getActiveConnections pool)
:idle-connections (.getIdleConnections pool)
:total-connections (.getTotalConnections pool)
:pending-requests (.getThreadsAwaitingConnection pool)}))
;; 健康检查端点(配合 Ring)
(defn health-check [_]
(try
(jdbc/execute-one! datasource ["SELECT 1"])
{:status 200 :body {:status "ok" :pool (pool-metrics)}}
(catch Exception e
{:status 503 :body {:status "down" :error (.getMessage e)}})))
6. 迁移:Migratus
数据库 schema 变更需要版本管理。Migratus 按目录 + 序号组织迁移 SQL,保证「已应用的迁移不会重复执行」。
6.1 项目配置
;; deps.edn
{:deps {migratus/migratus {:mvn/version "1.5.6"}
com.github.seancorfield/next.jdbc {:mvn/version "1.3.874"}
com.zaxxer/HikariCP {:mvn/version "5.1.0"}}
:aliases {:migrate {:main-opts ["-m" "migratus.main"]}}}
;; 迁移配置(可放在 migratus.clj)
{:store {:type :jdbc
:db {:dbtype "postgresql"
:dbname "myapp"
:host "localhost"
:user "app"
:password "secret"}}
:migration-dir "resources/migrations"
:init-script "resources/migrations/init.sql"}
6.2 迁移文件结构
Migratus 按时间戳/序号前缀排序执行:
resources/migrations/
├── 20260926090000-create-users.down.sql
├── 20260926090000-create-users.up.sql
├── 20260926093000-add-email-constraint.up.sql
└── 20260926093000-add-email-constraint.down.sql
-- 20260926090000-create-users.up.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 20260926090000-create-users.down.sql
DROP TABLE IF EXISTS users;
6.3 常用命令
# 应用所有待执行迁移
clojure -M:migrate migrate
# 回滚最近一个迁移
clojure -M:migrate rollback
# 查看状态
clojure -M:migrate status
# 带配置运行
clojure -M:migrate --config config/migratus.edn migrate
| 命令 | 行为 |
|---|---|
migrate | 按序执行所有未应用迁移 |
rollback | 回滚最近一次迁移(执行对应 .down.sql) |
pending | 列出待执行迁移 |
baseline | 标记已存在 schema,跳过初始化 |
up/down | 单步应用/回滚指定版本 |
6.4 迁移最佳实践
- 迁移只增不改:已发布的迁移文件永不再编辑,新变更写新迁移。
- 每个迁移可回滚:必须成对编写
.up.sql与.down.sql。 - 迁移幂等:
CREATE TABLE IF NOT EXISTS等写法降低重复执行风险。 - CI 中先跑
migrate再跑测试:保证测试库与生产 schema 一致。 - 长事务迁移拆分:大表加列可用多步迁移避免锁表过久。
7. 常见陷阱与最佳实践
7.1 陷阱清单
| 陷阱 | 症状 | 解决方案 |
|---|---|---|
| 字符串拼接 SQL | SQL 注入漏洞 | 一律用 ? 参数化或 HoneySQL |
| 每次请求新建连接 | 连接数爆炸、延迟高 | 全局 DataSource + HikariCP |
| 事务内做长 I/O | 连接池被长事务占满 | 事务保持短小,I/O 移出事务 |
| 忘记关闭 Connection | 连接泄漏 | 用 with-open/with-transaction 托管 |
| 返回值期望无前缀列 | :users/name vs :name 混淆 | 明确 :builder-fn |
| 迁移文件事后修改 | 已应用迁移 hash 不匹配 | 迁移只增不改 |
7.2 工程最佳实践
- 封装仓储(Repository)层:把 SQL 调用收敛到独立命名空间,handler 不直接接触 SQL。
- 统一结果集构建器:项目级默认
as-unqualified-maps,避免列名前缀反复纠结。 - 批量写入用
prepare或批量 INSERT:几千行插入,逐条执行慢 10 倍以上。
;; 批量插入(HoneySQL 多行 VALUES 一次执行)
(defn batch-insert-events! [ds events]
(jdbc/execute! ds
(sql/format
(-> (h/insert-into :events)
(h/values events)))
{:multi-rs? true}))
;; 或 prepared statement 批量
(jdbc/execute! ds
["INSERT INTO events (ts, kind) VALUES (?, ?)"]
[{:params [t1 "click"]} {:params [t2 "view"]}])
- 配置外置:数据库地址/凭据走环境变量或配置库,不写死在代码。
- 连接池与迁移分离:迁移用直连配置(避免池状态干扰),应用用池。
8. 总结
| 组件 | 职责 | 核心 API |
|---|---|---|
| next.jdbc | 数据访问核心 | execute!/execute-one!/plan/with-transaction |
| HoneySQL | SQL 生成(数据驱动) | sql/format + h/select 等 helpers |
| HugSQL | SQL 文件驱动 | def-db-fns + .sql 文件 |
| HikariCP | 连接池 | hikari/make-datasource + 调优参数 |
| Migratus | schema 迁移 | migrate/rollback + 迁移文件 |
整套栈的核心哲学:SQL 是数据、连接是资源、迁移是版本。用 next.jdbc 保证安全与性能、HoneySQL 让 SQL 可组合、HikariCP 管好连接生命周期、Migratus 让 schema 演进可控。结合 Clojure 现代 Web 全栈开发 的 reitit 路由层,即可搭建完整的生产级数据服务。部署细节可参考 Docker 专题 中的容器化数据库方案。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。