Clojure 数据库访问实战:next.jdbc、HoneySQL 与连接池管理

系统掌握 Clojure 数据库访问全链路:next.jdbc 数据源与参数化查询、事务处理与隔离级别、HoneySQL 数据驱动 SQL 生成、HugSQL SQL 文件驱动、HikariCP 连接池调优与监控、Migratus 数据库迁移,附完整可运行的 CRUD 与批量写入实战。

数据库访问是后端服务的核心能力。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 选型

维度HoneySQLHugSQL
SQL 位置Clojure 数据结构内.sql 文件
动态拼接cond-> + helperSQL 文件内嵌 Clojure
DBA 协作需要懂 ClojureDBA 可直接编辑 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 迁移最佳实践

  1. 迁移只增不改:已发布的迁移文件永不再编辑,新变更写新迁移。
  2. 每个迁移可回滚:必须成对编写 .up.sql 与 .down.sql。
  3. 迁移幂等:CREATE TABLE IF NOT EXISTS 等写法降低重复执行风险。
  4. CI 中先跑 migrate 再跑测试:保证测试库与生产 schema 一致。
  5. 长事务迁移拆分:大表加列可用多步迁移避免锁表过久。

7. 常见陷阱与最佳实践

7.1 陷阱清单

陷阱症状解决方案
字符串拼接 SQLSQL 注入漏洞一律用 ? 参数化或 HoneySQL
每次请求新建连接连接数爆炸、延迟高全局 DataSource + HikariCP
事务内做长 I/O连接池被长事务占满事务保持短小,I/O 移出事务
忘记关闭 Connection连接泄漏用 with-open/with-transaction 托管
返回值期望无前缀列:users/name vs :name 混淆明确 :builder-fn
迁移文件事后修改已应用迁移 hash 不匹配迁移只增不改

7.2 工程最佳实践

  1. 封装仓储(Repository)层:把 SQL 调用收敛到独立命名空间,handler 不直接接触 SQL。
  2. 统一结果集构建器:项目级默认 as-unqualified-maps,避免列名前缀反复纠结。
  3. 批量写入用 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"]}])
  1. 配置外置:数据库地址/凭据走环境变量或配置库,不写死在代码。
  2. 连接池与迁移分离:迁移用直连配置(避免池状态干扰),应用用池。

8. 总结

组件职责核心 API
next.jdbc数据访问核心execute!/execute-one!/plan/with-transaction
HoneySQLSQL 生成(数据驱动)sql/format + h/select 等 helpers
HugSQLSQL 文件驱动def-db-fns + .sql 文件
HikariCP连接池hikari/make-datasource + 调优参数
Migratusschema 迁移migrate/rollback + 迁移文件

整套栈的核心哲学:SQL 是数据、连接是资源、迁移是版本。用 next.jdbc 保证安全与性能、HoneySQL 让 SQL 可组合、HikariCP 管好连接生命周期、Migratus 让 schema 演进可控。结合 Clojure 现代 Web 全栈开发 的 reitit 路由层,即可搭建完整的生产级数据服务。部署细节可参考 Docker 专题 中的容器化数据库方案。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「clojure」更多文章

  1. Clojure 领域建模与事件溯源:DDD、CQRS 与函数式实现
  2. Clojure 性能优化与 GraalVM 原生编译
  3. Clojure 生成式测试实战:test.check、收缩与 spec 集成