简介
随着互联网的发展,越来越多的企业开始向多租户的方向转型,提高竞争力。微服务架构中允许同一个系统多套代码共存,这一般被称为多租户(multi-tenancy)。
多租户系统允许多个租户共享同一套应用程序和基础设施,每个租户都拥有自己的数据和隐私保护。
租户可以是测试,金丝雀发布,影子系统(shadow systems),甚至服务层或者产品线,使用租户能够保证代码的隔离性并且能够基于流量租户做路由决策。
染色发布
可以把待测试的服务 B
在一个隔离的沙盒环境中启动一个B'
,并且在沙盒环境下可以访问集成环境(UAT)C
和 D
。
把测试流量路由到服务 B'
,同时保持生产流量正常流入到集成服务(如在请求中带上泳道环境标识,请求便会优先路由到对应的泳道上去,链路上的任意服务部署了该泳道下的实例,则请求该泳道下的实例,若没有部署该泳道下的实例,则路由到基准环境(集成环境)下的实例
。
服务 B'
仅仅处理测试流量而不处理生产流量。
生产中的测试提出了两个基本要求,它们也构成了多租户体系结构的基础:
-
流量路由:能够基于流入栈中的流量类型做路由
(如POE(Product Offline Environment)PPE(Product Preview Environment)以及是否带泳道)
。 -
隔离性:能够可靠地隔离测试和生产中的资源,这样可以保证对于关键业务微服务没有副作用。
为了实现多租户系统,需要考虑多维度的设计,涉及到数据隔离、安全性等问题。
多租户系统设计需求
在多租户系统设计过程中,需要考虑如下需求:
- 数据隔离:每个租户的数据需要被隔离,不能相互干扰。
- 安全性:保证每个租户的数据隐私和安全性。
- 扩展性:系统需要支持横向和纵向扩展。
- 高可用性:系统需要保证高可用性,不能因为某个租户的问题导致整个系统崩溃。
- 管理性:系统需要提供方便的管理和维护功能。
多维度的设计方案
为了满足上述需求,需要从多个维度考虑设计方案。
数据隔离
在设计数据隔离方案时,可以采用以下策略:
- 独立数据库:对于每个租户,分配一个独立的数据库实例,确保数据不会被混淆。
- 共享数据库但不同表或不同Schema:对于每个租户,不同的租户使用同一个数据库,可以采用不同的
schema
或者表前缀,确保不同租户之间不会访问到相同的数据。 - 共享数据库、共享表、共享Schema:在数据表中新增
TenantID
字段,通过字段进行数据隔离
安全性
为了保证安全性,可以采用以下措施:
为每个租户分配一个唯一的标识符,确保不同租户之间数据不会被误用。
使用加密技术对租户数据进行保护。
采用权限控制机制,保证每个租户只能访问属于自己的数据。
扩展性
为了支持横向和纵向扩展,可以采用以下策略:
- 采用负载均衡机制,将请求分配到不同的节点上,以支持多节点的扩展。
- 设计合理的分表策略,以支持大规模数据量的存储。
- 对于大数据量或者并发量大的租户,可以采用分片或者分块技术,以支持高效的数据处理。
高可用性
为了保证高可用性,可以采用以下措施:
- 设计合理的系统架构,支持多节点、多副本、多数据中心等机制,防止单点故障。
- 采用容错机制,保证即使发生故障,也可以继续提供服务。
管理性
为了提高管理和维护效率,可以采用以下策略:
- 提供简单易用的管理界面,方便管理员进行维护和监控。
- 提供合理的数据备份和恢复机制,保证数据的安全性和可靠性。
- 采用自动化部署和配置管理机制,提高系统的可维护性。
简要实现
在Go
中实现多租户(multi-tenancy)
通常涉及到下面几个关键步骤:
-
租户识别: 需要一个机制来区分请求是针对哪个租户的。这可以通过多种策略实现,比如在
HTTP
请求的URL
、Header
或者是Cookie
中嵌入租户ID
,通常情况下用的是Header
方式。 -
数据隔离: 根据你选择的数据隔离策略(如数据库、
schema
或者数据表的隔离),需要确保租户只能访问到属于他们的数据。 -
中间件/拦截器: 在处理请求的过程中,可以引入中间件或者拦截器来确保租户的隔离策略得到执行。例如,在处理请求之前,中间件可以解析租户
ID
,并设置上下文(context)
来确保后续的数据库查询和操作都是在正确的租户上下文中进行的。 -
服务层: 在服务层(业务逻辑层)确保所有的数据访问都根据当前的租户上下文来进行。
-
数据库连接: 为每个租户提供一个安全的数据库连接,这可能涉及创建租户特定的数据库连接池。
Go代码示例
package main
import (
"context"
"fmt"
"net/http"
)
// 租户上下文键
type contextKey string
var tenantKey contextKey = "tenant"
// 中间件以从请求中提取租户ID,并将租户ID设置到context中
func tenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID := r.Header.Get("X-Tenant-ID") // 假设租户ID是通过HTTP Header传入的
if tenantID == "" {
http.Error(w, "Tenant ID is required", http.StatusBadRequest)
return
}
ctxWithTenantID := context.WithValue(r.Context(), tenantKey, tenantID)
// next为参数传进来的实际业务handler
next.ServeHTTP(w, r.WithContext(ctxWithTenantID))
})
}
// 数据库查询函数可以这样使用上下文来获取租户ID
func queryDatabase(ctx context.Context) {
tenantID, ok := ctx.Value(tenantKey).(string)
if !ok {
// 处理错误情况
fmt.Println("Tenant ID not found in context")
return
}
// 使用租户ID来进行查询...
fmt.Printf("Querying database for tenant ID: %s\n", tenantID)
}
// 一个示例HTTP处理函数
func myHandler(w http.ResponseWriter, r *http.Request) {
// 使用之前存储在context中的租户ID
queryDatabase(r.Context())
fmt.Fprintf(w, "Handled request for tenant\n")
}
func main() {
// 将中间件tenantMiddleware用于myHandler上
http.Handle("/", tenantMiddleware(http.HandlerFunc(myHandler)))
http.ListenAndServe(":8080", nil)
}
我们在tenantMiddleware
函数中解析HTTP
请求的租户ID
,并将其设置到上下文(context)
中。然后在服务的实际处理函数(myHandler)
中,我们从上下文中获取租户ID
。在实际的数据库操作中,queryDatabase
函数将根据上下文中的租户ID
来执行租户特定的查询。
上述是个简单Go
实现多租户的例子,但是大家有没有想过一个问题,既然多租户ID
是通过header
传进来的,在ToC
开发中有一句话: 用户的所有输入都是不可信的
,如果用户篡改了Header
头的租户ID
,是不是就可以拿到其他租户的信息了。对此应该怎么解决呢?
-
身份验证和授权:确保每个请求都通过身份认证
(Authentication)
过程,并为每个租户的用户分配适当的权限(Authorization)
。这可以通过OAuth2, JWT
等技术来实现。请注意,在解析租户ID之前先进行用户身份验证。
-
租户识别验证:在租户识别时,验证确定租户
ID
是否有效,是否与认证用户的租户ID
相匹配,避免用户尝试访问非授权租户的数据。 -
数据库设计:确保数据库的设计能够支持租户数据的隔离。例如,可以为每个租户使用独立的数据库,或在共享数据库中使用
schemas
,或在所有查询中使用tenant_id
作为过滤条件来隔离数据。
基于上面的方案,需要增加如下的Go代码
// 假设我们有一个函数来验证请求是否已认证,并且返回已认证的用户信息
func authenticateRequest(r *http.Request) (*UserInfo, error) {
// 实现身份认证逻辑,失败时返回错误
}
func tenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 在解析租户ID之前先进行用户身份验证
userInfo, err := authenticateRequest(r)
if err != nil {
http.Error(w, "Authentication failed", http.StatusUnauthorized)
return
}
tenantID := r.Header.Get("X-Tenant-ID")
if tenantID == "" || userInfo.TenantID != tenantID {
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
ctxWithTenantID := context.WithValue(r.Context(), tenantKey, tenantID)
next.ServeHTTP(w, r.WithContext(ctxWithTenantID))
})
}
func queryDatabase(ctx context.Context) {
tenantID, ok := ctx.Value(tenantKey).(string)
if !ok {
// 处理错误情况
fmt.Println("Tenant ID not found in context")
return
}
// 在执行数据库操作之前,确保所有请求都使用tenantID过滤
fmt.Printf("Querying database for tenant ID: %s\n", tenantID)
// 执行实际的数据库查询,确保使用tenantID作为查询条件
// ... 查询逻辑 ...
}
在上面的例子中我们引入了认证机制来确保租户的合理性。当然租户还需要考虑其他的,但是刚开始不可能所有情况都想到,随着项目的演进多样化的功能就会陆陆续续添加进来。