第 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 + 配额 |
| 计费 | 用量计量 + 月度账单 |
动手练习
- 实现一个支持多租户的简单 CRM demo
- 用 ThreadLocal + 拦截器实现租户上下文
- 用 Lua 脚本实现 API 配额限流
- 实现一个简单的用量计量 + 账单生成
下一章:下一章:第 246 章:综合项目实战 - 短视频平台