多租户实现
# 什么是多租户?
多租户技术是一种软件架构,允许多个用户或组织共享同一系统实例,同时保持数据隔离。这种技术在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 ''
1
2
3
4
5
6
2
3
4
5
6
上下文结构体定义(代码位置: 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)
}
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
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
中间件定义
(各自的业务代码可以直接引入该中间件,代码位置:
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
}
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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# 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{}
}
}
}
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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# 结构体定义
我们先来看一个租户隔离的表的定义实现
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"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
可以看到第二个字段名字为TenantCode 这是一个 stores.TenantCode
类型的参数,业务表如果需要租户隔离,添加该字段即可实现多租户隔离,无需填写租户号.
上次更新: 2024/11/12, 17:12:19