← 返回目录

SaaS多租户实战 — 一套代码服务万千客户

数据隔离 租户配置 计费 权限 个性化

写字楼公寓 — 一栋楼多个公司=多租户、每层独立门禁=数据隔离、各自装修=个性化配置、按面积收费=计费、前台统一=共享服务

🏢 业务全景 — SaaS多租户做什么

通俗类比:写字楼公寓
想象一栋写字楼——一栋楼里住着几十家公司,这就是"多租户"。每层楼有独立的门禁,A公司的人进不了B公司的办公室,这就是"数据隔离"。每家公司可以自己装修办公室、挂自己的Logo,这就是"个性化配置"。物业按每家公司租的面积收物业费,这就是"计费"。而前台、电梯、空调由物业统一管理,这就是"共享服务"——多租户系统用一套代码服务所有客户,既省成本又好维护。
🏢 写字楼 = SaaS平台 每层公司 = 租户 独立门禁 = 数据隔离
自装修 = 个性化配置 按面积收费 = 计费 物业统一管理 = 共享服务
核心流程:租户从入驻到退租的完整生命周期
租户入驻 环境配置 正常使用 按量计费 续约/退租
租户入驻:新公司搬进写字楼,物业给分配一层楼、办门禁卡、开通水电。
在SaaS系统中,就是管理员注册租户、系统自动创建租户空间、初始化数据隔离环境。

环境配置:公司决定办公室怎么装修——铺什么地板、挂什么Logo、开通哪些功能区。
在SaaS系统中,就是租户配置主题、Logo、功能开关、自定义字段等。

正常使用:公司员工每天刷卡进门上班,各干各的互不干扰。
在SaaS系统中,就是不同租户的用户登录后只能看到自己租户的数据。

按量计费:物业按面积收物业费,用会议室按次收费。
在SaaS系统中,就是按用户数/功能/用量等维度计费,生成账单。

续约/退租:租期到了决定续租还是搬走,搬走要把办公室清空。
在SaaS系统中,就是租户续费或数据导出、逻辑删除、资源回收。

🎯 核心挑战 — 多租户的5座大山

挑战1:数据隔离 — A租户的数据绝不能被B租户看到
类比:写字楼的门禁系统——
独立DB = 每层楼有独立电梯,A公司的人只能坐A电梯到A层,物理上就不可能走错楼层。
独立Schema = 楼层之间共享电梯,但每层有独立门锁,有A公司门禁卡的人打不开B公司的门。
共享表+tenant_id = 开放式办公,所有人在同一空间,但每个工位有编号(tenant_id),你只能坐自己的工位。
方案隔离级别成本运维复杂度扩展性适用场景
独立DB 最高 物理隔离 高(每个租户一个库) 高(库多维护难) 差(租户多了库爆炸) 金融/医疗等强隔离需求
独立Schema 中高 逻辑隔离 中(共享DB实例) 中(Schema管理) 中(Schema数量有限) 中型租户、合规要求中等
共享表+tenant_id 行级隔离 低(共享一切) 低(维护简单) 好(加租户只加数据) 小租户、SaaS标准版
实战选择:大多数SaaS系统采用"共享表+tenant_id"方案,成本最低、扩展最好。对于大客户,可以升级为独立Schema或独立DB。这就是"分层隔离"策略——小客户共享、大客户独享。
MyBatis-Plus 多租户拦截器实现(共享表方案)
// ====== 第一步:租户配置表 ====== // 每条SQL都会自动加上 tenant_id 条件 // 这样即使开发者忘了写 WHERE tenant_id = ?,拦截器也会自动补上 import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.StringValue; /** * 多租户拦截器 — 核心原理: * MyBatis-Plus在执行SQL之前,会自动在WHERE条件中追加 tenant_id = ? * 相当于一个"安全网",防止开发人员忘记加租户条件导致数据泄露 */ public class CustomTenantHandler implements TenantLineHandler { // 需要排除的表(不分租户的公共表,如 sys_config、sys_dict) private static final Set<String> IGNORE_TABLES = Set.of( "sys_config", "sys_dict", "sys_region", "tenant_info", "tenant_package" ); @Override public Expression getTenantId() { // 从当前上下文中获取租户ID // 比如从JWT Token中解析、从请求头中读取 Long tenantId = TenantContextHolder.getTenantId(); if (tenantId == null) { throw new RuntimeException("租户ID不能为空,请检查登录状态"); } return new StringValue(tenantId.toString()); } @Override public String getTenantIdColumn() { // 租户ID在数据库中的字段名 return "tenant_id"; } @Override public boolean ignoreTable(String tableName) { // 返回true表示这个表不需要加租户条件 return IGNORE_TABLES.contains(tableName); } } // ====== 第二步:注册拦截器 ====== @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加多租户拦截器 TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(new CustomTenantHandler()); interceptor.addInnerInterceptor(tenantInterceptor); // 添加分页拦截器(注意:租户拦截器要放在分页拦截器之前) interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; } } // ====== 第三步:租户上下文工具类 ====== // 用ThreadLocal在当前线程中保存租户ID public class TenantContextHolder { private static final ThreadLocal<Long> TENANT_HOLDER = new ThreadLocal<>(); public static void setTenantId(Long tenantId) { TENANT_HOLDER.set(tenantId); } public static Long getTenantId() { return TENANT_HOLDER.get(); } public static void clear() { TENANT_HOLDER.remove(); } } // ====== 第四步:Web过滤器,从请求中提取租户ID ====== @Component public class TenantFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; try { // 从请求头中获取租户ID(前端在登录后每次请求都带上) String tenantIdStr = request.getHeader("X-Tenant-Id"); if (tenantIdStr != null && !tenantIdStr.isEmpty()) { TenantContextHolder.setTenantId(Long.parseLong(tenantIdStr)); } chain.doFilter(req, resp); } finally { // 一定要清除!否则线程池复用会导致租户ID串了 TenantContextHolder.clear(); } } }
效果演示:你写 SELECT * FROM order,拦截器自动变成 SELECT * FROM order WHERE tenant_id = 1。你写 DELETE FROM order WHERE id = 100,拦截器自动变成 DELETE FROM order WHERE id = 100 AND tenant_id = 1——绝不会误删别的租户的数据!
挑战2:租户配置 — 每个租户的"装修风格"不同
类比:写字楼里每个公司可以自己装修——有的铺地毯,有的铺地砖;有的挂公司Logo,有的挂艺术画;有的开放工位,有的隔成独立办公室。SaaS系统也一样,每个租户可以自定义Logo、主题色、功能开关等。
全局配置(默认值) 租户配置(覆盖默认) 用户配置(覆盖租户)
优先级从左到右递增 — 用户配置 > 租户配置 > 全局配置
配置优先级链:就像CSS的样式优先级——
全局配置 = 浏览器默认样式(所有租户的兜底值)
租户配置 = 外部CSS(租户级别的自定义)
用户配置 = 内联样式(用户级别的自定义,最高优先级)

举例:系统默认主题色是蓝色(全局配置),租户A设置主题色为红色(租户配置),用户张三设置主题色为绿色(用户配置)。最终张三看到的是绿色——因为用户配置优先级最高。
Spring Boot 多租户配置代码
// ====== 租户配置实体 ====== @Data @TableName("tenant_config") public class TenantConfig { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long tenantId; // 租户ID private String configKey; // 配置键,如 "theme.color" private String configValue; // 配置值,如 "#FF5500" private String configType; // 类型:GLOBAL/TENANT/USER private Long userId; // 用户级配置时,对应的用户ID } // ====== 配置服务 — 三级配置优先级覆盖 ====== @Service public class TenantConfigService { @Autowired private TenantConfigMapper configMapper; /** * 获取配置值 — 按优先级链查找: * 1. 先查用户级配置(优先级最高) * 2. 再查租户级配置 * 3. 最后查全局配置(兜底) * 就像找快递 — 先看自己桌上有没有,再看公司前台,最后看物业 */ public String getConfig(String key, Long tenantId, Long userId) { // 第1步:查用户级配置 if (userId != null) { TenantConfig userConfig = configMapper.selectOne( new LambdaQueryWrapper<TenantConfig>() .eq(TenantConfig::getConfigKey, key) .eq(TenantConfig::getConfigType, "USER") .eq(TenantConfig::getUserId, userId) ); if (userConfig != null) { return userConfig.getConfigValue(); } } // 第2步:查租户级配置 TenantConfig tenantConfig = configMapper.selectOne( new LambdaQueryWrapper<TenantConfig>() .eq(TenantConfig::getConfigKey, key) .eq(TenantConfig::getConfigType, "TENANT") .eq(TenantConfig::getTenantId, tenantId) ); if (tenantConfig != null) { return tenantConfig.getConfigValue(); } // 第3步:查全局配置(兜底) TenantConfig globalConfig = configMapper.selectOne( new LambdaQueryWrapper<TenantConfig>() .eq(TenantConfig::getConfigKey, key) .eq(TenantConfig::getConfigType, "GLOBAL") ); return globalConfig != null ? globalConfig.getConfigValue() : null; } /** * 设置租户配置 * 就像给办公室换壁纸 — 只影响这个租户,不影响其他租户 */ public void setTenantConfig(Long tenantId, String key, String value) { TenantConfig config = configMapper.selectOne( new LambdaQueryWrapper<TenantConfig>() .eq(TenantConfig::getConfigKey, key) .eq(TenantConfig::getConfigType, "TENANT") .eq(TenantConfig::getTenantId, tenantId) ); if (config != null) { config.setConfigValue(value); configMapper.updateById(config); } else { config = new TenantConfig(); config.setTenantId(tenantId); config.setConfigKey(key); config.setConfigValue(value); config.setConfigType("TENANT"); configMapper.insert(config); } // 清缓存 — 配置变更后要立刻生效 ConfigCacheManager.invalidate(tenantId, key); } } // ====== 配置缓存管理 — 用Redis缓存配置,避免每次查数据库 ====== @Component public class ConfigCacheManager { @Autowired private StringRedisTemplate redisTemplate; private static final String CONFIG_KEY_PREFIX = "config:"; public void invalidate(Long tenantId, String key) { String cacheKey = CONFIG_KEY_PREFIX + tenantId + ":" + key; redisTemplate.delete(cacheKey); } public void put(Long tenantId, String key, String value) { String cacheKey = CONFIG_KEY_PREFIX + tenantId + ":" + key; redisTemplate.opsForValue().set(cacheKey, value, 24, TimeUnit.HOURS); // 缓存24小时 } public String get(Long tenantId, String key) { String cacheKey = CONFIG_KEY_PREFIX + tenantId + ":" + key; return redisTemplate.opsForValue().get(cacheKey); } }
挑战3:计费 — 怎么收钱才合理?
类比:物业费按面积收(=按用户数计费),停车费按次收(=按功能使用次数计费),会议室按时间收(=按用量时长计费)。不同的收费方式适用于不同场景,SaaS也一样。
计费模型类比计费维度优点缺点代表产品
按用户数 物业费按面积 活跃用户数 × 单价 简单易懂 用户多成本高 Slack / 飞书
按功能模块 停车费按次 开通的模块 × 模块单价 灵活搭配 功能边界难界定 企业微信 / 钉钉
按用量 会议室按时间 API调用次数/存储量/流量 公平、对大客户友好 用量统计复杂 AWS / 阿里云
计费系统代码实现
// ====== 计费模型枚举 ====== public enum BillingModel { PER_USER, // 按用户数 PER_MODULE, // 按功能模块 PER_USAGE // 按用量 } // ====== 租户套餐实体 ====== @Data public class TenantPackage { private Long id; private Long tenantId; private String packageName; // 套餐名:基础版/专业版/企业版 private BillingModel billingModel; // 计费模型 private BigDecimal unitPrice; // 单价(元/用户/月 或 元/次) private Integer maxUsers; // 最大用户数限制 private LocalDateTime expireAt; // 到期时间 private List<String> modules; // 包含的功能模块列表 } // ====== 用量采集记录 ====== @Data public class UsageRecord { private Long id; private Long tenantId; private String metricType; // 指标类型:API_CALL / STORAGE / BANDWIDTH private BigDecimal quantity; // 用量值 private LocalDate usageDate; // 用量日期 } // ====== 账单服务 — 月度账单生成 ====== @Service public class BillingService { @Autowired private TenantPackageMapper packageMapper; @Autowired private UsageRecordMapper usageMapper; @Autowired private UserMapper userMapper; @Autowired private BillMapper billMapper; /** * 生成月度账单 * 就像物业月底算账 — 本月你用了多少面积、多少次会议室,合计多少钱 */ @Transactional public Bill generateMonthlyBill(Long tenantId, YearMonth month) { TenantPackage pkg = packageMapper.selectByTenantId(tenantId); if (pkg == null) { throw new BizException("租户未开通套餐"); } BigDecimal totalAmount = BigDecimal.ZERO; List<BillItem> items = new ArrayList<>(); switch (pkg.getBillingModel()) { case PER_USER: // 按用户数计费 = 活跃用户数 × 单价 int activeUsers = userMapper.countActiveUsers( tenantId, month.atDay(1), month.atEndOfMonth() ); BigDecimal userAmount = BigDecimal.valueOf(activeUsers) .multiply(pkg.getUnitPrice()); items.add(new BillItem("用户数", activeUsers + "人", userAmount)); totalAmount = totalAmount.add(userAmount); break; case PER_MODULE: // 按功能模块计费 = 每个开通的模块 × 模块单价 for (String module : pkg.getModules()) { BigDecimal modulePrice = ModulePriceTable.getPrice(module); items.add(new BillItem(module, "1个模块", modulePrice)); totalAmount = totalAmount.add(modulePrice); } break; case PER_USAGE: // 按用量计费 = 各项用量 × 单价 List<UsageRecord> usages = usageMapper.selectByTenantAndMonth( tenantId, month ); for (UsageRecord usage : usages) { BigDecimal usageAmount = usage.getQuantity() .multiply(pkg.getUnitPrice()); items.add(new BillItem( usage.getMetricType(), usage.getQuantity() + "次", usageAmount )); totalAmount = totalAmount.add(usageAmount); } break; } // 生成账单 Bill bill = new Bill(); bill.setTenantId(tenantId); bill.setBillMonth(month.toString()); bill.setTotalAmount(totalAmount); bill.setStatus("UNPAID"); bill.setItems(items); billMapper.insert(bill); return bill; } } // ====== 用量采集 — 通过AOP拦截API调用次数 ====== @Aspect @Component public class UsageCollectorAspect { @Autowired private StringRedisTemplate redisTemplate; /** * 拦截所有API调用,统计用量 * 就像物业在每个房间门口装了计数器,进出一次记一次 */ @Around("execution(* com.example.controller..*.*(..))") public Object collectUsage(ProceedingJoinPoint pjp) throws Throwable { Long tenantId = TenantContextHolder.getTenantId(); if (tenantId != null) { // 用Redis的INCR命令原子性计数 String key = "usage:" + tenantId + ":" + LocalDate.now(); redisTemplate.opsForValue().increment(key); // 设置过期时间30天,自动清理 redisTemplate.expire(key, 30, TimeUnit.DAYS); } return pjp.proceed(); } }
挑战4:权限体系 — 租户内权限 + 跨租户协作
类比:一栋写字楼里,每家公司内部有自己的组织架构和权限——普通员工不能进老板办公室,部门经理能看部门数据但看不了全公司财务。而跨公司协作就像两家公司共用一个会议室,需要特别授权才能进入对方的区域。
租户A 角色: 管理员/部门经理/普通员工 权限: 读/写/删除
租户B 角色: 管理员/普通员工 权限: 读/写
跨租户协作 授权访问指定资源
Spring Security 多租户权限代码
// ====== 多租户RBAC权限模型 ====== // 核心思路:所有权限判断都要带上 tenant_id 维度 // 即使是同一个用户,在租户A是管理员,在租户B可能只是普通用户 @Data public class TenantUser implements UserDetails { private Long userId; private Long tenantId; // 当前登录的租户ID private String username; private String password; private List<String> roles; // 当前租户下的角色 private List<String> permissions; // 当前租户下的权限 @Override public Collection<? extends GrantedAuthority> getAuthorities() { // 把角色和权限都转为GrantedAuthority List<GrantedAuthority> authorities = new ArrayList<>(); roles.forEach(r -> authorities.add( new SimpleGrantedAuthority("ROLE_" + r) )); permissions.forEach(p -> authorities.add( new SimpleGrantedAuthority(p) )); return authorities; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } } // ====== 多租户权限校验Service ====== @Service public class TenantPermissionService { @Autowired private PermissionMapper permissionMapper; /** * 检查用户在指定租户下是否有某个权限 * 就像查门禁卡 — 你的卡能不能刷开这扇门 */ public boolean hasPermission(Long userId, Long tenantId, String permission) { // 查询用户在当前租户下的权限列表 List<String> perms = permissionMapper.selectPermissionsByUserAndTenant( userId, tenantId ); return perms.contains(permission); } /** * 跨租户协作授权 * 就像两家公司共用会议室 — 需要A公司的管理员给B公司的用户授权 */ @Transactional public void grantCrossTenantAccess(Long fromTenantId, Long toTenantId, Long toUserId, String resource, LocalDateTime expireAt) { CrossTenantGrant grant = new CrossTenantGrant(); grant.setFromTenantId(fromTenantId); grant.setToTenantId(toTenantId); grant.setToUserId(toUserId); grant.setResource(resource); grant.setExpireAt(expireAt); grant.setStatus("ACTIVE"); grantMapper.insert(grant); // 记录审计日志 — 跨租户授权是敏感操作,必须留痕 AuditLog log = new AuditLog(); log.setAction("CROSS_TENANT_GRANT"); log.setDetail("租户" + fromTenantId + "授权给租户" + toTenantId + "的用户" + toUserId + "访问资源:" + resource); auditLogMapper.insert(log); } } // ====== Spring Security配置 — 注入租户维度 ====== @Configuration @EnableWebSecurity public class TenantSecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .addFilterBefore(new TenantJwtFilter(), UsernamePasswordAuthenticationFilter.class) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/admin/**").hasRole("TENANT_ADMIN") .requestMatchers("/api/billing/**").hasAuthority("billing:view") .anyRequest().authenticated() ); return http.build(); } }
挑战5:个性化 — 租户自定义字段/流程/报表
类比:办公室可以加隔断改成独立会议室,可以改布局增加工位,可以定制白板和投影。SaaS系统也一样,租户可以自定义字段(给客户加一个"行业"标签)、自定义流程(审批从3级改成2级)、自定义报表(添加一个销售趋势图)。
EAV模型(Entity-Attribute-Value)+ JSON扩展字段
// ====== EAV模型 — 灵活扩展字段 ====== // 核心思路:不硬编码字段名,而是把"字段名"和"字段值"拆成行 // 就像Excel — 列名不是写死的,你可以自己加列 // 租户自定义字段定义表 @Data @TableName("tenant_custom_field") public class TenantCustomField { private Long id; private Long tenantId; // 哪个租户定义的 private String entityName; // 实体名:customer / order / product private String fieldKey; // 字段标识:industry / source private String fieldLabel; // 显示名:行业 / 来源 private String fieldType; // 字段类型:TEXT / NUMBER / SELECT / DATE private String options; // 下拉选项JSON:["互联网","金融","制造"] private boolean required; // 是否必填 private boolean visible; // 是否在列表中显示 } // 自定义字段值表(EAV) @Data @TableName("entity_custom_value") public class EntityCustomValue { private Long id; private Long tenantId; private String entityName; // 实体名 private Long entityId; // 实体ID(如客户ID) private String fieldKey; // 字段标识 private String fieldValue; // 字段值 } // ====== JSON扩展字段方案(更简单) ====== // 在主表中加一个 ext_data JSON字段,存扩展数据 @Data @TableName("customer") public class Customer { private Long id; private Long tenantId; private String name; private String phone; private String email; // 扩展字段 — 存租户自定义的数据 // 比如 {"industry": "互联网", "level": "VIP", "note": "大客户"} @TableField(type = JdbcType.VARCHAR) private String extData; // JSON格式 } // ====== 自定义字段服务 ====== @Service public class CustomFieldService { @Autowired private TenantCustomFieldMapper fieldMapper; @Autowired private EntityCustomValueMapper valueMapper; /** * 保存实体的自定义字段值 * 就像填表 — 标准字段是固定栏目,自定义字段是租户自己加的栏目 */ @Transactional public void saveCustomValues(Long tenantId, String entityName, Long entityId, Map<String, String> values) { // 1. 先校验字段定义是否存在 List<TenantCustomField> fields = fieldMapper.selectByTenantAndEntity( tenantId, entityName ); Map<String, TenantCustomField> fieldMap = fields.stream() .collect(Collectors.toMap( TenantCustomField::getFieldKey, f -> f )); // 2. 保存每个自定义字段的值 for (Map.Entry<String, String> entry : values.entrySet()) { TenantCustomField fieldDef = fieldMap.get(entry.getKey()); if (fieldDef == null) { throw new BizException("字段定义不存在: " + entry.getKey()); } // 校验必填 if (fieldDef.isRequired() && (entry.getValue() == null || entry.getValue().isEmpty())) { throw new BizException("必填字段不能为空: " + fieldDef.getFieldLabel()); } EntityCustomValue value = new EntityCustomValue(); value.setTenantId(tenantId); value.setEntityName(entityName); value.setEntityId(entityId); value.setFieldKey(entry.getKey()); value.setFieldValue(entry.getValue()); valueMapper.insertOrUpdate(value); } } /** * 查询实体的自定义字段值(返回带标签的Map) */ public Map<String, String> getCustomValues(Long tenantId, String entityName, Long entityId) { List<EntityCustomValue> values = valueMapper.selectByEntity( tenantId, entityName, entityId ); return values.stream().collect(Collectors.toMap( EntityCustomValue::getFieldKey, EntityCustomValue::getFieldValue )); } }
方案查询性能灵活性实现复杂度适用场景
EAV模型 差(行转列查询) 极高(完全自定义) CRM等需要高度自定义字段的系统
JSON扩展字段 中(MySQL支持JSON查询) 中(可以加字段但不方便索引) 大多数SaaS系统的轻量级扩展

🏗️ 架构设计 — SaaS多租户架构

🌐 Gateway网关 ⚙️ 配置中心 📦 业务服务 🔒 数据隔离层 🗄️ 数据库
🌐 Gateway 网关层
租户识别(解析tenant_id) 租户路由 流量控制 统一认证
关键设计:Gateway从请求头/域名/Token中提取tenant_id,放入请求上下文。比如租户A的域名是,Gateway解析子域名"a"映射到tenant_id=1。或者从JWT Token中解析出tenant_id。这是整个多租户的"入口",后面所有层都依赖这一层解析出的tenant_id。
⚙️ 配置中心
全局配置 租户配置 功能开关 主题/Logo
关键设计:配置中心采用"全局+租户+用户"三级配置体系,底层用Redis缓存热点配置。当租户修改配置时,通过MQ广播到所有服务节点刷新缓存。就像物业改了楼道灯,所有楼层都要同步知道。
📦 业务服务层
用户服务 订单服务 权限服务 计费服务 自定义字段服务
关键设计:所有业务服务在启动时注册到租户感知的RPC框架,自动从请求上下文中获取tenant_id。服务间调用时,tenant_id通过RPC header透传。就像公司内部所有员工都知道自己在几楼,不需要每次都问。
🔒 数据隔离层
MyBatis-Plus租户拦截器 动态数据源 租户上下文(ThreadLocal)
关键设计:这是整个多租户架构最关键的一层——数据安全就靠它。MyBatis-Plus拦截器自动给SQL加tenant_id条件;对于需要独立DB的大租户,动态数据源根据tenant_id路由到不同的数据库。就像大楼的门禁系统——小租户刷卡进门(共享表+tenant_id),大租户有专属电梯直达(独立DB)。
🗄️ 数据库层
共享数据库(小租户) 独立Schema(中租户) 独立数据库(大租户) Redis配置缓存

🔄 关键流程 — 代码级详解

流程1:多租户数据隔离完整实现
这个流程展示了从请求进入到SQL执行的完整数据隔离链路。每一步都不能少,否则就可能出现数据泄露。
// ====== 完整的多租户数据隔离流程 ====== // 1. Gateway解析tenant_id → 2. Filter设置ThreadLocal → 3. 拦截器自动加条件 // --- Gateway租户识别过滤器(Spring Cloud Gateway) --- @Component public class TenantGatewayFilter implements GlobalFilter { @Autowired private TenantDomainMapper domainMapper; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); // 方式1:从子域名解析租户(a.example.com → 租户A) String host = request.getURI().getHost(); String subDomain = extractSubDomain(host); // 方式2:从请求头解析租户 String tenantHeader = request.getHeaders().getFirst("X-Tenant-Id"); Long tenantId = null; if (subDomain != null) { // 通过域名映射查租户ID tenantId = domainMapper.findTenantByDomain(subDomain); } else if (tenantHeader != null) { tenantId = Long.parseLong(tenantHeader); } if (tenantId == null) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 把tenant_id放入请求头,传递给下游服务 ServerHttpRequest newRequest = request.mutate() .header("X-Tenant-Id", tenantId.toString()) .build(); return chain.filter(exchange.mutate().request(newRequest).build()); } private String extractSubDomain(String host) { // a.example.com → "a" String[] parts = host.split("\\."); if (parts.length >= 3) { return parts[0]; } return null; } } // --- 动态数据源路由(针对独立DB的大租户) --- public class TenantDynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { Long tenantId = TenantContextHolder.getTenantId(); if (tenantId == null) { return "default"; // 默认数据源 } // 检查租户是否有独立数据源 TenantDsConfig dsConfig = TenantDsManager.getConfig(tenantId); if (dsConfig != null && dsConfig.isIsolated()) { return "tenant_" + tenantId; // 路由到租户专属数据源 } return "default"; // 共享数据源 } }
流程2:租户配置覆盖链
// ====== 租户配置覆盖链 — 带缓存加速 ====== // 查配置就像找文件 — 先查内存(缓存),再查硬盘(数据库) @Service public class ConfigChainService { @Autowired private ConfigCacheManager cacheManager; @Autowired private TenantConfigMapper configMapper; /** * 带缓存的配置查询 * 就像CPU的L1/L2/L3缓存 — 越近越快 */ public String getConfigWithCache(String key, Long tenantId, Long userId) { // L1缓存:Redis String cached = cacheManager.get(tenantId, key); if (cached != null) { return cached; } // L2:数据库 — 按优先级链查找 String value = resolveConfigChain(key, tenantId, userId); // 写入缓存 if (value != null) { cacheManager.put(tenantId, key, value); } return value; } private String resolveConfigChain(String key, Long tenantId, Long userId) { // 优先级:用户 > 租户 > 全局 List<TenantConfig> configs = configMapper.selectByKeyAndTenant(key, tenantId); // 1. 找用户级配置 if (userId != null) { for (TenantConfig c : configs) { if ("USER".equals(c.getConfigType()) && userId.equals(c.getUserId())) { return c.getConfigValue(); } } } // 2. 找租户级配置 for (TenantConfig c : configs) { if ("TENANT".equals(c.getConfigType())) { return c.getConfigValue(); } } // 3. 找全局配置 for (TenantConfig c : configs) { if ("GLOBAL".equals(c.getConfigType())) { return c.getConfigValue(); } } return null; // 没找到任何配置 } }
流程3:计费账单生成完整流程
// ====== 月度账单生成 — 定时任务 ====== // 每月1号凌晨执行,给所有租户生成上个月账单 @Component public class MonthlyBillingJob { @Autowired private TenantInfoMapper tenantMapper; @Autowired private BillingService billingService; @Autowired private NotificationService notifyService; /** * 每月1号凌晨2点执行 * 就像物业月底统一发账单 */ @Scheduled(cron = "0 0 2 1 * ?") public void generateMonthlyBills() { YearMonth lastMonth = YearMonth.now().minusMonths(1); // 查所有活跃租户 List<TenantInfo> activeTenants = tenantMapper.selectActive(); for (TenantInfo tenant : activeTenants) { try { // 生成账单 Bill bill = billingService.generateMonthlyBill( tenant.getId(), lastMonth ); // 发送账单通知(邮件/站内信) notifyService.sendBillNotification(tenant.getId(), bill); log.info("租户{}账单生成成功,金额:{}", tenant.getId(), bill.getTotalAmount()); } catch (Exception e) { log.error("租户{}账单生成失败", tenant.getId(), e); // 发送告警 — 账单生成失败是严重问题 notifyService.sendAlert("账单生成失败", tenant.getId(), e.getMessage()); } } } } // ====== 用量数据同步 — 从Redis同步到数据库 ====== // Redis中的计数是实时的,但要落库才能生成账单 @Component public class UsageSyncJob { @Autowired private StringRedisTemplate redisTemplate; @Autowired private UsageRecordMapper usageMapper; /** * 每天凌晨1点执行 — 把昨天的用量从Redis同步到数据库 * 就像每天晚上盘点,把今天的流水记到账本上 */ @Scheduled(cron = "0 0 1 * * ?") public void syncUsageToDb() { LocalDate yesterday = LocalDate.now().minusDays(1); String keyPattern = "usage:*:" + yesterday; // 扫描所有昨天的用量Key Set<String> keys = redisTemplate.keys(keyPattern); if (keys == null || keys.isEmpty()) { return; } for (String key : keys) { // 解析key: usage:{tenantId}:{date} String[] parts = key.split(":"); Long tenantId = Long.parseLong(parts[1]); // 获取用量值 String countStr = redisTemplate.opsForValue().get(key); BigDecimal count = new BigDecimal(countStr); // 写入数据库 UsageRecord record = new UsageRecord(); record.setTenantId(tenantId); record.setMetricType("API_CALL"); record.setQuantity(count); record.setUsageDate(yesterday); usageMapper.insert(record); // 删除Redis中已同步的Key redisTemplate.delete(key); } } }

💥 踩坑实录 — 血泪教训

坑1:忘记加tenant_id条件 — 查到了别的租户的数据!
场景:新来的同事写了一个报表查询接口,直接用原生SQL:SELECT * FROM order WHERE status = 'PAID',没有加 tenant_id 条件。结果租户A查到了租户B的订单数据——这是严重的数据泄露事故!
解决:SQL拦截器兜底!MyBatis-Plus的租户拦截器会自动给SQL追加 WHERE tenant_id = ?。即使开发者忘了写,拦截器也会补上——这就是"防御性编程"的思想,不信任人的自觉性,用技术手段保底。

额外保险:代码Review时重点检查原生SQL和@Select注解,数据库层面给所有业务表加上tenant_id索引,确保即使漏了条件也不会全表扫描。
坑2:租户数据误删 — DELETE忘加条件
场景:运维同学在排查问题时,执行了 DELETE FROM customer WHERE name LIKE '%测试%',结果把所有租户叫"测试"的客户都删了——包括别的租户的真实客户!
解决:逻辑删除+审计日志!
1. 所有业务表使用逻辑删除(deleted字段),MyBatis-Plus配置 logic-delete-field: deleted,所有DELETE变成UPDATE。
2. 记录审计日志:谁在什么时间删除了什么数据。
3. 数据库开启binlog,万一真删了可以从binlog恢复。
4. 生产环境禁止直接操作数据库,必须通过API操作。
// 逻辑删除配置 @Data public class BaseEntity { @TableLogic // MyBatis-Plus逻辑删除注解 @TableField(select = true) private int deleted; // 0=未删除,1=已删除 @TableField(fill = FieldFill.INSERT) private Long createBy; // 创建人 — 审计追踪 @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; // 创建时间 }
坑3:计费不准 — 用量统计遗漏
场景:月底出账单时,发现租户A的API调用量只有Redis统计的一半——原来有部分请求经过CDN缓存直接返回了,没经过应用层,所以AOP拦截器没统计到。
解决:对账补偿机制!
1. 双重统计:应用层AOP统计 + Nginx访问日志统计,两边数据交叉验证。
2. 月底对账:账单金额 = 应用统计 和 日志统计取最大值(对客户友好)。
3. 差异超过5%时自动告警,人工介入核查。
4. 对账脚本代码:
// 对账脚本 — 比对应用统计和日志统计 @Service public class BillingReconciliation { public void reconcile(Long tenantId, YearMonth month) { // 应用统计 BigDecimal appCount = usageMapper.sumByTenantAndMonth(tenantId, month); // 日志统计(从ClickHouse查Nginx日志) BigDecimal logCount = nginxLogMapper.sumByTenantAndMonth(tenantId, month); BigDecimal diff = appCount.subtract(logCount).abs(); BigDecimal diffRate = diff.divide(logCount, 4, RoundingMode.HALF_UP); if (diffRate.compareTo(new BigDecimal("0.05")) > 0) { // 差异超过5%,告警 log.warn("租户{}用量差异异常:应用={}, 日志={}, 差异率={}", tenantId, appCount, logCount, diffRate); notifyService.sendAlert("用量统计异常", tenantId, diffRate.toString()); } // 取较大值(对客户友好,宁可少收也不多收) BigDecimal finalCount = appCount.max(logCount); billingService.updateBillWithReconciledCount(tenantId, month, finalCount); } }
坑4:大租户拖慢小租户 — 共享资源争抢
场景:租户A有10万用户,每天产生100万条数据,查询特别慢。而租户B只有100个用户,但因为和租户A共享数据库,租户A的大查询锁表导致租户B也跟着卡。
解决:租户级资源隔离!
1. 数据库隔离:大租户迁到独立DB,小租户继续共享。
2. 连接池隔离:每个租户独立的连接池配额,防止大租户占光连接。
3. 限流:Gateway层按租户限流,大租户QPS上限高但也不能无限。
4. 读写分离:大租户读走从库,减轻主库压力。
5. 就像写字楼的中央空调——不能让一家公司开16度把整栋楼都冻住。
// 租户级限流 — Sentinel配置 @Configuration public class TenantRateLimitConfig { @PostConstruct public void initRules() { // 大租户限流规则 FlowRule bigTenantRule = new FlowRule() .setResource("tenant_001_api") .setCount(1000) // QPS上限1000 .setGrade(RuleConstant.FLOW_GRADE_QPS); // 小租户限流规则 FlowRule smallTenantRule = new FlowRule() .setResource("tenant_002_api") .setCount(200) // QPS上限200 .setGrade(RuleConstant.FLOW_GRADE_QPS); FlowRuleManager.loadRules(List.of(bigTenantRule, smallTenantRule)); } }

🔍 排查思路 — 出了问题怎么找

问题现象可能原因排查步骤定位方法
用户看到了别的租户数据 1. SQL没加tenant_id
2. ThreadLocal被串
3. 跨租户授权泄露
1. 检查SQL日志,确认拦截器是否生效
2. 检查ThreadLocal设置和清除
3. 检查跨租户授权表
开启MyBatis SQL日志,看实际执行的SQL有没有tenant_id条件
租户配置不生效 1. 缓存未刷新
2. 优先级配置错误
3. 配置Key拼写错误
1. 清Redis缓存重试
2. 打印三级配置查找过程
3. 对比数据库中的Key
在配置服务中加DEBUG日志,打印每级配置查找结果
账单金额不对 1. 用量统计遗漏
2. 单价配置错误
3. 重复计费
1. 对比应用统计和日志统计
2. 检查套餐单价配置
3. 查是否有重复账单
跑对账脚本,交叉验证两个数据源的用量
某租户接口很慢 1. 大查询锁表
2. 连接池被占满
3. 未命中索引
1. 查慢SQL日志
2. 查连接池使用率
3. EXPLAIN分析执行计划
MySQL慢查询日志 + 连接池监控 + EXPLAIN
租户登录后看不到数据 1. tenant_id为空
2. 租户已过期/禁用
3. 用户不属于该租户
1. 检查Token中tenant_id
2. 查租户状态
3. 查用户-租户关联表
在Gateway日志中看tenant_id解析结果
排查万能公式
1. 先看日志 — SQL日志看有没有tenant_id,应用日志看有没有异常
2. 再看数据 — 直接查数据库,对比期望值和实际值
3. 然后看缓存 — Redis中的配置/用量是否过期或脏数据
4. 最后看代码 — 定位到具体代码行,分析逻辑是否正确
5. 复现&修复 — 本地复现问题,修复后加测试用例防止回归
$ 开启MyBatis SQL日志,检查拦截器是否生效 # application.yml 配置 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl $ 查看实际执行的SQL — 确认WHERE tenant_id = ? 是否存在 ==> Preparing: SELECT id,name,phone FROM customer WHERE tenant_id = ? AND status = ? ==> Parameters: 1(Long), ACTIVE(String) ✓ 拦截器生效!SQL中已自动追加 tenant_id 条件 $ 排查缓存问题 — 查看Redis中的配置值 redis-cli GET "config:1:theme.color" "#FF5500" ✓ 缓存值正确 $ 排查用量统计 — 对比Redis计数和数据库记录 redis-cli GET "usage:1:2026-06-12" "15234" SELECT SUM(quantity) FROM usage_record WHERE tenant_id=1 AND usage_date='2026-06-12' 15234.00 ✓ 两边数据一致

📝 面试高频 — 5道必考题

Q1:多租户数据隔离有哪几种方案?各有什么优缺点?
:三种方案——
1. 独立数据库:每个租户一个DB。隔离性最强,但成本最高,运维复杂。适合金融、医疗等强合规场景。
2. 共享DB独立Schema:同一个数据库实例,每个租户一个Schema。隔离性中高,成本适中。适合中型租户。
3. 共享DB共享表+tenant_id:所有租户共享表,通过tenant_id字段行级隔离。成本最低,扩展性最好,但隔离性依赖代码质量。适合大多数SaaS场景。

实战选择:采用"分层隔离"策略——小租户用共享表(省成本),大租户升级为独立Schema或独立DB(强隔离)。MyBatis-Plus的TenantLineInnerInterceptor可以自动加tenant_id条件,防止数据泄露。
Q2:如何保证SQL不会漏掉tenant_id条件?
:多层防御——
1. 拦截器兜底:MyBatis-Plus TenantLineInnerInterceptor自动给所有SQL追加tenant_id条件,这是最后一道防线。
2. 代码规范:禁止使用原生SQL和@Select注解,必须通过Mapper接口操作。
3. 代码Review:Review时重点检查SQL相关代码。
4. 数据库约束:所有业务表的tenant_id字段设为NOT NULL,确保数据必须有租户归属。
5. 自动化测试:在集成测试中验证每个接口返回的数据都属于当前租户。
Q3:多租户系统如何做权限控制?
:RBAC + 租户维度——
1. 租户内权限:标准的RBAC模型,但所有角色和权限都带上tenant_id维度。用户在租户A是管理员,在租户B可能只是普通用户。
2. 跨租户协作:通过授权表(cross_tenant_grant)实现。租户A的管理员可以授权租户B的特定用户访问A的特定资源,带过期时间。
3. 数据权限:行级权限——用户只能看自己部门的数据;列级权限——普通员工看不到薪资列。
4. Spring Security集成:自定义UserDetails包含tenantId,所有权限校验都带上租户维度。JWT Token中包含tenant_id,每次请求自动解析。
Q4:SaaS计费系统怎么设计?
:三种计费模型 + 用量采集 + 对账补偿——
1. 按用户数计费:每月统计活跃用户数 × 单价。适合协作类工具(Slack、飞书)。
2. 按功能模块计费:每个模块独立定价,租户按需开通。适合功能丰富的平台(钉钉)。
3. 按用量计费:统计API调用次数/存储量/流量。适合基础设施类(AWS、阿里云)。
4. 用量采集:AOP拦截器+Redis INCR实时计数,定时同步到数据库。
5. 对账补偿:应用统计和日志统计交叉验证,差异超5%告警,取较大值(对客户友好)。
6. 账单生成:定时任务每月1号执行,生成账单后发通知。
Q5:租户个性化怎么做?自定义字段、流程、报表?
:EAV模型 + JSON扩展字段——
1. 自定义字段:EAV模型(Entity-Attribute-Value),字段定义和字段值分离存储。租户可以给任意实体(客户/订单/产品)添加自定义字段。查询时需要行转列,性能较差但灵活性极高。
2. 轻量级方案:主表加ext_data JSON字段,MySQL 5.7+支持JSON类型和JSON函数查询。适合简单的扩展需求。
3. 自定义流程:流程引擎(Activiti/Camunda)+ 租户流程定义,每个租户可以自定义审批流程。
4. 自定义报表:报表模板表 + SQL模板,租户可以定义报表的维度、指标和展示方式。用Freemarker渲染SQL模板。