先从一个实际场景说起。如果你负责的 SaaS 平台准备上线产品经理提了一个需求系统要同时服务几十个企业客户每个客户的数据不能互相看到但你又不想为每个客户单独部署一套系统因为那样运维成本太高、升级太麻烦。这时候“多租户架构”就是解决问题的核心思路。更准确地说多租户架构解决的是“如何让一套软件系统安全地服务多个互不信任的客户”这个问题。你不仅需要保证功能可用还要在千万 QPS 量级的架构目标下让租户隔离不成为性能瓶颈。本文基于“千万 QPS 架构”系列第 220 讲的内容从“独占到共享”的演进路径切入完整梳理多租户架构的三种主流模型、核心实现、千万 QPS 场景下的性能设计以及工程落地中的踩坑与最佳实践。适合正在做 SaaS 平台、中间件、企业级基础服务的后端开发者阅读。1. 多租户架构是什么1.1 先理解“租户”在传统软件系统中一个系统服务一个客户客户之间不存在数据共享问题。但在 SaaSSoftware as a Service软件即服务模式中一套系统同时服务多个客户这里的每一个客户就被称为一个“租户”。租户可以是一个企业、一个部门、一个项目组甚至是一个个人用户。判断是否为租户关键看两点它有没有独立的、与其他租户隔离的数据范围。它的用户体系、权限体系、配置体系是否需要独立管理。1.2 多租户架构的定义多租户架构Multi-Tenancy是指在共享同一套应用实例、共享同一套基础设施的前提下通过逻辑手段将不同租户的数据和资源进行隔离的软件架构模式。这里要注意“共享”与“隔离”的关系共享的是代码、应用进程、数据库实例、中间件集群。隔离的是数据、权限、配置、资源配额。如果一份代码要服务于多个租户那么所有涉及数据的操作都必须带着“租户身份”去执行。这就是多租户架构最核心的约束。1.3 为什么千万 QPS 架构必须考虑多租户QPSQueries Per Second每秒查询数是衡量系统吞吐量的核心指标。千万 QPS 是一个极高的目标意味着系统每秒要处理千万级别的请求。在这种量级下如果多租户设计不合理会出现几个典型问题多租户条件导致索引失效全表扫描把数据库打垮。租户上下文在异步线程中丢失数据串号。租户数量增长后连接池耗尽单租户过载影响全局。缓存 key 没有租户维度一个租户的数据污染另一个租户。所以在高并发架构中多租户不是一个“加个 tenant_id 字段”那么简单的问题而是一个贯穿数据模型、SQL 执行、缓存设计、连接管理、流量控制的系统工程。2. 三种主流多租户模式从独占到共享多租户架构按照数据隔离与共享程度通常分成三种模式。它们的本质区别是“数据库资源如何分配”。2.1 模式一独立数据库模式每个租户独占一个数据库实例或一个数据库 schema。这是隔离性最强的方式。优点数据隔离级别最高租户之间天然物理隔离。备份、恢复、迁移可以按租户独立操作。某个租户出现慢 SQL 时不容易影响其他租户。缺点数据库实例数量多运维成本高。资源利用率低小租户也占有一个完整数据库。升级结构时需要逐个租户执行脚本成本高。适用场景大型企业客户、金融行业、对数据安全要求极高的场景。2.2 模式二共享数据库、独立 Schema所有租户共享同一个数据库实例但每个租户拥有独立的 Schema。Schema 是数据库中的逻辑命名空间可以将表按 Schema 分组。优点隔离性仍然较好不同租户的表结构相互独立。数据库实例数量少运维成本低于独立数据库模式。可以通过迁移 Schema 来调整租户所处实例。缺点一个租户的慢查询仍可能消耗实例的 CPU、IO影响同实例其他租户。当租户数量很多时Schema 数量会非常庞大。跨租户的统计、报表查询实现复杂。适用场景中型 SaaS 企业、需要一定隔离性但预算有限的场景。2.3 模式三共享 Schema、共享数据表所有租户共享同一个数据库、同一个 Schema、同一组表通过表中的租户标识字段通常叫 tenant_id来区分数据。优点资源利用率最高一个表可以服务海量租户。运维最简单只需要维护一套表结构。应用层改造相对统一新租户开通成本极低。缺点隔离性最弱完全依赖业务代码和 SQL 层正确过滤。一旦出现漏加 tenant_id 条件就会发生跨租户数据泄漏。单表数据量增长极快需要配套分库分表能力。适用场景用户量大的互联网 SaaS 产品、平台型业务、千万 QPS 目标下的高并发系统。2.4 三种模式对比对比维度独立数据库共享库独立 Schema共享表数据隔离强度最高较高逻辑隔离资源利用率低中高运维成本高中低新租户开通成本高中低高并发扩展难度简单中等较难典型适用对象大型客户中型客户海量小客户从“独占到共享”的演进本质是在隔离性、成本、运维复杂度、扩展性之间做权衡。千万 QPS 架构通常以共享表模式为基础再通过分库分表、多级缓存、读写分离等手段解决海量数据和高并发问题。3. 从独占到共享演进路径与关键决策3.1 业务驱动下的架构演进很多系统的多租户能力不是一开始就有的而是从单租户逐步演化而来。演进过程大体经历三个阶段。第一阶段独立部署。每个客户一套系统代码一样但环境独立。此时没有“租户”概念数据天然隔离。第二阶段遇到成本和管理瓶颈。客户数量增长后独立部署的服务器数量爆炸升级版本要逐个环境操作最终客户的数据版本不一致技术团队疲于应付。第三阶段改造为多租户。将应用收敛为一套数据层通过租户字段隔离产品能力从“交付部署”变成“开通即用”。这个演进过程中“共享”是目标但“隔离”是底线。3.2 演进中的三个关键决策决策一租户标识如何设计。租户标识一般使用整型或字符串 ID。整型在索引和关联查询中性能更好字符串则便于携带业务含义。无论如何租户标识必须是全局唯一、不可复用的稳定值。决策二隔离粒度如何选择。不要一概而论。对于付费意愿高、数据敏感的客户可以走独立 Schema 模式对于海量中小租户走共享表模式。成熟的 SaaS 平台通常支持混合模式根据客户等级动态分配。决策三由谁保证过滤条件不丢失。这是最核心的问题。人工在每条 SQL 里加where tenant_id ?很容易遗漏。正确做法是在数据访问层做强制过滤通过拦截器或框架能力自动注入租户条件。4. 共享表模式的核心实现租户上下文与自动 SQL 过滤共享表模式是多租户架构中实现难度最高、但对千万 QPS 架构最有价值的一种模式。下面我们来手把手实现一套最小可运行的共享表多租户方案。技术选型Spring Boot 2.7.xMyBatis-Plus 3.5.xMySQL 8.xMaven4.1 表结构设计以订单表为例表结构如下CREATE TABLE t_order ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, tenant_id bigint(20) NOT NULL COMMENT 租户ID, order_no varchar(64) NOT NULL COMMENT 订单号, amount decimal(10,2) NOT NULL COMMENT 订单金额, status tinyint(4) NOT NULL COMMENT 订单状态, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), KEY idx_tenant_order_no (tenant_id, order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;这里有一个非常重要的设计联合索引的字段顺序。查询时tenant_id和order_no都会出现所以应该把租户字段放在联合索引最左侧这样既能按租户过滤也能支持租户内订单号查询。4.2 定义租户上下文应用在处理一个请求时需要知道当前请求属于哪个租户。这个信息可以通过请求头、Token、登录态等方式获得。获得之后通常存放在 ThreadLocal 中。package com.example.multitenant.context; /** * 租户上下文 * 注意每次请求结束必须清理否则线程池复用会导致数据串号 */ public class TenantContext { private static final ThreadLocalLong CONTEXT new ThreadLocal(); public static void setTenantId(Long tenantId) { CONTEXT.set(tenantId); } public static Long getTenantId() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }ThreadLocal 保证同一个线程内共享数据每个线程只能看到自己的租户 ID。这里需要重点解释一个坑在 Web 应用中Tomcat 会复用线程如果请求处理结束后没有调用clear()下一个请求在同一个线程上执行时就会读到上一个请求残留的租户 ID造成数据串号。因此清理动作必须放在 finally 中。4.3 通过拦截器自动注入租户条件在共享表模式下最大的风险是开发人员写 SQL 时忘记加租户条件。因此不能依赖人工必须在框架层面自动处理。使用 MyBatis-Plus 的租户插件是最常见的方案。配置一个 TenantLineHandler 即可。package com.example.multitenant.config; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; import com.example.multitenant.context.TenantContext; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.LongValue; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { Override public Expression getTenantId() { Long tenantId TenantContext.getTenantId(); if (tenantId null) { throw new RuntimeException(无法获取租户ID请检查请求上下文); } return new LongValue(tenantId); } Override public boolean ignoreTable(String tableName) { // 对于系统级表例如租户配置表不需要注入租户条件 return t_tenant.equalsIgnoreCase(tableName); } Override public String getTenantIdColumn() { // 指定租户字段名 return tenant_id; } })); return interceptor; } }这段配置的核心逻辑是getTenantId()从租户上下文取出当前租户 ID。getTenantIdColumn()指定哪个字段是租户标识。ignoreTable()声明哪些表不参与租户隔离比如租户本身的信息表。配置完成后MyBatis-Plus 会自动改写 SQL。开发人员写的 SQL 是SELECT * FROM t_order WHERE order_no 202501010001;实际执行的 SQL 会变成SELECT * FROM t_order WHERE order_no 202501010001 AND tenant_id 1001;这样就实现了“自动过滤”从数据访问层杜绝了漏加租户条件的问题。4.4 请求入口解析租户 ID租户 ID 从哪来通常由网关或登录态解析。这里给出一个简单的拦截器示例。package com.example.multitenant.interceptor; import com.example.multitenant.context.TenantContext; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class TenantInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String tenantId request.getHeader(X-Tenant-Id); if (tenantId null || tenantId.isEmpty()) { throw new RuntimeException(请求缺少租户标识 X-Tenant-Id); } TenantContext.setTenantId(Long.parseLong(tenantId)); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束必须清理防止线程复用导致数据串号 TenantContext.clear(); } }注册拦截器package com.example.multitenant.config; import com.example.multitenant.interceptor.TenantInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new TenantInterceptor()) .addPathPatterns(/api/**); } }在真实项目中租户 ID 通常不是直接放在请求头里而是放在登录后的 Token 中由认证中心解析后写入上下文。这里为了演示原理简化处理。4.5 运行与验证完成以上代码后启动 Spring Boot 应用用带请求头的请求测试curl -H X-Tenant-Id: 1001 http://localhost:8080/api/order/query?orderNo202501010001预期结果是返回租户 1001 的订单数据。而如果没有请求头应用会抛出“无法获取租户ID”的异常。通过这种方式我们强制每个请求都携带租户身份。4.6 Service 层使用示例在 Service 层完全不需要手动拼接租户条件业务代码可以保持简洁package com.example.multitenant.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.multitenant.entity.Order; import com.example.multitenant.mapper.OrderMapper; import org.springframework.stereotype.Service; import javax.annotation.Resource; Service public class OrderService { Resource private OrderMapper orderMapper; public Order queryByOrderNo(String orderNo) { LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.eq(Order::getOrderNo, orderNo); return orderMapper.selectOne(wrapper); } }这里的Order实体类对应t_order表OrderMapper继承 MyBatis-Plus 的BaseMapper。由于租户插件已生效所有通过 BaseMapper 执行的 SQL 都会自动追加租户条件。5. 千万 QPS 场景下的多租户性能设计先说明一点千万 QPS 通常是指整个系统入口的吞吐量目标包含 CDN、网关、应用层、缓存、消息队列等多层协同后的综合指标。数据库层直接承受的 QPS 一般远低于千万可能需要配合分库分表和缓存。多租户架构在其中扮演的角色是确保租户隔离不成为性能瓶颈。5.1 索引设计租户字段要参与联合索引共享表模式下绝大部分查询都带有tenant_id条件。索引设计的第一原则是凡是高频查询条件都要把tenant_id放在联合索引的最前面。-- 推荐租户维度在前 KEY idx_tenant_status (tenant_id, status) -- 不推荐租户条件无法走索引 KEY idx_status (status)如果查询还包含等其他条件联合索引的字段顺序要按照“等值条件优先、范围条件靠后”的原则排列。因为tenant_id是等值查询所以它天然适合放在联合索引最左侧。5.2 缓存设计缓存 Key 必须包含租户维度多租户系统的缓存设计比单租户更复杂。如果没有在缓存 Key 中加入租户维度就会出现典型的数据污染问题租户 A 查询到的数据因为 Key 相同被租户 B 命中。错误示例// 错误缓存 key 没有租户维度 String cacheKey order: orderNo;正确示例// 正确缓存 key 包含租户维度 String cacheKey order: tenantId : orderNo;同时缓存失效策略也要考虑租户维度。比如租户 A 修改了订单状态不能清了租户 B 的缓存。更推荐使用 租户前缀 业务维度 的 Key 设计如tenant:1001:order:202501010001。5.3 连接池与动态数据源在共享表模式下多个租户共享同一个数据库连接池通常是全局共享的。但在某些场景下例如某个大租户占用资源过多我们需要做到“连接池级别”的资源隔离。Spring 的AbstractRoutingDataSource可以根据租户 ID 动态路由到不同数据源package com.example.multitenant.datasource; import com.example.multitenant.context.TenantContext; import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; public class TenantRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return TenantContext.getTenantId(); } }这个方案的核心用法是按租户维度配置多组数据源路由时根据当前租户 ID 选择对应数据源。适用场景少量大租户使用独立数据源。海量小租户共享默认数据源。对租户之间的资源占用做物理隔离。注意这种方案要求数据源配置是可列举的如果租户数量非常多不适合每个租户一个数据源。5.4 分库分表策略共享表模式下随着业务增长单表数据量会越来越大。此时需要引入分库分表。分片键的选择非常关键。对于多租户系统有两种常见的分片策略策略一按租户 ID 分片。某个租户的所有数据都落在同一个分片。优点是租户内查询可以单分片完成不会跨分片查询缺点是如果某租户数据量极大会造成数据倾斜。策略二按业务 ID 分片。如订单号取模。优点是数据分布均匀缺点是单个租户的数据分散在多个分片查询某租户所有订单时要跨分片聚合。在千万 QPS 目标下推荐优先考虑按租户 ID 分片因为绝大多数业务查询都以租户为维度。面对大租户倾斜问题可以再将大租户单独拆分出来或采用“租户 ID 业务 ID 组合分片键”的方式。5.5 限流与配额隔离多租户系统还有一个容易被忽略的问题某个租户的异常流量可能消耗掉整个系统的资源。所以在网关层做租户维度限流非常必要。典型的做法是按租户设置 QPS 配额。按租户设置并发数上限。当租户流量超过配额时快速失败返回 429 状态码。例如在 Sentinel 中可以使用热点参数限流将租户 ID 作为参数维度// 伪代码按租户 ID 限流每个租户 QPS 上限 1000 FlowRule rule new FlowRule(); rule.setResource(order_query); rule.setGrade(RuleConstant.FLOW_GRADE_QPS); rule.setCount(1000); // 关联参数索引为租户ID所在位置通过租户维度的限流可以避免“一人故障全体受牵连”。6. 常见问题与排查思路多租户系统上线后最容易出问题的地方往往是隐蔽的。下面整理几个高频问题。问题现象常见原因解决思路部分 SQL 没有自动追加租户条件MyBatis-Plus 版本过旧或 SQL 中有特殊写法无法被拦截升级到 3.4 版本检查自定义 SQL 写法优先使用框架提供的 Wrapper 方式请求结束后数据串号ThreadLocal 没有清理或异步线程内没有重新设置租户 ID在 finally 中调用TenantContext.clear()异步任务显式传参跨租户查询到其他租户数据缓存 Key 没有租户维度检查所有缓存 Key 设计强制加入租户标识联合索引未生效查询慢tenant_id 不在联合索引首位通过 EXPLAIN 查看执行计划重建索引某个大租户拖垮整个数据库缺少租户级限流和资源隔离网关层加租户限流考虑大租户独立数据源系统表也被注入租户条件未配置 ignoreTable在ignoreTable()中排除系统表6.1 排查清单遇到多租户数据异常时建议按以下顺序排查确认请求链路中TenantContext是否正确设置。查看打印的 SQL确认tenant_id条件是否追加。检查异步线程中租户上下文是否丢失。检查缓存 Key 是否包含租户标识。用EXPLAIN检查慢 SQL 是否走了有效索引。检查网关限流规则是否覆盖所有租户入口。7. 最佳实践与工程建议结合多个多租户项目的落地经验下面这些建议值得写进团队规范。7.1 租户字段命名统一所有租户相关表统一使用tenant_id字段名这样 MyBatis-Plus 拦截器和后续的数据治理工具才能统一处理。不要出现t_id、company_id、org_id混用的情况。7.2 数据访问入口强制拦截不要允许业务代码绕过框架手写原生 JDBC SQL。所有数据访问必须经过 MyBatis-Plus 或统一 DAO 层让租户条件拦截真正生效。7.3 所有缓存 Key 必须携带租户维度这是最容易出事故的点。建议在缓存工具类中强制要求租户参数无法提供租户的缓存操作直接抛异常。7.4 异步场景显式传递租户上下文ThreadLocal 不能跨线程传递。在使用线程池、MQ 消费、定时任务时需要显式传递租户 ID。例如可以在消息体中直接携带tenantId字段消费时先设置上下文再执行业务逻辑。7.5 定时任务按租户隔离执行定时任务不能用一个线程处理所有租户的数据。应该遍历租户列表逐个租户设置上下文后执行任务避免一个租户数据量过大导致任务超时拖累其他租户。7.6 生产环境变更必须走流程涉及多租户表结构的变更必须经过测试环境验证并针对存量数据制定迁移和回滚方案。有条件的话先在灰度租户验证再逐步扩大范围。7.7 租户数据备份与恢复共享表模式下备份可以按全库进行但恢复时要有“按租户恢复”的能力。可以通过备份表中tenant_id过滤数据实现租户级数据恢复。7.8 安全边界多租户意味着一个系统要面对多个“可能互相攻击”的主体。要遵循最小权限原则设计租户角色权限模型避免越权访问。敏感接口必须校验租户 ID 与登录用户的所属租户是否一致。8. 总结与下一步学习方向多租户架构的演进过程本质是“隔离”与“共享”的博弈。独立数据库模式隔离性最好但成本高共享表模式资源利用率最高但对工程能力要求也最苛刻。在千万 QPS 架构目标下共享表模式配合自动 SQL 过滤、租户级缓存、分库分表、租户级限流是比较现实的落地路径。本讲重点掌握这几个能力理解三种多租户模式的隔离级别、优缺点和适用场景。能够基于 MyBatis-Plus 实现租户上下文与自动 SQL 过滤。能够设计带租户维度的高性能索引和缓存 Key。理解 ThreadLocal 清理、异步传递租户上下文的必要性。下一步建议继续学习几个方向多租户与分库分表结合的完整方案比如使用 ShardingSphere 实现租户维度的数据分片。多租户下的分布式事务处理特别是跨租户、跨服务的资金类操作。多租户数据迁移工具例如把一个租户从共享表迁移到独立 Schema。多租户系统的可观测性设计如何按租户维度监控 QPS、耗时和错误率。多租户架构没有银弹关键是根据业务场景选择隔离模式并在工程规范上守住“租户条件不丢失”这条底线。动手把第 4 节的示例跑起来再结合你实际业务的查询场景逐步改造会比只看文章理解深得多。