在 MkSaaS 这类多租户应用里,数据库模型最优先要解决的是“查询不串数据”和“写入不越权”。常见做法是在每个业务表上增加 tenant_id 字段,由中间件从请求中解析出当前租户,并把过滤条件自动拼接到后续数据库操作上。这样业务代码不需要在每个查询里手写租户过滤,也能在模型层形成统一约束。
多租户隔离的常规方案是行级隔离:所有业务表都带 tenant_id,中间件从 JWT、请求头或子域中解析租户 ID 并写入上下文,数据访问层自动追加过滤条件。适用场景是租户数量中等、单租户数据量可控的 SaaS 应用;操作动作是在每个表上加字段、建复合索引、写中间件和验证用例;验证方式是使用两个不同租户令牌请求同一接口,确认各自只返回自己的数据。风险边界在于:如果漏给某张表加 tenant_id,或中间件未覆盖直接 SQL,隔离就会失效,需要结合具体环境做代码审查和数据抽查。
确定租户标识的传递方式
租户 ID 可以在三个地方传递:JWT 的声明字段、自定义请求头、子域名。三者的差异主要在解析成本和前端适配成本。JWT 字段适合单页应用和移动端,因为登录后令牌里已经包含用户身份,租户 ID 可以作为其中的一个 claim;请求头适合内部服务调用或 SDK 接入,前端每次请求都要显式携带,容易遗漏;子域名更直观,但需要 DNS 通配解析、TLS 证书覆盖,并且租户 ID 要从 host 里解析,部署时多一层配置。
建议优先采用 JWT 中的自定义声明,例如 tenant_id。理由是前端登录后从令牌里读取即可,后端中间件解析 JWT 时顺手取出,不需要额外维护请求头约定,也不依赖域名配置。即使未来要更换传递方式,中间件内部只依赖一个 getTenantId(request) 函数,改动点也集中。
在业务表中添加 tenant_id 字段
给已有表加字段可以使用 ALTER TABLE,也可以使用 ORM 迁移。以通用 SQL 为例:
ALTER TABLE orders
ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 0;
CREATE INDEX idx_orders_tenant_id ON orders (tenant_id, created_at);如果是新项目,ORM 迁移文件里直接给模型加字段。索引策略不是单独索引 tenant_id,而是要结合查询条件建立复合索引。比如订单列表通常按租户加创建时间排序,那 (tenant_id, created_at) 就比单独索引 tenant_id 更实用;如果业务上还要按状态过滤,再考虑 (tenant_id, status, created_at)。默认值 0 只是迁移阶段的临时值,写入正式数据前必须确保每个已有行都分配了真实的租户 ID,否则会产生“孤儿数据”。
这里不需要给 tenant_id 建唯一约束,因为允许跨租户存在相同业务编号;如果业务要求租户内唯一,可以在 ORM 层面定义复合唯一索引,例如 unique(tenant_id, order_no)。
编写中间件自动设置当前租户
中间件的作用是解析请求中的租户 ID,写入一个线程级或请求级的上下文对象,后续数据访问层统一消费。下面是通用骨架,语言无关,重点是“解析”和“写入上下文”的时机。
// 伪代码:租户上下文中间件
async function tenantMiddleware(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
const payload = verifyJWT(token); // 实际需要结合 JWT 密钥和算法
const tenantId = payload.tenant_id;
if (!tenantId) {
return res.status(401).json({ error: 'missing tenant_id' });
}
// 写入异步上下文,保证同一个请求的所有查询都能读到
TenantContext.set({ tenantId });
next();
}数据库访问层要强制读取这个上下文。比如在 ORM 的全局查询作用域里加过滤:
// 伪代码:模型全局作用域
class Order extends Model {
static boot() {
this.addGlobalScope('tenant', (builder) => {
const tenantId = TenantContext.get().tenantId;
builder.where('tenant_id', tenantId);
});
}
}中间件放置位置要在所有业务路由之前,并且数据库连接池最好按租户 ID 做绑定或隔离,防止请求结束后上下文残留。如果使用异步框架,要确保数据库操作在同一个上下文传播范围内执行。
模拟两个租户请求验证隔离
验证隔离不需要复杂工具,直接用两个不同租户的令牌请求同一列表接口即可。假设订单列表接口是 GET /api/orders,租户 A 和租户 B 各自有自己的令牌。
# 租户 A 请求
curl -H "Authorization: Bearer TOKEN_A" https://api.example.com/api/orders
# 租户 B 请求
curl -H "Authorization: Bearer TOKEN_B" https://api.example.com/api/orders预期结果:两次响应中只包含各自租户的订单,任何一条订单的 tenant_id 都与当前令牌中的 tenant_id 一致。还可以准备一条已知订单号,在数据库里确认该订单属于租户 A,然后用租户 B 的令牌去访问详情接口,应该返回 404 或“无权限”,而不是返回订单内容。
需要额外检查的是直接 SQL 和定时任务:如果代码里存在绕过 ORM 的原始查询,中间件不会自动加过滤。建议在验证阶段给数据库开启查询日志,抓取接口请求对应的 SQL,确认每条 SELECT 都带有 where tenant_id = ?。边界情况包括接口请求不携带令牌、租户 ID 为空、以及多租户共用的公共表(如系统字典表),这些表不需要加 tenant_id,但要明确排除在全局过滤之外。