Hono 生产实战 08:多租户 SaaS 架构设计
wxk1991 Lv5

Hono 生产实战 08:多租户 SaaS 架构设计

很多 Hono 项目一开始只是一个普通后台。

后来业务一变,就成了 SaaS:

1
2
3
一个系统
多个客户
每个客户都有自己的用户、数据、权限、套餐和配置

这时最容易犯的错是:

1
2
3
4
先把 tenant_id 随便加到几张表里
等数据串了再补权限
等客户变多再补隔离
等性能出问题再拆库

多租户不是一个字段。

它是一组贯穿请求、数据、权限、缓存、任务和审计的架构约束。

这篇文章讲 Hono 项目怎么从一开始就把多租户边界设计清楚。

技术栈快照时间:2026-06-23。文中会提到 Cloudflare Workers、D1、KV、R2 等部署形态,具体能力和限制以官方文档为准。


一、先定义租户是什么

在 SaaS 系统里,tenant 通常代表一个客户组织。

比如:

1
2
3
4
5
6
一个公司
一个团队
一个店铺
一个项目空间
一个学校
一个品牌

不要把 tenant 和 user 混在一起。

一个用户可能属于多个租户:

1
2
3
4
张三属于 A 公司
张三也属于 B 公司
张三在 A 公司是管理员
张三在 B 公司只是成员

所以基础模型通常是:

1
2
3
4
5
users
tenants
memberships
roles
permissions

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
create table tenants (
id text primary key,
slug text not null unique,
name text not null,
plan text not null,
created_at text not null
);

create table users (
id text primary key,
email text not null unique,
name text not null,
created_at text not null
);

create table memberships (
tenant_id text not null,
user_id text not null,
role text not null,
created_at text not null,
primary key (tenant_id, user_id)
);

有了 membership,系统才能表达:

1
2
3
谁属于哪个租户
在租户里是什么角色
是否有权访问当前资源

二、租户从哪里来

请求进入 Hono 后,第一件事就是识别当前 tenant。

常见方式有三种:

1
2
3
子域名:acme.example.com
路径:/t/acme/api/posts
Header:X-Tenant-ID: tenant_acme

子域名最像 SaaS:

1
2
3
acme.example.com
beta.example.com
demo.example.com

路径最容易本地开发和调试:

1
/api/tenants/acme/posts

Header 适合内部服务调用,但不建议作为浏览器端唯一来源,因为容易被伪造。

我的建议:

1
2
3
面向用户的 SaaS:优先子域名或路径
内部管理后台:可以路径
服务间调用:可以 header,但必须有服务鉴权

三、用 Hono middleware 注入 tenant

不要在每个 handler 里手动解析 tenant。

应该用 middleware 统一处理:

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
import { Hono } from 'hono'

type Variables = {
tenant: {
id: string
slug: string
plan: string
}
user: {
id: string
email: string
}
}

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

app.use('/api/*', async (c, next) => {
const host = c.req.header('host') ?? ''
const subdomain = host.split('.')[0]

const tenant = await c.env.DB.prepare(`
select id, slug, plan
from tenants
where slug = ?
`).bind(subdomain).first<Variables['tenant']>()

if (!tenant) {
return c.json({
error: {
code: 'TENANT_NOT_FOUND',
message: '租户不存在',
},
}, 404)
}

c.set('tenant', tenant)
await next()
})

后面的业务代码只从 context 里拿:

1
2
3
4
5
6
7
8
9
10
11
12
13
app.get('/api/posts', async (c) => {
const tenant = c.get('tenant')

const posts = await c.env.DB.prepare(`
select id, title, created_at
from posts
where tenant_id = ?
order by created_at desc
limit 20
`).bind(tenant.id).all()

return c.json({ data: posts.results })
})

这能减少“某个接口忘记加 tenant 条件”的概率。


四、数据隔离的三种模式

多租户数据隔离大概有三种模式。

第一种:共享数据库,共享表,用 tenant_id 区分。

1
2
3
4
posts
id
tenant_id
title

优点:

1
2
3
4
开发简单
成本低
查询和迁移方便
适合早期 SaaS

缺点:

1
2
3
每个查询都必须带 tenant_id
大客户可能影响小客户
隔离级别相对低

第二种:共享数据库,按租户拆 schema 或逻辑空间。

这种在 Postgres 里更常见。

优点:

1
2
隔离更强
部分迁移可以按租户做

缺点:

1
2
3
迁移复杂
连接和权限管理复杂
跨租户统计更麻烦

第三种:每个租户独立数据库或独立存储。

例如高价值客户一个独立数据库。

优点:

1
2
3
隔离最好
单租户备份恢复更方便
大客户性能影响更可控

缺点:

1
2
3
4
运维复杂
迁移复杂
成本更高
全局查询更难

对大多数早期 Hono SaaS,我建议先用:

1
共享数据库 + tenant_id + 严格查询封装

等客户规模、合规要求、性能压力真的上来,再考虑拆分。


五、Repository 层必须强制 tenant

多租户最危险的 bug 是串数据。

比如:

1
2
3
await db.prepare(`
select * from posts where id = ?
`).bind(postId).first()

这个查询在单租户系统里没问题。

但在多租户系统里很危险。

正确写法应该是:

1
2
3
4
await db.prepare(`
select * from posts
where id = ? and tenant_id = ?
`).bind(postId, tenantId).first()

更好的方式是封装 repository:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function createPostRepository(db: D1Database, tenantId: string) {
return {
async findById(id: string) {
return db.prepare(`
select id, title, content, created_at
from posts
where id = ? and tenant_id = ?
`).bind(id, tenantId).first()
},

async list() {
return db.prepare(`
select id, title, created_at
from posts
where tenant_id = ?
order by created_at desc
limit 50
`).bind(tenantId).all()
},
}
}

handler 里这样用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.get('/api/posts/:id', async (c) => {
const tenant = c.get('tenant')
const posts = createPostRepository(c.env.DB, tenant.id)
const post = await posts.findById(c.req.param('id'))

if (!post) {
return c.json({
error: {
code: 'POST_NOT_FOUND',
message: '文章不存在',
},
}, 404)
}

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

核心是让业务代码默认带着 tenant 运行。

不要把 tenant 当成“调用方记得传”的普通参数。


六、权限不是只有管理员和成员

早期可以只有:

1
2
3
owner
admin
member

但稍微复杂一点,就需要权限点。

例如:

1
2
3
4
5
6
7
8
post:read
post:create
post:update
post:delete
billing:read
billing:update
member:invite
member:remove

可以写一个权限中间件:

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 type { Context, Next } from 'hono'

type AppContext = Context<{
Bindings: Env
Variables: Variables
}>

function requirePermission(permission: string) {
return async (c: AppContext, next: Next) => {
const tenant = c.get('tenant')
const user = c.get('user')

const allowed = await hasPermission({
db: c.env.DB,
tenantId: tenant.id,
userId: user.id,
permission,
})

if (!allowed) {
return c.json({
error: {
code: 'PERMISSION_DENIED',
message: '没有权限执行该操作',
},
}, 403)
}

await next()
}
}

路由上使用:

1
2
3
4
5
6
7
app.post(
'/api/posts',
requirePermission('post:create'),
async (c) => {
// create post
}
)

权限检查必须同时包含:

1
2
3
tenantId
userId
permission

不能只看用户是不是 admin。

因为用户在不同租户里的角色可能不同。


七、缓存也要带 tenant

多租户系统里的缓存 key 必须包含 tenant。

错误示例:

1
const key = `posts:${postId}`

正确示例:

1
const key = `tenant:${tenantId}:posts:${postId}`

如果使用 KV:

1
2
3
const cached = await c.env.CACHE_KV.get(
`tenant:${tenant.id}:dashboard`
)

如果使用 HTTP cache,也要确保 cache key 不会跨租户复用。

对带身份的接口,默认不要公共缓存:

1
c.header('Cache-Control', 'private, max-age=60')

公开资源可以缓存,但仍然要看是否租户隔离:

1
2
/assets/public/logo.png
/tenant/acme/public/logo.png

缓存串租户,比数据库串租户更隐蔽。

因为你查数据库时条件可能是对的,但返回的是别人的缓存。


八、后台任务也要带 tenant

队列消息里也要带 tenant。

比如导出报表:

1
2
3
4
5
6
await c.env.REPORT_QUEUE.send({
jobId,
tenantId: tenant.id,
userId: user.id,
type: 'report.export',
})

消费者处理时,必须用 tenantId 限定查询范围:

1
2
3
4
5
const job = await env.DB.prepare(`
select *
from jobs
where id = ? and tenant_id = ?
`).bind(message.jobId, message.tenantId).first()

R2 对象 key 也要带 tenant:

1
const objectKey = `tenants/${tenantId}/reports/${jobId}.csv`

不要让后台任务绕过 API 里的多租户约束。

很多串数据事故不是发生在用户点击按钮时,而是发生在后台批处理里。


九、套餐和配额

SaaS 一定会遇到套餐差异。

比如:

1
2
3
4
5
免费版最多 3 个成员
专业版最多 20 个项目
企业版允许 SSO
不同套餐有不同 API 速率
不同套餐有不同存储空间

不要把套餐判断散落在业务代码里:

1
2
3
if (tenant.plan === 'free') {
// ...
}

更推荐封装成能力判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
async function assertQuota(input: {
tenantId: string
feature: string
amount: number
env: Env
}) {
const quota = await getQuota(input.tenantId, input.feature, input.env)
const used = await getUsage(input.tenantId, input.feature, input.env)

if (used + input.amount > quota.limit) {
throw new AppError('QUOTA_EXCEEDED', '当前套餐额度不足')
}
}

使用时:

1
2
3
4
5
6
await assertQuota({
tenantId: tenant.id,
feature: 'project',
amount: 1,
env: c.env,
})

这样以后套餐变化,不需要改一堆业务 handler。


十、审计日志

多租户 SaaS 一定要有审计日志。

至少记录:

1
2
3
4
5
6
7

在哪个租户
什么时间
做了什么
影响了哪个资源
请求来源是什么
结果是成功还是失败

表结构可以这样开始:

1
2
3
4
5
6
7
8
9
10
11
12
create table audit_logs (
id text primary key,
tenant_id text not null,
actor_user_id text not null,
action text not null,
resource_type text not null,
resource_id text,
ip text,
user_agent text,
result text not null,
created_at text not null
);

写日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
await c.env.DB.prepare(`
insert into audit_logs (
id, tenant_id, actor_user_id, action,
resource_type, resource_id, ip, user_agent, result, created_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
crypto.randomUUID(),
tenant.id,
user.id,
'post.delete',
'post',
postId,
c.req.header('cf-connecting-ip') ?? '',
c.req.header('user-agent') ?? '',
'succeeded',
new Date().toISOString()
).run()

审计日志不是只有企业客户才需要。

当你排查“谁删了这条数据”时,它会救命。


十一、迁移到更强隔离

早期用共享表并不丢人。

但要给未来留出口。

至少做到:

1
2
3
4
5
6
所有业务表都有 tenant_id
所有对象存储 key 都带 tenant 前缀
所有缓存 key 都带 tenant 前缀
所有任务都带 tenantId
所有审计日志都带 tenant_id
repository 层统一注入 tenant

这样以后要迁移到:

1
2
3
4
大客户独立数据库
大客户独立 R2 prefix 或 bucket
大客户独立 Worker route
大客户独立部署

会容易很多。

如果一开始 tenant 边界到处缺失,后面拆分会非常痛。

多租户架构的关键不是一开始就做得很重。

关键是一开始就不要把边界做乱。


十二、推荐落地清单

做 Hono 多租户 SaaS,我建议按这个顺序:

1
2
3
4
5
6
7
8
9
1. 明确定义 tenant、user、membership
2. 决定 tenant 来源:子域名、路径或 header
3. 用 middleware 注入 tenant
4. 所有业务表加 tenant_id
5. repository 层强制 tenant 查询
6. 权限检查同时绑定 tenantId 和 userId
7. 缓存 key、R2 key、队列消息全部带 tenant
8. 加套餐、配额和审计日志
9. 为高价值客户预留独立隔离方案

Hono 本身很轻。

这既是优点,也是提醒。

它不会替你自动做多租户隔离,也不会替你防止串数据。

但只要你把 tenant 作为请求上下文的第一等公民,Hono 可以非常干净地承载一个 SaaS API。

真正的多租户能力,不是“表里有 tenant_id”。

而是系统的每一层都知道:

1
2
3
4
5
当前请求属于哪个租户
当前数据属于哪个租户
当前权限属于哪个租户
当前缓存属于哪个租户
当前任务属于哪个租户

这条线清楚了,SaaS 才稳。