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
路径最容易本地开发和调试:
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 3 隔离最好 单租户备份恢复更方便 大客户性能影响更可控
缺点:
对大多数早期 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 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) => { } )
权限检查必须同时包含:
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 才稳。