Hono + Drizzle ORM + MySQL:传统业务 API 的数据库实践
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 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:
深分页建议 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 让代码更舒服,但不会自动让数据库更健康。
参考资料