多租户架构是指在一个应用中支持多个租户(Tenant)同时访问,每个租户拥有独立的资源
和数据
,并且彼此之间完全隔离。
设计和实现一个多租户架构需要考虑许多关键因素。以下是一些重要的注意点:
数据隔离是多租户架构的核心要求之一。租户之间的数据不能互相访问或泄露。常见的数据隔离方法有:
资源隔离是确保每个租户的数据和计算资源在同一系统环境下不被其他租户干扰的一项重要任务。资源隔离可以从多个维度进行划分,包括数据隔离、计算隔离、存储隔离和网络隔离。
在多租户架构中,资源隔离是确保每个租户的数据和计算资源在同一系统环境下不被其他租户干扰的一项重要任务。资源隔离可以从多个维度进行划分,包括数据隔离、计算隔离、存储隔离和网络隔离。
每个租户的资源隔离策略会影响到架构设计的复杂性、性能和可扩展性。
数据隔离是多租户架构中最重要的资源隔离方式之一。确保每个租户的数据在物理或逻辑上互相隔离,可以防止数据泄漏和不必要的访问。
独立数据库模型(Database-per-Tenant):每个租户使用独立的数据库。每个数据库完全隔离,保证了数据的安全性和独立性。
共享数据库模型(Single Database-per-Tenant):所有租户共享一个数据库实例,每个租户的数据通过 tenant_id
(租户标识符)字段来隔离。
tenant_id
来做隔离,可能导致查询效率降低。共享架构模型(Schema-per-Tenant):每个租户的数据存储在同一个数据库实例中的独立 schema 中。
计算资源隔离主要是指不同租户的计算任务在执行时不会互相干扰。对于一个云平台或者大规模多租户系统,计算资源的隔离通常是通过 容器化 技术来实现的。
通过容器(如 Docker)将每个租户的应用部署在独立的容器中,虽然多个容器可能运行在同一主机上,但每个容器相互隔离,确保资源隔离。
Kubernetes 中的命名空间(Namespace)是逻辑隔离租户资源的基本单元。每个租户可以有自己的命名空间,所有运行的应用容器实例都归属于特定的命名空间。
优点:
示例:
apiVersion: v1
kind: Namespace
metadata:
name: tenant-a
---
apiVersion: v1
kind: Namespace
metadata:
name: tenant-b
不同租户的应用部署到对应的命名空间内:
apiVersion: apps/v1
kind: Deployment
metadata:
name: tenant-a-app
namespace: tenant-a
spec:
replicas: 3
template:
metadata:
labels:
app: tenant-a-app
spec:
containers:
- name: app-container
image: tenant-a-app-image
每个容器化应用实例绑定到特定的租户,通过以下方式实现:
环境变量注入租户标识:
在容器启动时注入租户标识(如 TENANT_ID
或 TENANT_NAME
),让应用实例在运行时固定绑定某个租户。
apiVersion: apps/v1
kind: Deployment
metadata:
name: tenant-app
namespace: tenant-a
spec:
template:
spec:
containers:
- name: tenant-app-container
image: tenant-app-image
env:
- name: TENANT_ID
value: "tenant-a"
- name: DB_URL
value: "jdbc:mysql://tenant-a-db:3306/tenant_db"
应用代码可以根据注入的租户标识加载对应的配置(如数据库、缓存等)。
租户独立的资源:
资源配额(ResourceQuota):
为每个租户的命名空间分配资源配额,限制 CPU、内存、存储等使用量。
apiVersion: v1
kind: ResourceQuota
metadata:
name: tenant-a-quota
namespace: tenant-a
spec:
hard:
requests.cpu: "4"
requests.memory: "8Gi"
limits.cpu: "8"
limits.memory: "16Gi"
网络隔离(NetworkPolicy):
定义网络策略,限制租户的容器只能与特定的服务通信。
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-tenant-interaction
namespace: tenant-a
spec:
podSelector: {}
ingress:
- from:
- namespaceSelector:
matchLabels:
name: tenant-a
RBAC(基于角色的访问控制):
控制用户只能操作特定租户命名空间内的资源。
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: tenant-a-admin
namespace: tenant-a
subjects:
- kind: User
name: admin-tenant-a
roleRef:
kind: Role
name: admin
apiGroup: rbac.authorization.k8s.io
在要求资源隔离的场景下,Kubernetes 上运行的容器化应用通常应在运行时绑定到某个具体租户。这种方式能够通过 Kubernetes 提供的命名空间隔离、资源配额、网络策略和 RBAC 等机制,实现不同租户间的计算、存储、网络和权限隔离。同时,应用可以通过环境变量或动态配置加载租户特定的数据源,进一步增强隔离性和灵活性。
存储隔离是确保每个租户的数据在物理存储上互不干扰,防止数据泄漏和不必要的资源竞争。
每个租户的数据存储在独立的物理存储上,保证完全的存储资源隔离。
独立存储的实现方式通常涉及到为每个租户提供独立的存储实例、目录、数据库、对象存储桶等。
实现方式:
在对象存储系统中,为每个租户创建独立的存储桶(Bucket)或容器,确保每个租户的数据存储在不同的存储区域。
多个租户的数据存储在同一存储系统上,但通过租户标识符(如 tenant_id
)来区分不同租户的数据。
目录
或文件夹
或容器
或命名空间
访问同一个存储系统,确保不同租户的逻辑隔离。为了确保每个租户的数据隔离,通常通过目录结构、访问控制列表(ACL)、文件权限等来进行隔离。在多租户架构中,不同租户之间的网络隔离可以通过以下方式实现:
每个租户可以在云平台上分配独立的虚拟私有网络(VPC),确保不同租户的网络流量不会相互干扰。
在容器化环境中,尤其是使用 Kubernetes 等容器编排平台时,可以通过容器网络插件(CNI)来实现租户的网络隔离。Kubernetes 提供了多种网络策略,可以实现租户之间的网络隔离和流量控制。
虚拟网络命名空间:为每个租户创建独立的网络命名空间,保证不同租户的网络流量互不干扰。
网络策略:使用 Kubernetes 的网络策略控制哪些 Pod 可以和其他 Pod 通信,通过制定规则来限制租户之间的网络访问。
优点:
缺点:
实现多租户架构中的资源隔离需要综合考虑以下几个方面:
不同的隔离方式具有不同的优缺点,选择适合自己应用场景的资源隔离方式至关重要。对于大规模、多租户的 SaaS 系统,常常需要综合考虑性能、成本、扩展性、安全性等因素来设计最合适的资源隔离方案。
如何管理不同租户的数据库连接?
需要考虑几个关键方面:数据库连接池管理、动态数据源切换、配置管理、性能优化等。
在多租户架构中,使用数据库连接池来高效地管理数据库连接非常重要。对于每个租户,都需要为其提供独立的数据库连接池。可以通过以下几种方式来管理不同租户的连接池:
为了在运行时根据当前租户切换数据库连接,可以设计一个动态数据源。动态数据源可以根据不同租户的需求,动态切换到对应的数据库实例或架构。
Spring框架:Spring框架提供了强大的数据源管理功能,可以结合AbstractRoutingDataSource
类来实现动态数据源切换。
实现步骤:
AbstractRoutingDataSource
,重写determineCurrentLookupKey()
方法,根据当前请求的租户ID返回对应的数据库标识符。HikariCP
、Druid
等连接池库来管理每个租户的数据库连接。import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getTenantId(); // 获取当前租户ID
}
}
DataSource
。@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource() {
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
// 对每个租户配置一个对应的数据库连接
targetDataSources.put("tenant1", tenant1DataSource());
targetDataSources.put("tenant2", tenant2DataSource());
routingDataSource.setTargetDataSources(targetDataSources);
routingDataSource.setDefaultTargetDataSource(defaultDataSource());
return routingDataSource;
}
@Bean
public DataSource tenant1DataSource() {
// 配置tenant1的DataSource
return DataSourceBuilder.create()
.url("jdbc:mysql://localhost:3306/tenant1_db")
.username("user")
.password("password")
.build();
}
@Bean
public DataSource tenant2DataSource() {
// 配置tenant2的DataSource
return DataSourceBuilder.create()
.url("jdbc:mysql://localhost:3306/tenant2_db")
.username("user")
.password("password")
.build();
}
@Bean
public DataSource defaultDataSource() {
// 配置默认数据源(如果有)
return DataSourceBuilder.create()
.url("jdbc:mysql://localhost:3306/default_db")
.username("default")
.password("default")
.build();
}
}
常用的数据库连接池(如HikariCP、Druid、C3P0等)可以与动态数据源结合使用,保证每个租户有独立的数据库连接池。比如:
在动态数据源的实现中,可以为每个租户设置独立的连接池实例。你可以通过配置文件来读取每个租户的数据库连接信息。
在多租户系统中,首先要根据每个请求的上下文获取当前租户的信息(通常是租户ID)。这个信息可以通过以下几种方式获取:
通过请求头、参数或会话传递:每个请求可以带上租户标识(如租户ID、子域名、API Token等),并在请求进入时获取租户信息。
示例:通过过滤器或拦截器在每次请求中获取租户ID。
@Component
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String tenantId = request.getParameter("tenantId");
if (tenantId != null) {
TenantContext.setTenantId(tenantId); // 设置当前线程的租户ID
}
chain.doFilter(request, response);
}
}
通过上面的TenantContext
类,存储当前租户的上下文信息(比如租户ID),这样在数据库连接的动态切换时,能够正确地根据租户来选择数据源。
public class TenantContext {
private static final ThreadLocal<String> tenantThreadLocal = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
tenantThreadLocal.set(tenantId);
}
public static String getTenantId() {
return tenantThreadLocal.get();
}
public static void clear() {
tenantThreadLocal.remove();
}
}
为了便于管理不同租户的数据库配置,可以使用配置文件、数据库表或者服务中心来动态管理租户的数据库信息。
每个租户的连接池可以设置不同的隔离级别
(如最大连接数、连接超时时间等),根据租户的负载情况动态调整。每次处理完一个请求后,确保清理线程上下文中存储的租户信息,避免跨请求污染。
@PreDestroy
public void cleanup() {
TenantContext.clear(); // 清理租户上下文
}
AbstractRoutingDataSource
等机制,根据当前请求的租户ID动态切换数据源。配置中心
或配置表
中维护租户ID与数据库连接信息。示例:
CREATE TABLE tenant_db_config (
tenant_id VARCHAR(50) PRIMARY KEY,
db_url VARCHAR(255),
db_username VARCHAR(100),
db_password VARCHAR(100),
db_driver_class VARCHAR(50)
);
2.数据库连接缓存
public class TenantDataSourceCache {
private static final Cache<String, DataSource> dataSourceCache = CacheBuilder.newBuilder()
.expireAfterAccess(10, TimeUnit.MINUTES) // 缓存有效期
.build();
@Autowired
private JdbcTemplate jdbcTemplate;
public DataSource getTenantDataSource(String tenantId) {
DataSource dataSource = dataSourceCache.getIfPresent(tenantId);
if (dataSource == null) {
// 缓存中没有,则从数据库加载
String sql = "SELECT db_url, db_username, db_password FROM tenant_db_config WHERE tenant_id = ?";
Map<String, Object> config = jdbcTemplate.queryForMap(sql, tenantId);
String dbUrl = (String) config.get("db_url");
String dbUsername = (String) config.get("db_username");
String dbPassword = (String) config.get("db_password");
dataSource = DataSourceBuilder.create()
.url(dbUrl)
.username(dbUsername)
.password(dbPassword)
.build();
// 缓存数据库连接
dataSourceCache.put(tenantId, dataSource);
}
return dataSource;
}
}
3.动态数据库连接池缓存
public class DynamicDataSourceManager {
private final Map<String, HikariDataSource> dataSourceMap = new HashMap<>();
public HikariDataSource getDataSource(String tenantId) {
if (!dataSourceMap.containsKey(tenantId)) {
synchronized (this) {
if (!dataSourceMap.containsKey(tenantId)) {
// 动态创建连接池
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(getDbUrlForTenant(tenantId));
dataSource.setUsername(getDbUsernameForTenant(tenantId));
dataSource.setPassword(getDbPasswordForTenant(tenantId));
dataSourceMap.put(tenantId, dataSource);
}
}
}
return dataSourceMap.get(tenantId);
}
private String getDbUrlForTenant(String tenantId) {
// 根据租户ID从数据库或配置中加载数据库URL
}
private String getDbUsernameForTenant(String tenantId) {
// 根据租户ID从数据库或配置中加载用户名
}
private String getDbPasswordForTenant(String tenantId) {
// 根据租户ID从数据库或配置中加载密码
}
public void closeDataSource(String tenantId) {
HikariDataSource dataSource = dataSourceMap.remove(tenantId);
if (dataSource != null) {
dataSource.close();
}
}
}
同一个连接访问多个 schema 是一种常见的多租户数据库设计方式。
对于租户数较少、数据量较小的系统,这种方式是比较高效的;但如果系统需要支撑大量租户和大规模数据,可能需要考虑采用更复杂的架构(例如,独立数据库实例)。
通过为每个表增加 tenant_id 字段来实现多租户隔离,是一种高效且常见的方案。它的优点是简单、容易实现,适用于中小规模的多租户系统。然而,这种方法也存在性能、数据隔离、扩展性等问题。