多租户架构实战:从独立部署到共享表的数据隔离方案 📅 发布时间:2026/9/2 19:29:26 👁 浏览次数: 在“千万 QPS 架构”这个系列里我们聊过很多高并发场景下的通用技术缓存、分库分表、消息队列、限流熔断。但有一个问题几乎每个从私有化部署转向 SaaS 模式的团队都会反复纠结当一个系统要同时服务几十家甚至上千家客户时技术上到底应该怎么隔离很多团队在讨论多租户时第一反应是先回答一个“听起来很技术”的问题租户之间是独立部署还是共享一套系统但真正经历过这个阶段的人会明白这个问题背后其实是商业模式的抉择。独立部署隔离性最好但每接入一个大客户就要交付一套环境运维成本、机器成本、发布成本都会成倍上涨共享一套系统资源利用率高但一个租户的慢查询、热点请求、突发流量随时可能影响隔壁租户。如果你正在做 SaaS 平台、OpenAPI 开放平台或者正在把企业内部系统改造成对外输出的云服务这篇文章值得读完。我会从架构视角拆解多租户从“独占到共享”的演进路径讲清楚三种主流隔离模式、共享模式下最容易踩的坑以及如何用代码和配置把租户隔离真正落到工程里。1. 多租户架构要解决的本质问题先给多租户下一个不那么“百科”的定义。多租户Multi-Tenant指的是一套系统实例同时服务多个客户租户每个租户的数据、配置、权限、资源使用都相互隔离但底层基础设施是共享的。它要解决的本质问题有两个一是成本问题二是效率问题。成本问题很好理解。假设你是一个做零售 SaaS 的厂商客户 A、客户 B、客户 C 都是你的付费用户。如果不采用多租户架构每个客户都部署一套独立系统那么客户一多机器采购、网络规划、版本迭代就会变成灾难。更重要的是独立部署意味着每个客户都需要单独升级、单独备份、单独监控运维人力会随着客户数量线性增长。这种模式在项目管理软件、企业内部系统中还能接受但放到 SaaS 领域几乎不可能支撑规模化增长。效率问题是成本问题的延伸。共享一套系统后新版本上线只需要发布一次所有租户立刻就能使用新功能数据库也只需要维护一套集群备份、扩容、监控都能统一做。从工程效率来说这是巨大的提升。但资源一旦共享问题也来了你用什么标准来判断一个租户可以享受多少资源当一个租户出现异常流量时你要不要限制它限制以后客户体验会不会受影响这就是为什么多租户架构的设计本质上不是“要不要共享”的选择题而是“隔离粒度如何权衡”的工程题。更好的判断方式是看业务阶段。刚开始做 SaaS 时用户量小租户数量少独立部署简单直接租户量上来以后如果还不做共享就会被成本压垮但共享程度太高又会影响隔离性和稳定性。所以绝大多数成熟系统的路径是先独立跑通业务再逐步走向共享在中间找到适合自己业务的隔离粒度。2. 三种经典多租户隔离模式对比多租户架构在业界经过多年发展基本沉淀出了三种经典模式。了解这三种模式是理解整个多租户架构的起点。2.1 独立部署模式隔离数据库实例每个租户独占一套完整的系统环境包括独立的数据库实例、独立的应用服务、独立的中间件。这是隔离性最强、实施成本也最高的一种模式。它的优点是租户之间的故障完全隔离一个租户的慢查询不会影响别人数据安全级别最高可以针对大客户做定制化功能。缺点是运维成本高机器利用率低版本发布需要遍历所有租户环境。这种模式适合客单价高、数量少、有定制化需求的大型企业客户。国内很多银行、政府、大型国企的私有化交付就是这种模式。2.2 共享数据库、独立 Schema所有租户共享同一个数据库实例但每个租户拥有独立的 Schema或独立的表集合。这种模式是在隔离性和成本之间做了一次折中。数据库实例只需要维护一套备份和扩容成本大幅降低Schema 之间的数据天然隔离可以实现一定程度上的数据安全。但问题在于数据库连接数、CPU、内存仍然是共享的一个租户的慢查询依然可能拖垮整个实例。这种模式适合租户数量中等、业务规模相似的场景。2.3 共享数据库、共享表所有租户的数据都存放在同一组表中通过tenant_id字段来区分数据归属。这是成本最低、扩展性最好、也是实现难度最高的一种模式。它的核心挑战在于应用层必须保证每次数据库操作都带上租户上下文否则就很容易出现数据越权同时存储层难以对单个租户做资源隔离噪声隔离能力最弱。这种模式适合租户数量大、单租户数据量小、业务标准化程度高的场景也是目前绝大多数互联网 SaaS 系统的选择。三种模式的对比可以看下表隔离维度独立部署共享数据库、独立 Schema共享数据库、共享表数据隔离强较强弱靠 tenant_id 区分资源隔离强一般弱需额外配额机制运维成本高中低部署成本高中低单租户扩展可以独立扩容受数据库实例限制整体扩容适合租户数量少十几个以内中等几十到几百多上千到上万适合业务类型大型客户定制中型客户标准化标准 SaaS 产品需要特别说明的是这三种模式并不是互相排斥的。成熟的大型系统往往是混合模式大部分中小租户使用共享表模式少数核心大客户使用独立 Schema 或者独立部署模式。3. 从独占到共享架构演进的核心逻辑既然共享数据库、共享表的成本优势这么明显为什么不是所有系统一开始就这么做因为共享是有代价的。从独占到共享真正变化的不是代码而是隔离模型。在独立部署模式里隔离是由部署边界天然提供的。每个客户一套环境客户 A 的数据库和客户 B 的数据库根本不在一台机器上数据不可能串客户 A 的程序出 Bug只影响它自己不会影响客户 B。但在共享模式下租户之间只剩一个逻辑边界。原来由运维环境解决的隔离问题现在全部转移到了应用代码里。你必须在每一个 DAO、每一个 Service、每一个定时任务里都意识到当前请求属于哪个租户这条数据能不能被这个租户看到这个缓存 key 是否包含租户维度这个死信队列里的消息应该路由到哪个租户从架构演进的角度看从独占到共享主要经历了三个层次的改造第一层是数据层改造。需要在核心业务表上增加tenant_id字段并且把所有查询 SQL 从“按主键查”改造成“按租户 主键查”。这一层的难点在于存量数据的迁移和历史 SQL 的改造很多时候团队会在这一层翻车。第二层是应用层改造。需要引入租户上下文Tenant Context在请求链路中传递当前租户标识并基于租户上下文动态改写 SQL。这一层需要用 ThreadLocal、拦截器、MyBatis 插件等机制来实现。第三层是资源层改造。需要把连接池、线程池、缓存、消息队列等资源都做成按租户隔离或按租户配额管理。这一层解决的是“资源如何分配”的问题。很多团队做多租户改造时只关注了第一层以为加个tenant_id字段就算完了结果线上频繁出现数据越权、租户相互干扰的问题。原因就是没有理解多租户改造是整个架构层的改造不是加一个字段那么简单。4. 共享模式下的关键设计难点从独占到共享最容易被低估的是以下四个设计难点。4.1 租户上下文传递在共享模式下应用服务接收到的每个请求都必须明确它属于哪个租户。这里的第一个难点是租户标识如何从入口传到最底层的数据访问层常见做法是用 ThreadLocal 保存当前请求的租户 ID在请求开始时设置在请求结束后清除。但这里有一个非常隐蔽的坑如果代码里使用了线程池、异步任务、消息消费ThreadLocal 不会自动传递到子线程数据就可能在异步场景下串租户。解决思路一般是两种要么在异步提交任务时显式传递租户上下文要么使用支持上下文传递的链路标识组件比如基于 TransmittableThreadLocal 的方案。这块属于细节工程但做不好就是线上事故。4.2 数据库连接池与 SQL 改写共享数据库、共享表模式下所有租户共用同一个数据库连接池。为了保证租户数据隔离必须在 SQL 执行前自动拼接tenant_id条件而且这个拼接对业务开发应该是透明的不能要求每个开发都记得手动写条件。Java 生态里最常见的做法是实现 MyBatis 的 Interceptor在 SQL 解析阶段自动注入租户条件。这种方案的好处是开发无感知坏处是它要求所有查询都必须经过 MyBatis如果代码里还有原生 JDBC、存储过程或者其他 ORMSQL 改写就不一定能覆盖到。4.3 缓存与队列的租户维度共享模式下缓存也是一个容易踩坑的地方。如果使用 Redis 缓存用户数据缓存 key 里必须包含tenant_id。如果不加租户 A 查询的数据可能被租户 B 命中造成数据越权。类似地消息队列的消费逻辑也要按租户处理如果一个死信队列里的消息来自多个租户消费端必须根据消息内容重新定位租户上下文再执行对应的业务逻辑。4.4 资源配额与噪声隔离共享模式下一个租户的突发流量会“挤占”其他租户的资源。如果完全不限制某个租户的全表扫描或者热点促销就可能拖垮整个数据库。所以生产环境里还需要做资源配额管理比如按租户设置连接数上限、按租户限制 QPS、按租户设置缓存容量上限。在 Kubernetes 部署场景下还可以按租户划分 Namespace通过资源配额ResourceQuota和限流LimitRange实现更细粒度的控制。5. 代码示例基于 Spring Boot MyBatis 实现多租户隔离理论知识聊了很多接下来用一个最小示例把多租户隔离落到代码里。这里以 Java 技术栈为例使用 Spring Boot MyBatis 实现共享数据库、共享表模式下的租户隔离。5.1 创建带租户字段的业务表首先业务表需要增加tenant_id字段并且建议在tenant_id和业务主键上建立联合索引避免全表扫描。-- 文件路径src/main/resources/db/schema.sql CREATE TABLE t_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 订单ID, tenant_id VARCHAR(64) NOT NULL COMMENT 租户ID, order_no VARCHAR(64) NOT NULL COMMENT 订单编号, amount DECIMAL(10, 2) NOT NULL COMMENT 订单金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 订单状态, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (tenant_id, order_no), KEY idx_tenant_id (tenant_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 订单表;这里的关键设计点是uk_order_no联合唯一索引。因为不同租户可能生成相同的订单号如果只在order_no上建唯一索引就会导致租户间数据冲突。5.2 实现租户上下文 holder租户上下文的本质是在当前请求线程里保存租户 ID并在请求结束后清理。// 文件路径src/main/java/com/example/tenant/TenantContext.java public class TenantContext { private static final ThreadLocalString TENANT_ID_HOLDER new ThreadLocal(); public static void setTenantId(String tenantId) { TENANT_ID_HOLDER.set(tenantId); } public static String getTenantId() { return TENANT_ID_HOLDER.get(); } public static void clear() { TENANT_ID_HOLDER.remove(); } }这里使用remove()而不是set(null)来清理是为了避免线程池复用线程时旧租户上下文泄露到下一次请求。5.3 实现租户解析过滤器租户 ID 一般通过请求头X-Tenant-Id传递。在 Spring Boot 中可以用一个 Filter 在请求入口解析租户 ID并设置到TenantContext中。// 文件路径src/main/java/com/example/tenant/TenantFilter.java Component Order(1) public class TenantFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; String tenantId httpRequest.getHeader(X-Tenant-Id); if (StringUtils.hasText(tenantId)) { TenantContext.setTenantId(tenantId); } try { chain.doFilter(request, response); } finally { TenantContext.clear(); } } }注意这里使用try/finally保证无论如何都会清理租户上下文。如果忘记清理在高并发线程池场景下很容易出现串租户的数据事故。5.4 实现 MyBatis 多租户插件这是整个示例的核心。我们需要在 SQL 执行前自动为查询、更新、删除语句拼接tenant_id条件。// 文件路径src/main/java/com/example/tenant/TenantLineInnerInterceptor.java Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class TenantLineInnerInterceptor implements Interceptor { private static final String TENANT_COLUMN tenant_id; Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler (StatementHandler) invocation.getTarget(); BoundSql boundSql statementHandler.getBoundSql(); String originalSql boundSql.getSql(); String tenantId TenantContext.getTenantId(); if (StringUtils.hasText(tenantId) isNeedProcess(originalSql)) { String newSql rewriteSql(originalSql, tenantId); // 通过反射修改 BoundSql 中的 SQL Field sqlField BoundSql.class.getDeclaredField(sql); sqlField.setAccessible(true); sqlField.set(boundSql, newSql); } return invocation.proceed(); } private boolean isNeedProcess(String sql) { // 只处理 DML 语句跳过 INSERT 语句 String trimmedSql sql.trim().toLowerCase(); return trimmedSql.startsWith(select) || trimmedSql.startsWith(update) || trimmedSql.startsWith(delete); } private String rewriteSql(String originalSql, String tenantId) { // 简化处理直接在 where 前拼接 tenant_id 条件 // 生产环境建议使用 JSqlParser 解析 SQL自动识别表名和已有条件 if (originalSql.toLowerCase().contains(where)) { return originalSql AND TENANT_COLUMN tenantId ; } return originalSql WHERE TENANT_COLUMN tenantId ; } }上面这段代码是演示逻辑目的是让你理解多租户插件的工作原理。真实生产环境中直接拼接 SQL 是非常危险的很可能被注入或者产生语法错误。更稳妥的做法是使用 MyBatis-Plus 的TenantLineInnerInterceptor它内部通过 JSqlParser 解析 SQL 的抽象语法树可以自动识别表名、别名、已有 where 条件支持多表查询时给所有相关表拼接租户条件。使用 MyBatis-Plus 时只需要在配置类中注册该插件。// 文件路径src/main/java/com/example/tenant/MybatisPlusConfig.java Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); TenantLineInnerInterceptor tenantInterceptor new TenantLineInnerInterceptor( new TenantLineHandler() { Override public Expression getTenantId() { String tenantId TenantContext.getTenantId(); if (!StringUtils.hasText(tenantId)) { throw new RuntimeException(租户上下文缺失); } return new StringValue(tenantId); } Override public String getTenantIdColumn() { return tenant_id; } Override public boolean ignoreTable(String tableName) { // 字典表、系统配置表等全局表可以忽略租户条件 return t_dict.equalsIgnoreCase(tableName); } } ); interceptor.addInnerInterceptor(tenantInterceptor); return interceptor; } }这段配置意味着除了t_dict这类全局表其他表在执行 SQL 时都会自动追加tenant_id 当前租户ID的条件。业务开发人员不需要感知租户隔离逻辑只需要在入口设置租户上下文。5.5 编写业务查询示例加了插件之后普通业务代码不需要做任何特殊处理。// 文件路径src/main/java/com/example/order/OrderService.java Service public class OrderService { Autowired private OrderMapper orderMapper; public ListOrder listOrders() { // 直接调用 Mapper 即可SQL 改写由插件自动完成 return orderMapper.selectList( new LambdaQueryWrapperOrder() .eq(Order::getStatus, 1) ); } }从开发者的视角看这段代码和普通 Spring Boot 业务代码没有区别。但实际执行的 SQL 会自动变成SELECT * FROM t_order WHERE status 1 AND tenant_id tenant_a;这就是共享模式下多租户隔离的落地方式尽可能把隔离逻辑下沉到框架层让业务代码保持干净。6. 运行结果与效果验证代码写完后怎么验证多租户隔离是否生效第一步启动 Spring Boot 应用。应用启动成功之后使用curl模拟两个租户的请求# 租户 A 查询订单 curl -H X-Tenant-Id: tenant_a http://localhost:8080/order/list # 租户 B 查询订单 curl -H X-Tenant-Id: tenant_b http://localhost:8080/order/list第二步在数据库中预先插入两条不同租户的订单数据INSERT INTO t_order (tenant_id, order_no, amount, status) VALUES (tenant_a, NO2024001, 100.00, 1); INSERT INTO t_order (tenant_id, order_no, amount, status) VALUES (tenant_b, NO2024002, 200.00, 1);第三步分别请求后预期结果是租户 A 的请求只返回NO2024001这条订单。租户 B 的请求只返回NO2024002这条订单。如果租户 A 的请求返回了租户 B 的数据说明 SQL 改写没有生效优先排查以下方向MyBatis 插件是否被正确注册到 SqlSessionFactory 中。TenantContext中的租户 ID 是否在请求开始时被正确设置。是否走了二级缓存或本地缓存导致数据绕过 SQL 层。是否使用了Async或线程池导致子线程中租户上下文丢失。验证时还有一点需要特别注意要验证 INSERT 语句是否也带上了租户字段。很多团队只关注查询隔离结果新增数据时没有写入tenant_id导致后续查询出来的数据租户字段为空出现“数据凭空消失”的诡异问题。7. 常见问题与排查思路多租户改造过程中常见问题非常集中基本都出现在上下文传递、SQL 改写和缓存维度上。下面整理了一份排查清单供参考问题现象可能原因排查方式解决方案租户 A 查询到了租户 B 的数据SQL 改写未生效或查询走了缓存查看 MyBatis 打印的 SQL 是否包含 tenant_id检查插件注册顺序和 ignoreTable 配置新增数据后 tenant_id 为空INSERT 语句未写入租户字段查看数据库记录和 INSERT SQL使用 MyBatis-Plus 自动填充或插件处理 INSERT使用线程池后出现串租户ThreadLocal 未传递到子线程在子线程中打印租户 ID 检查是否为空使用 TransmittableThreadLocal 或在提交任务时显式绑定租户多表 join 查询报错或数据异常未给所有相关表拼接租户条件查看实际执行的 SQL 和表别名使用 JSqlParser 解析并给所有业务表追加条件字典表、公共配置表查询不到数据租户条件被错误追加到全局表检查 ignoreTable 配置将全局表加入忽略名单迁移数据后出现重复数据原有唯一索引不含 tenant_id检查表索引和迁移脚本增加联合唯一索引高峰期一个租户拖垮整个数据库数据库资源未做租户级限制查看数据库连接数和慢查询耗时增加连接池/线程池按租户隔离或 QPS 配额缓存中查到了其他租户的数据缓存 key 未包含租户维度检查 Redis key 是否一致缓存 key 增加 tenant_id 前缀第一个问题还需要补充说明SQL 改写不生效不一定全是插件的问题也有可能是应用里使用了TableName配置错误导致 MyBatis-Plus 解析的实体名和真实表名不一致。排查时建议先开启 MyBatis SQL 日志确认最终执行的 SQL 长什么样再顺藤摸瓜。关于慢查询问题还有一种常见场景某个租户使用了LIKE %关键词%查询无法走索引扫描了整个表导致共享数据库 CPU 飙升。这类问题只能在产品层面限制模糊查询范围或者把大租户迁移到独立 Schema运维上很难用一套规则覆盖所有场景。8. 最佳实践与工程建议多租户架构不是写完代码就结束的它需要一整套工程规范来支撑。以下是最值得关注的几条建议。8.1 一切全局表都要显式配置忽略名单共享表模式下不是所有表都是租户维度。像系统字典表、地区表、国家表这类基础数据是全局共享的。如果插件错误地给这些表追加了tenant_id条件会导致公共数据查询不到。所以ignoreTable名单必须和表结构评审一起做新增表时要明确它是“租户表”还是“全局表”。8.2 缓存 key 一定要带租户维度Redis 缓存是串数据的高发区。建议从设计层面规定所有涉及租户数据的缓存 key必须使用tenant_id:业务维度:业务ID的格式。比如order:tenant_a:NO2024001这样做的好处是即使两个租户的订单号相同也不会互相覆盖缓存。如果觉得 key 太长可以先对tenant_id做短编码但决不能不包含租户维度。8.3 租户上下文必须全链路传递只解决 Web 请求的租户上下文是不够的只要系统里有异步任务就要考虑上下文传递。生产环境中比较稳妥的方式是Web 层用 Filter 解析租户 ID 并设置上下文。线程池提交任务时显式把租户 ID 传入任务对象。消息队列消费时从消息头里读取租户 ID。定时任务如果按租户遍历执行需要在循环体内重置租户上下文。8.4 按租户做好资源配额共享模式不代表无限制共享。建议在接入层和数据库层都做租户维度的配额控制网关层给每个租户设置限流阈值超过阈值的请求快速失败。数据库层给核心租户设置连接数上限或者使用数据库代理的租户限流能力。应用层给异步任务设置租户维度信号量限制避免某个租户的批量任务占满线程池。在 Kubernetes 部署场景下也可以按租户拆分 Namespace通过 ResourceQuota 限制 CPU、内存和存储。这种方式比应用层限制更硬也更可靠。8.5 审计日志必须保留租户维度线上排查数据问题时如果审计日志里没有租户 ID去重和定位会非常困难。建议所有关键操作日志、慢查询日志、异常日志都统一打印租户 ID。这里推荐在日志框架中直接接入租户上下文的 MDC 值让租户 ID 自动进入所有日志。其实这类“看起来是小问题、线上是大问题”的细节在多租户架构里特别多。团队改造初期最好提前约定好规范比事后靠运维救火要省力得多。9. 总结与继续深入的方向多租户架构从独占到共享本质上是把“部署隔离”转变为“逻辑隔离”把“资源独占”转变为“资源按配额共享”。它真正考验的不是某一个数据库功能或者某一个框架插件而是整个团队对隔离边界、上下文传递、资源调度的理解是否一致。这篇文章里我们先梳理了多租户要解决的成本与效率问题然后对比了三种经典隔离模式并重点拆解了共享数据库、共享表模式下的四个关键设计难点。随后用 Spring Boot MyBatis 生态给出了一个可落地的租户隔离实现包括租户上下文、请求过滤器和 SQL 改写插件的完整示例。最后补充了常见问题排查清单和几条经过实践检验的工程规范。如果你的项目刚刚开始做 SaaS 化改造建议按照这个顺序推进先梳理业务表区分租户表和全局表再引入租户上下文和 SQL 改写插件跑通单租户隔离然后逐步排查异步任务、缓存、消息队列中的租户传递最后再做资源配额和监控告警。多租户架构还有几个值得深入的方向租户级别的数据迁移策略、按租户灰度发布、租户级配额与计费系统的联动、大规模租户下的成本分摊模型。这些内容每一块都可以单独展开。如果这篇文章对你有帮助可以收藏备用后续实践中有具体问题欢迎在评论区一起讨论。