Skip to content
第 245 / 250 章架构⏱ 18 分钟阅读

第 245 章:综合项目实战 - SaaS 多租户平台

学习目标

  • SaaS 多租户架构设计
  • 租户隔离方案
  • 动态配置与计费
  • 数据分片与扩展

一、SaaS 架构概览

二、租户模型

2.1 租户数据模型

java
@Data
@Entity
@Table(name = "t_tenant")
public class Tenant {

    @Id
    private Long id;

    /** 租户编码(唯一) */
    private String tenantCode;

    /** 租户名称 */
    private String name;

    /** 套餐: TRIAL/STANDARD/PRO/ENTERPRISE */
    private String plan;

    /** 状态: ACTIVE/SUSPENDED/EXPIRED */
    private String status;

    /** 数据库标识 */
    private String dbKey;

    /** 到期时间 */
    private LocalDateTime expireAt;

    /** 自定义域名 */
    private String customDomain;

    private LocalDateTime createdAt;
}

2.2 套餐定义

java
@Data
public class Plan {
    private String code;
    private String name;
    private BigDecimal price;
    private Integer maxUsers;
    private Long storageQuota;  // 字节
    private List<String> features;
    private Map<String, Integer> limits;  // 接口调用次数/月等
}

public class PlanCatalog {

    public static final Plan TRIAL = new Plan()
        .setCode("TRIAL")
        .setName("试用版")
        .setPrice(BigDecimal.ZERO)
        .setMaxUsers(5)
        .setStorageQuota(100L * 1024 * 1024)  // 100MB
        .setFeatures(List.of("基础功能"))
        .setLimits(Map.of("api", 1000));

    public static final Plan STANDARD = new Plan()
        .setCode("STANDARD")
        .setName("标准版")
        .setPrice(new BigDecimal("299"))
        .setMaxUsers(50)
        .setStorageQuota(10L * 1024 * 1024 * 1024)  // 10GB
        .setFeatures(List.of("基础功能", "工单系统"))
        .setLimits(Map.of("api", 50000));

    public static final Plan ENTERPRISE = new Plan()
        .setCode("ENTERPRISE")
        .setName("企业版")
        .setPrice(new BigDecimal("2999"))
        .setMaxUsers(Integer.MAX_VALUE)
        .setStorageQuota(1024L * 1024 * 1024 * 1024)  // 1TB
        .setFeatures(List.of("全部功能", "SLA 99.99%", "专属客服"))
        .setLimits(Map.of("api", Integer.MAX_VALUE));
}

三、租户隔离

3.1 三种隔离模式

我们选 B 方案(共享 DB,独立 Schema)。

3.2 数据源动态路由

java
@Component
@Slf4j
public class TenantDataSourceRouter extends AbstractRoutingDataSource {

    @Autowired
    private TenantService tenantService;

    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getTenantCode();
    }

    public void refreshDataSources() {
        List<Tenant> tenants = tenantService.findAllActive();
        Map<Object, Object> dataSources = new HashMap<>();

        for (Tenant tenant : tenants) {
            DataSource ds = createDataSource(tenant);
            dataSources.put(tenant.getTenantCode(), ds);
        }

        super.setTargetDataSources(dataSources);
        super.afterPropertiesSet();
    }

    private DataSource createDataSource(Tenant tenant) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://host:3306/" + tenant.getDbKey());
        config.setUsername("tenant_" + tenant.getTenantCode());
        config.setPassword(decrypt(tenant.getDbPassword()));
        config.setMaximumPoolSize(20);
        return new HikariDataSource(config);
    }
}

3.3 上下文传递

java
public class TenantContext {

    private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();

    public static void setTenant(String tenantCode) {
        CURRENT.set(tenantCode);
    }

    public static String getTenant() {
        return CURRENT.get();
    }

    public static void clear() {
        CURRENT.remove();
    }
}

@Component
public class TenantInterceptor implements WebRequestInterceptor {

    @Override
    public void preHandle(WebRequest request) {
        // 从 Header / JWT / 域名获取租户
        String tenant = extractTenant(request);
        TenantContext.setTenant(tenant);
    }

    @Override
    public void afterCompletion(WebRequest request, Exception ex) {
        TenantContext.clear();
    }
}

3.4 MyBatis 拦截器自动注入

java
@Intercepts(@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}))
@Component
@Slf4j
public class TenantSqlInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) {
        Object[] args = invocation.getArgs();
        Object parameter = args[1];

        if (parameter instanceof TenantAware) {
            ((TenantAware) parameter).setTenantCode(TenantContext.getTenant());
        }

        return invocation.proceed();
    }
}

public interface TenantAware {
    void setTenantCode(String tenantCode);
}

四、动态配置

4.1 租户级配置

java
@Data
public class TenantConfig {
    private String tenantCode;
    private Map<String, Object> features;
    private Map<String, Object> limits;
    private Map<String, Object> customizations;
}

@Service
public class TenantConfigService {

    @Autowired
    private RedisTemplate<String, TenantConfig> redisTemplate;

    @Autowired
    private NacosConfigService nacosService;

    public TenantConfig getConfig(String tenantCode) {
        String key = "tenant:config:" + tenantCode;
        TenantConfig config = redisTemplate.opsForValue().get(key);

        if (config == null) {
            config = loadFromNacos(tenantCode);
            redisTemplate.opsForValue().set(key, config, 10, TimeUnit.MINUTES);
        }

        return config;
    }

    @NacosConfigListener(dataId = "tenant-config")
    public void onConfigChange(String newConfig) {
        // 配置变更时清空缓存
        Set<String> keys = redisTemplate.keys("tenant:config:*");
        if (keys != null) {
            redisTemplate.delete(keys);
        }
    }
}

4.2 功能开关

java
@Component
public class FeatureToggle {

    @Autowired
    private TenantConfigService configService;

    public boolean isEnabled(String tenantCode, String feature) {
        TenantConfig config = configService.getConfig(tenantCode);
        Map<String, Object> features = config.getFeatures();
        return Boolean.TRUE.equals(features.get(feature));
    }
}

@Service
public class ReportService {

    @Autowired
    private FeatureToggle featureToggle;

    public Report generate(Long tenantId, ReportRequest req) {
        Tenant tenant = tenantService.findById(tenantId);

        if (!featureToggle.isEnabled(tenant.getTenantCode(), "ai-report")) {
            throw new BizException("当前套餐不支持 AI 报告");
        }

        // AI 生成报告
        return aiGenerate(req);
    }
}

五、限流与配额

5.1 接口配额

java
@Component
public class RateLimiter {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public boolean tryAcquire(String tenantCode, String api, int limit) {
        String key = "ratelimit:" + tenantCode + ":" + api
            + ":" + LocalDate.now();

        String luaScript = """
            local current = tonumber(redis.call('GET', KEYS[1]) or '0')
            if current < tonumber(ARGV[1]) then
                redis.call('INCR', KEYS[1])
                redis.call('EXPIRE', KEYS[1], 86400)
                return 1
            end
            return 0
            """;

        DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
        Long result = redisTemplate.execute(script, List.of(key), String.valueOf(limit));

        return result != null && result == 1;
    }
}

@Aspect
@Component
@Slf4j
public class RateLimitAspect {

    @Autowired
    private RateLimiter rateLimiter;

    @Autowired
    private TenantConfigService configService;

    @Around("@annotation(rateLimited)")
    public Object around(ProceedingJoinPoint pjp, RateLimited rateLimited) throws Throwable {
        String tenant = TenantContext.getTenant();
        TenantConfig config = configService.getConfig(tenant);
        Integer limit = (Integer) config.getLimits().get(rateLimited.value());

        if (!rateLimiter.tryAcquire(tenant, rateLimited.value(), limit)) {
            throw new BizException("API 调用次数超限");
        }

        return pjp.proceed();
    }
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimited {
    String value();
}

六、计费系统

6.1 用量计量

java
@Service
@Slf4j
public class UsageMeter {

    @Autowired
    private KafkaTemplate<String, UsageEvent> kafkaTemplate;

    public void record(String tenantCode, String metric, long value) {
        UsageEvent event = new UsageEvent();
        event.setTenantCode(tenantCode);
        event.setMetric(metric);
        event.setValue(value);
        event.setTimestamp(LocalDateTime.now());

        kafkaTemplate.send("usage-events", event);
    }
}

@Component
@Slf4j
public class UsageConsumer {

    @KafkaListener(topics = "usage-events", groupId = "usage-aggregator")
    public void onMessage(UsageEvent event) {
        // 聚合到 ClickHouse
        clickhouseService.insert(event);
    }
}

6.2 账单生成

java
@Service
public class BillingService {

    @Scheduled(cron = "0 0 1 1 * ?")  // 每月 1 号
    public void generateMonthlyBill() {
        List<Tenant> tenants = tenantService.findAllActive();

        for (Tenant tenant : tenants) {
            // 1. 计算用量
            UsageSummary usage = usageService.getMonthlyUsage(tenant.getTenantCode());

            // 2. 计算费用
            BigDecimal amount = calculateAmount(tenant.getPlan(), usage);

            // 3. 生成账单
            Bill bill = new Bill();
            bill.setTenantCode(tenant.getTenantCode());
            bill.setPeriod(LocalDate.now().withDayOfMonth(1));
            bill.setAmount(amount);
            bill.setStatus(BillStatus.PENDING);
            billMapper.insert(bill);

            // 4. 发送账单通知
            notificationService.sendBill(tenant, bill);
        }
    }

    private BigDecimal calculateAmount(String plan, UsageSummary usage) {
        // 套餐固定费 + 超出费用
        BigDecimal base = PlanCatalog.getByCode(plan).getPrice();
        BigDecimal overage = calculateOverage(plan, usage);
        return base.add(overage);
    }
}

七、数据迁移

7.1 租户数据导出

java
@Service
@Slf4j
public class TenantDataExport {

    public void export(String tenantCode, OutputStream out) throws IOException {
        try (ZipOutputStream zip = new ZipOutputStream(out)) {
            // 1. 导出用户表
            writeTableToZip(zip, "t_user", tenantCode, "users.csv");

            // 2. 导出订单表(分批)
            int pageSize = 10000;
            for (int page = 1; ; page++) {
                List<Order> orders = orderMapper.page(tenantCode, page, pageSize);
                if (orders.isEmpty()) break;
                writeToCsv(zip, "orders.csv", orders, true);
            }

            // 3. 导出文件
            storageService.copyTenantFiles(tenantCode, zip);
        }
    }
}

7.2 跨租户迁移

java
@Service
public class TenantMigrationService {

    public void migrate(String sourceTenant, String targetTenant) {
        // 1. 停止写入
        tenantService.setReadOnly(sourceTenant, true);

        // 2. 全量同步
        dataSyncService.fullSync(sourceTenant, targetTenant);

        // 3. 增量同步
        dataSyncService.incrementalSync(sourceTenant, targetTenant);

        // 4. 切换
        tenantService.switchTenant(sourceTenant, targetTenant);

        // 5. 恢复写入
        tenantService.setReadOnly(sourceTenant, false);
    }
}

八、本章小结

主题关键
多租户模型共享 DB + Schema 隔离
动态路由ThreadLocal + 拦截器
配置Nacos + 缓存 + 实时刷新
限流Redis Lua + 配额
计费用量计量 + 月度账单

动手练习

  1. 实现一个支持多租户的简单 CRM demo
  2. 用 ThreadLocal + 拦截器实现租户上下文
  3. 用 Lua 脚本实现 API 配额限流
  4. 实现一个简单的用量计量 + 账单生成

下一章:下一章:第 246 章:综合项目实战 - 短视频平台

本站基于 VitePress 构建 · 由 Codebook 团队维护