Hono + Drizzle ORM + MySQL:传统业务 API 的数据库实践
wxk1991 Lv5

Hono + Drizzle ORM + MySQL:传统业务 API 的数据库实践

MySQL 仍然是很多业务系统的默认数据库。

如果你的 Hono 项目是:

1
2
3
4
5
6
管理后台
订单系统
用户系统
传统 CRUD 服务
已有 MySQL 基础设施
团队熟悉 MySQL 运维

那 Hono + Drizzle + MySQL 是一套很务实的组合。

Hono 负责 HTTP API。

Drizzle 负责类型安全查询和 schema 管理。

MySQL 负责成熟稳定的关系型存储。

这篇文章讲一个适合生产项目的基础写法,而不是只跑通一个 demo。


一、适合 MySQL 的场景

MySQL 适合:

1
2
3
4
5
6
传统业务数据
中小型后台系统
读多写中等的 API
需要成熟运维体系
已有 MySQL 实例
团队熟悉索引和备份

不太适合:

1
2
3
4
想完全免运维
边缘运行时直接连接数据库
大量复杂分析查询
强依赖 PostgreSQL 特有能力

如果你部署在 Cloudflare Workers,不建议 Worker 直接裸连 MySQL。可以考虑 Cloudflare Hyperdrive 或通过后端服务中转。

如果你部署在 Node.js,MySQL 就非常自然。


二、安装依赖

1
2
pnpm add hono drizzle-orm mysql2
pnpm add -D drizzle-kit typescript tsx @types/node @hono/node-server

环境变量:

1
DATABASE_URL=mysql://app_user:[email protected]:3306/hono_app

目录:

1
2
3
4
5
6
7
8
9
10
src/
db/
schema.ts
index.ts
repositories/
user-repository.ts
routes/
users.ts
index.ts
drizzle.config.ts

三、定义 schema

src/db/schema.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
import {
boolean,
index,
int,
mysqlTable,
timestamp,
uniqueIndex,
varchar,
} from 'drizzle-orm/mysql-core'

export const users = mysqlTable(
'users',
{
id: int('id').primaryKey().autoincrement(),
email: varchar('email', { length: 255 }).notNull(),
name: varchar('name', { length: 100 }).notNull(),
active: boolean('active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
},
(table) => ({
emailIdx: uniqueIndex('users_email_idx').on(table.email),
activeCreatedIdx: index('users_active_created_idx').on(
table.active,
table.createdAt
),
})
)

export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert

几个点:

1
2
3
4
email 要唯一索引
常用筛选字段要建索引
created_at / updated_at 一开始就加
不要把 password_hash 返回给前端

MySQL 里字段长度要认真写。

比如 varchar(255)varchar(100),不要所有字符串都随手 text。


四、配置 Drizzle Kit

drizzle.config.ts

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

export default {
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'mysql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
} satisfies Config

生成迁移:

1
pnpm exec drizzle-kit generate

执行迁移:

1
pnpm exec drizzle-kit migrate

生产环境不要直接随手 push schema。

更稳妥的流程是:

1
2
3
4
5
本地改 schema
生成 migration
review SQL
测试环境执行
生产环境执行

数据库迁移是生产操作,不是普通代码格式化。


五、创建连接池

src/db/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { drizzle } from 'drizzle-orm/mysql2'
import mysql from 'mysql2/promise'
import * as schema from './schema'

const pool = mysql.createPool({
uri: process.env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
})

export const db = drizzle(pool, {
schema,
mode: 'default',
})

不要每个请求都创建连接。

Node.js 服务里,数据库连接池应该是进程级对象。

但也不要无限开连接。connectionLimit 要根据数据库配置、实例大小和应用副本数一起算。

例如你开了 5 个 API 实例,每个实例连接池 20,那理论上就是 100 个应用连接。数据库能不能承受,要提前算。


六、Repository 层

src/repositories/user-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 { db } from '../db'
import { users } from '../db/schema'

export const userRepository = {
async list(params: { active?: boolean; limit: number }) {
const fields = {
id: users.id,
email: users.email,
name: users.name,
active: users.active,
createdAt: users.createdAt,
}

if (params.active !== undefined) {
return db
.select(fields)
.from(users)
.where(eq(users.active, params.active))
.orderBy(desc(users.id))
.limit(params.limit)
}

return db
.select(fields)
.from(users)
.orderBy(desc(users.id))
.limit(params.limit)
},

async findByEmail(email: string) {
const rows = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1)

return rows[0] ?? null
},

async create(input: { email: string; name: string }) {
const result = await db.insert(users).values(input)

return {
id: Number(result[0].insertId),
...input,
}
},
}

注意:不同数据库 driver 的 insert 返回不完全一样。

MySQL 常见是拿 insertId,PostgreSQL 可以用 returning()。不要把不同数据库的行为混为一谈。


七、Hono 路由

src/routes/users.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
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import { userRepository } from '../repositories/user-repository'

const route = new Hono()

const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
})

route.get('/', async (c) => {
const active = c.req.query('active')

const items = await userRepository.list({
active: active === undefined ? undefined : active === 'true',
limit: 100,
})

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

route.post(
'/',
zValidator('json', createUserSchema),
async (c) => {
const input = c.req.valid('json')
const exists = await userRepository.findByEmail(input.email)

if (exists) {
return c.json({
error: {
code: 'EMAIL_EXISTS',
message: '邮箱已存在',
},
}, 409)
}

const user = await userRepository.create(input)

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

export default route

主入口:

1
2
3
4
5
6
7
8
9
10
11
12
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import usersRoute from './routes/users'

const app = new Hono()

app.route('/api/users', usersRoute)

serve({
fetch: app.fetch,
port: Number(process.env.PORT ?? 3000),
})

八、MySQL 生产注意点

第一,连接池。

每个 Node.js 进程一个 pool,不要每个请求创建连接,也不要 pool 开太大。

第二,索引。

常用查询字段要建索引:

1
2
CREATE UNIQUE INDEX users_email_idx ON users(email);
CREATE INDEX users_active_created_idx ON users(active, created_at);

第三,分页。

浅分页可以 OFFSET:

1
LIMIT 20 OFFSET 40

深分页建议 cursor:

1
2
3
WHERE id < ?
ORDER BY id DESC
LIMIT 20

第四,事务。

涉及多表写入时,事务放在 service 层,不要散落在 route 里。

第五,错误处理。

唯一索引冲突、外键错误、死锁重试,都应该转成稳定的业务错误,不要把 MySQL 原始错误丢给前端。


九、Drizzle 还是 Prisma

Hono + MySQL 里,Drizzle 和 Prisma 都能用。

我会这样选:

1
2
3
想轻量、贴近 SQL、兼容 Serverless 思路 -> Drizzle
团队熟悉 Prisma、重视 Studio 和成熟 DX -> Prisma
复杂 SQL 多、想强控制查询 -> Kysely

如果你是传统管理后台,团队已经会 Prisma,用 Prisma 没问题。

如果你是 Hono 新项目,我仍然会先看 Drizzle。


十、最后的建议

Hono + Drizzle + MySQL 是很务实的生产组合。

关键不是代码能不能跑,而是这些点有没有做好:

1
2
3
4
5
6
7
连接池合理
迁移流程可 review
索引跟着查询设计
分页避免深 OFFSET
事务放 service 层
ORM 不进入 route 层
数据库错误统一转换

MySQL 很成熟,但成熟不代表不用设计。

ORM 让代码更舒服,但不会自动让数据库更健康。


参考资料