Java实现多租户案例

wen java案例 4

从零到一:Java实现多租户架构的三种核心模式与实战案例解析


目录导读

  1. 多租户(Multi-Tenancy)的底层逻辑:为什么SaaS离不开它?
  2. Java生态下的三大隔离策略:共享Schema、独立Schema、分库分表
  3. 实战案例:基于Spring Boot + JPA的动态数据源切换(含核心代码)
  4. 高频面试问答:如何优雅处理租户级缓存与数据迁移?
  5. 架构演进路线图:从单租户到百万级租户的踩坑记录

多租户的底层逻辑:不只是“用户登录”那么简单

Java实现多租户案例

在SaaS平台(如钉钉、Salesforce)中,多租户的核心目标是让多个企业(租户)共享同一套应用实例,但数据逻辑完全隔离
如果你误以为“加个tenant_id字段过滤数据”就是多租户,那将在数据泄露的边缘疯狂试探,真正的多租户必须解决三个问题:

  • 数据隔离(租户A不能读取租户B的订单);
  • 性能隔离(某个租户的慢SQL不能拖垮全局);
  • 可扩展性(新增租户时无需停机改代码)。

Java生态下的三大隔离策略

策略 核心原理 适用场景 隔离强度 成本
共享Schema(行级隔离) 所有租户共用同一数据库表,通过tenant_id区分 中小型SaaS
独立Schema(表级隔离) 每个租户一套表,但共用数据库实例 中型企业客户
独立数据库(分库) 每租户独立数据库,物理隔离最彻底 金融、医疗等强合规

核心观点:99%的Java项目起步用“共享Schema”,当出现慢查询或租户投诉时,再迁移到“独立Schema”,切勿开局就分库,否则资源浪费巨大。

实战案例:动态数据源切换(共享Schema→独立Schema过渡方案)

想象一个场景:你的SaaS系统已有2000家租户,其中5家VIP客户要求“独立Schema”,如何在不重启应用的情况下,为不同租户路由到不同数据库?

第一步:定义租户上下文

public class TenantContext {
    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
    public static void setTenant(String tenantId) { CURRENT_TENANT.set(tenantId); }
    public static String getTenant() { return CURRENT_TENANT.get(); }
    public static void clear() { CURRENT_TENANT.remove(); }
}

第二步:动态数据源路由(核心逻辑)

@Component
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        // 根据租户ID返回数据源Key,若为VIP则返回"ds_vip_xxx",否则返回"ds_shared"
        String tenantId = TenantContext.getTenant();
        return TenantConfig.isVip(tenantId) ? "ds_vip_" + tenantId : "ds_shared";
    }
}

第三步:注册所有数据源

@Configuration
public class DataSourceConfig {
    @Bean
    @Primary
    public DataSource dataSource() {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put("ds_shared", buildDataSource("jdbc:mysql://shared-db:3306/saas_shared"));
        targetDataSources.put("ds_vip_1001", buildDataSource("jdbc:mysql://vip-db-1:3306/tenant_1001"));
        // 通过TenantConfig动态加载更多数据源...
        TenantRoutingDataSource routing = new TenantRoutingDataSource();
        routing.setDefaultTargetDataSource(targetDataSources.get("ds_shared"));
        routing.setTargetDataSources(targetDataSources);
        return routing;
    }
}

注意:此模式必须配合@Transactional的事务同步,否则ThreadLocal变量在事务提交前丢失,建议在拦截器中统一调用TenantContext.setTenant()

高频面试问答:租户级缓存与数据迁移

Q1:如何避免租户A的数据命中租户B的缓存?

  • 错误做法:仅用cacheKey = "order_" + orderId
  • 正确方案:强制将租户ID拼入缓存Key(order_${tenantId}_${orderId}),或使用Redis集群的Key Space做逻辑分区,亲测前者简单高效。

Q2:上线半年的共享Schema表,要拆分为独立Schema,不停机怎么做?

  • 使用迁移工具如Flyway区分版本,先为VIP租户建新库。
  • 采用“双写”策略(旧表写一份,新库写一份),利用MQ异步对比校验。
  • 最后切流时,只将VIP租户的域名解析到新应用实例,同时保留旧数据快照回滚。

架构演进路线图:从单租户到百万租户

  • 阶段一(0→100租户):单库单表 + tenant_id索引,早9晚6跑批即可。
  • 阶段二(100→1万租户):引入多数据源模块(如上述案例),将10%大租户分流到独立Schema。
  • 阶段三(1万以上):引入分片中间件ShardingSphere,按租户ID哈希分库分表;同时改造为“异步复制+读写分离”。

关键警告:提前监控“大租户效应”——某个租户的批量导出可能会占满连接池,对策是给数据源设置maxActiveconnectionTimeout,并用Hystrix做熔断降级。


结尾思考
多租户不是一道“做完就结束”的功能题,而是一项贯穿系统生命周期的架构决策,真正的高手会在前期预留租户路由钩子,避免后期拆表时推倒重来,如果你正在设计SaaS平台,建议从“共享Schema”起步,将数据源路由机制抽象为公共组件——这是成本最低、风险最可控的路径。


(全文完)
本文综合自Spring官方文档、阿里云开发者社区及InfoQ多租户实践案例,结合一线企业落地经验重新改编。

抱歉,评论功能暂时关闭!