做后台管理系统最头疼的,往往不是某个功能写不出来,而是“同一套系统,突然要多家公司一起用”。我在一次项目重构里,重新落地了这套多租户后台管理,核心是围绕一个内部代号叫 XinServer 的 Node.js 服务端中间件,把租户识别、数据隔离、权限下发和前端适配全都串了起来。这篇记录就把整个设计和落地过程梳理一遍,包括为什么选共享库而非独立库、租户上下文怎么传、动态数据源怎么接、以及上线之后最容易踩的几个坑。适合正在做 SaaS 化改造、或者准备把单体后台升级成多租户架构的全栈工程师参考。
1. 整体设计:多租户后台管理的核心矛盾
1.1 业务背景与选型动因
当时手里的项目是公司内部的一套业务运营后台,原本按“单租户”设计,所有用户、订单、配置项都直接放在同一套表结构里。随着外部客户陆续接入,诉求很明确:每个客户登录系统后,只能看到自己的业务数据,只能管理自己的用户和菜单,客户与客户之间完全隔离。表面来看,只要在每张业务表里加一个 tenant_id 字段,查询时带上条件就能解决,但真正落地时,问题远比想象中复杂。
第一个问题是权限体系的重构。原来后台的权限基于角色,角色本身是全局唯一的。变成多租户以后,角色必须变成“租户内角色”,同一名称的角色在不同租户下面,允许拥有完全不同的权限集合。比如租户 A 的“管理员”可以导出数据,租户 B 的“管理员”就不能,这种差异化要求直接推翻了原有的全局角色表设计。第二个问题是数据隔离的强制性。虽然所有租户的数据可以存放在同一套表里,但任何一次查询、更新、删除操作,都必须自动带上租户条件,不能依赖开发人员手动写where tenant_id = xxx,因为人一定会忘,而且代码评审也不可能覆盖每一条 SQL。第三个问题是前端适配。不同租户对菜单、按钮、首页配置可能都有不同的要求,不能为了某一个客户去拉一条前端分支,必须让前端具备动态渲染能力。
这些问题的共同点在于:租户信息不像普通业务参数那样是一次性的,而是贯穿整个请求生命周期。从请求进来、鉴权、调用业务逻辑、查询数据库、写缓存、返回响应,任何一个环节丢失了租户上下文,数据就可能串到别的租户那里。这也是我选择用 XinServer 重新整理架构的最主要原因——它提供了统一的请求上下文容器和中间件编排能力,能够把租户识别这类横切逻辑集中处理,而不是散落在各个业务模块里。
1.2 XinServer 在架构中的定位
先说明一下,XinServer 并不是某个公开的第三方框架,而是我们团队内部维护的一套 Node.js 服务端基础设施。最初它只是把路由注册、请求上下文、参数校验、统一错误处理这些重复代码抽了出来,做成一个可复用的内部库。做到第二版以后,中间件编排能力越来越强,多租户相关的逻辑就顺势沉淀了进去,最终成为这次改造的底座。
在整体架构里,XinServer 承担了三层职责。第一层是请求入口统一处理,所有到达后台的请求,先经过租户识别中间件,解析当前用户归属的租户,把租户 ID 写入请求上下文;第二层是数据访问层自动增强,提供统一的 Dao 基类和查询构造器,所有业务查询在底层自动追加租户过滤条件,业务代码里完全不用再手写 tenant_id;第三层是权限与菜单分发,在登录阶段根据租户上下文加载对应的权限模板,返回给前端去动态生成路由和按钮。
这层封装带来的直接好处,是业务代码的“去租户化”。团队里的开发同事可以不理解多租户细节,他们只需要按照原有的单租户习惯写业务逻辑,底层会保证数据隔离。这种开发体验非常重要,尤其是项目里前后端并行推进时,团队认知负担能被大幅降低。坏处当然也有,比如框架抽象增加了一定的学习成本,刚开始大家不太清楚为什么自己的查询会自动带上租户条件,但只要把文档和代码示例补齐,这个成本很快就能消化掉。
1.3 租户隔离方案取舍:共享库还是独立库
多租户最常见的隔离方案有三种,我按照落地维度做了对比:
| 方案 | 隔离性 | 成本 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|
| 独立数据库 | 最强 | 最高 | 高 | 大型客户、强合规场景 |
| 独立 Schema | 较强 | 中等 | 中 | 中型客户,数据可分类 |
| 共享库共享表 | 逻辑隔离 | 最低 | 低 | 中小客户,海量长尾 |
当时我们的实际情况是客户规模刚刚起步,单个客户的数据量不算大,平均在几十万行量级,独立数据库的硬件和维护成本完全覆盖不了收益。独立 Schema 方案虽然数据隔离性比共享表好,但当时我们用的是单实例 PostgreSQL,Schema 的维护和迁移工具链还不成熟,也没有采用。最终选定了共享库共享表,通过 tenant_id 做逻辑隔离。
选择共享表还有三个很实际的考量。第一,共享表配合合理的联合索引,在目前的量级下性能完全够用,不需要为每个租户单独建 Schema 或者建库;第二,平台运营人员需要在一个页面上跨租户查看数据,共享表天然支持这种场景,如果拆成独立库,反而要做联邦查询;第三,备份恢复特别简单,一个数据库实例全部搞定,日常巡检也省心。不过要特别强调,共享表方案对代码纪律要求极高,这就是为什么必须依靠 XinServer 这类框架来做强制过滤,而不是靠团队自觉。
2. 核心链路拆解:从登录到数据下发
2.1 租户上下文如何生成与传递
多租户后台管理的第一个关键节点,是租户身份从“未知”到“已知”。我把它拆成两个阶段:登录前和登录后。登录前的请求,比如获取验证码、登录接口本身,不依赖具体的租户上下文,或者只需要先通过账号找到租户。登录成功后,系统会签发一个带租户标记的会话凭证,通常是 JWT 里加一个 tenantId 声明。这个凭证同时也会存一份到 Redis,后续做会话失效控制。
登录后的所有请求,前端通过 Authorization 头把凭证带回来。XinServer 的租户识别中间件会解析凭证,校验有效性,然后把 tenantId 放进一个基于请求粒度的上下文对象里。为什么强调“请求粒度”?因为 Node.js 是单线程事件循环模型,如果直接把租户 ID 挂到一个全局变量上,并发请求之间一定会互相污染。这里我用了原生 AsyncLocalStorage 来管理上下文,把每个请求隔离在独立的异步栈空间里,后续所有异步操作只要不脱离该存储上下文,都能正确取到租户 ID。
这个环节最容易犯错的地方,是中间件的执行顺序。租户识别必须放在鉴权之后、业务处理之前,而且它本身要做的事情必须足够轻。最开始我把数据库连接池的状态检查也塞进了租户识别流程,导致每个请求都多一次甚至多次 IoC 查询,整体链路被拖慢不少。后来把连接检查挪到了数据访问层懒加载,租户识别中间件只做解析和赋值,性能才稳定下来。
2.2 数据模型中的租户字段设计
共享表方案下,每张业务表默认都要包含 tenant_id 字段,并且这个字段必须进联合索引。以订单表为例,标配索引是(tenant_id, created_at),查询列表时既能过滤租户,又能按时间排序。如果某个查询场景是同时过滤租户和状态,那就建(tenant_id, status, created_at)联合索引。不要只给 tenant_id 单列建索引,因为绝大多数业务查询都是组合条件,单列索引容易造成回表,性能差距在数据量上来之后非常明显。
tenant_id 的类型选择也有讲究。用整型作为租户 ID,在数据库比较和索引大小方面都有优势,缺点是分布式环境下生成租户号稍微麻烦。用字符串 UUID,扩展性好,但查询性能和存储成本略差。我们这次租户数量不会超过几十万个级别,直接用自增整数 ID 就够。如果以后要对外提供开放注册,再在租户表增加一个公开的 tenant_code 字符串字段,作为 API 层面的标识。
除了业务表,多租户后台里还有两类特殊表。一类是租户-成员关联表,解决用户与租户的多对多关系,一个用户可以同时属于多个租户,但在一次会话里只能激活一个租户,切换租户需要重新加载权限。另一类是租户-配置表,存放每个租户的自定义内容,包括菜单开关、Logo、页面主题色,这些字段前端在登录后一次性拉取并缓存,避免每个页面都去查配置表。
2.3 前后端联调中的租户标识约定
说个实际情况,很多多租户项目挂掉不是因为后端隔离没做好,而是前端把租户标识搞丢了。最常见的是用户登录后直接刷新页面,前端状态管理器里的租户信息还在,但异步请求发出时,因为某个请求没有走到统一封装的请求实例,租户标识就没有被带上。所以我要求前端把所有需要鉴权的 HTTP 请求,统一走一个封装好的 request 模块,在 Axios 拦截器里从当前租户 Store 取出 tenantId,加到请求头或者请求参数里。
这里有一个约定必须拉齐:前端传租户标识,后端不信任前端传的租户标识。最终租户归属以登录签发的会话凭证为准,请求头里的租户标识,后端会拿去与会话中的租户做一致性校验,如果不一致,直接拒绝。这样即使某个租户的用户手动篡改了请求头,也无法访问其他租户的数据。
后端返回给前端的数据中,如果涉及列表、详情这类资源,可以不需要再包含 tenantId,因为用户已经被隔离在当前租户里。但如果是操作审计、跨租户管理这类场景,响应里会显式带上租户名称或租户编码,方便平台运营人员辨认数据归属。前端也要在响应拦截器里处理一种异常:当后端返回“租户标识不一致”时,不能只是简单弹个错误,而是要引导用户确认是否已经切换了租户,必要时触发重新登录。
3. 实操实现:关键模块编码实录
3.1 租户识别中间件的实现
这里给一个简化版的 XinServer 中间件示例,去掉一些工程化细节,保留核心脉络。
// tenant-resolve.middleware.js const { AsyncLocalStorage } = require('async_hooks'); const tenantContext = new AsyncLocalStorage(); async function tenantResolver(ctx, next) { const token = ctx.headers.authorization?.replace('Bearer ', ''); const session = await ctx.authService.verify(token); if (!session) { ctx.status = 401; ctx.body = { code: 'UNAUTHORIZED', message: '未登录或会话过期' }; return; } // 会话里必须包含租户归属 if (!session.tenantId) { ctx.status = 403; ctx.body = { code: 'TENANT_REQUIRED', message: '会话缺少租户信息' }; return; } // 请求头里的租户标识只做一致性校验,不做落库依据 const requestTenantId = ctx.headers['x-tenant-id']; if (requestTenantId && String(requestTenantId) !== String(session.tenantId)) { ctx.status = 403; ctx.body = { code: 'TENANT_MISMATCH', message: '租户标识不一致' }; return; } const state = { tenantId: session.tenantId, userId: session.userId, traceId: ctx.traceId, }; await tenantContext.run(state, () => next()); } // 数据访问层内部通过该方法获取当前租户 function getTenantId() { const store = tenantContext.getStore(); return store?.tenantId; }这里最关键的一行是tenantContext.run(state, () => next())。它确保从中间件到业务代码再到数据库查询的整个过程,都处于同一个异步上下文中。后续如果业务代码里发起了 Promise 并发任务,只要这些任务是在 run 的回调里创建的,就能在各自请求的上下文里取到正确的 tenantId。这个机制在后面排查串号问题时会被反复提到,它也是整个 XinServer 多租户能力的基础。
3.2 动态数据源与连接隔离
因为采用的是共享库方案,严格来说不需要为每个租户建立独立连接池。但实际运行一段时间后,我发现每个租户的业务量并不均衡,QPS 高的租户会占用大量数据库连接,影响其他租户的响应。于是 XinServer 的数据访问层在设计上做了连接池按租户分组,不同租户的连接请求进入不同队列,即使某个租户出现慢查询,也只拖垮自己的队列,不会蔓延到全局。
实现上并不复杂,维护一个 Map,key 是租户 ID,value 是该租户的连接池对象。取连接时先从 Map 拿,拿不到就按统一数据库配置创建。但要注意,连接池对象不能被无限制创建,租户数量大时会造成数据库连接数暴涨。我当时设置了两个限制:一个是最空闲连接回收时间,比如 10 分钟内没有请求就销毁;另一个是每个租户最大连接数,默认给到 5,后台可以针对重点租户单独调高。
查询时,Dao 基类会自动做三件事:拼接租户条件、绑定参数、带上租户级打点日志。看一个简单例子:
// order.dao.js class OrderDao extends BaseDao { tableName = 'orders'; listByStatus(status, page, size) { return this.queryBuilder() .where({ tenant_id: getTenantId(), status }) .orderBy('created_at', 'desc') .limit(size) .offset((page - 1) * size); } }业务代码里完全没有显式出现租户条件,因为 queryBuilder 底层做了自动注入。这里我踩过一个坑:自动注入只在“主表”上生效,如果不小心 join 了其他业务表,join 表的过滤条件不会自动补全。比如查询订单详情时 join 了客户表,客户表如果不带 tenant_id 条件,就会出现订单属于租户 A、客户资料却是租户 B 的严重问题。所以 Dao 层增强时,必须对 join 子句做同样处理,或者强制要求所有多表查询都走统一入口,不允许业务代码里拼原生 SQL。
3.3 前端菜单与权限的租户适配
前端在多租户架构里的核心改造点,是路由和菜单必须由接口动态下发,不能写死在代码里。登录成功后,前端拿着会话凭证请求一个“当前用户完整权限资源”的接口,服务端根据租户 ID 加载该租户启用的菜单和按钮权限,组装成树形结构返回。前端拿到以后,用动态路由的方式注册到 Vue Router,再根据同样的树形结构渲染成侧边栏菜单。
这里有一个权限粒度的选择问题。菜单级权限适合用来控制整体框架,按钮级权限用来控制页面内的增删改查。有些后台系统把所有按钮都做成权限点,结果配置过程极其繁琐,业务方也嫌麻烦。我们的做法是只把高频的、有控制需要的按钮列入权限点,比如导入、导出、删除、审批,普通的查询和保存不做权限控制,避免过度设计。
前端租户适配还有两个容易被忽略的点。一个是多租户切换能力。用户可能在右上角下拉切换到另一个租户,切换动作要同时做四件事:更新当前租户状态、重新拉取菜单权限、清空旧租户的本地缓存、跳转到新租户的默认首页。另一个是页面标题、Logo 和主题色的动态化。这些配置字段从租户配置表下发,前端维护一套变量映射,避免每个租户走后端定制页面的老路。
4. 多租户场景下的常见问题与排查技巧
4.1 租户数据串号的典型案例
数据串号是多租户系统最严重的事故。我在测试阶段遇到过一个问题:租户 A 的管理员创建一个客服账号,登录后居然能看到租户 B 的工单列表。当时第一反应是查询条件没拼上,但翻代码发现工单查询方法里明明写了 where tenant_id,最后定位到问题竟然出现在 create 操作上。
原因出在创建工单时,接口读取了请求参数里的 tenantId,而会话中的 tenantId 被完全忽略了。某个调用方前端在切换租户后,本地 Store 还残留旧的租户标识,接口直接把旧 tenantId 传到了后端,后端又信任了参数,数据就落到了错误的租户下面。从那以后,我定了一条铁律:后端所有写操作的租户归属,一律以会话为准,请求参数里的租户标识只允许作为一致性校验存在,不允许作为落库依据。
排查这类问题有个实用心得:在 Dao 层统一打印“租户条件命中行数”日志。也就是 SQL 执行后,主动记录这次查询匹配了多少行。如果发现一个本应只查当前租户的接口,结果返回行数暴涨,十有八九是租户条件丢失。这种日志在正常排查中价值极大,我到现在都一直保留着。
4.2 异步任务中的租户上下文丢失
第一次把发送邮件的功能改成异步队列时,遇到了一个诡异现象:用户点击“发送验证码”,接口返回成功,但日志里显示的收件人却是另一个人的邮箱。复核下来发现,异步任务在队列消费时,拿不到创建请求时的租户上下文,兜底逻辑错误地从某个公共配置里取了默认用户,导致邮件发错了地方。
这个问题在 Node.js 里尤其隐蔽,因为普通全局变量不区分请求,AsyncLocalStorage 也只能解决“请求链路内”的传递。一旦遇到没有显式传入上下文的定时任务、消息队列消费、事件监听器,租户信息就会断掉。我的应对方式分成两层:一是所有异步任务入队时,把关键上下文对象快照成 payload 字段,消费端读取 payload 来恢复租户上下文;二是提供一个runWithTenant(tenantId, task)工具方法,任何脱离请求链路的任务,都要显式包裹这个工具方法,否则变量缺失时直接报错,不允许静默使用默认值。
这里也想提醒一句:异步链路里,宁可让任务失败并进入重试队列,也不要让任务在错误的租户上下文里“成功”执行。数据一旦写错,修复成本远比重试成本高,尤其是账单、订单这类强归属数据。
4.3 缓存键与连接池的隔离陷阱
多租户系统里,缓存键如果不带租户维度,会出现严重的越权缓存。比如租户 A 修改了一条配置,租户 B 刷新页面,因为缓存键完全一样,读到了租户 A 的数据。解决思路很直接:所有缓存键的前缀必须包含 tenantId。不要抱着“这个配置本身是全局的”这种侥幸心理,等租户个性化需求出现时再改键,迁移成本极高。
连接池隔离也是同样的道理。很多 ORM 框架默认只有一个连接池,如果只是简单切换数据库连接对象,而没有真正隔离,慢查询依然会互相影响。我当时的做法是每个租户的连接池写成独立对象,空闲连接可回收,最大连接数可配置,并对超时查询做动态采样。这样可以保证某个租户一条查询把连接占满时,其他租户的请求不会跟着一起阻塞。
| 症状 | 大概率原因 | 第一排查动作 |
|---|---|---|
| 列表出现其他租户数据 | 查询条件丢失或 join 表未过滤 | 查 Dao 层租户命中行数日志 |
| 创建数据归属错误租户 | 接口信任了请求参数中的租户标识 | 检查写操作是否统一使用会话租户 ID |
| 异步任务处理了错误数据 | 异步上下文未携带租户快照 | 查看队列 payload 是否包含 tenantId |
| 缓存命中其他租户配置 | 缓存键未包含租户维度 | 查看缓存读写键的拼接规则 |
| 某租户慢查询拖垮全站 | 连接池未隔离 | 检查是否按租户分组连接池 |
5. 上线前后的性能、成本与运维考量
5.1 数据库连接数估算与全局水位
共享库方案下,数据库连接数是最容易出问题的瓶颈。假设线上有 100 个租户,每个租户连接池上限 5,理论最大连接数就是 500,这个数字已经接近单台数据库的合理上限。所以我并没有把连接数配置成“租户数量乘以最大连接数”,而是设置全局连接池总上限,租户分组只是分配优先级的逻辑队列,底层连接数量受全局水位控制。
给一个估算公式参考:单库稳定连接数 = 最大并发请求数 × 平均单请求查询次数 × 0.3 的冗余系数,再与数据库自身限制取最小值。如果单库连接数超过 500,就应该考虑读写分离或者分库。当时线上租户约 80 个,日活请求峰值 1200 QPS,数据库 max_connections 设为 300,每个租户连接池上限 3,配合全局队列,跑了一个季度没有出现连接打满的情况。
如果未来租户数量继续涨,Shared Schema 加连接池分组的模式还能再撑一段,但需要引入更细的数据分片策略。到那个阶段,优先考虑的是把高频租户单独迁到独立 Schema,而不是整体改造成独立库,迁移成本和运行风险都更可控。
5.2 平台运营后台与超级管理员视角
多租户后台除了面向租户自身的管理界面,还需要一个面向平台运营方的租户管理后台。这个后台本身不受单个租户上下文约束,平台运营人员需要能查看所有租户的列表、状态、用量,甚至代表某个租户进行配置。这种“超级管理视角”和普通租户视角最大的区别,是绕过租户过滤逻辑时,必须有显式的系统级账号标记。
我实现时给账号体系增加了一个 scope 字段,取值是 platform 或 tenant。平台管理员请求时,租户识别中间件会跳过数据层的自动过滤,并把操作记录单独标记。这个能力一定要谨慎开放,只有极少数账号可以拥有,而且每一步操作都写入审计日志,避免运营误操作波及所有租户。
5.3 灰度发布与存量数据迁移策略
这次多租户改造不是在绿地上从零开发,而是从存量单租户系统迁移过来。我的迁移顺序很明确:先加租户字段,让旧数据归属到一个默认租户;再上线租户识别中间件,但暂时不做强制过滤,只做旁路日志,观察有没有出现无租户上下文的请求;然后逐步开启强制隔离,最后进行权限体系切换。
这个顺序看着慢,但非常稳。尤其是“旁路观察”那一步,能提前发现很多调用方没有经过统一入口的问题。例如当时发现有几个定时任务还在用直连数据库,没有走 XinServer 数据访问层,旁路日志里直接暴露出来。如果一上来就强制隔离,这些任务会在同一个瞬间大面积失败,后果完全不可控。
6. 最后叮嘱:一些个人体会
做完整套多租户改造,我最深的感受是:多租户不是加一个字段就能解决的问题,它是一整套贯穿请求、数据、缓存的纪律。XinServer 在其中扮演的是一个纪律执行者,把容易遗漏的租户条件、容易污染的上下文、容易越权的缓存统一收编到框架层,让开发人员写业务时不用每时每刻想着“我是哪个租户”。
如果让我重新来一遍,我会在项目启动第一天就把“租户上下文快照”这个能力做好,而不是等到排查异步任务时才想起来。审计日志和旁路开关也应该前置,这两个东西是线上排查事故最重要的抓手。希望这篇记录能给同样在做 SaaS 化改造或者后台多租户化的朋友一些参考,少踩几个我踩过的坑。