news 2026/9/2 2:05:54

多租户架构设计:从数据隔离到千万QPS的实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多租户架构设计:从数据隔离到千万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 ThreadLocal<String> 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(); Map<Object, 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 漏写了租户条件。比如下面这条 SQL:

SELECT * 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

注意这里有两个关键点:

  • 唯一索引uk_order_no包含了tenant_id。这样不同租户可以使用相同的业务订单号,互不冲突。
  • 普通索引idx_tenant_createtenant_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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</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 BaseMapper<OrderInfo> { }

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 List<OrderInfo> listOrdersByStatus(Integer status) { LambdaQueryWrapper<OrderInfo> 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 List<OrderInfo> 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?status=1"

再模拟租户 B 的请求:

curl -H "X-Tenant-Id: 1002" "http://localhost:8080/api/orders?status=1"

正常情况下,两个租户查询到的数据互不干扰。你可以打开 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 定时任务如何避免“全量误伤”

定时任务是最容易出多租户事故的地方。一个典型的错误写法是:

// 错误示例:未按租户维度遍历 List<OrderInfo> orders = orderInfoMapper.selectList(null); for (OrderInfo order : orders) { // 对订单做某些处理 }

在共享表模式下,这段代码会把所有租户的数据全部捞出来处理,既可能越权,也可能造成巨大的内存压力。

正确做法是先把需要处理的租户列表查出来,然后按租户逐个处理:

// 正确思路 List<Long> 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 排查租户数据越权问题的方法论

如果线上反馈“看到了其他租户的数据”,不要急于改代码。按以下顺序排查:

  1. 确认请求链路中租户 ID 是否透传正确。
  2. 查看最终执行 SQL,是否包含tenant_id条件。
  3. 检查是否走了本地缓存或 Redis 缓存,缓存 key 是否区分租户。
  4. 检查是否命中公共查询接口或配置表。
  5. 检查是否存在异步线程/Timer 绕过了租户上下文。
  6. 检查是否有自定义 SQL 绕过了 MyBatis-Plus 拦截器。

一般问题出在第 2 步或第 3 步的可能性最大。

10. 总结与下一步学习方向

多租户架构没有“银弹”。从独立数据库到共享 Schema,再到共享表,本质上是企业在成本、隔离性、扩展性之间做的权衡。单纯追求某种模式最美是没意义的,架构师要能在不同阶段动态调整:初期为了快速上线使用共享表,遇到头部大客户时为其升级为独立实例,再通过常态化监控识别需要特殊隔离的租户。

这套“从独占到共享”的演进思路,贯穿了多租户架构的整个生命周期。设计选型时先想清楚几个问题:你的客户是谁?他们对数据隔离的要求有多高?每个租户的数据量级和流量模型是什么?运维能力能否支撑混合模式?这些问题的答案,会直接影响租户模型的最终形态。

下一步建议大家动手做三件事:

  • 搭建一个最小可运行的多租户 Demo,把租户请求头、上下文、SQL 插件跑通。
  • 基于自己的业务表,审查一遍现有 SQL,有哪些是跨租户的隐患。
  • 为你的系统设计一套租户维度的监控大盘,然后观察线上租户的流量分布规律。

如果本文对你有帮助,可以收藏备用,后续遇到多租户相关问题时方便回查。也欢迎在评论区交流你在多租户落地过程中踩过的坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/2 2:05:48

Tool Calling、Skills与MCP:AI Agent工具调用的核心层次解析

最近在给团队做 AI Agent 技术分享时&#xff0c;发现大家经常把三个名词混在一起聊&#xff1a;Tool Calling、Skills、MCP。有人以为 Skills 就是 MCP 的另一种叫法&#xff0c;有人把 Tool Calling 当成 MCP 的一个功能&#xff0c;还有人觉得只要接入了 MCP 就天然支持了 T…

作者头像 李华
网站建设 2026/9/2 2:04:29

Elasticsearch Carrot2插件实现搜索结果聚类实战指南

简介&#xff1a;面向 Elasticsearch 7.6.0 的 Carrot2 聚类查询插件&#xff0c;专为大规模搜索场景设计&#xff0c;可自动将返回文档组织为结构化主题簇&#xff0c;解决结果冗杂、用户难以快速定位信息的问题。开发者或数据分析人员部署后&#xff0c;可在查询请求中指定多…

作者头像 李华
网站建设 2026/9/2 2:04:19

自研TCP调试助手源码解析:客户端/服务端双模式与Hex收发实现

简介&#xff1a;这是一份基于C#的TCP调试助手源码包&#xff0c;面向网络开发与嵌入式调试人员&#xff0c;提供可运行的TCP客户端与服务端实现。源码包含Form1.cs、myConfing.cs等核心模块&#xff0c;覆盖连接建立、数据发送接收、参数配置等关键逻辑&#xff0c;同时包含Fo…

作者头像 李华
网站建设 2026/9/2 2:03:13

SQLite版本管理痛点与Rust Cargo机制对比及迁移方案

这次我们不聊某一个新项目&#xff0c;而是聊一个很有意思的结构性问题&#xff1a;为什么 SQLite 到现在还在靠PRAGMA user_version 手工迁移脚本管理数据库版本&#xff0c;而不是像 Rust 的 Cargo 那样&#xff0c;有一套从依赖声明到锁文件、再到自动更新策略的完整版本机制…

作者头像 李华
网站建设 2026/9/2 2:01:14

UI动画+高级光影风:让产品价值在30秒内被看懂

判断一个产品页面是否成功的标准&#xff0c;通常不是单张截图有多惊艳&#xff0c;而是用户在前 30 秒内能否读清楚产品核心价值。在这 30 秒里&#xff0c;UI 动画负责引导视线&#xff0c;高级光影风负责建立质感和空间感&#xff0c;两者配合才能把视觉注意力转换成真正的信…

作者头像 李华