多租户架构设计:从数据隔离到千万QPS的实战指南
最近在整理高并发架构相关的系列内容时多租户Multi-Tenant这个话题被频繁提起。很多做 SaaS 的同学都会遇到一个核心问题多个客户的数据放在同一套系统里既要保证隔离性又要控制成本还要在流量涨到千万 QPS 级别时不至于被单租户拖垮。本文将以「多租户架构从独占到共享」为主线梳理多租户的三种核心隔离模型、落地时常见的路由与字段设计以及在千万级 QPS 场景下如何兼顾隔离、扩展和性能。文章会包含基于 Spring Boot 的完整实战示例并给出每一步的配置说明和排错思路适合后端开发工程师、SaaS 平台设计者和正在做系统架构升级的同学收藏阅读。1. 多租户架构是什么先搞清楚基本概念1.1 从一个场景说起假设你在开发一套企业级 CRM 系统客户 A 和客户 B 都注册使用了你的产品。此时系统里出现了一个无法回避的问题客户 A 提交的订单数据客户 B 不能看到。客户 A 的员工账号不能在客户 B 的组织架构里登录。客户 A 的流量暴增时不应该影响客户 B 的正常使用。这就是“租户”的由来。在 SaaS 领域一个租户通常指一个客户组织它可以是一家企业、一个部门也可以是一个独立团队。多租户架构解决的核心问题就是让多个租户共享同一套软件系统但数据与业务逻辑彼此隔离。从业务视角看多租户不是“一个用户一个数据库”这么简单。它要求我们在数据库设计、权限模型、缓存策略、API 网关、任务调度等各个层面都考虑“当前请求属于哪个租户”并围绕租户维度做隔离和控制。1.2 多租户架构解决什么问题多租户架构解决的核心问题可以概括为三点数据隔离A 租户的数据不能被 B 租户访问这是安全底线。资源共享多个租户共享应用实例、中间件、数据库资源从而降低平均成本。弹性伸缩当某个租户流量上涨时系统能够通过扩容、限流、缓存等手段保障整体稳定性。成本与隔离天然存在矛盾。隔离做得越彻底成本越高共享程度越高技术复杂度越难控制。这也是为什么多租户架构会有一个“从独占到共享”的演进过程。1.3 多租户和数据权限的区别很多初学者会混淆“多租户”和“数据权限”。简单区分数据权限在同一个租户内部不同角色的用户看到不同的数据范围例如普通员工只能看自己的工单部门经理可以看整个部门的工单。多租户隔离在系统层面直接把不同客户的数据从物理或逻辑上分割开例如 A 公司的员工永远无法查到 B 公司的工单。多租户是比数据权限更前置、更底层的隔离机制。多租户做不好数据权限做得再细也无济于事。2. 多租户的三种隔离模型从独占到共享业界最经典的多租户数据模型有三种它们正好对应了标题所说的“从独占到共享”的演变路径。2.1 独立数据库模式独占模式每个租户独享一个数据库实例或一个独立数据库。优点隔离性最强租户之间完全物理隔离。数据备份、恢复、迁移都比较简单。适合大客户、金融客户等有强合规要求的场景。缺点成本最高数据库连接数、存储资源消耗大。运维复杂新增租户需要创建数据库并执行脚本。不利于规模化推广无法快速服务大量中小客户。这种模式适合企业级定制化项目或者平台上的头部大客户。它本质上不是“多租户共享”而是“单租户独享”的部署模式。2.2 共享数据库、独立 Schema 模式多个租户共享同一个数据库实例但每个租户拥有独立的 Schema命名空间。优点隔离性较好Schema 级别天然隔离。数据库实例数量少资源共享度高成本适中。数据迁移和恢复相对简单一个 Schema 出错不影响其他 Schema。缺点单个数据库实例的连接数、存储容量仍有上限。当租户数量变大时Schema 数量过多会带来管理压力。跨 Schema 统计查询比较麻烦。这种模式适合客户数量中等、租户体量中等的 SaaS 平台。2.3 共享数据库、共享表模式所有租户共享同一个数据库和同一套数据表通过每张业务表中的“租户编号”tenant_id字段来区分数据归属。优点成本最低应用一套数据库一套维护最简单。扩展性好新增租户无需变更表结构。适合海量中小客户的标准化 SaaS 产品。缺点隔离性最弱一旦 SQL 漏写租户条件就会出现越权访问。数据量膨胀速度快必须考虑索引、分库分表和归档策略。跨租户批量任务需要特别小心避免“一把梭”操作到所有租户数据。这是目前互联网 SaaS 产品最常用的模式。很多号称支持千万级流量的系统底层恰恰是这种“共享 精确隔离”的组合。2.4 三种模式的对比维度独立数据库共享数据库、独立 Schema共享数据库、共享表隔离强度强中强弱成本高中低维护复杂度中中高低扩展能力弱中强适用客户大客户、金融客户中型 SaaS海量中小客户典型场景私有化部署行业 SaaS互联网多租户平台从“独占”到“共享”本质上是用一个确定的技术成本去换取更低的边际成本和更强的规模化能力。3. 千万 QPS 架构下多租户设计到底难在哪标题里提到“千万 QPS”这听起来很遥远但它背后真正的问题是当一个共享数据库里同时跑着上千个租户某一个大租户的流量突然增长系统会不会被拖垮缓存会不会被击穿数据库连接池会不会被打满3.1 租户维度的高并发挑战先说结论多租户架构在千万 QPS 场景下难点从“数据怎么隔离”变成了“隔离之后资源怎么调度”。具体来说存在以下挑战热点租户问题少量头部租户贡献了大部分流量导致某些数据节点过热而其他数据节点相对空闲。数据倾斜问题采用共享表模式时大租户的数据量可能是中小租户的成千上万倍查询索引的区分度会下降。缓存一致性缓存 key 如果不包含租户维度就会出现不同租户读到彼此数据的严重事故。连接数瓶颈数据库连接是有限资源多租户场景下如果每个租户都占用独立连接池连接数会迅速耗尽。限流和降级粒度一般的限流是全局限流多租户下更合理的是按租户维度限流否则一个租户的异常流量会挤压其他租户。3.2 千万 QPS 并不等于一个数据库扛下了所有一个重要的架构常识是千万 QPS 一定不是单机、单库能够承受的。在高并发架构里QPS 通常是通过多层分摊实现的CDN / 接入层承接静态资源和边缘请求。API 网关路由、鉴权、按租户限流。应用服务层无状态化部署水平扩容。缓存层抗住 90% 以上的读流量。数据库层承接最终一致性的写流量和少量读流量。多租户的隔离设计会贯穿这些层次。比如网关层需要识别租户缓存层需要按租户区分 key数据库层需要按租户路由或过滤数据。所以不要把“千万 QPS 多租户架构”理解成一张大表硬抗千万流量而应该理解成“一套共享基础设施 精细化的租户路由和资源隔离机制”。4. 多租户落地设计租户识别与数据路由4.1 租户标识如何传递无论采用哪种隔离模型系统第一步要解决的问题是从请求进来到最终 SQL 执行如何知道当前操作属于哪个租户。常见的租户标识传递方式有三种请求头方式前端在 HTTP Header 中携带X-Tenant-Id网关解析后写入上下文。子域名方式tenant-a.example.com适合门户类 SaaS。路径方式/api/{tenantId}/orders直观但 URL 会变长容易被搜索引擎收录造成困扰。在微服务架构中更推荐把租户 ID 放在统一的请求头中由网关鉴权后向下游透传。服务内部可以借助 ThreadLocal 或者上下文对象保存当前租户 ID避免在业务代码中到处传递。4.2 数据库路由如何把租户请求分发到正确的数据源如果采用独立数据库模式或独立 Schema 模式就需要在应用层实现动态数据源路由。Spring Boot 中一个经典的做法是使用AbstractRoutingDataSource通过当前租户 ID 动态决定使用哪个数据源。下面是一个最小实现示例。// 文件路径src/main/java/com/example/tenant/context/TenantContext.java public class TenantContext { private static final ThreadLocalString CURRENT_TENANT new ThreadLocal(); public static void setTenantId(String tenantId) { CURRENT_TENANT.set(tenantId); } public static String getTenantId() { return CURRENT_TENANT.get(); } public static void clear() { CURRENT_TENANT.remove(); } }// 文件路径src/main/java/com/example/tenant/datasource/TenantRoutingDataSource.java import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; public class TenantRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return TenantContext.getTenantId(); } }在配置文件里我们需要把每个租户对应的数据源注册到 Spring 容器中并交给TenantRoutingDataSource去管理。// 文件路径src/main/java/com/example/tenant/config/DataSourceConfig.java import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.sql.DataSource; import java.util.HashMap; import java.util.Map; Configuration public class DataSourceConfig { Bean public DataSource dataSource() { TenantRoutingDataSource routingDataSource new TenantRoutingDataSource(); MapObject, Object targetDataSources new HashMap(); targetDataSources.put(tenant-a, buildDataSource(jdbc:mysql://localhost:3306/tenant_a)); targetDataSources.put(tenant-b, buildDataSource(jdbc:mysql://localhost:3306/tenant_b)); // 实际项目中这里可以从注册中心或配置中心动态读取租户数据源列表 routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.setDefaultTargetDataSource(buildDataSource(jdbc:mysql://localhost:3306/default_tenant)); return routingDataSource; } private DataSource buildDataSource(String url) { // 这里以 HikariCP 为例实际项目中请使用配置类管理连接池参数 com.zaxxer.hikari.HikariDataSource dataSource new com.zaxxer.hikari.HikariDataSource(); dataSource.setJdbcUrl(url); dataSource.setUsername(root); dataSource.setPassword(123456); dataSource.setDriverClassName(com.mysql.cj.jdbc.Driver); dataSource.setMaximumPoolSize(20); return dataSource; } }注意示例中的数据库连接信息仅为演示实际项目中数据库账号应通过环境变量或配置中心管理且应当遵循最小权限原则不要给应用账号开放 DDL 权限。4.3 共享表模式下的 SQL 过滤MyBatis-Plus 多租户插件在共享数据库、共享表的模式下最危险的操作就是业务 SQL 漏写了租户条件。比如下面这条 SQLSELECT * FROM order_info WHERE status 1;一旦执行就会把所有租户的订单都查询出来这属于重大的数据泄露事故。为了避免这种问题生产级方案不是在每个 Mapper 里手动写WHERE tenant_id ?而是利用框架能力做 SQL 自动改写。MyBatis-Plus 提供了多租户插件通过拦截器自动给 SQL 追加租户条件。核心配置如下// 文件路径src/main/java/com/example/tenant/config/MybatisPlusConfig.java import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; 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() { // 从上下文获取当前租户 ID return new LongValue(Long.parseLong(TenantContext.getTenantId())); } Override public boolean ignoreTable(String tableName) { // 有些表不需要租户隔离例如系统配置表、字典表 return sys_dict.equalsIgnoreCase(tableName); } })); return interceptor; } }配置完成后应用发给数据库的 SQL 会被自动改写成SELECT * FROM order_info WHERE status 1 AND tenant_id 1001;这极大降低了因为人为遗忘租户条件导致的数据越权风险。但要注意多租户插件只能拦截 MyBatis 框架内的 SQL。如果项目里有自定义 JDBC 代码、存储过程、定时任务批量 SQL需要单独处理。4.4 业务表设计tenant_id 怎么加在共享表模式下tenant_id字段设计有几个经验性原则类型选择优先使用 BIGINT 或 VARCHAR(32)具体看业务主键类型。不能为空tenant_id必须NOT NULL默认值可以设置为 0但查询时必须显式带租户条件。联合索引tenant_id应该作为联合索引的最左列。例如(tenant_id, order_no)、(tenant_id, create_time)。归属明确每个业务表都需要明确它属于“租户私有表”还是“平台公共表”公共表在代码中显式声明。下面是一个常见的订单表 DDL 示例CREATE TABLE order_info ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, tenant_id BIGINT NOT NULL COMMENT 租户ID, order_no VARCHAR(64) NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL COMMENT 用户ID, amount DECIMAL(10,2) NOT NULL COMMENT 订单金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0待支付 1已支付 2已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (tenant_id, order_no), KEY idx_tenant_create (tenant_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;注意这里有两个关键点唯一索引uk_order_no包含了tenant_id。这样不同租户可以使用相同的业务订单号互不冲突。普通索引idx_tenant_create以tenant_id开头保证大多数按租户维度查询时能命中索引。4.5 三级缓存与租户维度在共用缓存时务必把租户 ID 作为缓存 key 的一部分否则会酿成跨租户数据错乱事故。错误示例key order:10001当租户 A 和租户 B 同时访问订单 10001 时后写入缓存的租户数据会覆盖对方的数据读到的数据就是错的。正确示例key order:1001:10001 key order:1002:10001在 Redis 中可以采用如下结构order:1001:10001 - { orderNo: A10001, amount: 199.00 } order:1002:10001 - { orderNo: B10001, amount: 299.00 }同时缓存命中的热点 key 要按租户维度做统计。当某个租户的 key 访问量明显高于其他租户时可以为其单独调整缓存过期时间或容量配置避免热点数据挤掉普通租户的缓存份额。5. 完整实战Spring Boot MyBatis-Plus 多租户模块落地5.1 创建项目结构本示例以共享数据库、共享表模式为例演示如何在一个 Spring Boot 项目中集成 MyBatis-Plus 多租户插件。tenant-demo/ ├── pom.xml └── src/main/java/com/example/tenant/ ├── TenantApplication.java ├── config/ │ ├── MybatisPlusConfig.java │ └── WebConfig.java ├── context/ │ └── TenantContext.java ├── controller/ │ └── OrderController.java ├── entity/ │ └── OrderInfo.java ├── mapper/ │ └── OrderInfoMapper.java └── service/ └── OrderService.java示例项目只包含核心模块实际项目中还需要加入 ServiceImpl、DTO、VO、异常处理等代码。5.2 添加依赖在pom.xml中加入 Spring Boot、MyBatis-Plus 和 MySQL 驱动依赖。版本请根据自身项目实际情况调整。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies需要注意的是MyBatis-Plus 3.5.x 的多租户插件 API 与 3.4.x 有差异。生产项目升级时一定要阅读官方升级文档不要直接替换依赖。5.3 编写租户上下文与拦截器租户上下文已经在上文给出。接下来需要一个拦截器在请求进入 Controller 之前从 Header 中解析租户 ID并把租户 ID 写入TenantContext请求结束后清理上下文。// 文件路径src/main/java/com/example/tenant/config/WebConfig.java import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; Component 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(缺少租户信息); } TenantContext.setTenantId(tenantId); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantContext.clear(); } }// 文件路径src/main/java/com/example/tenant/config/WebConfig.java 续 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 { private final TenantInterceptor tenantInterceptor; public WebConfig(TenantInterceptor tenantInterceptor) { this.tenantInterceptor tenantInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(tenantInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/public/**); } }这里的excludePathPatterns用于放行登录、注册、健康检查等公共接口。公共接口自然不需要租户上下文。5.4 编写实体类和 Mapper// 文件路径src/main/java/com/example/tenant/entity/OrderInfo.java import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.math.BigDecimal; import java.time.LocalDateTime; TableName(order_info) public class OrderInfo { TableId(type IdType.AUTO) private Long id; private Long tenantId; private String orderNo; private Long userId; private BigDecimal amount; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; // 省略 getter/setter }// 文件路径src/main/java/com/example/tenant/mapper/OrderInfoMapper.java import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.tenant.entity.OrderInfo; import org.apache.ibatis.annotations.Mapper; Mapper public interface OrderInfoMapper extends BaseMapperOrderInfo { }5.5 编写 Service 和 Controller// 文件路径src/main/java/com/example/tenant/service/OrderService.java import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.tenant.context.TenantContext; import com.example.tenant.entity.OrderInfo; import com.example.tenant.mapper.OrderInfoMapper; import org.springframework.stereotype.Service; import java.util.List; Service public class OrderService { private final OrderInfoMapper orderInfoMapper; public OrderService(OrderInfoMapper orderInfoMapper) { this.orderInfoMapper orderInfoMapper; } public ListOrderInfo listOrdersByStatus(Integer status) { LambdaQueryWrapperOrderInfo wrapper new LambdaQueryWrapper(); wrapper.eq(OrderInfo::getStatus, status); // 注意这里没有手动拼 tenant_id由 MyBatis-Plus 多租户插件自动改写 return orderInfoMapper.selectList(wrapper); } public void createOrder(OrderInfo orderInfo) { orderInfo.setTenantId(Long.parseLong(TenantContext.getTenantId())); orderInfoMapper.insert(orderInfo); } }// 文件路径src/main/java/com/example/tenant/controller/OrderController.java import com.example.tenant.entity.OrderInfo; import com.example.tenant.service.OrderService; import org.springframework.web.bind.annotation.*; import java.util.List; RestController RequestMapping(/api/orders) public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService orderService; } GetMapping public ListOrderInfo list(RequestParam(required false, defaultValue 0) Integer status) { return orderService.listOrdersByStatus(status); } PostMapping public OrderInfo create(RequestBody OrderInfo orderInfo) { orderService.createOrder(orderInfo); return orderInfo; } }5.6 运行与验证使用如下命令启动项目mvn spring-boot:run启动完成后使用 curl 模拟租户 A 的请求curl -H X-Tenant-Id: 1001 http://localhost:8080/api/orders?status1再模拟租户 B 的请求curl -H X-Tenant-Id: 1002 http://localhost:8080/api/orders?status1正常情况下两个租户查询到的数据互不干扰。你可以打开 MySQL 通用日志或者通过 MyBatis 控制台日志观察 SQL 自动改写后的效果。如果配置正确最终执行的 SQL 会类似SELECT * FROM order_info WHERE status 1 AND tenant_id 1001;这段实战流程虽然简短但覆盖了“租户透传 → 上下文保存 → SQL 自动改写”的完整链路。实际生产项目中还要在此基础上增加网关层透传、Feign 调用透传、MQ 消息携带租户 ID、异步任务上下文传递等能力。5.7 后续优化方向本示例可以继续扩展的方向包括接入 Nacos 或 Apollo 实现租户数据源的动态注册与灰度发布。在网关层使用 Sentinel 实现按租户维度的限流。引入 ShardingSphere 实现数据库分片同时保留多租户插件能力。对定时任务场景做租户上下文传递避免后台任务误操作全量数据。6. 多租户场景下的缓存、异步与定时任务隔离6.1 缓存中的租户维度上面提到缓存 key 必须包含租户 ID这是第一原则。第二个重要原则是不要让一个租户的热点数据把其他租户的数据全部挤掉。在 Redis 中常见的做法是为不同租户设置不同的 key 前缀例如tenant:1001:order:10001 tenant:1002:order:10001在更大规模的架构中还可以按租户维度建立独立的缓存命名空间甚至让大租户独占一组 Redis 分片。这样做的代价是运维和资源成本上升但对于超大规模租户来说是值得的。6.2 异步任务如何传递租户上下文在微服务架构中异步线程拿不到主线程的ThreadLocal租户 ID这是常见问题。解决办法有几种手动传递把租户 ID 作为方法参数传入或者在 MQ 消息体里带上租户 ID。装饰器模式使用TaskDecorator包装线程池任务在主线程提交任务时快照租户 ID在子线程执行时恢复。全链路 Trace 体系在 Trace ID 之外增加 Tenant ID 字段所有日志和异步调用自动携带。下面是一个使用TaskDecorator的实现片段// 文件路径src/main/java/com/example/tenant/config/TenantTaskDecorator.java import org.springframework.core.task.TaskDecorator; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; public class TenantTaskDecorator implements TaskDecorator { Override public Runnable decorate(Runnable runnable) { String tenantId TenantContext.getTenantId(); RequestAttributes requestAttributes RequestContextHolder.getRequestAttributes(); return () - { try { TenantContext.setTenantId(tenantId); RequestContextHolder.setRequestAttributes(requestAttributes); runnable.run(); } finally { TenantContext.clear(); RequestContextHolder.resetRequestAttributes(); } }; } }使用这个装饰器后线程池在执行任务时会自动恢复主线程的租户上下文。注意在finally块中一定要清理上下文否则线程池复用线程时会造成租户 ID 串线。6.3 定时任务如何避免“全量误伤”定时任务是最容易出多租户事故的地方。一个典型的错误写法是// 错误示例未按租户维度遍历 ListOrderInfo orders orderInfoMapper.selectList(null); for (OrderInfo order : orders) { // 对订单做某些处理 }在共享表模式下这段代码会把所有租户的数据全部捞出来处理既可能越权也可能造成巨大的内存压力。正确做法是先把需要处理的租户列表查出来然后按租户逐个处理// 正确思路 ListLong tenantIds tenantInfoMapper.selectAllTenantIds(); for (Long tenantId : tenantIds) { TenantContext.setTenantId(String.valueOf(tenantId)); try { // 处理当前租户的数据 } finally { TenantContext.clear(); } }如果租户数量巨大还需要分批处理并配合分布式任务框架如 XXL-Job 的分片参数实现任务拆分。7. 千万 QPS 场景的高性能设计建议7.1 按租户热点拆分缓存与数据库在千万 QPS 场景下数据模型的选型一定要有“分层思维”。纯共享表模式在租户数量少时很美好但在大规模场景下建议走向“共享 分片 热点隔离”的组合方案中小租户共享数据库、共享表通过tenant_id路由。大租户独立 Schema 或独立数据库实例流量不进公共池。超大租户独占集群甚至独占缓存分片和消息队列 Topic。这种架构被称为“混合多租户模式”。它不像“从独占到共享”那样是一条直线而是一个动态调节的过程系统根据租户的规模、流量、合规要求自动或半自动决定租户落在哪一层。7.2 数据库连接池的隔离数据库连接池数量是有限的。如果一个大租户的慢 SQL 占满了所有连接其他租户的请求就会排队超时。常见的处理方式按租户维度设置独立的 HikariCP 连接池并限制最大连接数。或者按“大租户独享连接池、普通租户共享连接池”的方式分组管理。开启连接池监控对超过阈值的租户及时告警。注意连接池也不是越多越好。连接本身占用数据库内存和进程资源过度拆分连接池反而会降低数据库整体吞吐。7.3 读写分离与数据归档多租户的读流量通常远大于写流量。读写分离可以有效降低主库压力主库负责写操作从库负责读操作。按照租户维度决定路由策略例如大租户单独配置从库。历史数据定期归档到冷存储或数据仓库减少热表数据量。在千万 QPS 场景下“一张表到底能存多少数据”不是最关键的最关键的是“热数据”一定要控制在合理范围内。比如订单查询只关心最近三个月的订单那么三个月前的数据就应该被归档到历史表。7.4 按租户限流与降级传统的全局限流无法解决多租户场景下的“一租户拖垮全局”问题。Sentinel 等限流组件支持按参数限流可以基于请求头中的租户 ID 做规则配置。例如我们可以配置租户 A 的 QPS 上限为 5000超过则返回 429。租户 B 的 QPS 上限为 500超过则进入排队。全局 QPS 上限为 10000超过则拒绝新请求。这样做的好处是即使某个租户出现了异常流量也只是影响它自己不会拖垮整个平台。7.5 连接池与缓存之外对账与监控生产环境中的多租户系统需要建立三套监控指标租户维度 QPS哪些租户是热点哪些租户在持续上涨。租户维度慢 SQL哪些租户的 SQL 查询效率在恶化。租户维度数据量哪些租户的数据量接近阈值需要扩容或归档。这些指标可以用 Prometheus Grafana 采集展示也可以在日志中输出租户 ID 维度统计。总之千万 QPS 不是一个静态目标而是一个持续运营和调优的过程。8. 多租户架构选型建议与最佳实践8.1 租户规模与模型选型项目阶段租户量级推荐模型初期 SaaS几十到几百个租户共享数据库、共享表中期 SaaS几百到几千个租户共享数据库 大租户独立 Schema大规模 SaaS几千以上租户混合模式共享表 独立实例 分片政企私有化少量大客户独立数据库模式8.2 方案落地技巧清单在实际项目中建议从下面几个点入手租户上下文必须由框架统一管理业务代码中禁止手动散落tenant_id。所有 SQL 必须经过多租户插件或手动过滤上线前做全量 SQL 审查。数据库账号权限最小化应用账号只允许 DML不允许 DDL。定时任务、MQ 消费者、异步线程必须显式传递租户上下文。大租户和普通租户做资源分组从连接池、缓存、限流三个层面隔离。数据备份和恢复必须支持按租户粒度执行。对租户数据导出、删除等操作必须加二次确认和审计日志。新增租户要自动化申请、初始化、配置、测试、上线尽量自动化减少人工操作带来的风险。8.3 安全与合规红线多租户系统的核心红线是“越权”。在开发阶段就要建立两条纪律上线前必须做数据隔离测试用租户 A 的 token 查询租户 B 的数据任何一条成功都算上线阻断问题。变更生产数据前必须备份无论是批量修改还是数据订正都要确认影响范围并且先在预发环境验证。运维侧要建立数据导出审批流程谁导出了哪个租户的多少数据都要有记录可查。9. 常见问题与排查思路9.1 问题汇总表问题现象常见原因解决思路请求报“缺少租户信息”前端未传租户请求头或网关未透传检查调用链在网关统一注入租户 ID查询到了其他租户的数据SQL 未带 tenant_id或多租户插件未生效检查 MyBatis-Plus 配置查看执行 SQL多租户插件没有改写 SQL框架版本不兼容或插件未加载检查依赖版本对照官方文档调整配置连接池被打满某租户慢 SQL 占用连接开启慢查询日志按租户维度分析 SQL缓存数据串租户缓存 key 未包含租户 ID重建 key 规则上线前做缓存隔离测试定时任务处理全量数据任务未按租户维度循环改为先查租户列表逐租户处理异步线程租户 ID 丢失ThreadLocal 未传递到子线程使用 TaskDecorator 或显式传递租户 ID大租户流量挤占小租户资源缺少按租户限流在网关接入 Sentinel 按参数限流9.2 排查租户数据越权问题的方法论如果线上反馈“看到了其他租户的数据”不要急于改代码。按以下顺序排查确认请求链路中租户 ID 是否透传正确。查看最终执行 SQL是否包含tenant_id条件。检查是否走了本地缓存或 Redis 缓存缓存 key 是否区分租户。检查是否命中公共查询接口或配置表。检查是否存在异步线程/Timer 绕过了租户上下文。检查是否有自定义 SQL 绕过了 MyBatis-Plus 拦截器。一般问题出在第 2 步或第 3 步的可能性最大。10. 总结与下一步学习方向多租户架构没有“银弹”。从独立数据库到共享 Schema再到共享表本质上是企业在成本、隔离性、扩展性之间做的权衡。单纯追求某种模式最美是没意义的架构师要能在不同阶段动态调整初期为了快速上线使用共享表遇到头部大客户时为其升级为独立实例再通过常态化监控识别需要特殊隔离的租户。这套“从独占到共享”的演进思路贯穿了多租户架构的整个生命周期。设计选型时先想清楚几个问题你的客户是谁他们对数据隔离的要求有多高每个租户的数据量级和流量模型是什么运维能力能否支撑混合模式这些问题的答案会直接影响租户模型的最终形态。下一步建议大家动手做三件事搭建一个最小可运行的多租户 Demo把租户请求头、上下文、SQL 插件跑通。基于自己的业务表审查一遍现有 SQL有哪些是跨租户的隐患。为你的系统设计一套租户维度的监控大盘然后观察线上租户的流量分布规律。如果本文对你有帮助可以收藏备用后续遇到多租户相关问题时方便回查。也欢迎在评论区交流你在多租户落地过程中踩过的坑。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →