Scala 数据库访问:Slick、Doobie 与 Quill 类型安全 SQL

系统对比 Scala 三大数据库访问方案——Slick(函数式 ORM)、Doobie(纯 JDBC 效果型)、Quill(编译期 SQL DSL):连接管理、类型安全查询、事务、schema 迁移,覆盖 PostgreSQL/MySQL 实战。

引言

Scala 访问数据库的三个主流方案各代表一种哲学:Slick 把表建模成函数式集合(TableQuery 就是集合);Doobie 让 SQL 字符串带上类型(纯 JDBC + Cats Effect);Quill 用「源码宏」把 DSL 在编译期翻译成 SQL(错误提前到编译)。三者都承诺「类型安全」,但落地手感差异很大。本文各给最小实战,并重点讲连接与事务这些绕不开的工程点。

前置:/scala-functional-programming/(Option/函数式)、/scala-functional-effects/(Doobie 依赖 CE)。Web 层配合见 /scala-web-http-apps/。


目录


1. 三方案对比与选型

方案模型SQL 生成效果系统上手曲线
Slick表 → TableQuery 集合式运行期(lazy 生成)独立(自有一套 DBIO)中等
DoobieSQL 字符串 + 类型解码手写 SQL(最大可控)Cats Effect平缓
Quill编译期 DSL → 生成 SQL编译期(错早发现)ZIO / Future陡(宏依赖)

一句话选型:

  • 想要 ORM 手感、组合查询 → Slick
  • 想写原生 SQL 又想要类型安全 → Doobie
  • 已用 ZIO、要编译期验证 → Quill

底层三兄弟都是 JDBC + 连接池(HikariCP),性能差异远小于写法差异——可控性与维护性决定选型。


2. 连接管理:连接池 HikariCP 与迁移 Flyway

连接池(连接创建昂贵,必须复用):

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource

val cfg = new HikariConfig()
cfg.setJdbcUrl("jdbc:postgresql://localhost:5432/app")
cfg.setUsername("app")
cfg.setPassword("secret")
cfg.setMaximumPoolSize(10)          // 核心:并行查询量上限
cfg.setMinimumIdle(2)
cfg.setConnectionTimeout(5000)
cfg.setPoolName("main-pool")

val ds: HikariDataSource = new HikariDataSource(cfg)

连接池参数速查:

参数推荐说明
maximumPoolSize~CPU×2太多反而拖慢数据库
minimumIdle与 max 相同避免频繁建连
connectionTimeout5s拿不到连接的等待上限
maxLifetime30min防止服务端断连(DB 默认超时)

Schema 迁移(Flyway)——版本化 SQL,杜绝「手动改库」:

-- db/migration/V1__create_users.sql
CREATE TABLE users (
  id        BIGSERIAL PRIMARY KEY,
  name      VARCHAR(100) NOT NULL,
  email     VARCHAR(255) UNIQUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
import org.flywaydb.core.Flyway

Flyway.configure()
  .dataSource(ds)
  .locations("classpath:db/migration")
  .load()
  .migrate()

3. Slick:表即集合

定义表:

import slick.jdbc.PostgresProfile.api._

class Users(tag: Tag) extends Table[User](tag, "users") {
  def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
  def name = column[String]("name")
  def email = column[String]("email")
  def * = (id, name, email) <> (User.tupled, User.unapply)
}

val users = TableQuery[Users]   // 可组合的查询对象

查询即组合集合:

// 全部用户
val all: Query[Users, User, Seq] = users

// 过滤 + 排序 + 分页
val adults: Query[Users, User, Seq] =
  users.filter(_.id > 0).sortBy(_.id.desc).take(20)

// 执行
val rows: Seq[User] = db.run(adults.result).futureValue

DBIO 组合事务:

val tx = for {
  inserted <- (users returning users.map(_.id)) += User(0, "Alice", "a@x.com")
  _        <- users.filter(_.id === inserted).map(_.name).update("Alice2")
} yield inserted

val id: Long = db.run(tx.transactionally).futureValue

4. Doobie:带类型的 SQL

Doobie 思路:SQL 是字符串,但通过 Read/Write 类型类在边界解编码。

import doobie._
import doobie.implicits._
import cats.effect.IO

// 定义类型映射
case class User(id: Long, name: String, email: String)
implicit val userRead: Read[User] = Read[(Long, String, String)].map(User.tupled)

// 查询:字符串 + 返回类型
val find: ConnectionIO[Option[User]] =
  sql"select id, name, email from users where id = ${1L}"
    .query[User].option

// 执行
val program: IO[Option[User]] = transactor.trans.apply(find)

// 更复杂的查询
val search: ConnectionIO[List[User]] =
  sql"""select id, name, email from users
        where name ilike ${"%" + q + "%"} order by id"""
    .query[User].to[List]

Doobie 片段拼接(动态 SQL 依然类型安全):

val cond: Fragment = if (active) fr"and active = true" else Fragment.empty
val stmt: Fragment = fr"select id, name from users where 1=1" ++ cond
方法返回
.query[T].option0 或 1 行
.query[T].to[List]多行
.query[T].unique恰好一行
.update.run影响行数

5. Quill:编译期 DSL

Quill 的查询 DSL 在编译期生成 SQL——拼错列名、类型不对,编译直接报错:

import io.getquill.{PostgresZioJdbcContext, Snakify}

case class User(id: Long, name: String, email: String)

object Ctx extends PostgresZioJdbcContext(Snakify)

// 编译期翻译成: select id, name, email from users order by id desc limit ?
val query: Quoted[Query[User]] =
  quote { query[User].filter(_.id > 0).sortBy(_.id).take(20) }

val rows: Task[List[User]] =
  Ctx.run(query).provideLayer(dbLayer)

Quill 三特质:sourcecode 宏 + lift(参数绑定)+ 规范化 SQL 引擎。任何 DSL 写法都能被宏翻译,错误信息里直接给 SQL。

与 Slick 对比:

维度SlickQuill
SQL 生成时机运行期编译期
错误发现运行时编译时
DSL 形态集合式(类型类方法)函数式 quote 块
典型搭配Play / 独立ZIO

6. 事务:ACID 与隔离级别

事务边界(Doobie 的 transact 自带事务):

val moneyTransfer: ConnectionIO[Unit] = for {
  _ <- sql"update accounts set balance = balance - 100 where id = 1".update.run
  _ <- sql"update accounts set balance = balance + 100 where id = 2".update.run
} yield ()

// transact 保证整个程序在一个事务里
moneyTransfer.transact(transactor)

隔离级别:

级别脏读不可重复读幻读适用
READ UNCOMMITTED可能可能可能极少用
READ COMMITTED无可能可能默认(PG/MySQL)
REPEATABLE READ无无可能报表一致性
SERIALIZABLE无无无强一致、性能代价高

事务陷阱:

  • 长事务持有锁 → 死锁/拖慢全库 → 事务要短
  • 连接池不够时事务排队 → 监控活跃连接
  • 嵌套事务要小心(JDBC 无真正嵌套,需 savepoint)

7. 分页、过滤与聚合

分页(三方案写法):

// Slick
val page = users.filter(_.name.startsWith(prefix))
  .sortBy(_.id).drop(offset).take(limit)

// Doobie
val pageSql = sql"""select id, name from users
                    order by id limit $limit offset $offset"""

// Quill
quote { query[User].sortBy(_.id).drop(lift(offset)).take(lift(limit)) }

注意 keyset pagination(大数据集别用 offset——越翻越慢):

-- 用上次的 id 做游标
select id, name from users
where id > ${lastId} order by id limit ${pageSize};

聚合:

// Doobie 统计
val counts = sql"select status, count(*) from users group by status"
  .query[(String, Int)].to[List]

// Slick 聚合(类型安全)
val avgAge = users.map(_.id).avg  // Query[Rep[Option[Double]], ...]

8. 关联与 join

Slick join:

class Orders(tag: Tag) extends Table[Order](tag, "orders") {
  def id = column[Long]("id", O.PrimaryKey)
  def userId = column[Long]("user_id")
  def amount = column[BigDecimal]("amount")
}

val orders = TableQuery[Orders]

// inner join
val userOrders: Query[(Users, Orders), (User, Order), Seq] =
  users.join(orders).on(_.id === _.userId)

Doobie join(原生 SQL + 类型映射):

case class UserOrder(userId: Long, userName: String, amount: BigDecimal)

val joined: ConnectionIO[List[UserOrder]] =
  sql"""
    select u.id, u.name, o.amount
    from users u join orders o on o.user_id = u.id
    where u.id = ${1L}
  """.query[UserOrder].to[List]

性能注意:join 查询要只取需要的列,避免 select * 拖全表;大结果用流式(stream)而非 to[List]。


9. 性能调优:N+1 与批量

N+1 问题——循环里逐条查询是数据库杀手:

// 坏:N 次查询
val bad: List[UserOrder] = userIds.map(id => queryOne(id))

// 好:一次 IN 查询
val good: List[UserOrder] =
  sql"select * from orders where user_id in (${userIds})".query[UserOrder].to[List]

批量写入(Slick ++=、Doobie batch):

// Doobie 批量插入
val batchInsert: ConnectionIO[Int] =
  Update[User]("insert into users(name, email) values(?, ?)")
    .updateMany(userList)     // 一条语句多组参数

// Slick 批量
val n: Int = db.run(users ++= List(user1, user2, user3)).futureValue

调优清单:

手段效果
索引(where/join 列)查询提速数倍~数量级
批量写入减少网络往返
连接池合理大小防排队
只取所需列减 IO 与序列化
流式大结果防 OOM
查询计划 EXPLAIN定位慢查询

10. 数据库访问选型速查表

需求推荐
ORM 手感、组合查询Slick
原生 SQL + 类型安全Doobie
编译期验证 SQLQuill
连接池HikariCP
Schema 迁移Flyway
事务三方案均支持(transact/transactionally)
分页大数据集keyset pagination
批量写入Update.batch / ++=
N+1 优化IN 查询替代循环

一句话记忆:Slick 表即集合、Doobie 字符串带类型、Quill 编译期生成 SQL;连接池用 Hikari,迁移用 Flyway,事务一律短平快。


延伸阅读

  • /scala-functional-effects/ — Doobie/Quill 依赖的效果系统生态
  • /scala-web-http-apps/ — Web 服务接入持久层的完整流程
  • /scala-testing-practice/ — 数据库集成测试(Testcontainers)
  • /scala-build-tooling/ — sbt 依赖与多模块管理

继续阅读

探索更多技术文章

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

全部文章 返回首页

「scala」更多文章

  1. 纯函数式效果系统实战:Cats Effect IO 与 ZIO
  2. Scala.js 与 Scala Native:跨平台编译、互操作与工程实践
  3. Scala 领域建模实战:ADT、类型驱动设计与模块化架构