Hono + Drizzle ORM + PostgreSQL:生产 API 的数据库设计实践
wxk1991 Lv5

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
DATABASE_URL=postgres://app_user:[email protected]:5432/hono_app

目录:

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 实例,每个实例都有自己的连接池。总连接数是:

1
实例数 * 每个实例 pool max

如果部署在 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);

复合索引的列顺序很关键。

一般来说:

1
2
等值筛选列放前面
范围和排序列放后面

不要只靠 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 承担真正的数据能力。


参考资料