ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Cloudflare D1 数据库选型:裸SQL、Drizzle与Prisma

Cloudflare D1 数据库选型:裸SQL、Drizzle与Prisma 最近把一个内部 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环境里没有连接池救N1查询的命。传统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这边就简单很多安装依赖定义schemadrizzle-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变更都需要重新generateCI里别忘了这个步骤。再看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 queryT unknown(db: D1Database, sql: string, ...params: unknown[]) { const stmt db.prepare(sql) stmt.bind(...params) return stmt.allT() } 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的APIsql模板用来处理动态条件。这样一个好处是迁移路径清晰哪天觉得Drizzle不值得把repo内部的调用替换成env.DB.prepare就行哪天觉得裸SQL撑不住了repo里一半代码都可以无痛切换到Drizzle。数据访问的细节被封装在repo层API业务代码完全不受影响。这个模式在我看来是既不交配置税又保留工程化下限的最佳平衡点。5. 部署与运维里的实战避坑选完工具只是第一步5.1 环境变量和bindingDATABASE_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里的varsDocker工具用单独的文件绝不在一个.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表删除冲突记录后重新applyDrizzle查询返回null或字段取不到schema对象和表名不一致始终导入schema里的表对象不要用字符串表名$queryRaw绑定异常手动拼接SQL变量全部改用${}占位符Tunnel连接失败或返回502本机服务未启动或端口配置不一致先确认本机监听端口和Tunnel配置完全一致这些坑不是理论推演都是我或者同事在真实项目里遇见并处理过的。尤其是前两行Prisma适配器初始化失败和D1并发写冲突几乎每个从传统数据库迁到Serverless环境的人都会遇到。遇到就按表处理处理完再回头看一眼SQL日志通常能直接定位到根因。最后说点个人体会技术选型这件事最忌讳的是别人都在用所以我也要用。Cloudflare D1最吸引人的地方恰恰是简单直接你有一个SQLite语法级别的数据库边缘查询延迟还低如果不用任何ORM就能高效解决那完全没必要给自己加负。我的做法是repository层做隔离简单场景裸SQL复杂场景借Drizzle的sql模板Prisma留给那些团队技能栈已经深度绑定、能接受配置摩擦的项目。选型没有标准答案但把你自己的项目规模、团队能力和运维习惯摆到桌面上答案其实很清楚。
返回列表