文章

三行代码让 Go 连接 Cloudflare D1快 2 到 18 倍(雾)

介绍一下我的项目,地址:github.com/intmian/gorm-d1-adapter

起因

D1 是 Cloudflare 的 serverless 关系数据库。对于我这种低频率使用的个人项目来说,几乎是免费的——免费额度每月 5 百万次读、10 万次写,个人项目根本用不完。最开始 platform 选型的是自建 mysql,但是服务器降配后带不动了,所以选了 D1。

不过 D1 也不是完美的。毕竟是跑在边缘节点上的 SQLite,延迟跟本机数据库没法比——优化来优化去也不可能降到本地查询那种几毫秒的水平。去年经常出现超过 1 秒的长查询,今年虽然好多了,但偶尔还是会来一次几小时的崩溃。所以该加缓存还是得加缓存。不过对于我这种做游戏服务端开发的人来说,缓存本来就是基操,倒也不算额外负担。

问题是官方没有提供 Go 库。Cloudflare 的 SDK 主要面向 JS/TS 生态,Go 开发者想用 D1,要么自己调 REST API,要么指望社区。

社区确实有一个——kofj 的 gorm-driver-d1。刚开始够用,但后来官方更新了 D1 API,这库就各种出问题。作者似乎已经不再维护了,issue 和 PR 堆在那里,没人理。

于是我 fork 了一份到自己的 mian_go_lib 里,哪里坏了修哪里。但修修补补终究不是办法——性能始终是块心病。单条查询轻松上百毫秒,没有真正的 batch 支持,而且老架构是直接调 REST API 的,这条路本身就决定了快不起来。

正好 Cloudflare 官方也推荐用 Worker 代理 D1 对外提供服务,于是推倒重来。完全重写,Worker 优先。

Worker Proxy 是怎么回事

先说一下为什么 REST 模式慢。Cloudflare 的 D1 REST API 走的是 api.cloudflare.com,这是 Cloudflare 的控制面(control plane)入口。每次请求都要经过公网 → Cloudflare 中心集群 → 鉴权 → 转发到 D1 所在区域 → 返回,链路长、延迟高。再加上 Go 服务要带上 Account API Token 去做鉴权,安全也是个隐患——token 一旦泄露,整个 Cloudflare 账号就裸奔了。

Worker Proxy 的思路是把"访问 D1"这件事挪到 Cloudflare 内部去做:

Go 服务 ──HTTP──▶ Worker(边缘节点)──D1 Binding──▶ D1 数据库

Worker 跑在 Cloudflare 的边缘节点上,通过 D1 Binding 直连数据库。这个 Binding 是 Cloudflare 内部的能力——Worker 代码里直接 env.DB.prepare(...) 就能操作 D1,不需要鉴权 token,不需要过公网。

Go 服务这边只跟 Worker 通信,走一个简单的 HTTP 接口。Worker 内部做两件事:校验 bearer token,然后把请求翻译成 D1 操作。

跟 REST 模式比,链路变成了:

  • REST:Go → 公网 → Cloudflare 控制面 → 鉴权 → D1 → 原路返回
  • Worker:Go → 边缘节点(Worker)→ D1 → 原路返回

少掉了"控制面鉴权"和"跨区域转发"这两段最慢的环节。而且 Worker 和 D1 通常在同一个边缘节点或邻近节点,延迟极低。这就是为什么建连能从 212ms 压到 11ms,批量操作能从 450ms 压到 72ms——不是毫秒级别的优化,是链路级别的重构。

做了什么

这个库叫 gorm-d1-adapter,支持两种模式:

Worker Proxy 模式(推荐): 在 Cloudflare 上部署一个轻量 Worker,Go 服务通过 HTTP 调用它。Worker 内部走 D1 binding 直连数据库,延迟极低。你的 Go 服务只需要一个 bearer token。

REST 模式: 直接调 Cloudflare REST API。保留作为迁移通道和回退方案,跟旧适配器兼容。

两种模式对外暴露完全相同的 GORM 和 database/sql 接口,改个配置就能切换,业务代码不用动。

另外顺带做了几件小事:Go 和 D1 之间的类型转换自动处理(布尔值、时间戳、BLOB 之类),GORM 迁移只允许建表加列这些安全操作,危险 DDL 直接拒绝。

性能

先说结论:建连快 18 倍,批量操作快 6 倍,单条 SQL 快 1.3 到 1.8 倍。

这数据不是拿本地 localhost 跑的。测试环境跟目标部署在同一个区域,同一个 D1 数据库,同一台 Docker 容器(golang:1.25),10x 基准设置。REST 和 Worker 各跑了 1100 次采样,正确性全部通过。

建连 —— 18 倍

建连是差距最大的地方。Worker 模式下的 Open + Ping 只需要 11ms——本质上是 Worker 边缘节点的一次 health check。REST 模式呢?要走 Cloudflare 控制面校验 token,一个来回 212ms

GormOpenAndPing:  REST 212.86ms  →  Worker 11.38ms  (18.71×)
StdlibOpenAndPing: REST 211.45ms  →  Worker 11.29ms  (18.73×)

服务启动或者连接池扩容的时候,这个差距感受非常明显。200ms 的建连延迟在微服务里已经够喝一壶了,降到 11ms 基本等于没有。

批量操作 —— 6 倍

这是 Worker 模式最核心的优势。Worker 的 env.DB.batch() 能把多条 SQL 语句打包成一个请求,同时保持 D1 原生的原子性——要么全成功要么全回滚。

REST 模式没有真正的 batch 能力,只能逐条发请求。一轮完整的增删改查下来,REST 450ms,Worker 72ms。差了 6.29 倍。

ExecutorBatchCRUD:       REST 450.56ms  →  Worker 71.65ms  (6.29×)
ExecutorBatchSelect:     REST 209.60ms  →  Worker 63.15ms  (3.32×)

如果只比两条查询的 batch select,REST 要 209ms(两次独立请求),Worker 只需要 63ms(一次 batch 调用)。3 倍差距,而且语句越多优势越大。

单条 SQL —— 1.3x 到 1.8x

单条操作的提升没那么夸张,但也稳定领先:

操作RESTWorker提升
GormSelectOne116.30ms64.58ms1.80×
InsertOne121.51ms70.65ms1.72×
DeleteOne132.25ms81.70ms1.62×

这些差异来自 Worker 链路上的开销更少——不需要过 Cloudflare 控制面的鉴权层,直连 D1 binding。单条操作虽然只是几十毫秒的差距,但在并发场景下积少成多。

跟老适配器比

老适配器(Legacy Adapter)没有真正的 batch 支持。拿它普通 CRUD 的 356ms/op 跟 Worker batch 的 72ms/op 比,差距大约 5 倍,但因为工作负载形状不同——一个是逐条发请求,一个是打包发——所以没放进严格对比图。公平地说,这是架构升级带来的提升,不是同一赛道的比拼。

完整数据、测试方法和复跑说明见 性能测试报告

怎么用

go get github.com/intmian/gorm-d1-adapter

创建配置,接入 GORM:

cfg := d1.Config{
    Mode:           d1.ExecutorModeWorker,
    WorkerEndpoint: "https://example.workers.dev",
    WorkerToken:    "<token>",
}

db, err := gorm.Open(gormd1.OpenConfig(cfg), &gorm.Config{})

Worker 的部署也很简单——项目里带了完整的 Worker 代码和部署说明,wrangler.toml 配一下,npm run deploy 就上去了。

最后

这个库解决的是我自己实际遇到的问题。如果你也用 Go 写后端,又想在个人项目里蹭 D1 的免费额度,可以试试。如果有 bug 或者需求,直接提 issue。

License:  CC BY 4.0