2. 先搞清楚边界:D1不是"普通数据库"
D1号称是跑在Cloudflare全球边缘网络上的SQLite数据库。但你要是把D1当成普通的PostgreSQL或者本地SQLite来用,拿MySQL那套思路往上套,很快就会被现实教育。
D1的底层确实是SQLite,但通过HTTP API暴露给开发者。每次查询都经过HTTP封装由边缘节点转发处理。这就带来几个关键约束,直接决定了ORM的选取空间。
第一个约束是延迟结构。传统数据库是长连接,一次TCP连接上可以连续发查询,连接成本被摊薄。D1是短连接,所有查询走HTTP,每次HTTP请求都有固定的网络往返成本。如果你在一个Worker函数里循环执行几十条INSERT,每条INSERT都单独走HTTP,延迟会让你怀疑人生。D1有个Batch API可以打包多条语句,但需要驱动层面支持,ORM自然也要在这个前提下做适配。
第二个约束是并发模型。D1是单节点写入,虽然写入会同步复制到多个副本保证持久性,但同一时刻只有主节点能执行写操作。并发写入多了会撞锁,报SQLITE_BUSY。ORM要是生成一堆复杂事务,把这重担全压给D1,高并发场景下事务冲突概率会明显上升。
第三个约束是计算资源的关联。D1不是独立数据库服务,它是绑定在Worker运行时里的。你用一个Worker + 一个D1绑定,数据库查询是拿Worker的CPU时间片跑的。ORM本身在JavaScript层做对象映射、查询构造、序列化反序列化,这些CPU开销全部计入你的Worker执行时间。Drizzle很轻,Prisma在运行时有一个查询引擎层,那个引擎在Worker隔离环境里跑到什么程度,一直是个值得掂量的问题。
把这些约束摆在桌面上再回头问要不要ORM,问题就变成了:在这些约束下加一层ORM,值是赚还是亏。这不是纯喜好问题,是工程权衡。
3. 两个ORM在D1上的真实表现差异
Prisma和Drizzle虽然都叫"ORM",但在D1上的工作方式完全不同。
Prisma的标准模式是通过查询引擎(query engine)来干活,引擎是一个独立的binary,和Node进程通信。传统部署环境没问题,但D1跑在Workers上,Workers是V8隔离的,没有一个独立的Node进程让你跑引擎binary。所以Prisma官方给出的方案是借助@prisma/adapter-d1,用driver adapter机制把查询引擎的请求转发到D1的HTTP API上。这条路能走通,但中间多了一层适配,生成的代码体积也偏大。你可以理解为穿了一件大衣再套雨衣,能防雨但动作变笨重了。
Prisma在D1上还有个老旧的痛点:对SQLite的方言支持不够彻底。迁移时Prisma Migrate生成的SQL在SQLite上偶尔出奇怪问题,内置某些字段类型和索引表达式的写法需要手动调整。官方迭代速度很快,文档也在不断完善,但相比传统PostgreSQL部署,还是能感觉到"二等公民"的待遇。
Drizzle的基线设计就是轻量级方案的思路。它没有运行时查询引擎,类型定义依赖生成的文件,执行路径直接映射到底层的query function。在D1上,drizzle-orm/d1这个入口直接把查询翻译成D1的binding调用。没有额外的引擎层,没有运行时映射魔法,Worker的CPU时间基本省下来了。代码体积也小——实测在一个空项目里,Drizzle相关的包体积比Prisma少了几个量级。
拿两段实践来对比会更直观。我在一个Worker项目里用Prisma查一张表,因为表结构变更先跑一下prisma migrate dev,结果在SQLite方言解析上报了语法错误,折腾了半天手动改SQL才迁移过去。换成Drizzle后,schema定义即SQL迁移,kit工具直接生成原生SQL语句,写什么是什么,没有中间解释层,反而少出了问题。
但Prisma不是没有优势。它的客户端API对复杂查询的体验依然在Drizzle之上,嵌套include、关系过滤、事务封装对开发者友好很多。特别是团队里有人不太熟SQL时,Prisma让你不写一行SQL也能把联表查询跑起来。Drizzle需要开发者有一定SQL功底,它把SQL的思维搬到TypeScript里,但不是让你偷懒,而是让你精确表达查询意图。
4. 四个决策维度:项目规模、查询复杂度、团队构成、长期维护
4.1 项目规模:小工具和大型业务的标准完全不同
如果你做的只是一个小工具,比如一个短链接跳转、一个简单的数据表单收集、一个页面浏览计数,几百行代码搞定业务逻辑,表结构不超过五六张——这时候上Prisma就是典型的杀鸡用牛刀。D1自带的prepare().bind().run()已经足够,即便想省事一点,Drizzle的schema定义方式也不会给项目增加多少复杂度。我自己做Helpers类的Worker时连Drizzle都没加,直接用D1的raw API,直到表结构复杂了才开始引入Drizzle。
反观大型业务系统,表有几十张,查询反复交错,多服务共享数据的场景频繁出现,此刻把schema和查询逻辑都交给ORM从长期看会省下大量维护成本。这种情况,我反而会认真考虑Prisma——前提是你愿意处理它在边缘环境里偶尔出现的适配问题。
4.2 查询复杂度:ORM帮你简化的是哪种复杂度
判断方式其实很接底气:你花更多时间在"描述数据关系"还是"构建具体查询"上。
关系很丰富——评论属于文章、文章属于用户、用户有标签——这种天然是ORM的主场。Prisma的嵌套include和事务机制有不可替代的优势。Drizzle也支持关系查询,但写法更接近SQL,需要你清楚知道表是怎么join的。
如果你只是做简单的CRUD,从表格读数据,过滤,写回去,再处理一下——那ORM带来的收益很有限。用裸SQL反而清晰直接,少了微妙的多余调用和隐式转换。
还有一类高动态场景,查询条件由用户动态拼装,过滤条件随上下文变化。这类场景ORM那种固定模型的方式会显得僵硬。直接拼SQL动态where反而可读性更好。D1自身支持的参数化查询也够用。我做过一个后台日志筛选工具,筛选维度是运行时动态拼的,ORM的API反而让我到处查文档,最后改写成了原生SQL。
4.3 团队构成:是人均SQL熟练还是以业务开发为主
这一点我觉得最该被重视。
团队里大家SQL功底扎实,对索引、执行计划、联表优化那套熟得很,那ORM到底引入多少收益,真得打个问号。人人都会写SQL,直接让D1 Client裸跑不会比加ORM慢,还少一层抽象。但团队以应用开发为主,很多人写SQL的水平还停留在select * from where的程度——Prisma这类自动接管关系的方案反而能防止"面条式SQL"出现在代码库。
Drizzle夹在中间。它要求你理解SQL但给你TypeScript的类型约束,降低写错列名的概率,同时保留对SQL语义的完全控制。如果一个团队SQL基础可以、但类型安全意识强,Drizzle确实是最舒服的中间方案。
4.4 长期维护:你打算在这套代码上活几年
ORM的长期价值在于:schema版本管理、类型同步、查询API的约束力。项目要活三年以上,表结构持续演进,成员来去变化大,那ORM作为"活文档"的作用才体现出来,新成员看着schema就能理解业务模型,而不必翻几十个SQL文件。
Prisma的schema文件是单一事实来源,数据模型清晰,配合迁移工具能追踪每次变更。Drizzle的schema定义是纯TypeScript文件,schema同步到SQL靠的是drizzle-kit的diff机制,同样好用。裸SQL项目长期来看最容易腐烂——SQL埋在不同文件里,缺少统一的schema快照,新人接手成本那叫一个酸爽。
另一个长期维护的隐藏问题是代码生成。Drizzle需要维护生成的类型文件,表结构变化后跑一下drizzle-kit generate,这会多一个不自动化就会落后的步骤。Prisma的client是在install时生成的,集成度更高但体积更大。两边都有维护动作,只是频率和方式不同。
5. 实战对比:同一套API用三种方式写出来的差距
5.1 场景设计
假设有用户、文章两个表,外加一对多的关系表。想实现一个接口:获取最近10篇文章,每篇文章带上作者名字和评论数,按文章创建时间倒序。这是一个非常典型的业务查询。
5.2 裸SQL + D1 Client
const { results } = await env.DB.prepare(` SELECT a.*, u.name as author_name, COUNT(c.id) as comment_count FROM articles a JOIN users u ON a.user_id = u.id LEFT JOIN comments c ON c.article_id = a.id WHERE a.status = 'published' GROUP BY a.id, u.name ORDER BY a.created_at DESC LIMIT 10 `).all();直接、快、一眼看懂SQL在干嘛。缺点:返回结果默认是kebab-case和snake_case的字符串键,要自己转成camelCase,而且results是扁平结构,得自己处理嵌套关系(如果前端需要嵌套JSON)。类型上,results是UnwrapRow类型的Object[],谈不上强类型,列名打错要运行期才能发现。
5.3 Drizzle方案
import { drizzle } from 'drizzle-orm/d1'; import { eq, and, desc, count, sql } from 'drizzle-orm'; const db = drizzle(env.DB); const rows = await db .select({ id: articles.id, title: articles.title, authorName: users.name, commentCount: count(comments.id).as('comment_count') }) .from(articles) .innerJoin(users, eq(articles.userId, users.id)) .leftJoin(comments, eq(comments.articleId, articles.id)) .where(eq(articles.status, 'published')) .groupBy(articles.id, users.name) .orderBy(desc(articles.createdAt)) .limit(10);类型从数据库查询开始一路贯穿,列名拼错编译期直接报错。SQL语义没有被遮蔽,所有join、group、order、limit都明明白白写着,只是变成TypeScript函数调用而已。写法和上面的SQL几乎一一对应。多出来的成本是:要预先定义schema文件、生成类型、table定义和数据库结构之间的同步由drizzle-kit负责。
5.4 Prisma方案
import { PrismaClient } from '@prisma/client'; import { PrismaD1 } from '@prisma/adapter-d1'; const adapter = new PrismaD1(env.DB); const prisma = new PrismaClient({ adapter }); const articles = await prisma.article.findMany({ where: { status: 'published' }, orderBy: { createdAt: 'desc' }, take: 10, include: { user: { select: { name: true } }, _count: { select: { comments: true } } } });接口层面最优雅,语义最接近业务语言。嵌套关系、计数聚合、条件过滤全部交给Prisma处理,生成的代码读起来像在读需求文档。代价是:client启动要初始化adapter,运行时查询引擎在Workers环境有额外开销,包体积比较大。而且这个场景的SQL涉及group by、join聚合,Prisma里的表示方式其实是它自己译SQL,给了你高层接口但把SQL藏了起来。
5.5 三种方案的取舍瞬间
上了对比表更直观:
| 维度 | 裸SQL + client | Drizzle | Prisma |
|---|---|---|---|
| 类型安全 | 弱,列名靠手写 | 强,编译期检查 | 强,编译期检查 |
| SQL表达力 | 完整,原生化 | 高,几乎1:1映射 | 中,被封装遮挡 |
| 查询引擎层 | 无 | 无 | 有(运行时) |
| 包体积 | 最小 | 小 | 偏大 |
| Worker CPU开销 | 最低 | 低 | 有额外开销 |
| 联表关系嵌套 | 自己拼 | 手动join | include自动处理 |
| schema维护 | 无 | drizzle-kit | prisma migrate |
| 对SQL能力要求 | 高 | 中高 | 低 |
平心而论,这三种方式各有各的舒服点。裸SQL适合绕开一切抽象直捣数据库的"硬核模式";Drizzle适合"我要SQL的控制感,又想要TS类型兜底";Prisma适合"我想拿业务语言写数据访问,不关心底层SQL"。
实测一次查询在D1上跑,三种方式的响应差异基本在一个位数的百分点内,真正拉开差距的是项目的演进周期和团队维护习惯,不是单次查询速度。
6. D1特有的那些坑:ORM也救不了你
6.1 连接数与并发写入限制
D1的并发写入限制是个容易踩的深坑。Worker是横向扩展到多个隔离的,每个隔离都可能有同名D1绑定。当一个查询要写入数据库时,这些请求同时在多个边缘节点上发起并同步到主节点。主节点同一时刻只能接受一个写事务,其他写入会报SQLITE_BUSY。
ORM会自动帮你重试吗?Prisma和Drizzle都有一定的事务重试机制,但默认配置通常不会对SQLITE_BUSY做过度激进的无限重试。因为重试太多,整个功能页面的响应时间会跟着膨胀,而且重试成功与否取决于写入什么时候释放了主节点。
实际经验:高并发写入场景优先考虑合并写入操作,批量接口一次写多条数据;或者适当限流,给D1一个喘气的节奏。这一层,用不用ORM都没有区别。
6.2 事务粒度要克制
D1是SQLite,事务也是SQLite语义:同一时间只有一个写事务活跃。你在Worker代码里开启一个事务并包住多条查询,如果冲突频繁,主写入节点会变成整个应用的热点。
ORM把事务封装成高层的prisma.$transaction()或者db.transaction(),看起来很方便,但一定要克制。保持事务短小,每个事务只放必需的读写。别把外部的HTTP调用、文件处理塞进事务里,延长事务时间等于增加和其他请求的碰撞面。
我见过一个团队把第三方接口调用放进事务里,结果第三方接口响应慢时,整个D1库都跟着堵。当场崩不崩是另一回事,但这会明显拉高整个P95延迟。ORM给你封装了事务,但没有给你使用事务的智慧——这个得靠自己的工程师来把持。
6.3 Batch API用起来,但它不是事务
D1的Batch API可以把多条prepare语句打包在一个HTTP请求里发给数据库。它减少的是网络往返次数,这是D1短连接模型下最省事的优化手段之一。Drizzle的batch操作和Prisma的batch都有对应支持,会自动复用同一个HTTP请求。
但要注意batch并不保证原子性。batch里的语句是顺序执行,不是事务包裹。中途某个语句失败,之前成功的语句不会自动回滚。如果你的业务逻辑需要"要么全部成功要么全部失败",还是得显式用事务。这是D1的文档写得很清楚、但好多人依然搞混的语义边界。
6.4 冷启动和连接复用
D1绑定在Worker冷启动时会初始化,这个初始化的开销本身就是轻微的(毕竟SQLite引擎是嵌入的,没有远程握手协议)。但如果你用Prisma的adapter,初始化PrismaClient和query engine的拖拽更大一些,冷启动压力会传到数据库访问路径上,整体感知就上来了。Drizzle没有引擎层,冷启动更轻一点,这是它在这类边缘场景的明显加分项。
如果项目很在意冷启动时间,或者函数一被调用就要快速拿数据,Drizzle比Prisma稳不少。我自己在一个访问量不低、对响应速度极度敏感的中间层服务里,就是因为这个原因把Prisma换成了Drizzle。
7. 我最终给的结论:分阶段决策,而不是二选一
先明确回答标题的问题:在Cloudflare D1上,大多数项目没有必要非此即彼地纠结Prisma还是Drizzle。小型、边缘友好、对冷启动敏感的项目可以直接裸SQL,真需要抽象时优先选Drizzle。大型、关系复杂、团队SQL能力薄弱的业务,选Prisma是合理的。Drizzle是个人觉得在D1约束下性价比最高的中间点。
具体决策我倾向于一个四象限:
| 项目类型 | 推荐方案 | 理由 |
|---|---|---|
| 小型工具/原型 | 裸SQL + D1 Client | 最少依赖,最小体积,最快上线 |
| 中型项目/短开发周期 | Drizzle | 体积小,类型安全,SQL表达力强 |
| 大型业务/复杂数据模型 | Prisma | 高层抽象、迁移成熟、适合团队协作 |
| 性能敏感/核心链路 | 裸SQL 或 Drizzle | 最小运行时开销,避免引擎拖后腿 |
如果项目还在MVP阶段,我甚至建议先裸SQL跑着,把业务逻辑验证清楚了再决定要不要抽象。加ORM这事儿,等真的疼再动也不迟。你永远不会因为一开始没用ORM而后悔,但会因为过早套ORM让项目变得臃肿而懊恼。
我自己的几个D1项目里,现在最稳定的那个服务反而是最早用Drizzle写的那套——因为后续改动时类型提示帮了大忙,减少了人在疲惫状态下改错列名的风险。而一个临时工具,我连Drizzle都没上,到现在也没出过问题,因为它压根没有复杂的关系查询。
8. 最后说点踩坑后的实在话
这类技术选型问题,最忌讳的是从"网上别人怎么说"出发做决定。Prisma教程多、社区大、用起来顺手是事实,但它在D1上毕竟是个后期适配方案,运行时引擎的额外开销和迁移方言问题是实际存在的东西。Drizzle的知名度和生态相对小一些,但底子更贴合D1这类serverless边缘数据库的形态。
另一个视角是:D1还在快速迭代中。Cloudflare团队一直在加功能(比如最近对session、更细粒度查询费用控制的支持),ORM对D1的支持也在变。所以当下的对比结论过几个月再看可能又会不一样。做选型时留一点"可替换"的空间——比如把数据库访问封装在仓储层后面,而不是业务代码里到处写死——会让以后的迁移顺畅很多。
我是这么处理这类项目的:业务核心逻辑坚决不碰任何数据库专属语句,统一走自己对外的数据接口;查询那块尽量让它在"裸SQL"和"Drizzle"之间可以平级迁移。这样哪天D1的SQLite限制真的卡住我了,把数据层一键迁移到别的数据库也不至于伤筋动骨。这才是用ORM或者不用ORM,最终真正要解决的问题。