Hono + Drizzle ORM + Cloudflare D1:边缘 API 数据库实践
wxk1991 Lv5

Hono + Drizzle ORM + Cloudflare D1:边缘 API 数据库实践

Hono 和 D1 是很自然的一组搭配。

Hono 负责轻量 API。

D1 负责结构化数据。

Drizzle ORM 负责让 SQL schema、查询和 TypeScript 类型保持一致。

这套组合适合:

1
2
3
4
5
6
7
个人项目
小型 SaaS
轻量后台
博客评论系统
Webhook 数据记录
边缘 API
Cloudflare 全家桶项目

但也要先说清楚:D1 不是“无限大的云数据库”。它是 Cloudflare 托管的 serverless SQL 数据库,语义接近 SQLite,非常适合中小型业务和边缘应用。高频写入、大型复杂事务、重型分析查询,不一定是它最舒服的场景。

技术栈快照时间:2026-06-23。D1、Wrangler、Drizzle D1 适配层更新较快,实际项目以 Cloudflare 和 Drizzle 官方文档为准。


一、项目结构

推荐结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
hono-d1-drizzle/
├── migrations/
├── src/
│ ├── db/
│ │ ├── schema.ts
│ │ └── index.ts
│ ├── repositories/
│ │ └── todo-repository.ts
│ ├── routes/
│ │ └── todos.ts
│ └── index.ts
├── drizzle.config.ts
├── package.json
└── wrangler.jsonc

不要把所有东西都写在 src/index.ts

Hono 很轻,但项目一复杂,还是要拆:

1
2
3
4
schema        数据库表结构
repository 数据查询
route HTTP 输入输出
index 应用入口和中间件

二、安装依赖

1
2
pnpm add hono drizzle-orm
pnpm add -D drizzle-kit wrangler typescript

创建 D1 数据库:

1
pnpm exec wrangler d1 create hono_orm_demo

创建后,Wrangler 会输出 database_id,写入 wrangler.jsonc

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "hono-orm-demo",
"main": "src/index.ts",
"compatibility_date": "2026-06-23",
"d1_databases": [
{
"binding": "DB",
"database_name": "hono_orm_demo",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"migrations_dir": "migrations"
}
]
}

Hono 里给 binding 加类型:

1
2
3
type Bindings = {
DB: D1Database
}

后面就能通过 c.env.DB 访问 D1。


三、定义 Drizzle schema

src/db/schema.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'

export const todos = sqliteTable('todos', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
})

export type Todo = typeof todos.$inferSelect
export type NewTodo = typeof todos.$inferInsert

D1 用 SQLite 语义,所以 Drizzle 这里使用 sqlite-core

有一个小细节:SQLite 没有传统意义上的 boolean 类型,通常用 integer 存 0/1。Drizzle 的 { mode: 'boolean' } 可以让 TypeScript 层更舒服。


四、配置 Drizzle Kit

drizzle.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import type { Config } from 'drizzle-kit'

export default {
schema: './src/db/schema.ts',
out: './migrations',
dialect: 'sqlite',
driver: 'd1-http',
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.D1_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
} satisfies Config

生成迁移:

1
pnpm exec drizzle-kit generate

应用迁移:

1
2
pnpm exec wrangler d1 migrations apply hono_orm_demo --local
pnpm exec wrangler d1 migrations apply hono_orm_demo --remote

我更喜欢让 Drizzle 负责生成 SQL,让 Wrangler 负责应用到 D1。

这样你能保留 D1 自己的 migration tracking,也能保持 schema 来源清楚。


五、创建 db 实例

src/db/index.ts

1
2
3
4
5
6
7
8
import { drizzle } from 'drizzle-orm/d1'
import * as schema from './schema'

export function createDb(d1: D1Database) {
return drizzle(d1, { schema })
}

export type Db = ReturnType<typeof createDb>

不要在模块顶层创建 db。

因为 D1 binding 来自每次请求的 c.env.DB,你应该在请求上下文里创建:

1
const db = createDb(c.env.DB)

六、Repository 层

src/repositories/todo-repository.ts

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import { desc, eq } from 'drizzle-orm'
import type { Db } from '../db'
import { todos } from '../db/schema'

export function createTodoRepository(db: Db) {
return {
async list() {
return db
.select()
.from(todos)
.orderBy(desc(todos.id))
.limit(100)
},

async create(title: string) {
const result = await db
.insert(todos)
.values({ title })
.returning()

return result[0]
},

async toggle(id: number) {
const row = await db
.select()
.from(todos)
.where(eq(todos.id, id))
.limit(1)

const todo = row[0]
if (!todo) return null

const result = await db
.update(todos)
.set({
completed: !todo.completed,
})
.where(eq(todos.id, id))
.returning()

return result[0]
},

async remove(id: number) {
await db.delete(todos).where(eq(todos.id, id))
},
}
}

route 不应该知道 Drizzle 查询细节。

Drizzle 待在 repository 层,Hono 待在 route 层。边界清楚,后面会舒服很多。


七、Hono 路由

src/routes/todos.ts

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import { createDb } from '../db'
import { createTodoRepository } from '../repositories/todo-repository'

type Bindings = {
DB: D1Database
}

const createTodoSchema = z.object({
title: z.string().min(1).max(100),
})

const todosRoute = new Hono<{ Bindings: Bindings }>()

todosRoute.get('/', async (c) => {
const db = createDb(c.env.DB)
const repo = createTodoRepository(db)
const items = await repo.list()

return c.json({
data: {
items,
},
})
})

todosRoute.post(
'/',
zValidator('json', createTodoSchema),
async (c) => {
const input = c.req.valid('json')
const db = createDb(c.env.DB)
const repo = createTodoRepository(db)
const todo = await repo.create(input.title)

return c.json({ data: todo }, 201)
}
)

todosRoute.patch('/:id/toggle', async (c) => {
const id = Number(c.req.param('id'))

if (!Number.isInteger(id)) {
return c.json({
error: {
code: 'INVALID_ID',
message: '无效的 Todo ID',
},
}, 400)
}

const db = createDb(c.env.DB)
const repo = createTodoRepository(db)
const todo = await repo.toggle(id)

if (!todo) {
return c.json({
error: {
code: 'TODO_NOT_FOUND',
message: 'Todo 不存在',
},
}, 404)
}

return c.json({ data: todo })
})

todosRoute.delete('/:id', async (c) => {
const id = Number(c.req.param('id'))
const db = createDb(c.env.DB)
const repo = createTodoRepository(db)

await repo.remove(id)

return c.body(null, 204)
})

export default todosRoute

主入口 src/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Hono } from 'hono'
import todosRoute from './routes/todos'

type Bindings = {
DB: D1Database
}

const app = new Hono<{ Bindings: Bindings }>()

app.get('/api/health', (c) => c.json({ ok: true }))
app.route('/api/todos', todosRoute)

export default app

八、D1 的几个关键注意点

第一,D1 适合中小型结构化数据。

它很适合 Todo、用户配置、评论、轻量订单、后台表单、Webhook 记录。不要一开始就拿它承载超高写入、复杂分析和大型事务。

第二,分页不要无限 OFFSET。

小数据量 OFFSET 没问题,但数据变大后建议改 cursor pagination:

1
2
3
4
SELECT * FROM todos
WHERE id < ?
ORDER BY id DESC
LIMIT 20;

第三,索引要跟查询走。

如果你常查:

1
2
WHERE user_id = ? AND completed = ?
ORDER BY created_at DESC

就应该考虑组合索引:

1
2
CREATE INDEX idx_todos_user_completed_created
ON todos(user_id, completed, created_at);

第四,避免 N+1 查询。

能 JOIN 就 JOIN。D1 也是 SQL 数据库,不要在循环里一条条查。


九、什么时候不要用这套

Hono + Drizzle + D1 不适合所有项目。

不建议的场景:

1
2
3
4
5
大型复杂事务系统
强依赖传统数据库连接池
大量长时间分析查询
超高频写入
需要 PostgreSQL 特有能力

这些场景更适合 PostgreSQL / MySQL,或者 Cloudflare Hyperdrive 连接外部数据库。

D1 的优势是轻、近、少运维。

不要把它当成传统数据库的完全替代品。


十、最后的建议

如果你的 Hono 项目部署在 Cloudflare Workers,并且数据库规模不大:

1
Drizzle + D1 是非常值得优先考虑的组合。

项目结构上保持:

1
Hono route -> service/repository -> Drizzle -> D1

迁移上保持:

1
schema.ts -> drizzle-kit generate -> wrangler d1 migrations apply

生产上记住:

1
2
3
4
5
限制结果集
加索引
避免 N+1
做好备份和迁移
不要把 D1 当无限数据库

这样写,Hono API 会很轻,数据库代码也不会失控。


参考资料