引言
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. 三方案对比与选型
- 2. 连接管理:连接池 HikariCP 与迁移 Flyway
- 3. Slick:表即集合
- 4. Doobie:带类型的 SQL
- 5. Quill:编译期 DSL
- 6. 事务:ACID 与隔离级别
- 7. 分页、过滤与聚合
- 8. 关联与 join
- 9. 性能调优:N+1 与批量
- 10. 数据库访问选型速查表
- 延伸阅读
1. 三方案对比与选型
| 方案 | 模型 | SQL 生成 | 效果系统 | 上手曲线 |
|---|---|---|---|---|
| Slick | 表 → TableQuery 集合式 | 运行期(lazy 生成) | 独立(自有一套 DBIO) | 中等 |
| Doobie | SQL 字符串 + 类型解码 | 手写 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 相同 | 避免频繁建连 |
| connectionTimeout | 5s | 拿不到连接的等待上限 |
| maxLifetime | 30min | 防止服务端断连(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].option | 0 或 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 对比:
| 维度 | Slick | Quill |
|---|---|---|
| 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 |
| 编译期验证 SQL | Quill |
| 连接池 | 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 依赖与多模块管理
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。