ElIauk

多租户架构设计入门

隔离模型的决定因素是成本与合规 多租户的第一道选择题不是技术,而是“每个租户的数据放在哪里”。三种主流模型各有明确的代价: 模型 隔离强度 成本 适用 每租户独立库 最强,故障与备份完全隔离 最高,几百个租户就是几百套库 大客户、金融、有合规要求 每租户独立 schema 较强,共享实例 中等,连接

评论
多租户架构设计入门

隔离模型的决定因素是成本与合规

多租户的第一道选择题不是技术,而是“每个租户的数据放在哪里”。三种主流模型各有明确的代价:

模型 隔离强度 成本 适用
每租户独立库 最强,故障与备份完全隔离 最高,几百个租户就是几百套库 大客户、金融、有合规要求
每租户独立 schema 较强,共享实例 中等,连接数与迁移管理变复杂 中等规模 SaaS
共享表 + tenant_id 最弱,靠代码保证 最低,扩容简单 大量中小租户

真实系统通常是混合的:头部客户独立库,长尾客户共享表。混合不是妥协,而是把隔离成本花在真正在意隔离的人身上。

选共享表就意味着一件事:任何一条 SQL 漏掉 tenant_id,都是跨租户数据泄露。所以后面的所有设计都围绕“如何让漏写在编译或测试阶段暴露”。

租户上下文从哪来,到哪去

tenant_id 绝对不能直接信任前端传的请求头。正确链路是:前端只传用户令牌,网关从令牌声明里取出租户,剥离客户端自带的 X-Tenant-Id,再重新注入。如果用户可以在多个租户间切换,切换时还要校验这个用户确实属于目标租户。

服务内部用线程上下文保存当前租户,用完必须清理,否则线程池复用会把上一个请求的租户带到下一个请求——这是多租户系统里最隐蔽、后果最严重的一类 Bug。

public final class TenantContext {

    private static final ThreadLocal<Long> HOLDER = new ThreadLocal<>();

    private TenantContext() {
    }

    public static void set(Long tenantId) {
        HOLDER.set(Objects.requireNonNull(tenantId, "tenantId 不能为空"));
    }

    public static Long get() {
        return HOLDER.get();
    }

    public static Long require() {
        Long tenantId = HOLDER.get();
        if (tenantId == null) {
            throw new IllegalStateException("当前线程没有租户上下文");
        }
        return tenantId;
    }

    public static void clear() {
        HOLDER.remove();
    }
}
public class TenantFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String header = request.getHeader("X-Tenant-Id");
        if (header == null || header.isBlank()) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "缺少租户标识");
            return;
        }
        TenantContext.set(Long.valueOf(header));
        try {
            chain.doFilter(request, response);
        } finally {
            TenantContext.clear();
        }
    }
}

finally 里的 clear() 不是可选项。Filter 是最后一道保险,业务代码里再怎么写都可能漏,这一层不能漏。

用拦截器把租户条件写进 SQL

手写 where tenant_id = ? 的结局一定是漏。可行的做法是让持久层框架自动加条件,MyBatis-Plus 的 TenantLineInnerInterceptor 就是干这个的:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
        @Override
        public Expression getTenantId() {
            return new LongValue(TenantContext.require());
        }

        @Override
        public String getTenantIdColumn() {
            return "tenant_id";
        }

        @Override
        public boolean ignoreTable(String tableName) {
            return GLOBAL_TABLES.contains(tableName);
        }
    }));
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    return interceptor;
}

全局表(字典、国家、币种、系统配置)必须列进白名单,否则会被加上不存在的 tenant_id 列而报错。白名单要显式维护,不要用表名前缀模糊匹配,那等于给未来埋雷。

缓存、异步与消息的租户归属

数据库加了条件,缓存没加,等于白做。所有缓存 key 都必须带租户前缀,tenant:{id}:order:{orderId} 这种格式比 order:{orderId} 安全一个数量级。

异步和消息是第二大漏洞来源:线程池里的任务拿不到请求线程的 ThreadLocal,定时任务默认没有租户概念,MQ 消费端如果消息体没带 tenantId,消费时只能猜。线程池要加 TaskDecorator 传递上下文:

public class TenantTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        Long tenantId = TenantContext.get();
        return () -> {
            if (tenantId != null) {
                TenantContext.set(tenantId);
            }
            try {
                runnable.run();
            } finally {
                TenantContext.clear();
            }
        };
    }
}

MQ 消息体里带 tenantId 是硬要求,消费端必须在处理前显式设置上下文,处理完清掉。定时任务则要遍历租户列表,逐个设置上下文执行,并在每个租户之间清理,避免串用。

开通与退出租户

租户的开通要有幂等:同一个租户重复提交不能建出两套库或两份初始数据。退出租户比开通更难:数据要保留一段时间(合规要求),期间允许恢复;到期后做逻辑删除,再异步清理物理数据,并且清理任务本身要能续跑和审计。

租户级的限流和配额也要提前设计。一个租户的批量导入把共享数据库打满,会拖垮所有租户,这是共享模型最大的风险,靠“事后道歉”解决不了。

常见坑

信任客户端传的租户头。 改一下请求头就能看到别家数据,这是多租户系统最严重也最常见的漏洞。

唯一索引不带 tenant_id。 两个租户用同一个订单号或商品编码,第二个租户直接插入失败,或者更糟——查到了别人的记录。

异步任务丢上下文。 日志里突然出现空租户或者别的租户 ID,多半是线程池复用没清理。

迁移脚本漏表。 新增一张表忘记加 tenant_id,拦截器照样加条件,SQL 直接报错;如果恰好被加进白名单,就变成跨租户可见。

备份与恢复按库做。 共享表模型下,恢复单个租户的数据需要按条件导出,不能直接回滚整个库,这个能力要在设计阶段就准备好。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者