🎯 核心挑战 — 多租户的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系统的轻量级扩展 |
🔄 关键流程 — 代码级详解
流程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));
}
}