多租户实现
约 2737 字大约 9 分钟
2025-03-03
什么是多租户?
多租户技术是一种软件架构,允许多个用户或组织共享同一系统实例,同时保持数据隔离。这种技术在SaaS(Software as a Service)模式中广泛应用,通过资源共享降低成本、提升效率和可扩展性。
应用场景与优势
多租户技术在SaaS模式中至关重要,因为它允许服务提供商在共享环境中服务多个租户,无需单独部署系统。这种架构显著降低运营成本,提高资源利用率,并支持系统的快速迭代和升级,因为所有租户共享同一套核心代码。
数据隔离模式
多租户技术的数据隔离存储方案通常有三种:
- 独立数据库:每个租户使用独立数据库,提供高数据隔离性和安全性。
- 优点:数据隔离性好,无需额外字段区分租户,需求扩展独立,故障恢复简单。
- 缺点:增加安装成本,支持租户数量有限,跨租户统计困难,新增租户需重启服务。
- 应用场景:适用于对数据隔离性有严格要求的租户,如银行、医院。
- 共享数据库、独立Schema:租户共享数据库但使用不同Schema。
- 优点:较好的数据隔离性,支持更多租户,安装成本较低。
- 缺点:跨租户统计困难,数据库SQL需带上Schema名称。
- 应用场景:适用于数据规模中等,租户数量中等的项目。
- 共享数据库、共享Schema、共享表:所有租户共享数据库、Schema和表,通过
tenant_code
字段区分记录。- 优点:安装成本最低,支持最多租户,添加租户无需重启服务,跨租户统计容易。
- 缺点:安全性最差,隔离级别最低,维护成本高,每个租户数据量不宜大。
- 应用场景:适用于低成本,租户数量多,数据量小,对安全性和隔离级别要求低的产品,如To C产品。
结论
选择合适的多租户数据隔离模式取决于具体的应用场景、成本预算和安全需求。每种模式都有其独特的优缺点,服务提供商应根据业务需求和资源情况做出合理选择。
联犀架构
数据隔离
联犀采用的是共享数据库、共享Schema、共享表的隔离级别,理由如下:
- 在物联网领域,关系型数据量并不会特别大,以用户表为例,绝大多数租户都不会超过10w个用户,一张表更是基本不可能超过100w的数据量,所以不用担心大表的问题.
- 联犀底层在orm层做了租户隔离的处理,让业务无需处理多租户的逻辑,在底层即可实现多租户隔离,而无需担心数据泄露的问题
- 在所有的系统中,数据统计都是重中之重,而另外两个隔离级别如果需要跨租户的统计,会非常复杂,而且开发成本非常高,联犀面向的是中小型企业, 是难以承担如此大的代价.
上下文传递
- 在一个请求过来之后,api网关有统一的中间件,将token信息传递给系统管理服务,系统管理服务会校验权限并返回用户的租户号等信息,然后中间件将这些信息放到ctx中.
- api网关请求rpc服务的时候,会通过ctx将租户信息传递到rpc服务中,rpc服务中获取数据库数据的时候,gorm通过自定义类型中的方法将ctx中获取到的租户信息加入到where中,如果是新增,则同样赋值到对应的字段中

请求curl示例:
curl --location --request POST 'http://localhost:7777/api/v1/things/device/info/index' \
--header 'Ithings-Project-Id: 1786838173980422144' \
--header 'Ithings-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySUQiOiIxNzQwMzU4MDU3MDM4MTg4NTQ0IiwiQXBwQ29kZSI6ImNvcmUiLCJleHAiOjE3NjA3NDQzNjQsImlhdCI6MTcyNDc0NDM2NH0.lo3kZ5MiYkIalkKpElrF3zePoZdrRd8aF3Zd9Uqx-pw' \
--header 'app-code: core' \
--header 'Content-Type: application/json' \
--data-raw ''
上下文结构体定义(代码位置: share/ctxs/ctx.go):
type UserCtx struct {
IsOpen bool //是否开放认证用户
AppCode string
Token string
TenantCode string //租户Code
AcceptLanguage string
ProjectID int64 `json:",string"`
IsAdmin bool //是否是超级管理员
IsSuperAdmin bool
UserID int64 `json:",string"` //用户id(开放认证用户值为0)
RoleIDs []int64 //用户使用的角色(开放认证用户值为0)
RoleCodes []string
IsAllData bool //是否所有数据权限(开放认证用户值为true)
IP string //用户的ip地址
Os string //操作系统
UserName string
Account string
ProjectAuth map[int64]*ProjectAuth
InnerCtx
}
type ProjectAuth struct {
Area map[int64]def.AuthType //key是区域ID,value是授权类型
AreaPath map[string]def.AuthType //key是区域ID路径,value是授权类型
// 1 //管理权限,可以修改别人的权限,及读写权限 管理权限不限制区域权限
// 2 //读权限,只能读,不能修改
// 3 //读写权限,可以读写该权限
AuthType def.AuthType //项目的授权类型
}
type InnerCtx struct {
AllProject bool
AllArea bool //内部使用,不限制区域
AllTenant bool //所有租户的权限
WithCommonTenant bool //同时获取公共租户
}
func SetUserCtx(ctx context.Context, userCtx *UserCtx) context.Context {
if userCtx == nil {
return ctx
}
info, _ := json.Marshal(userCtx)
//这步是给rpc传递上下文赋值
ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs(
UserInfoKey, base64.StdEncoding.EncodeToString(info),
))
return context.WithValue(ctx, UserInfoKey, userCtx)
}
中间件定义
(各自的业务代码可以直接引入该中间件,代码位置:
core/service/apisvr/exportMiddleware/middlewareCheckToken.go):
type CheckTokenWareMiddleware struct {
UserRpc user.UserManage
AuthRpc role.RoleManage
TenantRpc tenant.TenantManage
LogRpc operLog.Log
}
var respPool sync.Pool
var bufferSize = 512
func init() {
respPool.New = func() interface{} {
return make([]byte, bufferSize)
}
}
func NewCheckTokenWareMiddleware(UserRpc user.UserManage, AuthRpc role.RoleManage, TenantRpc tenant.TenantManage, LogRpc operLog.Log) *CheckTokenWareMiddleware {
return &CheckTokenWareMiddleware{UserRpc: UserRpc, AuthRpc: AuthRpc, TenantRpc: TenantRpc, LogRpc: LogRpc}
}
func NewCheckTokenWareMiddleware2(SysRpc conf.RpcClientConf) *CheckTokenWareMiddleware {
TenantRpc := tenant.NewTenantManage(zrpc.MustNewClient(SysRpc.Conf))
LogRpc := operLog.NewLog(zrpc.MustNewClient(SysRpc.Conf))
UserRpc := user.NewUserManage(zrpc.MustNewClient(SysRpc.Conf))
AuthRpc := role.NewRoleManage(zrpc.MustNewClient(SysRpc.Conf))
return &CheckTokenWareMiddleware{UserRpc: UserRpc, AuthRpc: AuthRpc, TenantRpc: TenantRpc, LogRpc: LogRpc}
}
func (m *CheckTokenWareMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
userCtx *ctxs.UserCtx
err error
//isOpen bool
token string
strIP, _ = utils.GetIP(r)
authType = "user"
appCode string = ctxs.GetHandle(r, ctxs.UserAppCodeKey, ctxs.UserAppCodeKey2)
)
authHeader := ctxs.GetHandle(r, "Authorization")
// 检查"Authorization"字段是否存在并且以"Bearer "为前缀
if strings.HasPrefix(authHeader, "Bearer ") {
authType = "open"
token = strings.TrimPrefix(authHeader, "Bearer ")
} else {
token = ctxs.GetHandle(r, ctxs.UserTokenKey, ctxs.UserToken2Key)
if token == "" {
logx.WithContext(r.Context()).Errorf("%s.CheckTokenWare ip=%s not find token",
utils.FuncName(), strIP)
result.HttpErr(w, r, http.StatusUnauthorized, errors.NotLogin.AddMsg("用户请求失败"))
return
}
authType = "user"
}
userCtx, err = m.Auth(r.Context(), w, token, strIP, authType)
if err != nil {
logx.WithContext(r.Context()).Errorf("%s.UserAuth error=%s", utils.FuncName(), err)
result.HttpErr(w, r, http.StatusUnauthorized, errors.Fmt(err).AddMsg("认证失败"))
return
}
if userCtx.AppCode != "" && userCtx.AppCode != appCode {
result.HttpErr(w, r, http.StatusUnauthorized, errors.Permissions.AddMsg("认证失败,应用不一致"))
return
}
userCtx.Os = ctxs.GetHandle(r, "User-Agent")
userCtx.AcceptLanguage = ctxs.GetHandle(r, "Accept-Language")
userCtx.Token = token
//注入 用户信息 到 ctx
ctx2 := ctxs.SetUserCtx(r.Context(), userCtx)
r = r.WithContext(ctx2)
var apiRet *sys.RoleApiAuthResp
////校验 Casbin Rule
req := user.RoleApiAuthReq{
Path: r.URL.Path,
Method: r.Method,
}
apiRet, err = m.AuthRpc.RoleApiAuth(r.Context(), &req)
if err != nil {
logx.WithContext(r.Context()).Errorf("%s.AuthApiCheck error=%s", utils.FuncName(), err)
http.Error(w, "接口权限不足:"+err.Error(), http.StatusUnauthorized)
//systems.SysNotify(fmt.Sprintf("接口权限不足userCtx:%v req:%v err:%s", utils.Fmt(userCtx), utils.Fmt(req), err))
return
}
m.OperationLogRecord(next, w, r, apiRet)
}
}
func (m *CheckTokenWareMiddleware) OpenAuth(r *http.Request, token string) (*ctxs.UserCtx, error) {
strIP, _ := utils.GetIP(r)
resp, err := m.TenantRpc.TenantOpenCheckToken(r.Context(), &sys.TenantOpenCheckTokenReq{
Token: token,
Ip: strIP,
})
if err != nil {
return nil, err
}
return &ctxs.UserCtx{
IsOpen: true,
TenantCode: resp.TenantCode,
UserID: resp.UserID,
IsAdmin: resp.IsAdmin == def.True,
IsAllData: true,
Account: resp.UserName,
}, nil
}
func (m *CheckTokenWareMiddleware) Auth(ctx context.Context, w http.ResponseWriter, strToken string, strIP string, authType string) (*ctxs.UserCtx, error) {
resp, err := m.UserRpc.UserCheckToken(ctx, &user.UserCheckTokenReq{
Ip: strIP,
Token: strToken,
AuthType: authType,
})
if err != nil {
er := errors.Fmt(err)
logx.WithContext(ctx).Errorf("%s.CheckTokenWare ip=%s token=%s return=%s",
utils.FuncName(), strIP, strToken, err)
return nil, er
}
if resp.Token != "" {
w.Header().Set("Access-Control-Expose-Headers", ctxs.UserSetTokenKey)
w.Header().Set(ctxs.UserSetTokenKey, resp.Token)
}
logx.WithContext(ctx).Debugf("%s.CheckTokenWare ip:%v in.token=%s checkResp:%v",
utils.FuncName(), strIP, strToken, utils.Fmt(resp))
return &ctxs.UserCtx{
IsOpen: authType == "open",
TenantCode: resp.TenantCode,
AppCode: resp.AppCode,
UserID: resp.UserID,
RoleIDs: resp.RoleIDs,
RoleCodes: resp.RoleCodes,
IsAdmin: resp.IsAdmin || resp.IsSuperAdmin,
IsSuperAdmin: resp.IsSuperAdmin,
IsAllData: resp.IsAllData == def.True,
Account: resp.Account,
ProjectAuth: utils.CopyMap[ctxs.ProjectAuth](resp.ProjectAuth),
}, nil
}
func (m *CheckTokenWareMiddleware) UserAuth(w http.ResponseWriter, r *http.Request) (*ctxs.UserCtx, error) {
strIP, _ := utils.GetIP(r)
strToken := ctxs.GetHandle(r, ctxs.UserTokenKey, ctxs.UserToken2Key)
if strToken == "" {
logx.WithContext(r.Context()).Errorf("%s.CheckTokenWare ip=%s not find token",
utils.FuncName(), strIP)
return nil, errors.NotLogin
}
resp, err := m.UserRpc.UserCheckToken(r.Context(), &user.UserCheckTokenReq{
Ip: strIP,
Token: strToken,
})
if err != nil {
er := errors.Fmt(err)
logx.WithContext(r.Context()).Errorf("%s.CheckTokenWare ip=%s token=%s return=%s",
utils.FuncName(), strIP, strToken, err)
return nil, er
}
if resp.Token != "" {
w.Header().Set("Access-Control-Expose-Headers", ctxs.UserSetTokenKey)
w.Header().Set(ctxs.UserSetTokenKey, resp.Token)
}
logx.WithContext(r.Context()).Debugf("%s.CheckTokenWare ip:%v in.token=%s checkResp:%v",
utils.FuncName(), strIP, strToken, utils.Fmt(resp))
return &ctxs.UserCtx{
IsOpen: false,
TenantCode: resp.TenantCode,
AppCode: resp.AppCode,
UserID: resp.UserID,
RoleIDs: resp.RoleIDs,
RoleCodes: resp.RoleCodes,
IsAdmin: resp.IsAdmin || resp.IsSuperAdmin,
IsSuperAdmin: resp.IsSuperAdmin,
IsAllData: resp.IsAllData == def.True,
Account: resp.Account,
ProjectAuth: utils.CopyMap[ctxs.ProjectAuth](resp.ProjectAuth),
}, nil
}
orm层
gorm自定义字段代码实现
代码位置:share/stores/tenantCode.go
type TenantCode string
func (t TenantCode) GormValue(ctx context.Context, db *gorm.DB) (expr clause.Expr) { //更新的时候会调用此接口
stmt := db.Statement
uc := ctxs.GetUserCtx(ctx)
if uc == nil { //系统初始化的时候会掉用这里
expr = clause.Expr{SQL: "?", Vars: []interface{}{string(t)}}
return
}
if uc.TenantCode == "" {
stmt.Error = errors.Parameter.AddDetail("tenantCode is empty")
return
}
if t != "" && uc.TenantCode == def.TenantCodeDefault && uc.AllTenant {
expr = clause.Expr{SQL: "?", Vars: []interface{}{string(t)}}
return
}
expr = clause.Expr{SQL: "?", Vars: []interface{}{uc.TenantCode}}
return
}
func (t *TenantCode) Scan(value interface{}) error {
ret := cast.ToString(value)
p := TenantCode(ret)
*t = p
return nil
}
// Value implements the driver Valuer interface.
func (t TenantCode) Value() (driver.Value, error) {
return string(t), nil
}
func (t TenantCode) QueryClauses(f *schema.Field) []clause.Interface {
return []clause.Interface{TenantCodeClause{Field: f, T: t, Opt: Select}}
}
func (t TenantCode) UpdateClauses(f *schema.Field) []clause.Interface {
return []clause.Interface{TenantCodeClause{Field: f, T: t, Opt: Update}}
}
func (t TenantCode) CreateClauses(f *schema.Field) []clause.Interface {
return []clause.Interface{TenantCodeClause{Field: f, T: t, Opt: Create}}
}
func (t TenantCode) DeleteClauses(f *schema.Field) []clause.Interface {
return []clause.Interface{TenantCodeClause{Field: f, T: t, Opt: Delete}}
}
func (t TenantCode) GetAuthIDs(f *schema.Field) GetValues {
return func(stmt *gorm.Statement) (authIDs []any, isRoot bool, allData bool, err error) {
uc := ctxs.GetUserCtx(stmt.Context)
if uc == nil {
return nil, false, false, nil
}
if uc.TenantCode == def.TenantCodeDefault { //只有core租户的可以修改其他租户的租户号
isRoot = true
}
return []any{TenantCode(uc.TenantCode)}, isRoot, uc.AllTenant, nil
}
}
type TenantCodeClause struct {
clauseInterface
Field *schema.Field
T TenantCode
Opt Opt
}
func (sd TenantCodeClause) GenAuthKey() string { //查询的时候会调用此接口
return fmt.Sprintf(AuthModify, "tenantCode")
}
func (sd TenantCodeClause) ModifyStatement(stmt *gorm.Statement) { //查询的时候会调用此接口
var (
tenantCode = def.TenantCodeDefault
allTenant bool
)
uc := ctxs.GetUserCtxNoNil(stmt.Context)
allTenant = uc.AllTenant
if uc.TenantCode != "" {
tenantCode = uc.TenantCode
}
switch sd.Opt {
case Create:
if uc != nil {
destV := reflect.ValueOf(stmt.Dest)
if destV.Kind() == reflect.Array || destV.Kind() == reflect.Slice {
for i := 0; i < destV.Len(); i++ {
dest := destV.Index(i)
if dest.Kind() == reflect.Pointer || dest.Kind() == reflect.Interface {
dest = dest.Elem()
}
field := dest.FieldByName(sd.Field.Name)
if tenantCode != "" && !field.IsZero() { //只有root权限的租户可以设置为其他租户
continue
}
var v TenantCode
v = TenantCode(tenantCode)
field.Set(reflect.ValueOf(v))
}
return
}
field := destV.Elem().FieldByName(sd.Field.Name)
if tenantCode != "" && !field.IsZero() { //只有root权限的租户可以设置为其他租户
return
}
var v TenantCode
v = TenantCode(tenantCode)
field.Set(reflect.ValueOf(v))
}
case Update, Delete, Select:
if uc.IsSuperAdmin && allTenant { //只有超管能修改其他租户
return
}
if _, ok := stmt.Clauses[sd.GenAuthKey()]; !ok {
if c, ok := stmt.Clauses["WHERE"]; ok {
if where, ok := c.Expression.(clause.Where); ok && len(where.Exprs) > 1 {
for _, expr := range where.Exprs {
if orCond, ok := expr.(clause.OrConditions); ok && len(orCond.Exprs) == 1 {
where.Exprs = []clause.Expression{clause.And(where.Exprs...)}
c.Expression = where
stmt.Clauses["WHERE"] = c
break
}
}
}
}
values := []any{tenantCode}
if sd.Opt == Select && uc.WithCommonTenant { //all租户可以让所有人查
values = []any{tenantCode, def.TenantCodeCommon}
}
stmt.AddClause(clause.Where{Exprs: []clause.Expression{
clause.IN{Column: clause.Column{Table: clause.CurrentTable, Name: sd.Field.DBName}, Values: values},
}})
stmt.Clauses[sd.GenAuthKey()] = clause.Clause{}
}
}
}
结构体定义
我们先来看一个租户隔离的表的定义实现
type DmUserDeviceCollect struct {
ID int64 `gorm:"column:id;type:bigint;primary_key;AUTO_INCREMENT"`
TenantCode stores.TenantCode `gorm:"column:tenant_code;index;type:VARCHAR(50);NOT NULL"` // 租户编码
ProjectID stores.ProjectID `gorm:"column:project_id;type:bigint;default:0;NOT NULL"` // 项目ID(雪花ID)
UserID int64 `gorm:"column:user_id;type:BIGINT;uniqueIndex:product_id_deviceName;NOT NULL"` // 问题提出的用户
ProductID string `gorm:"column:product_id;type:varchar(100);uniqueIndex:product_id_deviceName;NOT NULL"` // 产品id
DeviceName string `gorm:"column:device_name;uniqueIndex:product_id_deviceName;type:varchar(100);NOT NULL"` // 设备名称
stores.NoDelTime
DeletedTime stores.DeletedTime `gorm:"column:deleted_time;default:0;uniqueIndex:product_id_deviceName"`
}
func (m *DmUserDeviceCollect) TableName() string {
return "dm_user_device_collect"
}
可以看到第二个字段名字为TenantCode 这是一个 stores.TenantCode
类型的参数,业务表如果需要租户隔离,添加该字段即可实现多租户隔离,无需填写租户号.