租户标识与路由:如何识别和管理租户上下文

聊多租户系统,第一个绕不开的问题就是:当前请求到底属于哪个租户?

你想想看,如果连这个都搞不清楚,后面的权限隔离、数据分片、审计日志全都会乱套。我早期做过一个ERP项目,租户ID设计得很随意,结果线上出了好几次数据串门事故——后来改起来,简直是一场噩梦。

租户身份标识的设计

识别租户,说白了就是给每个请求打上一个“租户标签”。我个人习惯把这套机制叫做租户上下文(Tenant Context)。它的核心是怎么生成、怎么传递、怎么验证。

核心原则:租户标识必须全局唯一、不可篡改、且能在整个请求链路中稳定传递。

常见的做法有以下几种:

  • 域名/子域名隔离:每个租户用独立域名,比如 tenant1.example.com。这种方式最直观,但DNS管理和SSL证书配置会比较繁琐。
  • URL路径前缀:在URL中嵌入租户ID,比如 /api/tenant123/orders。我个人觉得这是中小系统性价比最高的方案。
  • 请求头(Header):通过自定义Header传递,比如 X-Tenant-ID: abc123。这种方式对RESTful API非常友好。
  • JWT/嵌入:把租户信息放在的claims里。我在实现OAuth2.0认证时,就会把租户ID写进access_。
方案类型 优点 缺点 适用场景
域名隔离 天然隔离,安全性高 证书/运维成本高 大型SaaS,对安全敏感
URL路径 实现简单,调试方便 URL冗余,RESTful不够优雅 中小型系统,快速迭代
请求头 不影响URL结构 浏览器环境需额外处理 API网关模式,微服务架构
JWT嵌入 自带签名,防篡改 JWT过期策略需谨慎 身份认证与租户绑定场景

特别注意:不要单纯依赖客户端传来的租户ID做授权判断。我在审计过的项目中,遇到过有人伪造Header的情况。服务端必须连同认证信息一起校验。

上下文传递与路由策略

租户ID拿到手了,怎么在整个请求链路里传递?我记得有一次,一个同事把租户ID塞进了全局静态变量里,结果高并发下数据全乱了。嗯,这里要注意,多线程环境下的上下文传递,必须使用线程局部变量或协程上下文。

我推荐的做法是:在API网关或Filter层解析租户标识,然后存入一个可继承的上下文对象中。这样后面的Service、DAO层都能拿到。

// Java示例:使用ThreadLocal管理租户上下文
public class TenantContext {
    private static final ThreadLocal<String> TENANT_ID_HOLDER = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        TENANT_ID_HOLDER.set(tenantId);
    }

    public static String getTenantId() {
        return TENANT_ID_HOLDER.get();
    }

    public static void clear() {
        TENANT_ID_HOLDER.remove();
    }
}

// Filter中拦截
public class TenantFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
        try {
            String tenantId = extractTenantId(request);
            TenantContext.setTenantId(tenantId);
            chain.doFilter(request, response);
        } finally {
            TenantContext.clear(); // 一定要清理,避免内存泄漏
        }
    }
}

路由策略其实就两种思路:

  • 静态路由:租户和数据源的关系是写死的。适合租户数量少、变化不频繁的场景。
  • 动态路由:根据租户ID实时查找对应的数据源配置。适合大规模SaaS平台。

我做过一个金融SaaS,租户数超过5000,用的就是动态路由。当时我们维护了一张租户-数据库映射表,每次请求都去查。后来发现这个表查得太频繁成了瓶颈,就加了一层本地缓存,问题才解决。

我的经验:动态路由的映射表不要只放内存,至少要做成“内存缓存 + 数据库兜底”的双层结构。缓存失效时,降级到数据库查询,并异步刷新缓存。

租户上下文路由流程 租户上下文路由流程 用户请求 API网关解析租户标识 提取 X-Tenant-ID 或 JWT Claim 存入 TenantContext 路由决策:静态 or 动态? 静态映射表 配置死,适合小规模 动态映射表 缓存+数据库兜底 执行数据访问 & 审计

实战中的常见陷阱

做了这么多年多租户,我总结了几条容易踩的坑:

陷阱一:上下文泄漏——我最怕团队里有人忘记在请求结束后清理ThreadLocal。一旦发生,下一个请求就可能用到上一个租户的ID。我曾经排查过一个非常诡异的bug,就是这个问题导致的。

避坑指南:使用try-finally保证清理逻辑一定执行。有条件的可以用Spring的RequestContextHolder或MDC(Mapped Diagnostic Context)这类框架封装好的机制。

另一个常见问题是租户ID的校验没有做到底。网关层校验了,但内部RPC调用时又丢了上下文。结果下游服务拿到的是一个空值或者默认值。

我习惯的做法是:每个服务入口都强制进行租户上下文的校验。如果发现租户ID为空,直接抛401,别让它继续往下走。你想想看,数据泄露往往就是从这种“小疏忽”开始的。

// 在Service层二次校验
public void processOrder(Order order) {
    String tenantId = TenantContext.getTenantId();
    if (StringUtils.isBlank(tenantId)) {
        throw new SecurityException("缺少租户上下文,请求已被拒绝");
    }
    // 继续业务逻辑...
}

还有一点,登录态的租户和请求中的租户必须强制一致性。如果用户A登录的是租户1,但请求带了租户2的ID,这显然有问题。我在审计日志的预检逻辑里加了一层交叉校验,这个后面讲审计设计时会细说。