Hono + Drizzle ORM + PostgreSQL:生产 API 的数据库设计实践
Hono + Drizzle ORM + PostgreSQL:生产 API 的数据库设计实践
PostgreSQL 是我最愿意给生产后端长期使用的数据库之一。
它不只是“能存数据”。
它有很强的 SQL 能力、索引能力、JSONB、事务、约束、扩展、全文搜索、并发控制和生态。
如果你的 Hono 项目未来会变复杂:
1 2 3 4 5
| 业务表越来越多 查询条件越来越复杂 权限越来越细 报表和统计越来越多 数据一致性越来越重要
|
PostgreSQL 通常比 SQLite 和 D1 更适合长期承载核心业务。
Hono + Drizzle + PostgreSQL 的组合,既能保留 Hono 的轻量,也能利用 PostgreSQL 的成熟能力。
一、适合 PostgreSQL 的场景
适合:
1 2 3 4 5 6 7
| 生产业务系统 复杂筛选查询 多表关系 事务一致性 权限和审计 JSONB 和全文搜索 未来可能增长的数据量
|
不适合:
1 2 3
| 只想做一个零运维小工具 极轻量边缘 API 完全不想维护数据库服务
|
如果你只是一个小型 Cloudflare Workers 项目,D1 可能更简单。
如果你是核心业务后端,PostgreSQL 更稳。
二、安装依赖
1 2
| pnpm add hono drizzle-orm pg pnpm add -D drizzle-kit typescript tsx @types/node @types/pg @hono/node-server
|
环境变量:
目录:
1 2 3 4 5 6 7 8 9 10
| src/ db/ schema.ts index.ts repositories/ post-repository.ts routes/ posts.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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid, varchar, } from 'drizzle-orm/pg-core'
export const users = pgTable( 'users', { id: uuid('id').primaryKey().defaultRandom(), email: varchar('email', { length: 255 }).notNull(), name: varchar('name', { length: 100 }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ emailIdx: uniqueIndex('users_email_idx').on(table.email), }) )
export const posts = pgTable( 'posts', { id: uuid('id').primaryKey().defaultRandom(), authorId: uuid('author_id').notNull().references(() => users.id), title: varchar('title', { length: 200 }).notNull(), body: text('body').notNull(), published: boolean('published').notNull().default(false), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ authorCreatedIdx: index('posts_author_created_idx').on( table.authorId, table.createdAt ), publishedCreatedIdx: index('posts_published_created_idx').on( table.published, table.createdAt ), }) )
|
几个建议:
1 2 3 4
| 主键可以用 uuid 时间字段建议 timestamptz 唯一约束从 schema 一开始就写 常用 WHERE / JOIN / ORDER BY 字段提前考虑索引
|
PostgreSQL 很强,但你不给索引,它也不会自动变快。
四、配置 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: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL!, }, } satisfies Config
|
生成迁移:
1
| pnpm exec drizzle-kit generate
|
执行迁移:
1
| pnpm exec drizzle-kit migrate
|
生产环境建议:
1 2 3 4
| 迁移 SQL 必须 review 大表加索引要评估锁和耗时 高风险迁移先在 staging 跑 保留回滚方案
|
不要把数据库迁移当成普通代码改动。
五、创建连接池
写 src/db/index.ts:
1 2 3 4 5 6 7 8 9 10 11 12
| import { drizzle } from 'drizzle-orm/node-postgres' import { Pool } from 'pg' import * as schema from './schema'
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: Number(process.env.PG_POOL_MAX ?? 10), idleTimeoutMillis: 30_000, connectionTimeoutMillis: 5_000, })
export const db = drizzle(pool, { schema })
|
PostgreSQL 连接是有成本的。
不要每个请求创建连接。
也不要把 pool 开得特别大。
如果你有多个 Node.js 实例,每个实例都有自己的连接池。总连接数是:
如果部署在 Serverless 或连接数压力大,应该考虑连接池服务,比如 PgBouncer,或者云厂商提供的 pooler。
六、Repository 示例
写 src/repositories/post-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
| import { and, desc, eq, lt } from 'drizzle-orm' import { db } from '../db' import { posts, users } from '../db/schema'
export const postRepository = { async listPublished(params: { cursor?: Date; limit: number }) { const conditions = [eq(posts.published, true)]
if (params.cursor) { conditions.push(lt(posts.createdAt, params.cursor)) }
return db .select({ id: posts.id, title: posts.title, createdAt: posts.createdAt, author: { id: users.id, name: users.name, }, }) .from(posts) .innerJoin(users, eq(posts.authorId, users.id)) .where(and(...conditions)) .orderBy(desc(posts.createdAt)) .limit(params.limit) },
async create(input: { authorId: string title: string body: string }) { const result = await db .insert(posts) .values(input) .returning({ id: posts.id, title: posts.title, createdAt: posts.createdAt, })
return result[0] }, }
|
这里用 cursor pagination,而不是深 OFFSET。
数据量小时 OFFSET 没问题,但越到后面越慢。PostgreSQL 生产项目里,列表页最好尽早考虑 cursor。
七、Hono 路由
写 src/routes/posts.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
| import { Hono } from 'hono' import { z } from 'zod' import { zValidator } from '@hono/zod-validator' import { postRepository } from '../repositories/post-repository'
const route = new Hono()
const createPostSchema = z.object({ authorId: z.string().uuid(), title: z.string().min(1).max(200), body: z.string().min(1), })
route.get('/', async (c) => { const cursor = c.req.query('cursor')
const items = await postRepository.listPublished({ cursor: cursor ? new Date(cursor) : undefined, limit: 20, })
const nextCursor = items.at(-1)?.createdAt?.toISOString()
return c.json({ data: { items, nextCursor, }, }) })
route.post( '/', zValidator('json', createPostSchema), async (c) => { const input = c.req.valid('json') const post = await postRepository.create(input)
return c.json({ data: post }, 201) } )
export default route
|
这里 route 只做三件事:
1 2 3
| 读请求参数 调用 repository/service 返回 JSON
|
数据库查询细节不要散在 route 里。
八、索引设计要跟查询走
这条非常重要。
如果你的查询是:
1 2 3 4 5
| SELECT * FROM posts WHERE published = true ORDER BY created_at DESC LIMIT 20;
|
那你应该考虑:
1 2
| CREATE INDEX posts_published_created_idx ON posts (published, created_at);
|
如果你的查询是:
1 2 3 4
| SELECT * FROM posts WHERE author_id = $1 ORDER BY created_at DESC;
|
那你应该考虑:
1 2
| CREATE INDEX posts_author_created_idx ON posts (author_id, created_at);
|
复合索引的列顺序很关键。
一般来说:
不要只靠 ORM 生成查询,然后完全不看 SQL 和执行计划。
九、连接池和部署
如果 Hono 跑在普通 Node.js 服务:
1
| Node.js Hono API -> pg Pool -> PostgreSQL
|
这是最直接的结构。
如果 Hono 跑在 Serverless:
1 2 3
| Serverless 函数数量可能突然增加 每个实例都建连接池 数据库连接容易被打爆
|
这时要考虑:
- PgBouncer
- 云厂商连接池
- Neon / Supabase 这类 serverless-friendly 连接方式
- Cloudflare Hyperdrive
不要把传统长连接思维直接搬到所有 Serverless 环境。
PostgreSQL 很稳,但连接数不是无限的。
十、常见生产坑
第一,没有索引。
小数据量时看不出来,数据一多直接慢。
第二,深 OFFSET。
后台第 1 页很快,第 10000 页越来越慢。
第三,N+1 查询。
列表 20 条数据,然后循环查 20 次作者。应该 JOIN 或批量查询。
第四,事务过长。
事务里做外部 HTTP 请求、发邮件、等第三方回调,都可能让锁持有时间变长。
第五,连接池太大。
看起来提高并发,实际把数据库连接打满。
十一、最后的建议
Hono + Drizzle + PostgreSQL 很适合长期生产项目。
我的建议是:
1 2 3 4 5 6 7
| 连接池要保守 查询字段要有索引 列表优先 cursor pagination 复杂查询要看 SQL 和 EXPLAIN 事务尽量短 repository 隔离 Drizzle 迁移 SQL 必须 review
|
PostgreSQL 强,不代表可以随便用。
Drizzle 轻,也不代表可以不懂 SQL。
这套组合最舒服的状态是:Hono 保持 HTTP 层干净,Drizzle 保持类型安全,PostgreSQL 承担真正的数据能力。
参考资料