☰
Cloudflare D1 数据库选型:裸SQL、Drizzle与Prisma
2026/10/1 3:12:55 网站建设 项目流程

最近把一个内部 API 服务迁到 Cloudflare Workers 上,数据层选了 D1,还没写几条 SQL,同事就在群里抛了个经典问题:"要不要上个 Prisma 或者 Drizzle?"这个问题我前后回答过不下五遍,每次项目背景不同,结论也不同。如果你也在做 Cloudflare + D1 的技术选型,或者已经在 Workers 上写数据访问层但被 ORM 折腾得够呛,这篇应该能帮你把决策理清楚。先说我的总体判断:小项目、接口少,裸 SQL 就是最优解;中大型项目、多人协作、表结构变化频繁,Drizzle 是目前在 Workers 上最舒服的选择;Prisma 不是不能用,但它在这个环境里需要额外付出配置成本。全文按"为什么会有这个问题、ORM 的代价、两者对比、我的选择建议、实战避坑"五部分展开,全程记录式写法,直接对标我自己的真实项目经历。

1. 先认识D1:很多ORM纠结其实源于对D1的误解

1.1 它是分布式SQLite,不是普通云数据库

D1是Cloudflare基于SQLite构建的Serverless数据库,开发者通过Wrangler配置binding后,在Worker代码里直接用db.prepare()裸SQL就能访问。它和你在AWS上开一个MySQL托管实例不是一回事:D1没有传统意义上的连接字符串,没有长连接池,查询通过Cloudflare内置的API完成。

这个定位直接影响了ORM的适配方式。SQLite方言支持大多数常规SQL,但D1又不完全等于本机SQLite:它有分布式写入的语义,在启用全局读复制后,读请求可能访问到副本数据。你如果完全不理解这层语义,单纯把D1当成"ORM配置好的一个数据库",遇到"为什么刚写入的数据读不到"这种问题时会非常被动。

ORM在传统数据库上最大的价值之一是屏蔽方言差异,但在D1这里反而可能制造理解偏差。比如SQLite支持RETURNING语句,D1也支持;但某些ORM在生成UPDATE时并不会默认带上RETURNING,导致你想确定"修改影响了几行"还得再查一次。这种多出来的查询在本地MySQL里无所谓,在D1这种按查询计费的环境里就是实打实的成本。

1.2 配额和延迟:ORM默认行为可能不帮你省钱

D1的免费层额度有限,超了之后会限流。更关键的是,计费模型是按"读出的行数和字节数"计算的,和你本地跑MySQL完全不是一回事。ORM生成的查询默认往往是"全列取出"。

Prisma 的findMany不带select时会查所有列,Drizzle 的select()也是全字段展开。如果你表里有几个 JSON 字段或大的文本字段,又不小心把所有列取出来,配额消耗会比想象中快很多。裸SQL模式下,你可以非常自然地控制 SELECT 哪几列,甚至可以为了列表页专门写一条轻量查询,这样D1的读取成本很可控。

延迟方面,Serverless环境里没有连接池救N+1查询的命。传统Node服务可以靠连接池复用和懒加载解决性能问题,Workers冷启动到D1的每次查询都是独立的HTTP往返,关系嵌套越多,延迟放大越明显。ORM的include/relations功能在别的环境可能有帮助,在这里更容易变成性能陷阱。我自己在处理详情页这种数据时,宁可拆成两条裸SQL然后手动组装,也不敢把希望寄托在ORM自动生成的多表关联上。

1.3 裸SQL在D1上的真实体验

D1的裸SQL体验其实非常好。binding到一个D1Database实例后,prepare().bind().all()/first()/run(),一套流程非常顺,还支持batch多条SQL原子执行。批量插入用户信息、批量更新状态这类场景,用db.batch处理非常简单。

const stmt = db.prepare('INSERT INTO users (name, status) VALUES (?, ?)') const results = await db.batch([ stmt.bind('张三', 'active'), stmt.bind('李四', 'disabled'), ])

这段代码没有任何ORM包装,但它就是D1推荐的高效姿势。ORM能不能做到同样的事?能,但你通常得先绕到D1原生接口上,等于裸SQL换了个写法。所以每当有人问我"不上ORM会不会很原始",我的回答都是:在D1场景,裸SQL不是原始,是原生。

2. ORM在Cloudflare环境里到底值不值:收益与代价

2.1 收益:类型安全、代码生成和工程化约束

先别急着否定ORM。在团队协作场景里,ORM的价值不体现在运行效率上,体现在"让团队不容易写出烂代码"。

以Drizzle为例,schema定义完,你的查询API天然带类型。字段改名了,TS编译阶段直接报错。这点比裸SQL强很多,裸SQL你可能要等到查询返回undefined才发现字段名写错了。Prisma更激进,schema.prisma是单一事实来源,模型关系、枚举、索引全在schema里定义,生成client后整个数据模型对CI和编辑器都是可见的。

对于表结构频繁变动的业务,代码生成带来的自动化效率提升也真实存在。改schema,生成新client,编辑器提示同步更新。这一套在传统Node项目里很好用,在Workers项目里同样成立,只是要额外考虑体积和构建的问题。

2.2 代价:脚本体积和冷启动是绕不过去的坎

Workers对打包后脚本大小有限制,免费额度下大约是1MB,付费计划高一些,但相比传统Node服务动辄几十MB的node_modules还是很紧张。Drizzle基本是纯TypeScript实现,打包阶段可以被tree-shake掉很多无用代码,最终产物通常只比你手写SQL的版本大几十到一百多KB。Prisma呢?它从设计之初就不是为边缘运行时准备的,现在虽然提供driver adapter,运行时代码、生成client、可能的编译器shim都比Drizzle重得多。一个只有两个CRUD接口的Worker,裸SQL打包可能不到100KB,上Drizzle还有余量,上Prisma后体积就会肉眼可见地往上涨。

冷启动虽然不像体积那么直观,但也有感受。Workers是V8 isolate,模块加载和初始化越轻,边缘冷启动越快。我做过一个对照实验,同样功能的两个Worker,一个用裸SQL,一个用Prisma,冷启动毛感觉Prisma那个明显更久。关键是,这种"毛感觉"在用户访问量上来后,配合D1的查询延迟,整体体验差距会被放大。

2.3 代价:构建链路和部署复杂度才是最大的隐形成本

体积是显性的,构建链路是隐性的。Prisma在Workers上启动要三个环节:prisma generate生成client、通过driver adapter把D1绑定传进去、保证打包器正确处理这些生成文件。本地没问题,一上CI或者wrangler deploy就可能有奇怪的问题,比如client找不到引擎、adapter未实例化等。Drizzle这边就简单很多:安装依赖,定义schema,drizzle-kit generate出迁移SQL,代码里直接 import 后使用。整个链路和普通的TS项目没有差别,esbuild、rollup、wrangler都能平滑处理。

我在团队里经常提醒一句话:技术选型的成本不是第一次跑通demo的成本,是后续每一次部署和问题排查的成本。ORM在传统Node里积累的优势没有被削弱,但在边缘运行时里,这些额外的部署摩擦是真实存在的。选择Drizzle而不是Prisma,很多时候不是功能差异,而是它在这个特定环境下保留的"低配置感"太重要了。

3. Prisma与Drizzle的正面对比:从配置到运行

3.1 初始化流程:两套截然不同的体验

先看Prisma在Workers + D1上的标准接入方式。

安装依赖:prisma、@prisma/client、@prisma/adapter-d1。

schema.prisma里数据源写:

datasource db { provider = "sqlite" url = "file:d1.db" }

然后生成client,代码里初始化:

import { PrismaD1 } from '@prisma/adapter-d1' import { PrismaClient } from '@prisma/client' export interface Env { DB: D1Database } export default { async fetch(request: Request, env: Env) { const adapter = new PrismaD1(env.DB) const prisma = new PrismaClient({ adapter }) const users = await prisma.user.findMany({ where: { status: 'active' }, select: { id: true, name: true }, orderBy: { createdAt: 'desc' }, }) return Response.json({ users }) }, }

这串代码看着不复杂,但里面有几个容易翻车的点:adapter必须和env.DB绑定,漏传后部署到线上直接报client初始化失败;schema里的provider只能写"sqlite",不能写d1,很多第一次按文档走的人会卡在这里;每次schema变更都需要重新generate,CI里别忘了这个步骤。

再看Drizzle的标准接入:

// schema.ts import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' export const users = sqliteTable('users', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), status: text('status').notNull().default('active'), }) // worker 入口 import { drizzle } from 'drizzle-orm/d1' import * as schema from './schema' export default { async fetch(request: Request, env: Env) { const db = drizzle(env.DB, { schema }) const list = await db.select({ id: users.id, name: users.name }) .from(users) .where(eq(users.status, 'active')) .orderBy(desc(users.id)) return Response.json({ list }) }, }

Drizzle几乎没有"适配器"的概念。env.DB直接传进drizzle()工厂函数,剩下的都是普通TypeScript。查询API的写法从上到下都紧密贴着SQL原生结构,select什么、from哪个表、where条件是什么,一眼能看明白。这种透明感是它最重要的优点。

3.2 查询能力与SQL透传:谁更接近"手写SQL"的自由度

ORM最容易被质疑的地方是复杂查询。Prisma的解决路径是提供$queryRaw和$executeRaw,让你可以在对象式API写不顺手时直接跳回SQL。实际用下来,$queryRaw配合模板字符串还算顺手:

const stats = await prisma.$queryRaw` SELECT status, COUNT(*) as cnt FROM users GROUP BY status `

注意别把外部变量手动拼进SQL,要用${}占位,否则既危险又容易出奇怪的绑定错误。Prisma的$queryRaw确实能解决问题,但它本质上就是给ORM留的"后门",遇到复杂查询时你在Prisma的抽象和Raw SQL之间来回横跳,代码风格会有点撕裂。

Drizzle的SQL透传更加顺滑。它的sql模板标签可以做条件拼装,也能执行任意查询:

import { sql } from 'drizzle-orm' const rows = await db.run(sql` SELECT status, COUNT(*) as cnt FROM users GROUP BY status `)

更关键的是,Drizzle允许你在任意时刻退回到D1原生接口。env.DB还握在你手里,db.batch、env.DB.prepare()都可以直接用,说明ORM没有把你的逃生门焊死。对于一个基于D1的项目,这种"想用ORM时用ORM,想裸SQL时随时裸SQL"的灵活性,是我最终长期选择Drizzle的核心原因。

3.3 迁移方案:谁和D1生态配合得更顺畅

D1官方迁移机制是wrangler d1 migrations,本质是SQL文件集合,按版本号依次应用。裸SQL工作流天然契合这一点,创建migrations/0000_init.sql,执行wrangler d1 migrations apply即可。

Drizzle和D1的配合也很直接。drizzle-kit generate会把schema diff生成SQL文件,然后丢给wrangler d1 migrations apply。中间的格式转换基本为零,只是注意drizzle-kit的dialect要配置为sqlite,生成目录对应migrations。我习惯在package.json里加一条脚本,把generate和apply串起来,减少漏跑。

Prisma迁移就有点绕。prisma migrate dev生成自己的迁移历史,这些SQL需要额外处理才能套进D1的migrations体系。如果团队里已经有Prisma skill,不是不能对接,但它确实多了一层翻译和校验工作。自动迁移在生产环境也要谨慎操作,容易产生状态错乱。另外要注意不同工具跟踪的表名不一样,wrangler认d1_migrations这张表,如果你同时混用两套迁移工具,可能出现互相干扰。我现在的做法是只让wrangler管理迁移状态,drizzle-kit只负责产出SQL文件,职责分得很开。

3.4 一张表,看清三个方案的取舍

维度裸SQLDrizzlePrisma
打包体积最小很小较大
类型安全手写type/不保证schema推导,强schema推导,强
查询可读性就是SQL贴近SQL对象式,复杂查询要raw
迁移wrangler原生drizzle-kit后applyprisma migrate,需转换
D1 batch支持直接直接需绕回原生接口
学习成本低中低中高
适合场景小项目、快速原型中大型项目、团队协作Prisma技能栈、复杂关系
部署摩擦几乎没有低高一些

这张表是我的实际感受,不是跑分结果。体积数据会因项目而异,但相对关系很稳定。还有一个隐藏因素没放进表里:团队里如果已经有人熟练使用某个ORM,他的熟悉程度本身就是选型权重。工具最终是给人用的,一个团队里大多数成员能轻松上手的方案,长期维护成本通常更低。

4. 我的选择建议:什么时候该上ORM,什么时候别硬上

4.1 接口少、逻辑简单:直接裸SQL,别折腾

如果你的项目就是五六个接口、两三个表,上线日期还很赶,那裸SQL是最优解。手写一个简单的执行封装,几十行代码搞定,不需要安装任何额外依赖,也不需要操心版本兼容和打包规则。

我在小项目里常用这样的封装:

function query<T = unknown>(db: D1Database, sql: string, ...params: unknown[]) { const stmt = db.prepare(sql) stmt.bind(...params) return stmt.all<T>() } async function getUser(db: D1Database, id: number) { const { results } = await query(db, 'SELECT * FROM users WHERE id = ?', id) return results[0] as User | null }

这套东西虽然简陋,但足够稳定。一个Worker从0到上线,不会因为ORM版本升级而出现编译错误。等需求长出来,接口多了、表关系复杂了,再引入Drizzle也不迟。迁移成本并不高,因为在裸SQL阶段你已经把数据访问集中在一个目录里了。

4.2 多人协作、表结构频繁演进:Drizzle优先

中大型项目,尤其是两三个人以上一起改后端,schema即文档的优势就体现出来了。Drizzle的schema文件是TypeScript,字段类型、默认值、外键关系全部可被编辑器自动提示,review代码时不需要打开数据库客户端也能看懂表结构。配合drizzle-kit generate,字段变更直接变成SQL迁移文件,团队里每个人都能看到这个表改动的历史脉络。

举个例子,我们项目里有个订单表,之前裸SQL阶段加一个字段要同时改几十处SQL中的星号和映射逻辑,切到Drizzle后,改schema加字段,生成的查询类型会自动带上新字段,老代码里出现冲突的位置编译期就标红,整个改动从一晚上缩短到一个多小时。查询性能方面,Drizzle足够透明,基本不会生成你完全看不懂的复杂SQL。它默认行为同样需要你显式select字段,但这反而逼着团队在写查询时思考"这次到底要取哪些列",长期看对D1配额控制是有利的。

4.3 团队已经有Prisma经验:能用,但要先排雷

Prisma不是不能在Workers上用,特别是团队已经全员都会Prisma时,强行换Drizzle反而增加认知成本。如果决定用Prisma,请在项目一开始就建立一个检查清单:schema数据源用sqlite占位、每次变更后跑prisma generate、client初始化时必须把PrismaD1 adapter传进去、所有复杂查询统一走$queryRaw、部署前在本地用wrangler dev测一遍。

这些雷我在前面都踩过,整理成清单后就没那么可怕了。但如果你是在同一个项目里从裸SQL迁移到Prisma,我建议再想想,因为Prisma对现有SQL代码的兼容成本可能超过收益。团队里如果没有Prisma的历史包袱,从零起步我更推荐Drizzle,省下的配置时间足够把迁移脚本和监控补上。

4.4 我最喜欢的一种折中:repository + 裸SQL

说实话,我现在的主力做法既不是纯裸SQL,也不完全依赖Drizzle。我习惯按repository模式组织数据访问层,每个repo文件负责一块业务表的SQL,对外暴露类型化方法。查询复杂时内部用裸SQL,简单CRUD用Drizzle的API,sql模板用来处理动态条件。

这样一个好处是迁移路径清晰:哪天觉得Drizzle不值得,把repo内部的调用替换成env.DB.prepare就行;哪天觉得裸SQL撑不住了,repo里一半代码都可以无痛切换到Drizzle。数据访问的细节被封装在repo层,API业务代码完全不受影响。这个模式在我看来是"既不交配置税,又保留工程化下限"的最佳平衡点。

5. 部署与运维里的实战避坑:选完工具只是第一步

5.1 环境变量和binding:DATABASE_URL是个大坑

Worker里访问D1走的是binding,不是DATABASE_URL。所以你会在Prisma的配置里看到要传url,在Drizzle配置里也可能看到connectionString,这些和Workers的env.DB完全是两套机制。D1没有传统连接串可拿,你需要在代码里把env.DB显式传给ORM的适配器或工厂函数。

还有配置识别顺序的问题:worker运行时优先读binding的DB,很多ORM文档里的DATABASE_URL只是为了让本地生成client时不报错。我在CI里踩过坑,把DATABASE_URL写成线上地址,结果prisma generate在构建机上网络不通直接失败。正确做法是本地schema里给个假的file:./dev.db就行。

如果项目还要配合Docker跑一些工具,环境变量更容易混乱。我见过用Docker部署Cloudflare Tunnel做本地回调和服务调试的,cloudflared的token、D1相关的环境变量、业务API地址全塞在一个.env里,部署时漏配一项,服务能启动但请求全都失败。我的习惯是:把环境变量分成"worker运行时"和"本地开发工具"两组,wrangler dev用wrangler里的vars,Docker工具用单独的文件,绝不在一个.env里混放。

5.2 爬虫和扫描流量:会把D1配额一夜烧光

Worker部署到公网后,URL是公开的,爬虫、扫描器、恶意探测很快就会来。如果你的Worker对所有请求都不做校验直接就查D1,那免费配额很容易被打满。我遇到过一个大半夜配额被耗尽的案例,后来查日志发现全是针对/wp-admin、/.env等常见路径的扫描请求,每个都返回了数据库查询结果,资源全浪费在这上面。

建议所有D1查询入口前面都要加一层防护:至少检查请求来源和关键Header,不合法请求直接返回401或空响应,不要进数据库。稍微重要一点的接口建议加签名校验或接入Cloudflare Access。别忘了把健康检查路径也排除在外。裸SQL也好、ORM也好,在D1前面挡一刀是必须的。

5.3 本机调试与Webhook回调:Tunnel能解决接入,解决不了数据一致性

用Cloudflare Tunnel把本机服务临时暴露出来接收Webhook,在开发阶段非常方便。但很多人会忽略一个事实:本地wrangler dev连的是本地D1模拟数据,线上Worker连的是线上D1。某个回调打过来,本地处理成功,线上订单没同步,于是出现一堆"为什么本地好好的,线上就不行"的问题。

我在本地调试回调类功能时,会要求数据一致性必须是可追踪的。要么让本机工具直接连线上D1,要么在处理逻辑里加上完整的日志输出,每个关键节点打一条。这样Tunnel接入的流量到底打到哪、处理到哪一步,全都有迹可循。另外,如果你确实在Docker容器里跑cloudflared,记得把环境变量里的tunnel token和业务变量分开维护,容器重启后token失效导致的公共错误超级常见,优先检查它。

5.4 报错速查表:遇到这些现象先别慌

现象常见原因处理方式
PrismaClient initialization erroradapter未传或schema provider不对确认new PrismaClient({ adapter: new PrismaD1(env.DB) })
D1_ERROR: SQLITE_BUSY / database is locked并发写冲突用db.batch合并写入、降低并发、加简单重试
wrangler d1 migrations apply报表已存在迁移状态不同步检查d1_migrations表,删除冲突记录后重新apply
Drizzle查询返回null或字段取不到schema对象和表名不一致始终导入schema里的表对象,不要用字符串表名
$queryRaw绑定异常手动拼接SQL变量全部改用${}占位符
Tunnel连接失败或返回502本机服务未启动或端口配置不一致先确认本机监听端口和Tunnel配置完全一致

这些坑不是理论推演,都是我或者同事在真实项目里遇见并处理过的。尤其是前两行,Prisma适配器初始化失败和D1并发写冲突,几乎每个从传统数据库迁到Serverless环境的人都会遇到。遇到就按表处理,处理完再回头看一眼SQL日志,通常能直接定位到根因。

最后说点个人体会:技术选型这件事,最忌讳的是"别人都在用所以我也要用"。Cloudflare D1最吸引人的地方恰恰是简单直接,你有一个SQLite语法级别的数据库,边缘查询延迟还低,如果不用任何ORM就能高效解决,那完全没必要给自己加负。我的做法是repository层做隔离,简单场景裸SQL,复杂场景借Drizzle的sql模板,Prisma留给那些团队技能栈已经深度绑定、能接受配置摩擦的项目。选型没有标准答案,但把你自己的项目规模、团队能力和运维习惯摆到桌面上,答案其实很清楚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询