第469集设计一个多租户 SaaS(组织、资源隔离、计费)
|字数总计:4.5k|阅读时长:20分钟|阅读量:
设计一个多租户 SaaS(组织、资源隔离、计费)
1. 概述
1.1 多租户SaaS的重要性
多租户SaaS(Software as a Service)是一种软件交付模式,多个租户共享同一套系统实例,但数据和应用逻辑相互隔离。
多租户SaaS的优势:
- 成本降低:共享基础设施,降低运营成本
- 快速部署:新租户快速接入
- 统一维护:统一升级和维护
- 资源复用:资源共享,提高利用率
1.2 核心挑战
技术挑战:
- 数据隔离:如何保证租户数据安全隔离
- 资源隔离:如何隔离计算资源
- 性能隔离:如何避免租户间相互影响
- 计费管理:如何准确计费和结算
1.3 本文内容结构
本文将从以下几个方面全面解析多租户SaaS系统:
- 多租户概述:多租户模型、隔离方案
- 组织管理:租户管理、组织架构
- 资源隔离:数据隔离、计算隔离、网络隔离
- 计费系统:计费模型、计费规则、账单管理
- 架构设计:整体架构、模块设计
- 实现方案:完整实现代码
- 实战案例:实际应用场景
2. 多租户概述
2.1 多租户模型
2.1.1 共享数据库,共享Schema
模型:
- 所有租户共享同一个数据库
- 所有租户共享同一个Schema
- 通过tenant_id字段区分租户
特点:
- 优点:成本最低,维护简单
- 缺点:数据隔离性差,安全性低
适用场景:
2.1.2 共享数据库,独立Schema
模型:
- 所有租户共享同一个数据库
- 每个租户有独立的Schema
特点:
- 优点:数据隔离性好,成本适中
- 缺点:Schema管理复杂
适用场景:
2.1.3 独立数据库
模型:
特点:
- 优点:数据隔离性最好,安全性最高
- 缺点:成本高,维护复杂
适用场景:
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; private String tenantCode; private String tenantName; private String domain; private String status; private Date expireTime; private String planType; private Integer maxUsers; private Integer maxStorage; 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) { tenant.setTenantCode(generateTenantCode()); tenantMapper.insert(tenant); initializeTenantResources(tenant); return tenant; }
public Tenant getTenant(Long tenantId) { String cacheKey = "tenant:" + tenantId; String cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return JSON.parseObject(cached, Tenant.class); } Tenant tenant = tenantMapper.selectById(tenantId); 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) { initializeTenantData(tenant); } }
|
3.2 组织架构
3.2.1 组织模型
组织数据结构:
1 2 3 4 5 6 7 8 9 10 11
| public class Organization { private Long orgId; private Long tenantId; private String orgCode; private String orgName; private Long parentId; 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) { 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 { MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0]; if (needTenantFilter(mappedStatement)) { Long tenantId = TenantContext.getCurrentTenantId(); if (tenantId == null) { throw new BusinessException("租户ID不能为空"); } BoundSql boundSql = mappedStatement.getBoundSql(invocation.getArgs()[1]); String sql = boundSql.getSql(); sql = addTenantFilter(sql, tenantId); 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) { 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 { String tenantCode = getTenantCode(request); Tenant tenant = tenantService.getTenantByCode(tenantCode); if (tenant == null) { response.setStatus(HttpStatus.BAD_REQUEST.value()); response.getWriter().write("租户不存在"); return false; } if (!"ACTIVE".equals(tenant.getStatus())) { response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write("租户已暂停或过期"); return false; } 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) { String tenantCode = request.getHeader("X-Tenant-Code"); if (StringUtils.isNotBlank(tenantCode)) { return tenantCode; } String domain = request.getServerName(); Tenant tenant = tenantService.getTenantByDomain(domain); if (tenant != null) { return tenant.getTenantCode(); } 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; }
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
| 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 * ?") public void generateMonthlyInvoice() { LocalDate lastMonth = LocalDate.now().minusMonths(1); List<Long> tenantIds = tenantMapper.selectAllTenantIds(); for (Long tenantId : tenantIds) { Map<String, Double> usage = calculateUsage(tenantId, lastMonth); double amount = calculateAmount(tenantId, usage); if (amount > 0) { createInvoice(tenantId, amount, lastMonth); } } }
private Map<String, Double> calculateUsage(Long tenantId, LocalDate month) { Map<String, Double> usage = new HashMap<>(); 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(); int maxApiCalls = plan.getMaxApiCalls(); double apiCalls = usage.get("API_CALL"); if (apiCalls > maxApiCalls) { amount += (apiCalls - maxApiCalls) * 0.01; } int maxStorage = plan.getMaxStorage(); double storage = usage.get("STORAGE"); if (storage > maxStorage) { amount += (storage - maxStorage) * 0.1; } 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 租户识别
租户识别策略:
- 域名识别:根据域名识别租户
- Header识别:从Header中获取租户信息
- 子域名识别:根据子域名识别租户
实现:
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) { String tenantCode = request.getHeader("X-Tenant-Code"); if (StringUtils.isNotBlank(tenantCode)) { return tenantService.getTenantByCode(tenantCode); } String domain = request.getServerName(); Tenant tenant = tenantService.getTenantByDomain(domain); if (tenant != null) { return tenant; } 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) { 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 核心要点
- 多租户模型:共享DB+共享Schema、共享DB+独立Schema、独立数据库
- 组织管理:租户管理、组织架构
- 资源隔离:数据隔离、计算隔离、网络隔离
- 计费系统:订阅计费、按量计费、混合计费
- 架构设计:租户识别、数据隔离、资源限制
8.2 关键设计
- 数据隔离:通过tenant_id字段或独立数据库隔离
- 租户识别:域名、Header、子域名多种方式
- 资源限制:用户数、存储、API调用限制
- 计费管理:订阅、按量、混合计费模式
8.3 最佳实践
- 租户上下文:ThreadLocal管理租户信息
- SQL拦截器:自动添加tenant_id条件
- 资源限制:实时检查资源使用情况
- 计费记录:详细记录使用量,准确计费
相关文章: