多租户数据隔离架构设计

1 阅读1小时+

概述

系列定位说明

本文是分布式数据架构与存储选型系列的第三篇。在第 1 篇:分库分表架构选型中,我们深入讨论了水平分片的关键——分片键的选择与容量规划,确立了 tenant_id 作为多租户场景天然分片键的理论基础;在第 2 篇:读写分离与数据库中间件选型中,我们拆解了 ProxySQL 的查询规则路由机制与 ShardingSphere 的读写分离嵌套配置,为多租户流量路由提供了中间件层的技术铺垫。本文将在前两篇的基础上,进一步探讨多租户 SaaS 架构的核心挑战:如何在保证租户间数据严格隔离的前提下,最大化资源利用率与运维效率

多租户数据隔离设计是分库分表与读写分离的上层应用策略,它直接决定了系统的安全边界、成本结构和可伸缩性。本文将从数据库层(独立数据库/共享表/混合方案)到安全加固层(PostgreSQL RLS + Redis ACL),再到中间件层(ShardingSphere-JDBC tenant 分片 + Mycat2 租户路由 + 租户级读写分离),构建一个完整的多租户数据架构闭环。我们将深入剖析“安全隔离 vs 资源成本”这一核心矛盾,提供从方案选型、安全加固到零停机迁移的全链路工程方案。


总结性引言

在一个拥有 10000 个租户的 SaaS 平台中,如果每个租户分配一个独立数据库,运维团队需要管理 10000 个数据库实例——备份、恢复、DDL 变更、监控,每一项操作都要重复 10000 次。更致命的是,连接的指数级增长会让应用服务器的连接池资源迅速耗尽。如果所有租户共享一张订单表,一次忘记加 WHERE tenant_id = ? 的 SQL 缺陷就可能让租户 A 看到租户 B 的订单数据——这是一个致命的安全事故。多租户数据隔离的核心矛盾正在于此:如何在安全隔离与资源成本之间找到平衡

PostgreSQL 的行级安全策略(RLS)给出了一个精妙的回答:CREATE POLICY tenant_isolation USING (tenant_id = current_setting('app.current_tenant'))。无论应用是否忘记 WHERE 条件,PG 自动为每一条 SQL 追加 tenant_id 过滤,将应用层疏忽的风险降为零。Redis ACL 的 Key 模式隔离(~tenant:100:*)在缓存层建立了同样的防线——即使代码中存在 DEL order:12345 的 Bug,Redis 也会因 Key 模式不匹配而拒绝执行。

current_setting('app.current_tenant') 在连接获取时被自动设置为 tenant_id,当 ShardingSphere-JDBC 的 tenant 分片策略将大租户路由到独立库、小租户路由到共享表,当租户规模增长触发 Debezium CDC 零停机迁移——这些机制的背后,是对多租户安全隔离与资源效率的深度设计。本文将从三种隔离方案的量化对比出发,到 PG RLS 与 Redis ACL 的安全加固原理,再到中间件层的租户路由与动态迁移,完整拆解多租户数据隔离架构的工程内核。


核心要点

  • 三种隔离方案:独立数据库(数据库级物理隔离,金融合规)vs 共享表(行级逻辑隔离 + RLS 加固,高资源效率)vs 混合方案(大租户独立库 + 小租户共享表,兼顾安全与成本)。
  • 安全 vs 成本的核心矛盾:独立库安全性最高但成本线性增长,共享表成本最低但 SQL 缺陷可能导致跨租户数据泄露。
  • PG RLS 安全加固CREATE POLICY tenant_isolation USING (tenant_id = current_setting('app.current_tenant')) 自动追加过滤条件,current_setting 上下文传递机制,零额外性能开销。
  • Redis ACL Key 模式隔离~tenant:{tenantId}:* 前缀匹配,强制 Key 命名规范 tenant:{tenantId}:{entity}:{id},命令级权限控制。
  • 中间件层多租户路由:ShardingSphere-JDBC 自定义 tenant 分片算法实现混合路由 + 读写分离嵌套,Mycat2 的规则路由。
  • 混合方案迁移策略:Debezium CDC Binlog 订阅实现零停机租户迁移,全量+增量同步,配置中心热切换。
  • 跨系列关联tenant_id 与分库分表(第 1 篇)、ProxySQL 租户级路由(第 2 篇)、PG RLS(PG 系列第 13 篇)、CDC 迁移(事务系列第 6 篇)、Redis 热 Key 问题(Redis 系列第 8 篇)。

文章组织架构图

flowchart TD
    1[1. 多租户核心概念: 三种隔离级别 + 安全vs成本矛盾 + 租户规模分层 + tenant_id天然分片键]
    2[2. PostgreSQL RLS 安全加固: 策略创建 + current_setting上下文传递 + 性能与连接池配合 + 策略管理进阶]
    3[3. Redis ACL Key模式隔离: Key命名规范 + ACL完整配置 + 命令权限细粒度控制 + 与RLS互补]
    4[4. 中间件层多租户路由: ShardingSphere tenant分片算法详解 + Mycat2租户路由 + 租户级读写分离嵌套]
    5[5. 三种方案量化对比: 隔离性/成本/运维/扩展性/性能/合规 + 成本量化模型 + 选型决策树]
    6[6. 混合方案租户迁移策略: Debezium CDC零停机迁移全流程 + 回滚预案]
    7[7. 面试高频专题: 12道深度面试题 + 系统设计题 + 追问与加分回答]
    1 --> 2
    2 --> 3
    3 --> 4
    4 --> 5
    5 --> 6
    6 --> 7

架构图说明

  • 总览:全文 7 个模块从多租户基础概念与三种隔离方案出发,逐步深入到安全加固层(PG RLS + Redis ACL)、中间件层路由、方案对比与迁移策略,最后以面试题收尾,形成“概念—加固—路由—决策—迁移—巩固”的认知闭环。
  • 模块 1:建立多租户前置知识,阐明独立库、共享表、混合方案的本质区别,以及 tenant_id 作为分片键的优势与容量规划。
  • 模块 2-3:安全加固层,分别从数据库(行级)和缓存(Key 级)两个层面,展示如何用 RLS 和 ACL 实现零信任安全,并深入探讨策略管理、命令权限等进阶话题。
  • 模块 4:中间件路由层,深入讲解 ShardingSphere-JDBC 自定义分片算法的实现细节、与读写分离的嵌套配置,以及 Mycat2 的规则路由。
  • 模块 5-6:选型决策与迁移,提供量化对比、成本估算模型、决策树和零停机迁移策略,包括回滚预案。
  • 模块 7:面试巩固,每个问题均包含详细解释、多角度追问和加分回答,系统设计题综合全部知识点。
  • 关键结论:多租户数据隔离的选型核心是“安全隔离 vs 资源成本”的权衡。独立数据库提供数据库级物理隔离但成本随租户数线性增长,共享表通过 PG RLS + Redis ACL 安全加固在低资源成本下实现了行级/Key 级强制隔离,混合方案按租户规模分层兼顾两者。tenant_id 是共享表方案下的天然分片键,PG RLS 的 current_setting 上下文传递是 RLS 落地生产的关键。

1. 多租户核心概念:三种隔离级别 + 安全 vs 成本矛盾 + 租户规模分层 + tenant_id 天然分片键

多租户架构的核心是在单一应用实例(或共享基础设施)上服务多个租户(客户/组织/工作空间),同时确保各租户的数据严格隔离,仿佛每个租户拥有独立的系统。数据隔离的设计直接决定了系统的安全边界、资源成本和运维复杂度。

1.1 三种隔离级别详解

业界普遍认可的多租户数据隔离方案有三种经典模式,由微软在 2006 年的架构论文中提出,并沿用至今。我们逐一深入剖析:

方案一:独立数据库(Database per Tenant)——数据库级隔离

这是隔离性最强的方案。每个租户拥有一个完全独立的数据库实例或至少一个独立的数据库 Schema(在 PostgreSQL 中一个实例可包含多个数据库,MySQL 中一个实例可包含多个 Schema,但通常 Schema 等同于 Database)。优点如下:

  • 物理资源隔离:可以为每个租户独立分配 CPU、内存、磁盘 IO 配额,一个租户的慢查询或资源消耗不会影响其他租户。
  • 权限隔离:每个数据库拥有独立的用户和权限体系,租户 A 的数据库账户无法访问租户 B 的数据,即使获取了连接也无权限。
  • 精准运维:备份、恢复、归档可以做到租户粒度。例如租户要求数据恢复,只需还原该租户的备份,不影响其他租户。
  • 合规认证:金融、医疗等行业监管要求数据物理或逻辑隔离,独立库可天然满足 SOC2、ISO 27001、GDPR 等合规审计。

缺点同样突出:

  • 成本线性增长:100 个租户需要 100 个数据库(或实例),10000 个租户需要 10000 个。无论是自建还是云数据库,实例费用和管理开销都随租户数线性攀升。
  • 运维复杂度爆炸:DDL 变更需要逐库执行。假设有一次紧急加字段,需要开发一个脚本遍历所有租户库执行,且要处理部分库失败的回滚。监控、备份、升级的重复劳动量巨大。
  • 连接池压力:应用服务器必须为每个租户库维护连接池,或使用动态数据源路由。连接数 = 租户数 × 每租户连接池大小,1000 租户每个池 10 个连接就是 10000 个连接,数据库服务器难以承受。必须引入中间件或服务端代理收敛连接。

方案二:Schema 级隔离(Schema per Tenant)

在 PostgreSQL 中,一个数据库可包含多个 Schema,每个租户使用独立的 Schema。这相当于共享了数据库实例的资源(如连接、缓冲池),但 Schema 间逻辑隔离。MySQL 中可以通过不同的数据库名(Schema)实现。其隔离性略低于独立实例,因为共享缓冲池和连接资源,可能发生资源争抢。但优点是实例数量少,运维相对简单,DDL 可通过模板库统一管理。

方案三:共享表(Shared Table)——行级隔离

所有租户的数据混合存储在同一套数据库表中,通过一个**租户标识列(tenant_id)**来区分每条记录归属。这是资源利用率最高的方案,但对应用层的 SQL 编写规范要求极高。

优点:

  • 极致资源利用:仅需维护一套数据库集群,10000 个租户的数据都在一套表中,硬件成本极低。
  • 统一运维:DDL 变更只需执行一次,备份恢复针对整个数据库即可。
  • 连接数低:应用只需维护少量连接即可服务所有租户。

缺点:

  • 隔离依赖应用层:所有 SQL 必须包含 WHERE tenant_id = ?。一旦遗漏,立即发生跨租户数据泄露。这是共享表方案的原罪
  • 单表数据膨胀:单表数据量 = 租户数 × 单租户平均数据量。10000 租户每租户 1 万条订单就是 1 亿条,需要分库分表。
  • 性能热点:大租户的高频查询会与其他租户争抢缓冲池和 CPU,可能拖慢小租户。

1.2 核心矛盾:安全隔离 vs 资源成本

独立库提供的是“物理”隔离,共享表提供的是“逻辑”隔离。二者之间的鸿沟就是安全与成本的矛盾。为了量化这种矛盾,我们建立一个简化的成本模型:

假设每个数据库实例(云 RDS 2C8G)的月成本为 CdbC_{db},可舒适承载的数据量上限为 500GB,连接数上限 200。有 N 个租户。

  • 独立库方案:总成本 N×Cdb\approx N \times C_{db}。N=1000 时,月成本 = 1000 × CdbC_{db}。运维工具开发和自动化框架的固定投入也随 N 增加。
  • 共享表方案:只需满足总数据量 i=1NDi\sum_{i=1}^N D_i 和总连接数需求,分片数 S=Di500GBS = \lceil \frac{\sum D_i}{500GB} \rceil,成本 S×Cdb\approx S \times C_{db}。通常 SNS \ll N,成本远低于独立库。
  • 安全隐患代价:独立库中一次误操作只影响单租户;共享表中一次 WHERE 条件缺失,可能泄露所有租户的数据。根据 OWASP 多租户安全最佳实践,应用层隔离不应被视为主要防线,必须借助基础设施或数据库内核机制强制执行

因此,共享表方案必须在数据库内核或中间件层引入强制隔离机制,才能将安全水平提升到可接受的程度。PostgreSQL 的 RLS 和 Redis 的 ACL 正是为此而生。

1.3 租户规模分层与混合方案

真实的 SaaS 业务中,租户规模往往是“二八分布”:20% 的大租户贡献了 80% 的数据量和请求量,而剩下 80% 的小租户总数据量可能还不及一个大租户。因此,混合方案(Hybrid) 应运而生:

  • 大租户(数据量 > 100GB 或 TPS > 500)分配 独立库,享受资源独占和强隔离。
  • 中小租户 共享一套数据库,使用 共享表 + 安全加固

这个分层标准不是绝对的,需要根据业务和硬件动态调整。建议从以下维度综合评判:

指标大租户阈值示例说明
数据量> 100GB超过单分片舒适容量的 20%,独立库可避免分片倾斜
峰值 TPS> 500在共享库中占比超过 30% 的总 TPS 时,影响其他租户
连接数> 50占用共享库连接资源超过 25%
存储 IOPS> 1000磁盘资源争抢明显

混合方案在安全隔离与资源成本之间取得了最佳平衡,但也引入了新的复杂度:如何动态地在两种模式间迁移租户?这将在第 6 节详述。

1.4 tenant_id 作为天然分片键

本系列第 1 篇中我们强调,理想的分片键应具备 高区分度、与绝大多数查询条件匹配、避免跨分片查询 的特性。在多租户共享表方案下,tenant_id 是进行分库分表的天然完美分片键:

  • 高区分度:不同租户的 tenant_id 值均匀分布,可选用取模或哈希算法分片。
  • 查询条件匹配:SaaS 应用中,99% 的业务查询都带有 tenant_id 作为过滤条件。即使在代码中未显式出现,也可能通过 RLS 自动注入。
  • 数据亲和性:同一租户的所有数据被路由到同一个分片,租户内部的关联查询(如 Order JOIN OrderItem ON order_id)均可在单分片内完成,无需跨分片 JOIN。这使得业务逻辑无需大幅修改即可适应分片架构。
  • 容量规划简便:分片数 = 总租户平均数据量 × 租户数 / 单分片舒适容量。设总租户 5000,平均每租户 20GB,总数据量 100TB,单分片舒适容量 500GB,则需要 200 个分片。

注意事项:对于超大租户(数据量超过单分片容量),不能将其部分数据分到不同分片,因为这会导致该租户内部查询跨分片。此时,该租户应该从共享分片集群中迁出,升级到独立库(即混合方案)。

三种多租户隔离方案架构图

flowchart LR
    subgraph DB_Level[独立数据库方案 - 数据库级隔离]
        direction TB
        App[应用实例]
        DB_TenantA[(数据库实例 A<br>租户 A 独占)]
        DB_TenantB[(数据库实例 B<br>租户 B 独占)]
        DB_TenantC[(数据库实例 C<br>租户 C 独占)]
        App -->|连接池 A| DB_TenantA
        App -->|连接池 B| DB_TenantB
        App -->|连接池 C| DB_TenantC
    end

    subgraph Shared_Table[共享表方案 - 行级隔离]
        direction TB
        App2[应用实例]
        DB_Shared[(共享数据库集群<br>所有租户)]
        Table_Order[订单表 orders<br>tenant_id + 业务字段]
        App2 -->|单一连接池| DB_Shared
        DB_Shared --> Table_Order
    end

    subgraph Hybrid[混合方案 - 分层隔离]
        direction TB
        App3[应用实例]
        DB_Large[(独立库<br>大租户 A)]
        DB_Shared2[(共享库集群<br>中小租户 B, C, D...)]
        Table_Shared[共享表 orders<br>tenant_id + 业务字段]
        App3 -->|路由| DB_Large
        App3 -->|路由| DB_Shared2
        DB_Shared2 --> Table_Shared
    end

    DB_Level --- Shared_Table --- Hybrid

图表说明

  • 总览:该图展示了三种多租户隔离方案的数据物理分布。从左至右依次为独立数据库方案、共享表方案、混合方案,分别对应数据库级隔离、行级隔离和分层隔离。
  • 独立数据库方案:每个租户的数据库实例(或 Schema)物理隔离,应用实例通过不同的数据库连接或连接池访问不同租户数据。隔离性最强,但实例数量随租户线性增长,连接数管理复杂。
  • 共享表方案:所有租户数据混合存储在同一张表中,仅通过 tenant_id 列区分。成本最低,所有租户共享连接池,但应用层必须保证每条 SQL 都包含正确的 tenant_id 条件。
  • 混合方案:根据租户规模分流,大租户走独立库通道,中小租户走共享表通道。这要求中间件层具备按租户路由的能力。
  • 关键结论:方案的选型是 安全 vs 成本 的权衡。独立库提供了“物理”隔离,共享表提供了“逻辑”隔离,混合方案试图在两者间取得平衡。下一节我们将看到如何通过安全加固将共享表的逻辑隔离提升到接近物理隔离的水平。

2. PostgreSQL RLS 安全加固:策略创建 + current_setting 上下文传递 + 性能与连接池配合 + 策略管理进阶

共享表方案最大的安全风险在于 应用层 SQL 缺陷。如果开发人员在某个查询中忘记添加 WHERE tenant_id = ? 条件,租户 A 就能看到租户 B 的数据。PostgreSQL 的行级安全策略(Row-Level Security,RLS)正是为了从数据库内核层面杜绝此类风险而设计的。它强制性地、自动地为所有 SQL 追加过滤条件,将安全防线从应用层下沉到数据库引擎。

关联前文:PostgreSQL RLS 的完整语法与权限管理已在 PostgreSQL 系列第 13 篇:安全机制 中详述,本文仅聚焦其在多租户场景下的实战应用与进阶陷阱。

2.1 RLS 原理:策略自动注入

RLS 的工作原理是为表定义一条或多条安全策略。策略本质上是一条隐式的 WHERE 子句,每当用户对表执行查询、插入、更新或删除操作时,PostgreSQL 自动将策略表达式附加到 SQL 语句上。这个过程发生在查询分析器和执行器之间,对客户端完全透明。

2.1.1 基础策略创建

-- 1. 开启行级安全(必须由表所有者或超级用户执行)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- 为安全起见,同时应禁止表所有者绕过 RLS(可选)
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

-- 2. 创建多租户隔离策略
-- 策略含义:所有操作只能针对 tenant_id 列等于当前应用租户上下文的行
CREATE POLICY tenant_isolation ON orders
    FOR ALL
    TO app_user  -- 指定应用使用的数据库角色,可选
    USING (tenant_id = current_setting('app.current_tenant')::integer)
    WITH CHECK (tenant_id = current_setting('app.current_tenant')::integer);

设计意图与安全语义解读

  • ENABLE ROW LEVEL SECURITY:将表从“无策略模式”切换为“强制策略模式”。表的所有者(通常是超级用户)默认不受 RLS 限制(除非使用 FORCE ROW LEVEL SECURITY),但普通应用用户必须遵守策略。
  • FORCE ROW LEVEL SECURITY:强制表所有者同样受 RLS 限制。强烈建议在多租户场景开启,避免管理员操作时疏忽泄露数据。
  • FOR ALL:此策略适用于 SELECTINSERTUPDATEDELETE 所有操作。
  • TO app_user:限定此策略仅对 app_user 角色生效。若省略,对所有非特权用户生效。
  • USING 子句:用于过滤已存在的行(对 SELECTUPDATEDELETE 生效)。只有 tenant_id 等于 current_setting('app.current_tenant') 的行才对当前会话可见。
  • WITH CHECK 子句:用于验证新插入或更新后的行(对 INSERTUPDATE 生效)。确保写入的数据必须属于当前租户,防止代码漏洞写入错误租户 ID 的数据。例如,若应用错误执行 INSERT INTO orders (tenant_id, ...) VALUES (9999, ...) 而当前租户为 12345,WITH CHECK 会导致插入失败,抛出错误。

2.1.2 策略类型与组合

PostgreSQL RLS 支持两种策略类型:PERMISSIVE(默认)和 RESTRICTIVE

  • PERMISSIVE:多个 PERMISSIVE 策略之间是 OR 关系,即满足任一策略的行可见。在多租户场景,通常只需一个策略。
  • RESTRICTIVE:多个 RESTRICTIVE 策略之间是 AND 关系,且必须与 PERMISSIVE 策略一起工作。最终可见行 = (满足至少一个 PERMISSIVE 策略) AND (满足所有 RESTRICTIVE 策略)。

复杂场景示例(如数据分级 + 多租户):

-- PERMISSIVE 策略:租户隔离
CREATE POLICY tenant_isolation ON documents FOR ALL
    USING (tenant_id = current_setting('app.current_tenant')::integer)
    WITH CHECK (tenant_id = current_setting('app.current_tenant')::integer);

-- RESTRICTIVE 策略:仅查看已发布文档
CREATE POLICY published_only ON documents AS RESTRICTIVE FOR SELECT
    USING (status = 'published');

此时,SELECT 查询相当于自动追加 WHERE tenant_id = 12345 AND status = 'published',实现双重约束。

2.2 current_setting 上下文传递机制

RLS 必须知道“当前是哪个租户在操作”,这通过 PostgreSQL 的会话级自定义配置参数实现。

2.2.1 参数设置

应用在获取连接后,执行业务 SQL 前,必须设置租户上下文:

SET app.current_tenant = '12345';

这个变量名 app.current_tenantapp. 前缀命名,表明是应用自定义参数,避免与系统参数冲突。变量作用域可以是 SESSION(连接级)或 LOCAL(事务级)。

  • SET app.current_tenant = '12345':默认是 SESSION 级,整个连接生命周期有效。
  • SET LOCAL app.current_tenant = '12345':仅当前事务有效,事务结束自动清除。在连接池场景下更安全,但需要在每个事务开始时设置。

2.2.2 应用层集成

// 方法1:使用 AOP 切面在事务开始设置
@Component
@Aspect
public class TenantAspect {
    @Around("@annotation(org.springframework.transaction.annotation.Transactional)")
    public Object setTenantContext(ProceedingJoinPoint pjp) throws Throwable {
        String tenantId = TenantContext.getCurrentTenant();
        // 获取当前连接并执行 SET LOCAL
        Connection conn = DataSourceUtils.getConnection(dataSource);
        try (Statement stmt = conn.createStatement()) {
            stmt.execute("SET LOCAL app.current_tenant = '" + tenantId + "'");
        }
        return pjp.proceed();
    }
}

// 方法2:使用 HikariCP 的 connectionInitSql(不适用于动态 tenant_id)
// 但可以通过动态数据源路由实现

最佳实践:在 Spring 的 TransactionSynchronization 中管理 SET LOCAL,事务提交或回滚后无需手动 RESET,彻底避免连接池污染。对于非事务操作(如只读查询),需用 SET SESSION 并在归还连接时显式 RESET。

2.3 连接池上下文污染防护

连接池(如 HikariCP、Druid)复用连接时,必须彻底清除租户上下文,否则租户 B 的请求可能继承租户 A 的 app.current_tenant,导致数据隔离完全失效。

2.3.1 显式 RESET 策略

// 在连接归还时执行 RESET
public class TenantAwareDataSource implements DataSource {
    private DataSource delegate;
    
    @Override
    public Connection getConnection() throws SQLException {
        Connection conn = delegate.getConnection();
        return new TenantAwareConnection(conn);
    }
    
    class TenantAwareConnection extends ConnectionProxy {
        @Override
        public void close() throws SQLException {
            // 归还前重置租户上下文
            try (Statement stmt = delegate.createStatement()) {
                stmt.execute("RESET app.current_tenant;");
                // 或者使用 DISCARD ALL 清除所有会话状态(慎用)
            }
            delegate.close(); // 真正归还连接池
        }
    }
}

2.3.2 连接池配置配合

某些连接池支持连接归还时的测试查询或清理动作:

  • HikariCPconnectionTestQuery 可配置为验证连接有效性的 SQL,但不应依赖它来清理状态。
  • PgBouncer 的事务模式(pool_mode = transaction)会在事务结束时自动执行 DISCARD ALL,彻底清除所有会话状态,包括自定义参数。这是最安全的连接池上下文清理机制,在多租户场景强烈推荐使用 PgBouncer 的事务模式。
# PgBouncer 配置
[databases]
shared_small = host=db-host port=5432 dbname=shared_small

[pgbouncer]
pool_mode = transaction
server_reset_query = DISCARD ALL

此时,应用无需手动 RESET,每个事务结束自动清除,上下文污染风险降为零。

2.4 RLS 的性能影响

结论:RLS 没有额外的性能开销。PostgreSQL 在查询优化阶段会将 RLS 策略转换为等价的条件表达式,合并到查询计划中。

验证实验:

-- 手动添加 tenant_id
EXPLAIN ANALYZE SELECT * FROM orders WHERE tenant_id = 12345 AND order_id = 100;

-- 使用 RLS(应用只发送 SELECT * FROM orders WHERE order_id = 100)
SET app.current_tenant = '12345';
EXPLAIN ANALYZE SELECT * FROM orders WHERE order_id = 100;

两者的执行计划完全一致:

Index Scan using idx_orders_tenant_order on orders  (cost=0.42..8.44 rows=1 width=...)
  Index Cond: ((tenant_id = 12345) AND (order_id = 100))

优化要点

  • tenant_id 列必须建立索引,并且建议作为复合索引的前缀列:
    CREATE INDEX idx_orders_tenant_order ON orders(tenant_id, order_id);
    
  • 对于 tenant_id 区分度不高的小租户,索引效果仍然良好,因为查询条件中总是包含 tenant_id = ?
  • RLS 策略中避免使用开销大的函数或子查询,应保持条件简单,确保优化器可内联。

2.5 RLS 与 ShardingSphere 的双重保险

在多租户分库分表架构中,ShardingSphere-JDBC 负责根据 tenant_id 将请求路由到正确的数据库分片。但万一分片路由出现 bug,或者运维人员误操作,可能会将租户 A 的 SQL 发送到租户 B 的分片。此时,如果目标分片上的 PostgreSQL 开启了 RLS:

  • ShardingSphere 将 SQL 路由至分片 B 的数据库。
  • 该数据库的 RLS 策略要求 tenant_id = current_setting('app.current_tenant')
  • 会话中的 app.current_tenant 依然为租户 A 的 ID,而分片 B 中数据的 tenant_id 均为租户 B,因此查询结果为空。
  • 双重保险生效:即使路由错误,也不会泄露数据。

这是“应用层路由 + 数据库层过滤”的完美协作,体现了深度防御原则。

2.6 与 Oracle VPD 的对比

Oracle 的 Virtual Private Database (VPD) 与 PG RLS 机制同构。VPD 通过 DBMS_RLS 包添加策略,同样自动追加谓词。但 PG RLS 的优势在于:

  • current_setting 灵活性:Oracle VPD 通常通过 CLIENT_IDENTIFIERCONTEXT 传递租户标识,配置相对复杂。PG 的 SET 命令和 current_setting 简洁直观。
  • 策略组合:PG RLS 的 PERMISSIVE/RESTRICTIVE 组合方式比 Oracle 的策略组更清晰。
  • 开源成本:无 License 限制。

PG RLS 安全加固原理图

sequenceDiagram
    participant App as 应用服务
    participant Pool as 连接池(如HikariCP/PgBouncer)
    participant PG as PostgreSQL 数据库
    participant RLS as RLS 策略引擎

    App->>Pool: 获取连接
    Pool-->>App: 连接(可能复用)
    App->>PG: SET app.current_tenant = '12345'
    Note over PG: 会话级变量设置
    App->>PG: SELECT * FROM orders WHERE order_id = 100
    Note over App,PG: 应用未添加 tenant_id 条件
    PG->>RLS: 检查 orders 表 RLS 策略
    RLS-->>PG: 注入 USING: AND tenant_id = current_setting('app.current_tenant')
    PG-->>App: 实际执行: SELECT * FROM orders WHERE tenant_id = 12345 AND order_id = 100
    App->>App: 处理结果
    App->>PG: COMMIT / ROLLBACK
    Note over App,Pool: 归还连接前清除上下文
    App->>PG: RESET app.current_tenant
    App->>Pool: 归还连接
    Pool->>Pool: 标记连接空闲,已重置

图表说明

  • 总览:该时序图展示了 PostgreSQL RLS 从连接建立到 SQL 执行的完整自动过滤流程,重点突出 current_setting 上下文传递与连接池上下文清除。
  • 会话变量设置:应用获取连接后立即执行 SET app.current_tenant = '12345',将租户上下文注入数据库会话。
  • SQL 自动过滤:即使应用只发送了 SELECT * FROM orders WHERE order_id = 100,PostgreSQL 的 RLS 引擎也会自动根据策略追加 AND tenant_id = current_setting('app.current_tenant'),从而转化为等价的带 tenant_id 条件的查询。
  • 上下文清除:连接归还连接池前,必须执行 RESET app.current_tenant。若使用 PgBouncer 事务模式,此步骤由 DISCARD ALL 自动完成。
  • 关键结论:RLS 实现了数据库层面的零信任安全:即使应用完全忘记 tenant_id 条件,数据隔离也不会被破坏。配合连接池的上下文清理,确保了整个生命周期的安全。

3. Redis ACL Key 模式隔离:Key 命名规范 + ACL 完整配置 + 命令权限细粒度控制 + 与 RLS 互补

Redis 在多租户架构中通常作为缓存、会话存储、分布式锁或轻量级消息队列。如果所有租户共享同一个 Redis 实例,Key 的命名和管理就变得至关重要。没有规范的 Key 命名,租户 A 的代码可能无意中操作到租户 B 的 Key。Redis 从 6.0 版本开始引入了完整的 ACL(Access Control List),支持对 Key 的模式匹配和命令授权,为共享实例的多租户隔离提供了内核级的支持。

3.1 共享实例的风险与命名规范

风险场景

  • 误删 Key:代码 bug DEL order:12345 可能误删除属于租户 B 的订单缓存。
  • Key 冲突:两个租户都使用了 user:profile 作为 Key,后者覆盖前者。
  • 数据泄露:租户 A 通过 KEYS * 可能看到租户 B 的 Key 名称,推测其业务数据。

强制 Key 命名规范:所有 Key 必须遵循 tenant:{tenantId}:{entity}:{id} 模式。

  • 租户 100 的订单 Key:tenant:100:order:12345
  • 租户 100 的用户 Key:tenant:100:user:1
  • 租户 200 的订单 Key:tenant:200:order:12345
  • 全局缓存(非租户相关):global:{entity}:{id},并由专门的无租户限制用户访问。

3.2 Redis ACL 完整配置

为每个租户创建一个 Redis 用户,并精确限制其 Key 模式和可执行命令。

3.2.1 创建租户用户

# 连接 Redis 后,使用 ACL 命令创建租户用户
# 租户 100:只能访问 tenant:100:* 前缀的 Key,可使用所有命令
ACL SETUSER tenant_100 ON >strong_password_100 ~tenant:100:* &* +@all

# 租户 200:只能访问 tenant:200:* 前缀的 Key,可使用所有命令
ACL SETUSER tenant_200 ON >strong_password_200 ~tenant:200:* &* +@all

# 持久化 ACL 到配置文件
ACL SAVE

参数详解

  • ON:启用用户。
  • >strong_password_100:设置密码。
  • ~tenant:100:*:允许的 Key 模式,支持 glob 风格。可多个 ~ 叠加,如 ~tenant:100:* ~common:*
  • &*:允许订阅所有 Pub/Sub 频道(如果不需要可去掉)。
  • +@all:允许所有命令。此权限过宽,生产环境应最小化。

3.2.2 最小权限原则配置

生产环境应仅授予必须的命令类别或具体命令,禁用危险命令:

ACL SETUSER tenant_100 ON >strong_password_100 \
    ~tenant:100:* \                    # Key 前缀限制
    &tenant:100:* \                    # 仅订阅本租户频道(Redis 7.0+ 支持频道模式)
    +@read +@write +@keyspace \        # 允许读写和键空间命令
    -FLUSHALL -FLUSHDB \               # 禁用清库命令
    -CONFIG -SHUTDOWN \                 # 禁用管理命令
    +CLIENT|SETNAME +CLIENT|GETNAME    # 允许设置连接名(监控用)
  • +@read:包含 GETMGETHGETZRANGE 等读命令。
  • +@write:包含 SETDELHSET 等写命令。
  • +@keyspace:包含 KEYSSCANRENAMEEXPIRE 等。注意 KEYS 可能遍历所有 Key,但由于已有 ~ 模式限制,只能看到本租户的 Key。
  • -FLUSHALL-CONFIG:显式禁用高危管理命令,即使密码泄露也不会删除所有数据。

3.2.3 应用层使用

// Spring Boot 2.7.x + Lettuce 配置多租户 Redis 连接
@Configuration
public class RedisConfig {
    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
        config.setHostName("redis-cluster-host");
        config.setPort(6379);
        // 使用当前服务的租户身份(或从线程上下文获取)
        String tenantId = TenantContext.getCurrentTenant();
        config.setUsername("tenant_" + tenantId);
        config.setPassword("strong_password_" + tenantId);
        return new LettuceConnectionFactory(config);
    }
}

3.3 ACL 与 RLS 的互补

层面数据库层 (PG RLS)缓存层 (Redis ACL)
隔离粒度行级(表内行过滤)Key 级(前缀模式匹配)
防护目标防止 SQL 缺陷导致跨租户数据泄露防止代码缺陷导致跨租户 Key 操作
强制机制自动追加 WHERE 条件到 SQL在命令执行前校验 Key 模式与命令权限
操作类型SELECT/INSERT/UPDATE/DELETEGET/SET/DEL/HGET 等所有 Redis 命令
策略管理SQL 策略,可组合(PERMISSIVE/RESTRICTIVE)用户级 ACL,可叠加 Key 模式和命令类别
性能影响零额外开销(等价的 WHERE 条件)轻微增加,ACL 检查在命令解析时完成

两者互补,构成数据库 + 缓存的全栈多租户安全防护。即使所有中间件和应用层都失败,底层存储依然坚守最后的隔离防线。

Redis ACL Key 模式隔离原理图

sequenceDiagram
    participant App100 as 租户100应用
    participant Redis as Redis 服务器
    participant ACL as ACL 引擎

    App100->>Redis: AUTH tenant_100 strong_password_100
    Redis->>ACL: 验证用户 tenant_100
    Note over ACL: 权限:~tenant:100:* +@read +@write

    App100->>Redis: GET tenant:100:order:12345
    Redis->>ACL: 检查 Key 模式与命令
    ACL-->>Redis: 允许(匹配 ~tenant:100:*,命令在+@read内)
    Redis-->>App100: "order_data"

    App100->>Redis: GET tenant:200:order:12345
    Redis->>ACL: 检查 Key 模式与命令
    ACL-->>Redis: 拒绝(不匹配 ~tenant:100:*)
    Redis-->>App100: -ERR NOPERM this user has no permissions to access one of the keys used as arguments

    App100->>Redis: DEL order:12345
    Redis->>ACL: 检查 Key 模式与命令
    ACL-->>Redis: 拒绝(不匹配 ~tenant:100:*)
    Redis-->>App100: -ERR NOPERM ...

图表说明

  • 总览:该时序图演示了 Redis ACL 如何通过 Key 模式匹配和命令权限校验拦截违规操作。租户 100 的用户仅被授权操作 tenant:100: 前缀的 Key。
  • 合法操作GET tenant:100:order:12345 因匹配 ~tenant:100:*GET 在允许命令中,被成功执行。
  • 跨租户访问拦截:尝试读取租户 200 的数据 tenant:200:order:12345,ACL 模式匹配失败,返回 NOPERM 错误。
  • 无前缀 Key 拦截:如果代码错误地使用了不含租户前缀的 Key order:12345,同样被 ACL 拒绝。这倒逼团队必须遵循 Key 命名规范。
  • 关键结论:Redis ACL 在命令执行前进行强制拦截,即使应用代码存在严重缺陷,也无法绕过 Key 模式限制。这为共享 Redis 实例的多租户场景提供了类似数据库 RLS 的“最后一道防线”。

4. 中间件层多租户路由:ShardingSphere tenant 分片算法详解 + Mycat2 租户路由 + 租户级读写分离嵌套

当混合方案落地时,需要中间件能够根据 tenant_id 智能地将流量路由到不同的数据源:大租户去独立库,小租户去共享表库。ShardingSphere-JDBC 和 Mycat2 都提供了这种能力。

4.1 ShardingSphere-JDBC tenant 分片策略深度拆解

ShardingSphere-JDBC 的 StandardShardingStrategy 允许我们自定义分片算法,实现租户到数据源的动态映射。

4.1.1 自定义分片算法

package com.example.sharding;

import org.apache.shardingsphere.sharding.api.sharding.standard.PreciseShardingValue;
import org.apache.shardingsphere.sharding.api.sharding.standard.RangeShardingValue;
import org.apache.shardingsphere.sharding.api.sharding.standard.StandardShardingAlgorithm;

import java.util.Collection;
import java.util.Collections;
import java.util.Set;

public class TenantShardingAlgorithm implements StandardShardingAlgorithm<Long> {

    // 大租户列表,可从配置中心(如Apollo、Nacos)动态加载
    private Set<Long> largeTenants;

    // 通过 init 方法注入配置
    @Override
    public void init(Properties props) {
        // 从 props 读取配置或通过其他方式加载
        largeTenants = ConfigCenter.getLargeTenants();
    }

    @Override
    public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) {
        Long tenantId = shardingValue.getValue();
        if (largeTenants.contains(tenantId)) {
            // 大租户路由到独立库
            for (String target : availableTargetNames) {
                if (target.startsWith("ds_tenant_" + tenantId)) {
                    return target;
                }
            }
            // 如果未找到专属数据源,抛异常或降级到共享库
            throw new RuntimeException("No datasource for large tenant: " + tenantId);
        } else {
            // 中小租户全部落到共享库
            for (String target : availableTargetNames) {
                if (target.startsWith("ds_shared_small")) {
                    return target;
                }
            }
        }
        throw new RuntimeException("No datasource found for tenant: " + tenantId);
    }

    @Override
    public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<Long> shardingValue) {
        // 范围查询(如 tenant_id BETWEEN 100 AND 200)可能涉及多个租户
        // 通常禁止不带精确 tenant_id 的查询,或者广播到所有共享库分片
        // 这里简化处理:返回所有共享库数据源
        return availableTargetNames; // 谨慎使用,可能导致性能问题
    }
}

4.1.2 完整 YAML 配置(混合方案 + 读写分离)

# ShardingSphere-JDBC 5.4.x 配置 (Spring Boot starter 模式)
spring:
  shardingsphere:
    datasource:
      names: ds_tenant_large_1_primary,ds_tenant_large_1_replica_0,ds_tenant_large_1_replica_1,
             ds_tenant_large_2_primary,ds_tenant_large_2_replica_0,ds_tenant_large_2_replica_1,
             ds_shared_small_primary,ds_shared_small_replica_0,ds_shared_small_replica_1
      # 每个物理数据源连接配置
      ds_tenant_large_1_primary:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: org.postgresql.Driver
        jdbc-url: jdbc:postgresql://host1:5432/tenant_large_1
        username: app_user
        password: pwd
      # ... 其他数据源配置类似

    rules:
      # 读写分离规则
      - !READWRITE_SPLITTING
        dataSources:
          ds_tenant_large_1_rw:   # 大租户1的读写分离逻辑数据源
            writeDataSourceName: ds_tenant_large_1_primary
            readDataSourceNames:
              - ds_tenant_large_1_replica_0
              - ds_tenant_large_1_replica_1
            loadBalancerName: round_robin
          ds_tenant_large_2_rw:   # 大租户2的读写分离逻辑数据源
            writeDataSourceName: ds_tenant_large_2_primary
            readDataSourceNames:
              - ds_tenant_large_2_replica_0
              - ds_tenant_large_2_replica_1
            loadBalancerName: round_robin
          ds_shared_small_rw:     # 中小租户共享库的读写分离逻辑数据源
            writeDataSourceName: ds_shared_small_primary
            readDataSourceNames:
              - ds_shared_small_replica_0
              - ds_shared_small_replica_1
            loadBalancerName: round_robin
      # 分片规则
      - !SHARDING
        tables:
          t_order:
            # 这里 actualDataNodes 引用的是读写分离逻辑数据源名
            actualDataNodes: ds_tenant_large_1_rw.t_order, ds_tenant_large_2_rw.t_order, ds_shared_small_rw.t_order
            databaseStrategy:
              standard:
                shardingColumn: tenant_id
                shardingAlgorithmName: tenant_route
          t_user:
            actualDataNodes: ds_tenant_large_1_rw.t_user, ds_tenant_large_2_rw.t_user, ds_shared_small_rw.t_user
            databaseStrategy:
              standard:
                shardingColumn: tenant_id
                shardingAlgorithmName: tenant_route

        shardingAlgorithms:
          tenant_route:
            type: CLASS_BASED
            props:
              strategy: standard
              algorithmClassName: com.example.sharding.TenantShardingAlgorithm
              # 可通过 props 传递配置
              largeTenants: 1001,1002
    props:
      sql-show: true

解读

  • 物理数据源(primary/replica_*)首先被定义。
  • 读写分离规则将物理数据源组合成逻辑数据源(_rw 后缀)。
  • 分片规则的 actualDataNodes 直接引用读写分离逻辑数据源名,实现先租户路由,后读写分离的两级代理。
  • 分片算法 tenant_route 动态识别租户规模,返回对应的逻辑数据源。

4.2 Mycat2 按租户路由配置

Mycat2 通过 prototype 数据源和分片规则实现类似功能。其 rule.xml 配置示例如下:

<!-- rule.xml -->
<tableRule name="tenant_shard">
    <rule>
        <columns>tenant_id</columns>
        <algorithm>tenant_hash</algorithm>
    </rule>
</tableRule>

<function name="tenant_hash" class="io.mycat.router.function.PartitionByMurmurHash">
    <property name="seed">0</property>
    <property name="count">3</property>  <!-- 分片数 -->
    <property name="virtualBucketTimes">160</property>
</function>

在 Mycat2 的 datasources 配置中,可以将不同分片映射到不同的物理库(大租户独立库或共享表分片)。但 Mycat2 的动态租户判断不如 ShardingSphere-JDBC 的自定义算法灵活,通常需要提前将 tenant_id 通过哈希固定到特定分片,然后在配置中将大租户的哈希槽位映射到独立库。这需要更为复杂的哈希槽位规划。

4.3 HintManager 强制路由

在某些特殊场景(如后台管理系统需要跨租户查询),可以通过 HintManager 强制指定数据源:

// 强制路由到特定租户数据源
HintManager hintManager = HintManager.getInstance();
hintManager.setDatabaseShardingValue("tenant_12345");
try {
    // 执行 SQL,将忽略分片算法,直接使用 hint
    orderMapper.selectAll();
} finally {
    hintManager.close(); // 必须关闭,避免污染后续操作
}

ShardingSphere-JDBC 租户路由与读写分离嵌套配置图

flowchart TD
    Request[用户请求<br>tenant_id=1001]
    SD[ShardingSphere-JDBC<br>分片路由层]
    Algorithm[TenantShardingAlgorithm<br>判断租户规模]
    Large[大租户独立库组<br>ds_tenant_large_1_rw]
    Small[中小租户共享库组<br>ds_shared_small_rw]
    RWS[读写分离路由]
    Primary[主库<br>ds_tenant_large_1_primary]
    Replica0[从库0<br>ds_tenant_large_1_replica_0]
    Replica1[从库1<br>ds_tenant_large_1_replica_1]

    Request --> SD
    SD --> Algorithm
    Algorithm -- 大租户 --> Large
    Algorithm -- 中小租户 --> Small
    Large --> RWS
    RWS -- 写请求 --> Primary
    RWS -- 读请求 --> Replica0
    RWS -- 读请求 --> Replica1

图表说明

  • 总览:该流程图展示了 ShardingSphere-JDBC 处理多租户请求时的两级路由机制:第一级基于 tenant_id 的租户规模路由,第二级基于 SQL 类型的读写分离路由。
  • 第一级路由:自定义 TenantShardingAlgorithm 检查 tenant_id 是否为预定义的大租户。若是,路由到该租户专属的读写分离组 ds_tenant_large_1_rw;否则路由到共享库读写分离组 ds_shared_small_rw
  • 第二级路由:在选定的读写分离组内,根据 SQL 是读还是写,分别转发到主库或从库(负载均衡器为 round_robin)。
  • 配置关联:该架构对应 YAML 中 actualDataNodes 使用 _rw 后缀的数据源组名,而 _rw 组又在 READWRITE_SPLITTING 规则中定义。整体配置层次清晰。
  • 关键结论:ShardingSphere 通过规则嵌套优雅地解决了混合方案下的两级路由问题,使大租户既能享受独立库的隔离性,又能具备读写分离的读扩展能力。

5. 三种方案量化对比:隔离性/成本/运维/扩展性/性能/合规 + 成本量化模型 + 选型决策树

5.1 八维度对比矩阵

维度独立数据库 (Database per Tenant)共享表 (Shared Table)混合方案 (Hybrid)
隔离级别数据库级物理隔离行级逻辑隔离大租户物理隔离,小租户逻辑隔离
安全性(SQL 缺陷风险)极低,误操作仅影响单租户高,忘记 WHERE 条件即泄露数据大租户低,小租户需 RLS 加固
成本极高,成本随租户数线性增长 (1000 租户 ≈ 1000 实例)低,单一数据库集群中,只需为少数大租户准备独立实例
运维复杂度极高,备份/恢复/DDL 需逐库执行低,统一运维,但单表热点风险中,需维护多套库,但数量可控
扩展性优,可租户级迁移到更高配实例差,需分库分表(分片键=tenant_id)优,大租户纵向扩展,小租户横向分片
性能优,资源隔离,无邻居干扰差,共享资源,大租户可能拖慢小租户优,大租户独占资源,小租户池化共享
合规审计天然符合 SOC2/ISO 27001需应用层日志配合,审计复杂大租户合规,小租户需额外审计
推荐租户规模< 100 且对隔离要求极高> 1000 且成本敏感租户规模分层明显(20%大租户)
数据库连接数N × pool_size (数千)1 × pool_size (数十)L × pool_size (L 为大租户数,可控)
是否需要分库分表否,单个租户数据量有限是,单表数据量 = 租户数 × 单租户量共享部分需要,独立部分不需要

5.2 成本量化模型

为了帮助决策,我们建立一个简化的 3 年总拥有成本(TCO)模型。

假设:N = 5000 租户,大租户占比 20% (L = 1000),小租户 4000。单租户平均数据量:大租户 500GB,小租户 20GB。数据库实例月成本 C=2002C8G高可用版,可承载500GB数据)。运维人力成本C = 200(2C8G 高可用版,可承载 500GB 数据)。运维人力成本 P = 10000/月。

  • 独立库方案

    • 实例数 = N = 5000
    • 每月基础设施 = 5000 × 200 = $1,000,000
    • 运维人力:至少需要 5 名 DBA 专职维护,$50,000/月
    • 月 TCO ≈ $1,050,000
    • 3 年 TCO ≈ $37,800,000
  • 共享表方案(含分库分表)

    • 总数据量 = 1000×500 + 4000×20 = 500TB + 80TB = 580TB
    • 需要分片数 = 580TB / 500GB = 1160,冗余设计取 1200
    • 实例数 = 1200(假设一主一从,实际需 2400,此处简化按高可用版计)
    • 每月基础设施 = 1200 × 200 = $240,000
    • 运维人力:2 名 DBA,$20,000/月
    • 月 TCO ≈ $260,000
    • 3 年 TCO ≈ $9,360,000
    • 但需额外投入安全加固开发(RLS/ACL 集成),一次性成本约 $50,000
  • 混合方案

    • 大租户独立库:1000 个大租户各一个独立实例(部分可共享高配实例,此处按简单模型计)= 1000 实例
    • 小租户共享表:总数据 80TB,分片数 = 80TB/500GB = 160,冗余设计 200
    • 总实例数 = 1000 + 200 = 1200
    • 每月基础设施 = 1200 × 200 = $240,000
    • 运维人力:3 名 DBA,$30,000/月
    • 月 TCO ≈ $270,000
    • 3 年 TCO ≈ $9,720,000
    • 安全加固一次性成本同上

分析:共享表和混合方案在基础设施成本上远低于独立库(仅约 1/4),运维人力也大幅减少。混合方案比共享表略高(因大租户独立实例较多),但提供了更好的隔离性和合规性。

5.3 选型决策树

  1. 业务是否要求金融级合规(如 SOC2)?

    • → 租户数是否 < 100 且预算充足?
      • 是 → 独立数据库方案。每租户一库,提供强隔离和合规审计。
      • 否 → 使用 混合方案,大租户独立库满足审计,小租户共享表+RLS 辅助审计,并出具渗透测试报告证明隔离有效性。
    • → 租户数量级?
      • 1000 且成本敏感 → 共享表 + PG RLS + Redis ACL。以最低成本支持海量租户,用安全加固弥补隔离缺陷。

      • 租户规模差异大(有大有小) → 混合方案,按数据量/TPS 动态分层。
  2. 技术栈是否使用 PostgreSQL?

    • → 强烈推荐在共享表上开启 RLS,这是低成本高安全的利器,实施成本极低。
    • 否(如 MySQL) → 共享表方案必须依赖严格的应用层代码规范和 Code Review,或者使用 ProxySQL 的查询重写功能模拟 RLS(详见第 2 篇),但无法做到 100% 自动。
  3. 是否需要 Redis 缓存?

    • 是 → 无论数据库选哪种方案,Redis 层必须使用 ACL + Key 前缀隔离。共享 Redis 实例时,ACL 是唯一的内核级保障。

三种方案量化对比雷达图

以下用分数(1-5 分,5 为最优)可视化对比:

flowchart TB 
    subgraph 独立库 ["独立库"]
        direction LR
        A1["隔离性: 5"]
        A2["成本: 1"]
        A3["运维: 1"]
        A4["扩展性: 4"]
        A5["性能: 5"]
        A6["合规: 5"]
    end
    subgraph 共享表 ["共享表"]
        direction LR
        B1["隔离性: 2"]
        B2["成本: 5"]
        B3["运维: 5"]
        B4["扩展性: 3"]
        B5["性能: 3"]
        B6["合规: 2"]
    end
    subgraph 混合方案 ["混合方案"]
        direction LR
        C1["隔离性: 4"]
        C2["成本: 3"]
        C3["运维: 3"]
        C4["扩展性: 5"]
        C5["性能: 4"]
        C6["合规: 4"]
    end

    classDef lib fill:#f1f5f9,stroke:#334155,stroke-width:2px,color:#0f172a
    classDef shared fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b
    classDef mix fill:#ede9fe,stroke:#8b5cf6,stroke-width:2px,color:#3b2f4b

    class A1,A2,A3,A4,A5,A6 lib
    class B1,B2,B3,B4,B5,B6 shared
    class C1,C2,C3,C4,C5,C6 mix

图表说明

  • 总览:通过对比独立库、共享表、混合方案在六个关键维度上的表现(1-5分),直观展示三者的优势与短板。
  • 独立库:在隔离性、性能和合规上获得满分,但成本和运维复杂度得分极低,适合对安全要求极高且预算充足的小规模租户场景。
  • 共享表:成本和运维复杂度满分,性能与扩展性中等,但隔离性和合规性是短板。适合成本敏感的大规模中小租户,必须配合 RLS/ACL。
  • 混合方案:所有维度均获得中高分,尤其在扩展性上表现优异,是最具工程弹性的方案。隔离性虽略低于独立库,但通过 RLS/ACL 可逼近满分。
  • 关键结论:没有银弹。混合方案在多数 SaaS 场景中提供了最佳的平衡,而安全加固(RLS+ACL)是提升共享表及混合方案隔离性的关键手段。

6. 混合方案租户迁移策略:Debezium CDC 零停机迁移全流程 + 回滚预案

混合方案的动态性在于租户规模会增长:今天的小租户明天可能变成大租户,需要从共享表迁移到独立库。这要求一套平滑、零停机的迁移方案。

6.1 迁移触发条件与决策

迁移是重量级操作,需要明确的触发阈值,避免频繁抖动。建议同时满足以下条件至少两条,且持续时间超过 1 小时:

  • 数据量阈值:单租户数据超过 100GB 或共享表总数据量的 10%。
  • TPS/QPS 阈值:单租户峰值 TPS 超过共享库总 TPS 的 30%,且共享库出现资源瓶颈(CPU > 80%,连接数接近上限)。
  • 慢查询数量:该租户慢查询数量占共享库 40% 以上。
  • 业务重要性:该租户为 VIP 客户,合同要求独立资源。

6.2 迁移流程详解

  1. 目标环境准备:创建独立数据库实例,并从模板库或共享库导出该租户的表结构(DDL)执行到目标库。确保 PostgreSQL 版本一致、扩展一致。
  2. 全量数据同步:使用 Debezium 的初始快照(Snapshot)模式,将共享表中该租户的所有现存数据导出并导入目标库。Debezium 的快照保证了一致的读取点。
  3. 增量数据同步:Debezium 订阅共享表数据库的 Binlog(或 PG 的逻辑复制),实时捕获该租户的后续数据变更(INSERT/UPDATE/DELETE),同步至独立库。注意配置 transforms 过滤器仅传递该租户的事件,避免资源浪费。
  4. 数据一致性校验:在全量和增量同步期间,持续执行数据校验。可采用工具(如 pg_comparator)或自定义脚本,对比源和目标库该租户的各表行数和校验和。当延迟稳定在秒级且校验通过时,进入切换阶段。
  5. 路由切换:修改配置中心(如 Apollo)的大租户列表,将目标租户 ID 加入。ShardingSphere-JDBC 通过配置监听器感知变更,热加载分片规则。对于服务端代理(如 ShardingSphere-Proxy),直接更新配置并在线生效。
  6. 流量切换验证:在配置中心切换后,监控该租户的流量是否全部转向新独立库,同时观察错误日志和延迟。可灰度切换(先放少量流量验证)。
  7. 旧数据清理:确认业务稳定运行 24-48 小时后,异步清理共享表中该租户的数据。注意:执行 DELETE 时需分批进行,避免对共享库造成大事务影响其他租户。建议每批 1000 条,间隔 100ms。

6.3 Debezium CDC 配置示例

{
  "name": "tenant-migration-1001",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "shared-db-host",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "password",
    "database.dbname": "shared_small",
    "topic.prefix": "shared_db",
    "table.include.list": "public.t_order,public.t_user",
    "plugin.name": "pgoutput",
    "slot.name": "debezium_tenant_1001",
    "publication.autocreate.mode": "filtered",
    "snapshot.mode": "initial",
    "transforms": "filterTenant",
    "transforms.filterTenant.type": "io.debezium.transforms.Filter",
    "transforms.filterTenant.language": "jsr223.graal.js",
    "transforms.filterTenant.condition": "value.payload.tenant_id == 1001"
  }
}

解读

  • plugin.name: pgoutput:使用 PostgreSQL 逻辑复制,需提前设置 wal_level = logical
  • slot.name:复制槽名称,用于保持增量同步位置。
  • transforms.filterTenant.condition:过滤条件,确保只同步 tenant_id = 1001 的数据变更,减少网络和存储开销。

6.4 零停机与回滚预案

切换的原子性:通过配置中心,所有服务实例会在数秒内感知到配置变更,新请求使用新路由规则。在变更瞬间,可能存在极少量请求仍使用旧路由,但由于目标库数据实时同步,最终一致。

回滚预案:迁移失败或独立库性能不佳时,需快速回滚:

  1. 将租户 ID 从大租户列表中移除(配置中心回滚)。
  2. 同时检查共享表中该租户数据是否完整(因迁移后可能已有新数据写入独立库,需反向同步回共享表)。为简化,迁移后 24 小时内共享表数据暂不清理,回滚时只需回滚配置即可,短暂的数据不一致由业务重试解决。
  3. 确认流量回到共享库后,再评估问题。

混合方案租户迁移流程图

flowchart TD
    Trigger[监控触发: 租户1001 超过阈值]
    CreateDB[创建独立库实例<br>执行DDL]
    Snapshot[Debezium 初始快照<br>全量同步]
    CDC[Debezium 增量同步<br>基于PG逻辑复制]
    Verify[数据一致性校验<br>行数/校验和比对]
    PreSwitch[预切换: 配置中心更新大租户列表<br>灰度路由]
    FullSwitch[正式切换<br>全量流量进入独立库]
    Observe[观察期 24-48小时<br>监控性能与错误]
    Clean[异步分批清理<br>共享表中旧数据]
    Rollback[回滚: 从大租户列表移除<br>流量回共享库]
    Complete[迁移完成<br>关闭Debezium任务]

    Trigger --> CreateDB
    CreateDB --> Snapshot
    Snapshot --> CDC
    CDC --> Verify
    Verify -- 通过 --> PreSwitch
    Verify -- 失败 --> CDC
    PreSwitch --> FullSwitch
    FullSwitch --> Observe
    Observe -- 正常 --> Clean
    Observe -- 异常 --> Rollback
    Clean --> Complete

图表说明

  • 总览:该流程图描述了将一个小租户从共享表迁移到独立库的完整生命周期,包含正反向流程。
  • 同步与校验:Debezium 先全量后增量,保证数据不丢。校验步骤是必须的安全闸门,未通过则持续重试增量同步。
  • 灰度和正式切换:预切换阶段可以只放少量请求验证独立库正确性,再全量切换。配置中心变更可秒级完成。
  • 观察与清理:切换后保留 24-48 小时观察期,确认独立库运行稳定。此期间共享表旧数据保留,支持快速回滚。
  • 关键结论:基于 CDC 的租户迁移实现了混合方案的动态伸缩能力,使架构能自适应业务增长,避免提前过度投资独立库。回滚预案降低了迁移风险。

7. 面试高频专题

1. 多租户数据隔离有哪三种方案?各自在隔离性、成本、运维上的优劣是什么?

  • 一句话回答:独立数据库(数据库级隔离,高成本高隔离)、共享表(行级隔离,低成本低隔离)、混合方案(大租户独立库+小租户共享表,兼顾两者)。
  • 详细解释:独立库提供天然物理隔离,每个租户一个数据库实例,单租户故障不影响其他,备份恢复精准,但租户数量增多时实例成本、运维工作量指数级上升,连接池管理复杂。共享表通过 tenant_id 列区分,所有租户共享实例,资源利用率最高,运维统一,但应用层 SQL 缺陷可能导致数据泄露,且单表数据量膨胀快,必须分库分表。混合方案按租户规模分层,大租户独占实例,小租户共享,实现了隔离与成本的折中,但需中间件路由和动态迁移能力。
  • 多角度追问:(1) Schema 级隔离与数据库级隔离的本质区别是什么?(2) 共享表方案如何与分库分表结合?(3) 金融行业为什么倾向独立库?(4) 如何向非技术经理解释共享表的数据安全风险?
  • 加分回答:提到 OWASP 多租户安全最佳实践中建议“最小权限原则”和“数据隔离深度防御”,共享表方案必须辅以数据库 RLS 或应用层 AOP 切面强制注入 tenant_id,并定期进行渗透测试验证隔离有效性。同时,独立库方案可借助云数据库的逻辑实例或模板库半自动化运维。

2. 共享表方案的最大安全隐患是什么?PG RLS 如何解决?

  • 一句话回答:最大隐患是应用 SQL 忘记 WHERE tenant_id = ? 导致跨租户数据泄露;PG RLS 通过自动注入 USING 子句彻底杜绝此类疏漏。
  • 详细解释:开发人员在高频迭代中难免遗漏条件,尤其在动态拼接 SQL、复杂报表、后台任务时。RLS 将过滤逻辑下沉到数据库内核,无论上层代码如何编写,所有 SQL 都等价地追加了 tenant_id = ? 条件。甚至 pg_dump 备份工具也会受 RLS 约束,保证了全链路安全。RLS 通过 current_setting 获取租户上下文,实现了与应用的解耦。
  • 多角度追问:(1) RLS 对 INSERT 语句是如何保护的?(2) 如果应用使用超级用户连接,RLS 还有效吗?(3) RLS 策略是否会影响查询性能?(4) 多个策略如何组合?
  • 加分回答:RLS 可通过 EXPLAIN 查看策略对执行计划的影响,实际证明是零额外开销。Oracle 的 VPD 机制与 RLS 同构,PG 的实现更开放和灵活。推荐使用 FORCE ROW LEVEL SECURITY 强制包括表所有者也受限制。WITH CHECK 子句对写入进行校验,防止错误写入其他租户 ID。对于复杂场景,可用 RESTRICTIVE 策略实现多条件 AND 组合。

3. PG RLS 的 current_setting 上下文传递是如何工作的?连接池场景如何防止上下文污染?

  • 一句话回答:应用通过 SET app.current_tenant = '123' 设置自定义会话变量,RLS 策略引用该变量实现动态过滤;连接归还时需 RESET 或使用 PgBouncer 事务模式自动清除防止污染后续请求。
  • 详细解释current_setting 是 PG 读取配置参数的函数。SET 命令设置的变量仅对当前会话有效。连接池复用连接时,如果不清除,下一个租户的请求会沿用上一个租户的 app.current_tenant 值,造成严重的数据泄露。解决方法包括:(1) 在事务结束时显式执行 RESET;(2) 使用 SET LOCAL 使变量仅事务内有效,事务结束自动清除;(3) 使用 PgBouncer 的事务模式,配置 server_reset_query = DISCARD ALL 在每个事务结束后清除所有会话状态。这是最安全的方案。
  • 多角度追问:(1) 如果使用 PgBouncer 事务模式,是否还需要手动 RESET?(2) SET LOCALSET SESSION 有什么区别?(3) HikariCP 如何配置连接归还时的清理?(4) 如何验证上下文确实被清除了?
  • 加分回答:生产实践建议使用 ThreadLocal 存储租户上下文,并在 Spring 的 TransactionSynchronization 中统一执行 SET LOCAL,利用事务边界自动清除,避免遗忘。对于非事务操作,显式在 finally 块中 RESET。验证方法:在测试中模拟并发租户请求,检查查询结果是否隔离正确。

4. Redis ACL 如何实现多租户 Key 模式隔离?Key 命名规范是什么?

  • 一句话回答:通过 ACL SETUSER 为每个租户创建用户并限定 Key 前缀 ~tenant:{id}:* 和命令权限,强制 Key 命名遵循 tenant:{tenantId}:{entity}:{id} 规范。
  • 详细解释:Redis ACL 的 ~ 参数支持 glob 风格的模式匹配。当某个用户尝试访问不匹配其前缀的 Key 时,Redis 直接返回 NOPERM 错误。这迫使所有代码必须使用规范的 Key 命名,从根本上避免了 Key 冲突和误操作。同时可结合 +@read-FLUSHALL 等细粒度控制命令权限,实现最小权限。
  • 多角度追问:(1) 除了 Key 限制,还能限制命令吗?(2) Redis Cluster 模式下 ACL 如何配合 Hash Tag?(3) ACL 文件如何持久化?(4) 如何审计 Redis 用户的操作?
  • 加分回答:应遵循最小权限原则,除 ~ 外,通过 +- 精确控制可执行命令,如 +@read +@hash,禁用 FLUSHALL 等危险命令。在 Redis Cluster 中,Key 前缀 tenant:{tenantId} 可作为 Hash Tag 确保同一租户的 Key 落在一个槽位,避免跨节点操作。ACL SAVE 将规则持久化,ACL 日志可记录被拒绝的命令,用于安全审计。

5. tenant_id 为什么是共享表方案的天然分片键?容量规划如何做?

  • 一句话回答:因为 SaaS 查询几乎都带 tenant_id,且同一租户数据在同一分片可避免跨分片 JOIN;容量规划公式:分片数 = (租户数 × 单租户平均数据量) / 单分片舒适容量
  • 详细解释:分片键的首选是高区分度且与查询条件一致的字段。tenant_id 完美符合,它保证了租户内的数据亲和性。容量规划时需预估租户增长(3年)和数据膨胀率(年增30%),单分片舒适容量建议 < 500GB(MySQL)或 < 1TB(PostgreSQL),以保持运维弹性(备份、迁移时间可接受)。
  • 多角度追问:(1) 如果大租户的单租户数据量超过单分片容量怎么办?(2) 分片键能否用 tenant_id + order_id 的组合?(3) 容量规划时如何考虑数据副本和备份?(4) 如何应对租户数据分布不均匀(倾斜)?
  • 加分回答:大租户超过单分片容量,正是启动混合方案中独立库的触发条件。此时该租户从共享分片中迁出,享受独立资源。组合分片键(如 tenant_id + order_id)可进一步分散单个租户的大表,但会牺牲租户内 JOIN 的亲和性,需慎用。应对倾斜可通过定期监控分片数据量,对超大租户提前迁移。

6. 混合方案如何按租户规模分层?大租户和小租户的划分标准是什么?

  • 一句话回答:根据数据量(>100GB)或 TPS(>500)划分大租户,并结合资源监控动态调整,通过配置中心维护大租户列表。
  • 详细解释:标准不是绝对的,需结合业务与硬件。核心是当某个租户的资源消耗开始显著影响共享池中其他租户的 SLA 时,就应该“隔离”出去。监控指标包括磁盘占用、IOPS、CPU 使用率、连接数、慢查询数量等。建议设定一个综合评分,超过阈值且持续一段时间即触发迁移。
  • 多角度追问:(1) 划分标准多久评估一次?(2) 如果大租户又缩水为小租户,是否需要迁回?(3) 大租户列表如何在分布式微服务中一致更新?(4) 如何避免频繁迁移抖动?
  • 加分回答:通过 Prometheus + Grafana 监控每个租户的资源消耗,设定基于租户的动态阈值告警,半自动化地触发迁移流程。为避免抖动,设置一个冷却期(如 7 天),迁移后短期内不再触发回迁。配置中心推送变更,微服务实例通过长轮询或事件监听感知列表变化。

7. 混合方案下小租户增长为大租户时如何迁移?如何保证零停机?

  • 一句话回答:使用 Debezium CDC 实时同步数据到新独立库,完成后动态切换分片路由规则,旧数据异步清理。
  • 详细解释:零停机的关键在于数据实时同步和路由配置热加载。Debezium 捕获所有变更,使源和目标数据始终保持亚秒级延迟。切换时只需将租户 ID 加入大租户列表,ShardingSphere 等中间件通过配置中心感知,新事务自动使用新路由,旧连接自然消亡。整个过程无需重启应用,对用户完全透明。
  • 多角度追问:(1) 迁移过程中如果原共享库数据被修改怎么办?(2) 如何验证数据完全一致?(3) 切换后如何快速回滚?(4) 迁移失败如何补偿?
  • 加分回答:Debezium 的增量同步保证了修改被实时捕获。数据一致性可通过行数、校验和、或专业工具比对。回滚策略:在观察期内保留共享表旧数据,配置中心回滚大租户列表即可快速恢复,但需评估短暂的不一致。迁移失败则停止同步,清理目标库,重新评估。

8. PG RLS 与 ShardingSphere tenant 分片如何协作实现双重保险?

  • 一句话回答:ShardingSphere 负责路由请求到正确的分片,PG RLS 在分片内部再次根据 current_setting 过滤,防止路由错误导致的数据泄露。
  • 详细解释:这是“深度防御”原则的体现。应用层路由(ShardingSphere)处理性能(减少目标数据量),数据库层策略(RLS)处理安全(最后一道防线)。即使分片算法因 bug 将请求发错数据库,RLS 也会令其返回空集,而不是泄露数据。两者独立配置,互不依赖,形成纵深防御。
  • 多角度追问:(1) 双重过滤会不会增加额外延迟?(2) 如果分片算法已完美,是否还需要 RLS?(3) MySQL 下如何实现类似双重保险?(4) 如何测试这层保险是否有效?
  • 加分回答:RLS 无额外延迟(等价的 WHERE)。即使分片完美,人为运维失误可能配错数据源,RLS 是保底。MySQL 无法通过内核实现,可借助 ProxySQL 查询规则自动注入 AND tenant_id = ... 作为应用层 RLS 的替代,但需注意动态性。测试方法:故意将请求路由到错误分片,验证返回结果为空集而非数据。

9. 独立数据库方案在 1000+ 租户规模下的运维挑战是什么?如何自动化?

  • 一句话回答:挑战是 DDL 变更、备份恢复和监控的线性重复劳动;自动化依赖 Schema 管理工具(Flyway/Liquibase)、配置管理(Ansible/Terraform)和统一的监控告警平台。
  • 详细解释:1000 个数据库,一次加字段操作就需要执行 1000 次。必须采用数据库迁移工具以代码化管理 DDL,结合 CI/CD 流水线按批次执行,并处理部分失败重试。备份策略也需要元数据管理,实现自动发现新库并纳入备份计划。监控需要聚合所有实例的指标,实现租户级别的资源视图。
  • 多角度追问:(1) 如何保证 1000 个库的表结构严格一致?(2) 独立库的连接池如何管理,避免连接数爆炸?(3) 是否考虑使用云数据库的“逻辑实例”功能?(4) 如何高效地对 1000 个库执行查询(如全局报表)?
  • 加分回答:使用 Flyway 管理迁移脚本,并通过自定义框架遍历所有租户执行,失败告警。连接池需引入动态数据源路由,按需创建连接,或使用服务端代理(如 ShardingSphere-Proxy)收敛连接。云厂商的 RDS 实例克隆可快速创建新租户库。全局报表可通过联邦查询或分布式查询引擎(如 Trino)实现。

10. Redis ACL 和 PG RLS 在多租户安全体系中各自解决什么层面的问题?如何互补?

  • 一句话回答:PG RLS 解决数据库行级数据隔离,Redis ACL 解决缓存 Key 级数据隔离,两者构成从数据库到缓存的全栈存储安全防线。
  • 详细解释:现代应用的数据往往同时存在于数据库和缓存中。攻击面包括数据库 SQL 注入或代码逻辑 bug,以及缓存 Key 的误操作。RLS 和 ACL 分别在各自的存储层提供强制性的访问控制,不依赖上层代码是否正确。两者结合,确保即使应用代码完全失控,也无法跨过存储层的隔离屏障。
  • 多角度追问:(1) 如果缓存 Key 不包含租户前缀,ACL 还能防护吗?(2) 如何统一管理 RLS 和 ACL 的权限策略?(3) 有没有必要在应用层再做一套过滤?(4) 跨存储层的事务如何保证隔离?
  • 加分回答:ACL 依赖 Key 前缀,若 Key 无租户前缀,则无法防护,因此必须强制命名规范。统一管理可自研管理平台,新建租户时自动创建 PG 的 RLS 策略和 Redis 用户。应用层过滤作为快速失败的第一道防线,是深度防御的补充。跨存储事务隔离需应用层保证,或采用分布式事务(如 Seata),但存储层的隔离依然各自有效。

11. 多租户场景下 ProxySQL 如何实现租户级读写分离路由?

  • 一句话回答:利用 ProxySQL 的 mysql_query_rules 匹配 tenant_id 值或 SQL 注释,将不同租户的流量路由到不同的后端数据库组。
  • 详细解释:ProxySQL 正则匹配能力强大,可在查询规则中通过正则提取 tenant_id,然后结合 mysql_servershostgroup 进行路由。例如,匹配 /* tenant_id:1001 */ 注释,将请求转发至大租户 1001 的独立库的读写分离组。这在服务端代理模式下尤其有用,无需修改应用代码。规则可动态加载,实现租户迁移。
  • 多角度追问:(1) 如何自动注入 tenant_id 注释?(2) 大租户列表变更时,ProxySQL 如何热加载规则?(3) 与 ShardingSphere-Proxy 对比有何优劣?(4) 正则匹配对性能影响大吗?
  • 加分回答:可通过数据库拦截器或 ORM 拦截器在 SQL 前加注释。ProxySQL 支持 LOAD MYSQL QUERY RULES TO RUNTIME 热加载。ProxySQL 优势在极致性能和 C 语言实现,但正则复杂规则可能成为瓶颈。ShardingSphere-Proxy 优势在分片、加密等一体化治理,社区生态更完善。

系统设计:SaaS 订单管理平台多租户数据架构设计

题目:一个 SaaS 订单管理平台,预计 3 年内从 100 个租户增长到 5000 个租户,其中 20% 为大租户(数据量 >200GB,峰值 TPS >500),80% 为中小租户。要求设计多租户数据隔离方案,涵盖数据库层、缓存层、中间件路由层,并给出容量规划和租户动态迁移方案。

12.1 总体架构设计

我们采用混合隔离方案,结合独立数据库与共享表,并通过 PostgreSQL RLS 和 Redis ACL 进行安全加固。中间件层使用 ShardingSphere-JDBC 实现按租户路由和读写分离。

总体架构图
flowchart TB
    subgraph 客户端
        User[租户用户]
    end

    subgraph 接入层
        Gateway[API Gateway<br>租户识别与JWT校验]
    end

    subgraph 应用层
        Service[订单管理微服务]
        TenantCtx[TenantContext<br>ThreadLocal + AOP]
    end

    subgraph 中间件层
        SS[ShardingSphere-JDBC<br>tenant分片 + 读写分离]
    end

    subgraph 数据层
        subgraph 大租户独立库集群
            DB_Large1[(租户A独立库<br>Primary + Replica)]
            DB_Large2[(租户B独立库<br>Primary + Replica)]
        end
        subgraph 中小租户共享库集群
            DB_Shared[(共享库分片集群<br>Shard 0..N)]
        end
        subgraph Redis缓存层
            Redis[Redis Cluster<br>ACL Key隔离]
        end
    end

    subgraph 运维支撑
        Config[配置中心 Apollo<br>大租户列表]
        Monitor[Prometheus + Grafana<br>租户级监控]
        CDC[Debezium CDC<br>迁移引擎]
    end

    User --> Gateway
    Gateway --> Service
    Service --> TenantCtx
    Service --> SS
    SS --> DB_Large1
    SS --> DB_Large2
    SS --> DB_Shared
    Service --> Redis
    Config -.-> SS
    Monitor -.-> Service
    CDC --> DB_Shared
    CDC --> DB_Large1

架构图说明

  • 总览:图展示了 SaaS 订单管理平台的全栈多租户架构,从客户端接入、应用服务、中间件路由到数据存储和运维支撑,体现了混合隔离方案的完整拓扑。
  • 接入层:API Gateway 负责从 JWT 中提取 tenant_id,并将其通过请求头(如 X-Tenant-Id)传递给下游服务,完成租户身份识别。
  • 应用层:微服务通过 TenantContext (ThreadLocal) 持有当前请求的租户 ID,在数据库连接获取时通过 AOP 执行 SET app.current_tenant = ?,建立 RLS 上下文。
  • 中间件层:ShardingSphere-JDBC 根据配置中心的大租户列表,通过自定义 TenantShardingAlgorithm 将流量路由到大租户独立库(如 ds_tenant_large_A)或中小租户共享库(ds_shared_small),并在每个数据源组内嵌套读写分离。
  • 数据层:大租户独立库为一主多从架构,资源独占;共享库集群按 tenant_id 分库分表,并开启 PostgreSQL RLS 策略;Redis 集群通过 ACL 实现 Key 前缀隔离。
  • 运维支撑:配置中心(Apollo)动态维护大租户列表,监控系统(Prometheus)采集租户级指标,Debezium CDC 负责租户迁移时的数据同步。

12.2 核心业务流程:租户请求处理时序

以下时序图展示一个租户订单查询请求的完整链路,展示隔离机制如何生效。

sequenceDiagram
    participant User as 租户A用户
    participant GW as API Gateway
    participant Svc as 订单服务
    participant Ctx as TenantContext
    participant SS as ShardingSphere-JDBC
    participant Config as Apollo配置中心
    participant PG as PostgreSQL (独立库A)
    participant RLS as RLS策略引擎

    User->>GW: GET /orders/12345 (JWT)
    GW->>GW: 解析JWT,提取tenant_id=1001
    GW->>Svc: GET /orders/12345 (Header: X-Tenant-Id: 1001)
    Svc->>Ctx: TenantContext.setTenant("1001")
    Svc->>SS: 获取数据库连接
    SS->>Config: 查询大租户列表
    Config-->>SS: [1001, 1002, ...]
    SS->>SS: TenantShardingAlgorithm判断1001为大租户,路由至 ds_tenant_1001_rw
    SS-->>Svc: 返回连接 (逻辑数据源 ds_tenant_1001_rw)
    Svc->>PG: SET app.current_tenant = '1001'
    Svc->>PG: SELECT * FROM orders WHERE order_id = 12345
    PG->>RLS: 应用策略 tenant_isolation
    RLS-->>PG: 追加 WHERE tenant_id = 1001
    PG-->>Svc: 返回订单数据 (仅tenant_id=1001的行)
    Svc->>Ctx: 事务结束,清除ThreadLocal
    Svc-->>GW: 响应
    GW-->>User: 订单数据

时序图说明

  • 总览:该时序图详细描绘了租户A查询订单的请求全链路,从网关鉴权、租户上下文传递,到 ShardingSphere 路由判断、RLS 策略自动过滤的完整过程。
  • 租户识别与传递:API Gateway 从 JWT 提取 tenant_id=1001,通过 HTTP 头传递给订单服务。服务将其放入 ThreadLocal,供后续步骤使用。
  • 动态路由决策:ShardingSphere-JDBC 在获取连接时,通过自定义算法调用 Apollo 获取大租户列表,确定租户 1001 属于大租户,将请求路由至其独占的读写分离数据源 ds_tenant_1001_rw
  • RLS 上下文设置:在获取物理连接后,服务立即执行 SET app.current_tenant = '1001',为 RLS 策略提供会话变量。随后即使业务 SQL 中未显式包含 tenant_id 条件,PostgreSQL 也会自动追加。
  • 安全隔离验证:PG 的 RLS 引擎在查询执行前强制注入 WHERE tenant_id = 1001,确保返回的数据绝对属于当前租户,实现双重保险。
  • 上下文清除:事务结束后,TenantContext 清理 ThreadLocal,连接归还时执行 RESET app.current_tenant,防止上下文污染。

12.3 核心方案设计详解

1. 多租户数据隔离方案

  • 大租户:独立数据库实例(PostgreSQL 15),一主两从架构,使用 pg_basebackup 或云厂商克隆创建。数据量 >200GB 或峰值 TPS >500 时分配。
  • 中小租户:共享 PostgreSQL 集群,通过 tenant_id 分库分表。初始阶段 4 个分片,每个分片容量设计为 500GB,支持水平扩展。所有表开启 RLS 行级安全策略。
  • 租户目录:维护一个全局租户元数据表(独立于业务库),记录租户 ID、隔离模式(独立/共享)、对应的数据源名称、创建时间等。

2. 安全加固策略(PG RLS + Redis ACL)

  • 数据库层:共享库的所有业务表执行:
    ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
    ALTER TABLE orders FORCE ROW LEVEL SECURITY;
    CREATE POLICY tenant_iso ON orders FOR ALL
        TO app_user
        USING (tenant_id = current_setting('app.current_tenant')::integer)
        WITH CHECK (tenant_id = current_setting('app.current_tenant')::integer);
    
    应用层通过 SET LOCAL app.current_tenant = ? 在事务开始设置,利用 SET LOCAL 自动在事务结束清除,避免连接池污染。对于非事务只读操作,使用 AOP 在获取连接时 SET SESSION 并在归还前 RESET
  • 缓存层:Redis 7 Cluster 部署,为每个租户创建专用用户:
    ACL SETUSER tenant:1001 ON >pwd ~tenant:1001:* +@read +@write +@keyspace -FLUSHALL -CONFIG
    
    所有业务 Key 强制命名为 tenant:{id}:{entity}:{pk},如 tenant:1001:order:12345。Hash Tag 确保同一租户 Key 落在同一个 Redis Cluster slot。

3. ShardingSphere-JDBC 配置与读写分离

  • 采用自定义 TenantShardingAlgorithm,从 Apollo 动态加载大租户列表。列表变更时,ShardingSphere 通过 Apollo 的实时通知更新分片策略。
  • 分片规则嵌套读写分离:每个大租户独立库和共享库均配置 READWRITE_SPLITTING 规则,形成 ds_tenant_large_1_rw 等逻辑数据源,分片算法的 actualDataNodes 直接引用这些逻辑数据源。
  • 连接池管理:ShardingSphere-JDBC 内部使用 HikariCP,每个物理数据源独立连接池,大小按需配置(大租户池较大,共享库池适中)。

4. 租户动态迁移方案(零停机)

  • 触发条件:监控指标超过阈值,如单租户数据量 >150GB 或 TPS 占共享库 30%,且持续时间超过 2 小时,自动创建迁移工单。
  • 迁移流程
    1. 调用自动化脚本创建新独立库实例,初始化表结构。
    2. 部署 Debezium PostgreSQL Connector,配置 snapshot.mode=initial 全量同步 + 增量逻辑复制,过滤器仅捕获目标租户数据。
    3. 数据校验通过后,运维在 Apollo 中将该租户 ID 加入大租户列表,触发所有服务热加载路由。
    4. 观察 48 小时,确认无异常后,分批清理共享库旧数据。
  • 回滚预案:观察期内若出现问题,直接从 Apollo 移除租户 ID,流量回切至共享库,独立库数据暂不删除。

5. 3 年后 5000 租户的容量规划

  • 大租户:1000 个,每租户一个独立实例(一主两从),其中约 300 个为超大规模(>1TB)可能需更高配实例。总计约 1000 个主实例 + 2000 个从实例,硬件资源按规格估算。
  • 中小租户共享集群:4000 个租户,平均每租户数据 30GB,总数据 120TB。单分片 500GB,需 240 个分片,规划 256 个分片(方便扩容)。采用 32 个物理数据库实例,每个实例承载 8 个分片(可灵活调整)。每个实例一主两从,共 96 个节点。
  • 缓存集群:Redis Cluster 预估热数据占总数据 15%,即约 18TB,考虑副本和内存碎片,规划 30 台 512GB 内存节点。
  • 连接数:应用服务器通过 ShardingSphere-JDBC 直连数据库,总连接数 = 大租户数 × 每租户连接池大小 + 共享分片数 × 每分片连接池大小。需控制单个应用实例连接数不超过 2000,通过服务端代理(ShardingSphere-Proxy)进一步收敛。

12.4 租户迁移时序图

sequenceDiagram
    participant Monitor as 监控系统
    participant Ops as 运维平台
    participant Apollo as Apollo配置中心
    participant SS as ShardingSphere-JDBC
    participant CDC as Debezium
    participant Shared as 共享库
    participant Large as 独立库

    Monitor->>Monitor: 检测到租户1005 TPS > 500 持续2小时
    Monitor->>Ops: 触发迁移告警
    Ops->>Large: 创建独立库实例,执行DDL
    Ops->>CDC: 启动Debezium任务,快照+增量同步
    CDC->>Shared: 全量读取租户1005数据
    CDC->>Large: 写入独立库
    Shared->>Shared: 业务持续读写
    CDC->>Shared: 捕获Binlog变更
    CDC->>Large: 增量写入独立库
    Ops->>Ops: 数据校验通过
    Ops->>Apollo: 更新大租户列表,加入1005
    Apollo-->>SS: 推送配置变更
    SS->>SS: 热加载路由规则
    Note over SS: 此后新请求路由至独立库
    SS->>Shared: 已无新请求
    Ops->>Ops: 观察48小时,无异常
    Ops->>Shared: 分批删除tenant_id=1005的旧数据
    Ops->>CDC: 停止Debezium任务

时序图说明

  • 总览:该图展示了从小租户增长到触发阈值到完成迁移的全过程,突出自动化、零停机、可回滚的特点。
  • 触发与准备:监控系统持续跟踪租户指标,满足阈值后通知运维平台。运维平台自动化创建独立库并初始化表结构。
  • 数据同步:Debezium CDC 任务先进行全量快照,然后持续增量捕获共享库的变更,保证目标库数据实时性。
  • 路由切换:数据校验一致后,运维在 Apollo 中将该租户加入大租户列表,配置推送到所有服务实例,ShardingSphere 热加载新规则,新请求无缝切换至独立库。
  • 清理与收尾:观察期确保业务平稳后,安全地清理共享库中旧数据,释放空间,最后关闭 CDC 任务。
  • 关键设计:切换零停机依赖于 CDC 的实时同步和配置中心的热加载能力;观察期和保留旧数据提供了快速回滚的安全网。

12.5 设计要点总结与面试回答建议

当被问及此系统设计题时,可按如下层次作答:

  1. 方案选型:明确选择混合方案,阐述大租户独立、小租户共享的理由,以及安全与成本的权衡。
  2. 安全加固:突出 PG RLS 和 Redis ACL 的原理与配置,强调其作为强制底线的价值,并解释连接池上下文污染的处理。
  3. 路由实现:说明 ShardingSphere-JDBC 的自定义算法如何与配置中心联动,以及两级路由(租户路由 + 读写分离)的嵌套配置。
  4. 动态迁移:描述 CDC 同步和零停机切换的流程,提及回滚预案,体现架构弹性。
  5. 容量规划:给出量化的预估,包括分片数、实例数、Redis 内存,展示对资源增长的把控。
  6. 运维与监控:提及租户级监控、告警和自动化运维工具,展现可运维性。

多角度追问(面试官可能的深挖方向):

  • 追问 1:如果某个大租户的数据量超过了独立库的容量怎么办?
  • 回答:对该租户内部进一步分库分表,使用 order_id 等业务键进行二级分片,或在独立库内使用分区表(如按时间分区)并定期归档历史数据到冷存储。
  • 追问 2:如何保证 Debezium 同步的数据一致性?如果在同步过程中共享库发生了结构变更怎么办?
  • 回答:Debezium 使用快照的一致性读点和逻辑复制槽保证数据不错不丢。DDL 变更需要暂停 CDC,在目标库同步执行 DDL 后再重启,或使用支持 DDL 复制的工具(如 PG 的逻辑复制本身支持部分 DDL)。
  • 追问 3:Redis ACL 用户密码如何安全分发和轮转?
  • 回答:通过 Vault 或配置中心加密存储,应用启动时动态获取并设置到 Redis 连接工厂,支持定期轮转和热更新。
  • 追问 4:如何实现租户级别的数据导出(如 GDPR 数据可携带权)?
  • 回答:通过独立库的 pg_dump 或共享库的按 tenant_id 筛选导出,配合 RLS 策略保证只导出该租户数据,并通过异步任务执行,提供下载链接。

多租户数据隔离方案速查表

方案隔离级别安全性成本运维复杂度安全加固方案推荐租户规模关键配置参数
独立数据库数据库级物理隔离极高,误操作不跨租户高(线性增长)高(逐库运维)数据库用户权限隔离,资源配额< 100 租户,金融合规actualDataNodes 映射独立数据源,独立连接池
共享表行级逻辑隔离低(有 SQL 缺陷风险)PG RLS + Redis ACL + 应用AOP> 1000 租户,成本敏感tenant_id 分片键 + RLS current_setting + ACL ~tenant:*
混合方案混合高(大租户物理隔离 + 小租户 RLS 加固)独立库权限 + 共享表 RLS + Redis ACL租户规模分层明显(20%大租户)ShardingSphere 自定义 TenantShardingAlgorithm + 配置中心动态列表 + 读写分离嵌套
PostgreSQL RLS行级强制过滤极高(内核级,自动化)无额外成本低(创建策略即可)CREATE POLICY ... USING (tenant_id = current_setting(...))任何共享表方案ENABLE ROW LEVEL SECURITY; FORCE 可选; 索引 (tenant_id)
Redis ACLKey级强制隔离高(命令执行前拦截)无额外成本低(配置用户)ACL SETUSER ... ~tenant:* + 命令限制任何共享 RedisKey 命名规范 tenant:{id}:{entity}:{id}; ACL SAVE

延伸阅读


系列关联总结

  • 分库分表(第 1 篇)tenant_id 是共享表的天然完美分片键,容量规划公式直接适配多租户,分片算法基础。
  • 读写分离(第 2 篇):租户级读写分离可通过 ShardingSphere 嵌套规则实现;ProxySQL 的 mysql_query_rules 可基于 tenant_id 注释动态路由。
  • PG RLS(PG 系列第 13 篇):完整 RLS 语法、策略管理与权限模型见该篇,本文为多租户场景的落地实战。
  • CDC 迁移(分布式事务系列第 6 篇):Debezium CDC 的详细配置、全量+增量快照原理、一致性保证见该篇。
  • Redis 热 Key 问题(Redis 系列第 8 篇):热 Key 与多租户共享实例的冲突,及大租户 Key 迁移时的热 Key 处理。

结语

多租户数据隔离的本质是在安全效率之间寻求动态平衡。独立数据库用资源换安全,共享表用风险换效率,而混合方案与安全加固技术(RLS、ACL)的成熟,让我们有能力在两者之间找到最优解。本文提供的决策框架、安全加固实现、中间件路由配置和零停机迁移策略,希望能为你的 SaaS 架构设计铺设坚实的工程基础。在下一篇中,我们将进入搜索与分析引擎的选型,探讨多租户场景下 Elasticsearch、OpenSearch 等全文检索系统的索引隔离与权限控制,敬请期待。