租户标识与路由:如何识别和管理租户上下文
聊多租户系统,第一个绕不开的问题就是:当前请求到底属于哪个租户?
你想想看,如果连这个都搞不清楚,后面的权限隔离、数据分片、审计日志全都会乱套。我早期做过一个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,用的就是动态路由。当时我们维护了一张租户-数据库映射表,每次请求都去查。后来发现这个表查得太频繁成了瓶颈,就加了一层本地缓存,问题才解决。
我的经验:动态路由的映射表不要只放内存,至少要做成“内存缓存 + 数据库兜底”的双层结构。缓存失效时,降级到数据库查询,并异步刷新缓存。
实战中的常见陷阱
做了这么多年多租户,我总结了几条容易踩的坑:
陷阱一:上下文泄漏——我最怕团队里有人忘记在请求结束后清理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,这显然有问题。我在审计日志的预检逻辑里加了一层交叉校验,这个后面讲审计设计时会细说。