记录一次从 Prisma 到 Drizzle 的迁移

本文最后更新于:2025年2月26日 凌晨

背景

最近使用 Cloudflare D1 作为服务端的数据库,ORM 选择了很多人推荐的 Prisma,但使用过程中遇到了一些问题,主要包括

  1. 不支持 D1 的 batch 批处理,完全没有事务 https://www.prisma.io/docs/orm/overview/databases/cloudflare-d1#transactions-not-supported
  2. 不支持复杂查询,例如多表 Join SQL 语法 https://github.com/prisma/prisma/discussions/12715
  3. 单次查询很慢,通常在 200ms 以上,这很奇怪,吾辈相信这与 prisma 内部使用 wasm 导致初始化时间更长有关 https://github.com/prisma/prisma/discussions/23646#discussioncomment-9059560

不支持事务

首先说一下第一个问题,Cloudflare D1 本身并不支持事务,仅支持使用 batch 批处理,这是一种有限制的事务。https://developers.cloudflare.com/d1/worker-api/d1-database/#batch

例如

1
2
3
4
5
6
7
const companyName1 = `Bs Beverages`
const companyName2 = `Around the Horn`
const stmt = env.DB.prepare(`SELECT * FROM Customers WHERE CompanyName = ?`)
const batchResult = await env.DB.batch([
stmt.bind(companyName1),
stmt.bind(companyName2),
])

而如果你使用 Prisma 的 $transaction 函数,会得到一条警告。

1
prisma:warn Cloudflare D1 does not support transactions yet. When using Prisma's D1 adapter, implicit & explicit transactions will be ignored and run as individual queries, which breaks the guarantees of the ACID properties of transactions. For more details see https://pris.ly/d/d1-transactions

这条警告指向了 cloudflare/workers-sdk,看起来是 cloudflare d1 的问题(当然,不支持事务确实是问题),但也转移了关注点,问题是为什么 prisma 内部不使用 d1 batch 函数呢?嗯,它目前不支持,仅此而已,检查 @prisma/adapter-d1 的事务实现

不支持复杂查询

例如下面这个统计查询,统计 + 去重,看起来很简单?

1
2
3
SELECT spamUserId, COUNT(DISTINCT reportUserId) as reportCount
FROM SpamReport
GROUP BY spamUserId;

你在 prisma 中可能会想这样写。

1
2
3
4
5
6
const result = await context.prisma.spamReport.groupBy({
by: ['spamUserId'],
_count: {
reportUserId: { distinct: true },
},
})

不,prisma 不支持,检查已经开放了 4 年issue#4228

顺便说一句,drizzle 允许你这样做。

1
2
3
4
5
6
7
const result = await context.db
.select({
spamUserId: spamReport.spamUserId,
reportCount: countDistinct(spamReport.reportUserId),
})
.from(spamReport)
.groupBy(spamReport.spamUserId)

单次查询很慢

这一点没有真正分析过,只是体感上感觉服务端 API 请求很慢,平均时间甚至达到了 1s,而目前最大的一张表数据也只有 30+k,而大多数其他表还不到 1k,这听起来不正常。但事后来看,从 prisma 切换到 drizzle 之后,bundle 尺寸从 2776.05 KiB / gzip: 948.21 KiB 降低到了 487.87 KiB / gzip: 93.10 KiB,gzip 之后甚至降低了 90%,这听起来并不那么不可思议了。

image (26).png

有人做了一些测试,似乎批量插入 1k 条的性能问题更糟糕,甚至超过了 30s。https://github.com/prisma/prisma/discussions/23646#discussioncomment-10965747

踩坑

说完了 Prisma 的这么多问题,接下来说一下在迁移过程中踩到的坑。

坑 1: 从 schema.prisma 生成 schema.ts 有问题

在迁移时,首先使用 Grok 从 schema.prisma 自动生成了 drizzle 需要的 schema.ts。但发现了以下问题

原本的表结构

1
2
3
4
5
CREATE TABLE "LocalUser" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
)

Grok 自动转换生成

1
2
3
4
5
6
7
8
9
10
11
export const localUser = sqliteTable('LocalUser', {
id: text('id')
.primaryKey()
.default(sql`uuid()`),
createdAt: integer('createdAt', { mode: 'timestamp' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: integer('updatedAt', { mode: 'timestamp' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
})

这里的自动转换有几个问题

  1. sql`uuid()` 在 prisma 中由应用抽象层填充,但 schema.ts 里使用 sql`uuid()`,这里应该同样由应用抽象层填充
  2. updatedAt 有相同的问题,schema.ts 里使用了 sql`CURRENT_TIMESTAMP`
  3. createdAt/updatedAt 实际上是 text 类型,而 schema.ts 里使用了 integer,这会导致无法向旧表插入数据,也无法正确查询到对应的字段,只会得到 Invalid Date

实际上需要修改为

1
2
3
4
5
6
7
8
9
export const localUser = sqliteTable('LocalUser', {
id: text('id').primaryKey().$defaultFn(uuid),
createdAt: text('createdAt')
.notNull()
.$defaultFn(() => new Date().toISOString()),
updatedAt: text('createdAt')
.notNull()
.$defaultFn(() => new Date().toISOString()),
})

坑 2: db.batch 批量查询有时候会出现返回的 Model 填充数据错误的问题

嗯,在 join 查询时 drizzle 并不会自动解决冲突的列名。假设有 User 和 ModList 两张表

id screenName name
user-1 user-screen-name user-name
id name userId
modlist-1 modlist-name user-1

然后执行以下代码,非批量查询的结果将与批量查询的结果不同。

1
2
3
4
5
6
7
8
9
const query = db
.select()
.from(modList)
.innerJoin(user, eq(user.id, modList.userId))
.where(eq(modList.id, 'modlist-1'))
const q = query.toSQL()
const stmt = context.env.DB.prepare(q.sql).bind(...q.params)
console.log((await stmt.raw())[0])
console.log((await context.env.DB.batch([stmt]))[0].results[0])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 非批量查询
;[
'modlist-1',
'modlist-name',
'user-1',

'user-1',
'user-screen-name',
'user-name',
]

// 批量查询
{
// id: 'modlist-1', 被覆盖
// name: 'modlist-name', 被覆盖
id: 'user-1',
name: 'user-name',
userId: 'user-1',
screenName: 'user-screen-name',
}

这里的 ModList 和 User 中有冲突的列名 id/name,在批量查询时后面的列将会覆盖掉前面的,参考相关的 issue。

https://github.com/cloudflare/workers-sdk/issues/3160
https://github.com/drizzle-team/drizzle-orm/issues/555

需要手动指定列的别名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
db.select({
modList: {
id: sql<string>`${modList.id}`.as('modlist_id'),
name: sql<string>`${modList.name}`.as('modlist_name'),
},
user: {
id: sql<string>`${user.id}`.as('user_id'),
screenName: sql<string>`${user.screenName}`.as('user_screen_name'),
name: sql<string>`${user.name}`.as('user_name'),
},
})
.from(modList)
.innerJoin(user, eq(user.id, modList.twitterUserId))
.where(eq(modList.id, 'modlist-1'))

然后就会得到一致的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 非批量查询
;[
'modlist-1',
'modlist-name',
'user-1',
'user-screen-name',
'user-name'
]
// 批量查询
{
modlist_id: 'modlist-1',
modlist_name: 'modlist-name',
user_id: 'user-1',
user_screen_name: 'user-screen-name',
user_name: 'user-name'
}

甚至可以实现一个通用的别名生成器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import {
AnyTable,
TableConfig,
InferSelectModel,
getTableName,
getTableColumns,
sql,
SQL,
} from 'drizzle-orm'

export function getTableAliasedColumns<T extends AnyTable<TableConfig>>(
table: T,
) {
type DataType = InferSelectModel<T>
const tableName = getTableName(table)
const columns = getTableColumns(table)
return Object.entries(columns).reduce(
(acc, [columnName, column]) => {
;(acc as any)[columnName] = sql`${column}`.as(
`${tableName}_${columnName}`,
)
return acc
},
{} as {
[P in keyof DataType]: SQL.Aliased<DataType[P]>
},
)
}

然后就不再需要手动设置别名,而且它还是类型安全的!

1
2
3
4
5
6
7
db.select({
modList: getTableAliasedColumns(modList),
user: getTableAliasedColumns(user),
})
.from(modList)
.innerJoin(user, eq(user.id, modList.twitterUserId))
.where(eq(modList.id, 'modlist-1'))

总结

数据迁移时兼容性最重要,修改或优化 schema 必须放在迁移之后。整体上这次的迁移结果还是挺喜人的,后续开新坑数据库 ORM 可以直接用 drizzle 了。


记录一次从 Prisma 到 Drizzle 的迁移
https://blog.rxliuli.com/p/4140cb8be8b044a5bb9cebe0930e3a4a/
作者
rxliuli
发布于
2025年2月23日
许可协议