设计一个多租户 SaaS(组织、资源隔离、计费)

1. 概述

1.1 多租户SaaS的重要性

多租户SaaS(Software as a Service)是一种软件交付模式,多个租户共享同一套系统实例,但数据和应用逻辑相互隔离。

多租户SaaS的优势

  • 成本降低:共享基础设施,降低运营成本
  • 快速部署:新租户快速接入
  • 统一维护:统一升级和维护
  • 资源复用:资源共享,提高利用率

1.2 核心挑战

技术挑战

  • 数据隔离:如何保证租户数据安全隔离
  • 资源隔离:如何隔离计算资源
  • 性能隔离:如何避免租户间相互影响
  • 计费管理:如何准确计费和结算

1.3 本文内容结构

本文将从以下几个方面全面解析多租户SaaS系统:

  1. 多租户概述:多租户模型、隔离方案
  2. 组织管理:租户管理、组织架构
  3. 资源隔离:数据隔离、计算隔离、网络隔离
  4. 计费系统:计费模型、计费规则、账单管理
  5. 架构设计:整体架构、模块设计
  6. 实现方案:完整实现代码
  7. 实战案例:实际应用场景

2. 多租户概述

2.1 多租户模型

2.1.1 共享数据库,共享Schema

模型

  • 所有租户共享同一个数据库
  • 所有租户共享同一个Schema
  • 通过tenant_id字段区分租户

特点

  • 优点:成本最低,维护简单
  • 缺点:数据隔离性差,安全性低

适用场景

  • 小型SaaS应用
  • 对数据隔离要求不高的场景

2.1.2 共享数据库,独立Schema

模型

  • 所有租户共享同一个数据库
  • 每个租户有独立的Schema

特点

  • 优点:数据隔离性好,成本适中
  • 缺点:Schema管理复杂

适用场景

  • 中型SaaS应用
  • 对数据隔离有一定要求的场景

2.1.3 独立数据库

模型

  • 每个租户有独立的数据库

特点

  • 优点:数据隔离性最好,安全性最高
  • 缺点:成本高,维护复杂

适用场景

  • 大型SaaS应用
  • 对数据隔离要求高的场景

2.2 隔离方案对比

方案 数据隔离 成本 维护复杂度 性能 适用场景
共享DB+共享Schema 最低 小型应用
共享DB+独立Schema 中型应用
独立数据库 大型应用

3. 组织管理

3.1 租户管理

3.1.1 租户模型

租户数据结构

1
2
3
4
5
6
7
8
9
10
11
12
public class Tenant {
private Long tenantId; // 租户ID
private String tenantCode; // 租户编码
private String tenantName; // 租户名称
private String domain; // 租户域名
private String status; // 状态:ACTIVE, SUSPENDED, EXPIRED
private Date expireTime; // 过期时间
private String planType; // 套餐类型:FREE, BASIC, PREMIUM
private Integer maxUsers; // 最大用户数
private Integer maxStorage; // 最大存储(GB)
private Date createTime; // 创建时间
}

3.1.2 数据库设计

租户表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE `sys_tenant` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '租户ID',
`tenant_code` VARCHAR(50) NOT NULL COMMENT '租户编码',
`tenant_name` VARCHAR(100) NOT NULL COMMENT '租户名称',
`domain` VARCHAR(100) COMMENT '租户域名',
`status` VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE,SUSPENDED,EXPIRED',
`expire_time` DATETIME COMMENT '过期时间',
`plan_type` VARCHAR(20) DEFAULT 'FREE' COMMENT '套餐类型:FREE,BASIC,PREMIUM',
`max_users` INT DEFAULT 10 COMMENT '最大用户数',
`max_storage` INT DEFAULT 10 COMMENT '最大存储(GB)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_code` (`tenant_code`),
UNIQUE KEY `uk_domain` (`domain`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户表';

3.1.3 租户服务

租户服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@Service
public class TenantService {

@Autowired
private TenantMapper tenantMapper;

@Autowired
private RedisTemplate<String, String> redisTemplate;

/**
* 创建租户
*/
public Tenant createTenant(Tenant tenant) {
// 1. 生成租户编码
tenant.setTenantCode(generateTenantCode());

// 2. 保存租户
tenantMapper.insert(tenant);

// 3. 初始化租户资源
initializeTenantResources(tenant);

return tenant;
}

/**
* 获取租户信息
*/
public Tenant getTenant(Long tenantId) {
// 1. 查询缓存
String cacheKey = "tenant:" + tenantId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, Tenant.class);
}

// 2. 查询数据库
Tenant tenant = tenantMapper.selectById(tenantId);

// 3. 写入缓存
if (tenant != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(tenant),
1, TimeUnit.HOURS);
}

return tenant;
}

/**
* 根据域名获取租户
*/
public Tenant getTenantByDomain(String domain) {
return tenantMapper.selectByDomain(domain);
}

/**
* 初始化租户资源
*/
private void initializeTenantResources(Tenant tenant) {
// 1. 创建租户数据库(如果使用独立数据库)
// createTenantDatabase(tenant);

// 2. 创建租户Schema(如果使用独立Schema)
// createTenantSchema(tenant);

// 3. 初始化租户数据
initializeTenantData(tenant);
}
}

3.2 组织架构

3.2.1 组织模型

组织数据结构

1
2
3
4
5
6
7
8
9
10
11
public class Organization {
private Long orgId; // 组织ID
private Long tenantId; // 租户ID
private String orgCode; // 组织编码
private String orgName; // 组织名称
private Long parentId; // 父组织ID
private Integer level; // 组织层级
private String path; // 组织路径
private Integer sortOrder; // 排序
private Date createTime; // 创建时间
}

3.2.2 数据库设计

组织表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE `sys_organization` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '组织ID',
`tenant_id` BIGINT(20) NOT NULL COMMENT '租户ID',
`org_code` VARCHAR(50) NOT NULL COMMENT '组织编码',
`org_name` VARCHAR(100) NOT NULL COMMENT '组织名称',
`parent_id` BIGINT(20) COMMENT '父组织ID',
`level` INT DEFAULT 1 COMMENT '组织层级',
`path` VARCHAR(500) COMMENT '组织路径',
`sort_order` INT DEFAULT 0 COMMENT '排序',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_org_code` (`tenant_id`, `org_code`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_parent_id` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组织表';

4. 资源隔离

4.1 数据隔离

4.1.1 共享数据库+共享Schema

实现方式:通过tenant_id字段区分租户。

数据表设计

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `user` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`tenant_id` BIGINT(20) NOT NULL COMMENT '租户ID',
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
`email` VARCHAR(100) COMMENT '邮箱',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_username` (`tenant_id`, `username`),
KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

数据访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class UserService {

@Autowired
private UserMapper userMapper;

public User getUser(Long userId) {
// 获取当前租户ID
Long tenantId = TenantContext.getCurrentTenantId();

// 查询用户(自动过滤租户)
return userMapper.selectByIdAndTenantId(userId, tenantId);
}

public List<User> listUsers() {
Long tenantId = TenantContext.getCurrentTenantId();
return userMapper.selectByTenantId(tenantId);
}
}

4.1.2 MyBatis拦截器

租户数据过滤拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
@Component
public class TenantInterceptor implements Interceptor {

@Override
public Object intercept(Invocation invocation) throws Throwable {
// 1. 获取MappedStatement
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];

// 2. 检查是否需要租户过滤
if (needTenantFilter(mappedStatement)) {
// 3. 获取当前租户ID
Long tenantId = TenantContext.getCurrentTenantId();
if (tenantId == null) {
throw new BusinessException("租户ID不能为空");
}

// 4. 修改SQL,添加tenant_id条件
BoundSql boundSql = mappedStatement.getBoundSql(invocation.getArgs()[1]);
String sql = boundSql.getSql();
sql = addTenantFilter(sql, tenantId);

// 5. 创建新的BoundSql和MappedStatement
BoundSql newBoundSql = new BoundSql(mappedStatement.getConfiguration(), sql,
boundSql.getParameterMappings(), boundSql.getParameterObject());
MappedStatement newMappedStatement = copyFromMappedStatement(mappedStatement,
new BoundSqlSqlSource(newBoundSql));
invocation.getArgs()[0] = newMappedStatement;
}

return invocation.proceed();
}

private String addTenantFilter(String sql, Long tenantId) {
// 在SQL中添加tenant_id条件
if (sql.toUpperCase().contains("WHERE")) {
return sql + " AND tenant_id = " + tenantId;
} else {
return sql + " WHERE tenant_id = " + tenantId;
}
}
}

4.1.3 租户上下文

租户上下文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class TenantContext {

private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();

public static void setTenantId(Long tenantId) {
TENANT_ID.set(tenantId);
}

public static Long getCurrentTenantId() {
return TENANT_ID.get();
}

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

租户拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Component
public class TenantInterceptor implements HandlerInterceptor {

@Autowired
private TenantService tenantService;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 1. 从请求中获取租户信息
String tenantCode = getTenantCode(request);

// 2. 查询租户
Tenant tenant = tenantService.getTenantByCode(tenantCode);
if (tenant == null) {
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.getWriter().write("租户不存在");
return false;
}

// 3. 检查租户状态
if (!"ACTIVE".equals(tenant.getStatus())) {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.getWriter().write("租户已暂停或过期");
return false;
}

// 4. 设置租户上下文
TenantContext.setTenantId(tenant.getId());

return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
// 清理租户上下文
TenantContext.clear();
}

private String getTenantCode(HttpServletRequest request) {
// 1. 从Header获取
String tenantCode = request.getHeader("X-Tenant-Code");
if (StringUtils.isNotBlank(tenantCode)) {
return tenantCode;
}

// 2. 从域名获取
String domain = request.getServerName();
Tenant tenant = tenantService.getTenantByDomain(domain);
if (tenant != null) {
return tenant.getTenantCode();
}

// 3. 从子域名获取
String subdomain = extractSubdomain(domain);
if (StringUtils.isNotBlank(subdomain)) {
return subdomain;
}

throw new BusinessException("无法获取租户信息");
}
}

4.2 计算隔离

4.2.1 线程池隔离

租户线程池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class TenantThreadPoolConfig {

@Bean
public ThreadPoolExecutor tenantThreadPool() {
return new ThreadPoolExecutor(
10, // 核心线程数
50, // 最大线程数
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("tenant-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
}

4.2.2 资源限制

资源限制服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Service
public class ResourceLimitService {

@Autowired
private RedisTemplate<String, String> redisTemplate;

/**
* 检查用户数限制
*/
public boolean checkUserLimit(Long tenantId) {
Tenant tenant = tenantService.getTenant(tenantId);

// 查询当前用户数
int currentUserCount = userService.getUserCount(tenantId);

return currentUserCount < tenant.getMaxUsers();
}

/**
* 检查存储限制
*/
public boolean checkStorageLimit(Long tenantId, long size) {
Tenant tenant = tenantService.getTenant(tenantId);

// 查询当前存储使用量
long currentStorage = storageService.getStorageUsage(tenantId);

return (currentStorage + size) <= tenant.getMaxStorage() * 1024 * 1024 * 1024L;
}

/**
* 检查API调用限制
*/
public boolean checkApiLimit(Long tenantId) {
String key = "api:limit:" + tenantId + ":" + LocalDate.now();
Long count = redisTemplate.opsForValue().increment(key);

if (count == 1) {
redisTemplate.expire(key, 1, TimeUnit.DAYS);
}

Tenant tenant = tenantService.getTenant(tenantId);
int maxApiCalls = getMaxApiCalls(tenant.getPlanType());

return count <= maxApiCalls;
}

private int getMaxApiCalls(String planType) {
switch (planType) {
case "FREE":
return 1000;
case "BASIC":
return 10000;
case "PREMIUM":
return 100000;
default:
return 1000;
}
}
}

4.3 网络隔离

4.3.1 域名隔离

域名配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class DomainConfig {

@Bean
public TenantResolver tenantResolver() {
return new DomainTenantResolver();
}
}

public class DomainTenantResolver implements TenantResolver {

@Autowired
private TenantService tenantService;

@Override
public Tenant resolve(HttpServletRequest request) {
String domain = request.getServerName();
return tenantService.getTenantByDomain(domain);
}
}

4.3.2 子域名隔离

子域名配置

1
2
3
4
5
6
7
8
9
10
11
12
# Nginx配置
server {
listen 80;
server_name *.example.com;

location / {
# 提取子域名作为租户标识
set $tenant_code $host;
proxy_set_header X-Tenant-Code $tenant_code;
proxy_pass http://backend;
}
}

5. 计费系统

5.1 计费模型

5.1.1 订阅计费

订阅模型

  • 按月/年订阅
  • 固定价格
  • 包含一定资源配额

套餐类型

  • FREE:免费版,基础功能
  • BASIC:基础版,标准功能
  • PREMIUM:高级版,完整功能

5.1.2 按量计费

按量模型

  • 按实际使用量计费
  • 按API调用次数
  • 按存储使用量
  • 按带宽使用量

5.1.3 混合计费

混合模型

  • 基础订阅 + 超出部分按量计费
  • 灵活组合

5.2 计费规则

5.2.1 数据库设计

套餐表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE `billing_plan` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '套餐ID',
`plan_code` VARCHAR(50) NOT NULL COMMENT '套餐编码',
`plan_name` VARCHAR(100) NOT NULL COMMENT '套餐名称',
`price` DECIMAL(10,2) NOT NULL COMMENT '价格',
`billing_cycle` VARCHAR(20) NOT NULL COMMENT '计费周期:MONTHLY,YEARLY',
`max_users` INT DEFAULT 10 COMMENT '最大用户数',
`max_storage` INT DEFAULT 10 COMMENT '最大存储(GB)',
`max_api_calls` INT DEFAULT 1000 COMMENT '最大API调用次数',
`features` TEXT COMMENT '功能列表(JSON)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_plan_code` (`plan_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐表';

订阅表

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE `billing_subscription` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '订阅ID',
`tenant_id` BIGINT(20) NOT NULL COMMENT '租户ID',
`plan_id` BIGINT(20) NOT NULL COMMENT '套餐ID',
`status` VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE,CANCELLED,EXPIRED',
`start_time` DATETIME NOT NULL COMMENT '开始时间',
`end_time` DATETIME NOT NULL COMMENT '结束时间',
`auto_renew` TINYINT(1) DEFAULT 1 COMMENT '自动续费:1-是,0-否',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅表';

账单表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CREATE TABLE `billing_invoice` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '账单ID',
`tenant_id` BIGINT(20) NOT NULL COMMENT '租户ID',
`invoice_no` VARCHAR(50) NOT NULL COMMENT '账单号',
`amount` DECIMAL(10,2) NOT NULL COMMENT '金额',
`status` VARCHAR(20) DEFAULT 'PENDING' COMMENT '状态:PENDING,PAID,FAILED',
`billing_period` VARCHAR(20) COMMENT '计费周期',
`start_date` DATE COMMENT '开始日期',
`end_date` DATE COMMENT '结束日期',
`due_date` DATE COMMENT '到期日期',
`paid_time` DATETIME COMMENT '支付时间',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_invoice_no` (`invoice_no`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账单表';

使用记录表

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE `billing_usage` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`tenant_id` BIGINT(20) NOT NULL COMMENT '租户ID',
`usage_type` VARCHAR(20) NOT NULL COMMENT '使用类型:API_CALL,STORAGE,BANDWIDTH',
`usage_amount` DECIMAL(10,2) NOT NULL COMMENT '使用量',
`unit_price` DECIMAL(10,4) COMMENT '单价',
`amount` DECIMAL(10,2) COMMENT '金额',
`usage_date` DATE NOT NULL COMMENT '使用日期',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_tenant_date` (`tenant_id`, `usage_date`),
KEY `idx_usage_type` (`usage_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='使用记录表';

5.3 计费服务

5.3.1 订阅服务

订阅服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Service
public class SubscriptionService {

@Autowired
private SubscriptionMapper subscriptionMapper;

@Autowired
private PlanMapper planMapper;

/**
* 创建订阅
*/
public Subscription createSubscription(Long tenantId, Long planId) {
Plan plan = planMapper.selectById(planId);

Subscription subscription = new Subscription();
subscription.setTenantId(tenantId);
subscription.setPlanId(planId);
subscription.setStatus("ACTIVE");
subscription.setStartTime(new Date());

// 计算结束时间
Calendar calendar = Calendar.getInstance();
if ("MONTHLY".equals(plan.getBillingCycle())) {
calendar.add(Calendar.MONTH, 1);
} else if ("YEARLY".equals(plan.getBillingCycle())) {
calendar.add(Calendar.YEAR, 1);
}
subscription.setEndTime(calendar.getTime());

subscriptionMapper.insert(subscription);

// 更新租户套餐
updateTenantPlan(tenantId, plan);

return subscription;
}

/**
* 检查订阅状态
*/
public boolean checkSubscription(Long tenantId) {
Subscription subscription = subscriptionMapper.selectActiveByTenantId(tenantId);
if (subscription == null) {
return false;
}

// 检查是否过期
if (subscription.getEndTime().before(new Date())) {
subscription.setStatus("EXPIRED");
subscriptionMapper.updateById(subscription);
return false;
}

return true;
}
}

5.3.2 计费服务

计费服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
@Service
public class BillingService {

@Autowired
private UsageMapper usageMapper;

@Autowired
private InvoiceMapper invoiceMapper;

/**
* 记录使用量
*/
public void recordUsage(Long tenantId, String usageType, double amount) {
Usage usage = new Usage();
usage.setTenantId(tenantId);
usage.setUsageType(usageType);
usage.setUsageAmount(amount);
usage.setUsageDate(LocalDate.now());

// 计算金额(按量计费)
double unitPrice = getUnitPrice(usageType);
usage.setUnitPrice(unitPrice);
usage.setAmount(amount * unitPrice);

usageMapper.insert(usage);
}

/**
* 生成账单
*/
@Scheduled(cron = "0 0 1 1 * ?") // 每月1号凌晨1点
public void generateMonthlyInvoice() {
LocalDate lastMonth = LocalDate.now().minusMonths(1);
List<Long> tenantIds = tenantMapper.selectAllTenantIds();

for (Long tenantId : tenantIds) {
// 1. 计算使用量
Map<String, Double> usage = calculateUsage(tenantId, lastMonth);

// 2. 计算金额
double amount = calculateAmount(tenantId, usage);

// 3. 生成账单
if (amount > 0) {
createInvoice(tenantId, amount, lastMonth);
}
}
}

/**
* 计算使用量
*/
private Map<String, Double> calculateUsage(Long tenantId, LocalDate month) {
Map<String, Double> usage = new HashMap<>();

// 查询API调用次数
Double apiCalls = usageMapper.sumUsageByType(tenantId, "API_CALL", month);
usage.put("API_CALL", apiCalls != null ? apiCalls : 0.0);

// 查询存储使用量
Double storage = usageMapper.sumUsageByType(tenantId, "STORAGE", month);
usage.put("STORAGE", storage != null ? storage : 0.0);

// 查询带宽使用量
Double bandwidth = usageMapper.sumUsageByType(tenantId, "BANDWIDTH", month);
usage.put("BANDWIDTH", bandwidth != null ? bandwidth : 0.0);

return usage;
}

/**
* 计算金额
*/
private double calculateAmount(Long tenantId, Map<String, Double> usage) {
Subscription subscription = subscriptionMapper.selectActiveByTenantId(tenantId);
Plan plan = planMapper.selectById(subscription.getPlanId());

double amount = plan.getPrice(); // 基础订阅费用

// 超出部分按量计费
// API调用超出
int maxApiCalls = plan.getMaxApiCalls();
double apiCalls = usage.get("API_CALL");
if (apiCalls > maxApiCalls) {
amount += (apiCalls - maxApiCalls) * 0.01; // 超出部分0.01元/次
}

// 存储超出
int maxStorage = plan.getMaxStorage();
double storage = usage.get("STORAGE");
if (storage > maxStorage) {
amount += (storage - maxStorage) * 0.1; // 超出部分0.1元/GB
}

return amount;
}

/**
* 创建账单
*/
private void createInvoice(Long tenantId, double amount, LocalDate month) {
Invoice invoice = new Invoice();
invoice.setTenantId(tenantId);
invoice.setInvoiceNo(generateInvoiceNo());
invoice.setAmount(BigDecimal.valueOf(amount));
invoice.setStatus("PENDING");
invoice.setBillingPeriod("MONTHLY");
invoice.setStartDate(month.withDayOfMonth(1));
invoice.setEndDate(month.withDayOfMonth(month.lengthOfMonth()));
invoice.setDueDate(month.plusMonths(1).withDayOfMonth(1));

invoiceMapper.insert(invoice);

// 发送账单通知
sendInvoiceNotification(tenantId, invoice);
}
}

6. 架构设计

6.1 整体架构

6.1.1 架构图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
用户请求

Nginx(域名路由)

API网关(租户识别 + 鉴权)

业务服务集群

├──→ 租户服务(租户管理)
├──→ 组织服务(组织管理)
├──→ 用户服务(用户管理)
├──→ 业务服务(业务功能)
└──→ 计费服务(计费管理)

├──→ Redis(租户缓存、资源限制)
├──→ MySQL(租户数据、业务数据)
└──→ 消息队列(异步处理)

6.2 模块设计

6.2.1 租户识别

租户识别策略

  1. 域名识别:根据域名识别租户
  2. Header识别:从Header中获取租户信息
  3. 子域名识别:根据子域名识别租户

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Component
public class TenantResolver {

@Autowired
private TenantService tenantService;

public Tenant resolve(HttpServletRequest request) {
// 1. 从Header获取
String tenantCode = request.getHeader("X-Tenant-Code");
if (StringUtils.isNotBlank(tenantCode)) {
return tenantService.getTenantByCode(tenantCode);
}

// 2. 从域名获取
String domain = request.getServerName();
Tenant tenant = tenantService.getTenantByDomain(domain);
if (tenant != null) {
return tenant;
}

// 3. 从子域名获取
String subdomain = extractSubdomain(domain);
if (StringUtils.isNotBlank(subdomain)) {
return tenantService.getTenantByCode(subdomain);
}

throw new BusinessException("无法识别租户");
}
}

7. 实战案例

7.1 案例1:多租户CRM系统

7.1.1 场景

需求:多租户CRM系统,每个租户独立管理客户、订单等数据。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class CustomerService {

public Customer createCustomer(Customer customer) {
// 自动设置租户ID
Long tenantId = TenantContext.getCurrentTenantId();
customer.setTenantId(tenantId);

// 创建客户
return customerMapper.insert(customer);
}

public List<Customer> listCustomers() {
// 自动过滤租户数据
Long tenantId = TenantContext.getCurrentTenantId();
return customerMapper.selectByTenantId(tenantId);
}
}

7.2 案例2:多租户文档系统

7.2.1 场景

需求:多租户文档系统,每个租户独立存储文档。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class DocumentService {

public void uploadDocument(MultipartFile file) {
Long tenantId = TenantContext.getCurrentTenantId();

// 检查存储限制
if (!resourceLimitService.checkStorageLimit(tenantId, file.getSize())) {
throw new BusinessException("存储空间不足");
}

// 上传文档(按租户隔离存储)
String path = "/tenant/" + tenantId + "/" + file.getOriginalFilename();
storageService.upload(file, path);

// 记录使用量
billingService.recordUsage(tenantId, "STORAGE", file.getSize());
}
}

8. 总结

8.1 核心要点

  1. 多租户模型:共享DB+共享Schema、共享DB+独立Schema、独立数据库
  2. 组织管理:租户管理、组织架构
  3. 资源隔离:数据隔离、计算隔离、网络隔离
  4. 计费系统:订阅计费、按量计费、混合计费
  5. 架构设计:租户识别、数据隔离、资源限制

8.2 关键设计

  1. 数据隔离:通过tenant_id字段或独立数据库隔离
  2. 租户识别:域名、Header、子域名多种方式
  3. 资源限制:用户数、存储、API调用限制
  4. 计费管理:订阅、按量、混合计费模式

8.3 最佳实践

  1. 租户上下文:ThreadLocal管理租户信息
  2. SQL拦截器:自动添加tenant_id条件
  3. 资源限制:实时检查资源使用情况
  4. 计费记录:详细记录使用量,准确计费

相关文章