后台权限系统为什么不能每次都查数据库?从 ShiyuAdmin 看 RBAC + Redis 权限缓存设计

做后台管理系统,权限几乎是绕不开的一块。
很多项目刚开始实现权限的时候,都非常直接:
用户请求接口
↓
解析 JWT
↓
查询用户角色
↓
查询角色拥有的权限
↓
判断有没有权限
↓
执行业务逻辑
逻辑没有问题。
但系统跑起来以后,很快就会发现一个问题:
每一次接口请求,都可能触发好几次数据库查询。
如果一个页面一次加载 20 个接口,就意味着权限系统可能重复查询很多次:
SELECT * FROM sys_user_roles WHERE user_code = ?;
SELECT * FROM sys_roles WHERE role_code = ?;
SELECT m.*
FROM sys_role_menus rm
JOIN sys_menus m ON rm.menu_code = m.menu_code
WHERE rm.role_code = ?;
用户量小的时候无所谓。
但当 QPS 上来以后,权限查询本身反而可能成为数据库的一块额外压力。
这也是我在写 ShiyuAdmin 权限系统时比较值得展开的一个问题:
RBAC 权限到底应该怎么查?权限又该不该缓存?
项目地址:
https://github.com/Rodert/ShiyuAdmin
ShiyuAdmin 后端使用:
Go
Gin
Gorm
JWT
Redis
今天我们就从里面的 RBAC 权限实现往下拆。
一、先看 RBAC 到底是什么
RBAC 全称:
Role-Based Access Control
也就是:
基于角色的访问控制。
最简单的模型不是:
用户 → 权限
而是:
用户
↓
角色
↓
权限
比如:
王仕宇
↓
管理员
↓
用户查询
用户新增
用户修改
用户删除
角色管理
菜单管理
数据库里面通常会拆成几张表。
ShiyuAdmin 也是这个思路:
sys_users
sys_roles
sys_menus
sys_user_roles
sys_role_menus
关系大概是:
User
│
│ N:M
▼
Role
│
│ N:M
▼
Menu
│
▼
Perms
这里有一个容易被新手忽略的设计:
菜单和权限节点可以放在一起。
例如菜单表:
type Menu struct {
ID int64
MenuCode string
ParentCode string
// M = 目录
// C = 菜单
// F = 按钮
MenuType string
MenuName string
// 权限标识
Perms string
Path string
Component string
Status int
SortOrder int
}
真正做接口鉴权时,并不是判断:
if role == "admin"
而是判断:
system:user:list
system:user:create
system:user:update
system:user:delete
这就比在代码里硬编码角色灵活很多。
二、为什么不要直接判断角色?
很多项目最开始会这么写:
if user.Role != "admin" {
return errors.New("没有权限")
}
小项目确实可以。
但是业务稍微复杂一点就会失控。
假设现在有:
超级管理员
管理员
运营
客服
财务
开发
审计
然后产品经理告诉你:
运营可以:
查看用户
修改用户
但是不能:
删除用户
客服只能:
查看用户
财务可以:
查看订单
导出订单
查看支付流水
如果全部在代码里判断 Role:
if role == "admin" || role == "operator" {
// ...
}
后面维护起来会非常痛苦。
所以应该进一步抽象:
Role
↓
Permission
最终业务代码根本不关心你是什么角色。
它只关心:
你有没有 system:user:delete
三、Gin 中间件做统一权限校验
在 ShiyuAdmin 里,我把权限检查放到了 Gin Middleware。
思路类似:
func RequirePermission(
permissionSvc PermissionService,
permission string,
) gin.HandlerFunc {
return func(c *gin.Context) {
claimsVal, exists := c.Get("currentUser")
if !exists {
c.JSON(401, gin.H{
"message": "未授权",
})
c.Abort()
return
}
claims := claimsVal.(*Claims)
// 超级管理员直接放行
if claims.IsSuperAdmin {
c.Next()
return
}
hasPermission, err :=
permissionSvc.CheckPermission(
c.Request.Context(),
claims.UserCode,
permission,
)
if err != nil {
c.JSON(500, gin.H{
"message": "权限检查失败",
})
c.Abort()
return
}
if !hasPermission {
c.JSON(403, gin.H{
"message": "权限不足",
})
c.Abort()
return
}
c.Next()
}
}
这样路由就会非常清晰。
比如用户管理:
rg.GET(
"/users",
middleware.RequirePermission(
permissionSvc,
"system:user:list",
),
listUsers,
)
新增用户:
rg.POST(
"/users",
middleware.RequirePermission(
permissionSvc,
"system:user:create",
),
createUser,
)
修改:
rg.PUT(
"/users/:code",
middleware.RequirePermission(
permissionSvc,
"system:user:update",
),
updateUser,
)
删除:
rg.DELETE(
"/users/:code",
middleware.RequirePermission(
permissionSvc,
"system:user:delete",
),
deleteUser,
)
到这里整个权限体系已经比较清楚:
JWT
↓
UserCode
↓
UserRole
↓
Role
↓
RoleMenu
↓
Menu.Perms
↓
RequirePermission
四、真正的问题来了:每次都查数据库吗?
我们继续看权限查询。
一个用户可能有多个角色:
user001
├─ operator
└─ auditor
首先查询角色:
roles, err := userRoleRepo.GetUserRoles(
ctx,
userCode,
)
然后遍历角色:
for _, role := range roles {
if role == nil || role.Status != 1 {
continue
}
menus, err :=
roleMenuRepo.GetRoleMenus(
ctx,
role.RoleCode,
)
if err != nil {
return nil, err
}
// ...
}
再把所有权限收集起来:
permsMap := make(map[string]bool)
for _, menu := range menus {
if menu == nil {
continue
}
if menu.Status != 1 {
continue
}
if menu.Perms == "" {
continue
}
permsMap[menu.Perms] = true
}
最后得到:
[
"system:user:list",
"system:user:create",
"system:user:update",
"system:role:list",
"system:menu:list"
]
逻辑非常直观。
问题也非常明显。
假设:
1000 QPS
每个请求都走一次权限判断。
那么数据库会不停重复回答同一个问题:
user001 到底有什么权限?
user001 到底有什么权限?
user001 到底有什么权限?
user001 到底有什么权限?
而实际上:
一个用户的权限通常不会每秒钟发生变化。
这就是非常典型的缓存场景。
五、把用户权限放进 Redis
于是可以增加一层:
┌───────────┐
│ Redis │
└─────┬─────┘
│
HTTP Request │
│ │
▼ │
Permission Middleware ────┘
│
│ Cache Miss
▼
PostgreSQL / MySQL
缓存 Key 可以设计成:
user:perms:v1:user001
对应 Value:
[
"system:user:list",
"system:user:create",
"system:role:list"
]
核心代码:
type CachedService struct {
base *Service
redis *redis.Client
ttl time.Duration
version string
}
获取权限时:
func (s *CachedService) GetUserPermissions(
ctx context.Context,
userCode string,
) ([]string, error) {
cacheKey := fmt.Sprintf(
"user:perms:%s:%s",
s.version,
userCode,
)
cached, err :=
s.redis.Get(ctx, cacheKey)
if err == nil && cached != "" {
var perms []string
if err := json.Unmarshal(
[]byte(cached),
&perms,
); err == nil {
return perms, nil
}
}
return s.loadFromDatabase(
ctx,
userCode,
cacheKey,
)
}
Redis 命中:
HTTP Request
↓
Permission Middleware
↓
Redis
↓
system:user:list
↓
PASS
整个过程中根本不需要查询权限相关数据库。
六、Cache Aside 模式
这里实际上使用的是后端非常常见的:
Cache Aside Pattern
流程:
1. 查询 Redis
2. Redis 有
↓
直接返回
3. Redis 没有
↓
查询数据库
4. 数据库查询成功
↓
写 Redis
5. 返回结果
代码:
perms, err :=
s.base.GetUserPermissions(
ctx,
userCode,
)
if err != nil {
return nil, err
}
写入 Redis:
permsJSON, err :=
json.Marshal(perms)
if err != nil {
return nil, err
}
err = s.redis.Set(
ctx,
cacheKey,
permsJSON,
s.ttl,
)
if err != nil {
// Redis 写失败不应该影响主业务
// 可以记录日志
}
return perms, nil
这里还有一个非常重要的原则:
Redis 可以是权限查询的加速层,但不能轻易成为权限数据唯一来源。
真正的数据源仍然应该是:
Database
Redis 挂了以后:
Redis Error
↓
Database
↓
正常鉴权
最多性能下降,而不是整个后台直接不可用了。
这就是缓存和数据库职责的区别。
七、最麻烦的问题不是缓存,而是缓存失效
做到这里,其实只是完成了一半。
真正麻烦的问题来了。
假设:
user001
原本拥有:
system:user:delete
管理员现在把他的角色权限取消了。
数据库已经更新:
sys_role_menus
但是 Redis 里面还保存着:
[
"system:user:list",
"system:user:delete"
]
这时候 user001 继续调用:
DELETE /api/v1/system/users/user002
Redis:
system:user:delete
存在
于是:
PASS
这就是经典的:
缓存一致性问题
而且权限数据与普通商品列表缓存还不完全一样。
商品缓存晚几秒刷新,可能问题不大。
但是权限撤销晚几分钟:
可能就是安全问题。
八、修改用户角色后立即删除权限缓存
比如:
user001
原角色:
operator
修改为:
viewer
更新数据库:
func (s *UserRoleService) SetUserRoles(
ctx context.Context,
userCode string,
roleCodes []string,
) error {
err := s.repo.SetUserRoles(
ctx,
userCode,
roleCodes,
)
if err != nil {
return err
}
return s.permissionCache.
InvalidateUserCache(
ctx,
userCode,
)
}
删除 Redis:
func (s *CachedService) InvalidateUserCache(
ctx context.Context,
userCode string,
) error {
key := fmt.Sprintf(
"user:perms:%s:%s",
s.version,
userCode,
)
return s.redis.Delete(
ctx,
key,
)
}
这样下一次请求:
Redis Cache Miss
↓
重新查询数据库
↓
生成新的权限
↓
重新写缓存
问题就解决了。
九、修改角色权限,比修改用户角色更麻烦
如果只修改一个用户角色,很简单。
因为我们知道:
userCode
直接删除:
user:perms:v1:user001
即可。
但是如果修改:
operator
这个角色的权限呢?
operator 可能被:
1000 个用户
使用。
此时所有这 1000 个用户的缓存都已经失效。
也就是说:
Role
↓
User1
User2
User3
User4
...
User1000
必须全部清除。
最简单的方案当然是:
SCAN user:perms:v1:*
然后删除。
但是生产环境不建议粗暴使用:
KEYS user:perms:*
因为 Redis KEYS 是一个典型的 O(N) 操作。
Key 数量非常大的情况下,可能阻塞 Redis。
更合理的方案有几个。
十、方案一:维护 Role → User 关系
Redis 保存:
role:users:operator
数据:
user001
user002
user003
user004
修改 operator 权限时:
users, err := redis.SMembers(
ctx,
"role:users:operator",
).Result()
然后:
for _, userCode := range users {
key := fmt.Sprintf(
"user:perms:v1:%s",
userCode,
)
pipeline.Del(
ctx,
key,
)
}
最后:
_, err = pipeline.Exec(ctx)
这比全库扫描更加精准。
十一、方案二:直接使用版本号
还有一种我比较喜欢的做法:
Permission Version
例如最开始:
permission:version = 100
用户缓存:
user:perms:100:user001
角色权限发生重大变化以后:
permission:version = 101
之后查询的新 Key:
user:perms:101:user001
旧 Key:
user:perms:100:user001
虽然暂时还在 Redis 里面,但已经永远不会被读取。
等 TTL 到期后自动删除。
代码甚至非常简单:
func buildPermissionKey(
version string,
userCode string,
) string {
return fmt.Sprintf(
"user:perms:%s:%s",
version,
userCode,
)
}
这种方式的优点:
不需要批量 DELETE
不需要 SCAN
不需要 KEYS
瞬间全局失效
特别适合:
RBAC 规则整体变化
菜单权限大规模调整
权限模型升级
十二、但版本号也不能乱用
版本号虽然简单,但有一个问题。
假设你只是修改:
user001
一个人的权限。
如果直接:
v100
→
v101
那就意味着:
全部用户权限缓存失效
下一秒大量请求同时进来:
Redis全部MISS
↓
数据库
数据库
数据库
数据库
数据库
有可能形成:
缓存雪崩
所以实际系统最好组合使用:
单用户权限变化
↓
删除 User Cache
角色权限变化
↓
删除 Role 关联用户 Cache
全局权限模型变化
↓
Version + 1
这样粒度更加合理。
十三、还要加 TTL 作为最后一道保险
即使我们已经主动删除缓存,也建议给权限缓存设置 TTL。
比如:
permissionCacheTTL := 10 * time.Minute
写缓存:
redis.Set(
ctx,
cacheKey,
data,
10*time.Minute,
)
为什么?
因为任何系统都有可能出现:
删除缓存失败
消息丢失
Redis 网络抖动
代码 Bug
异常退出
TTL 相当于最后的保险。
就算主动失效机制全部失败:
10 分钟后
缓存也会自然过期。
对于权限系统来说,还可以根据安全要求进一步缩短:
1 分钟
5 分钟
10 分钟
十四、最终权限请求链路
最后整个系统会变成:
HTTP Request
│
▼
Gin Router
│
▼
JWT Middleware
│
▼
获取 UserCode / Admin
│
▼
RequirePermission Middleware
│
▼
Permission CachedService
│ │
Cache Hit Cache Miss
│ │
│ ▼
│ Database
│ │
│ ▼
│ 写入 Redis
│ │
└─────┬─────┘
▼
Permission Set
│
▼
system:user:delete ?
│ │
YES NO
│ │
▼ ▼
Handler 403
这时候 RBAC 才算从一个:
能用
的权限系统,逐渐变成:
可以支撑真实业务
的权限系统。
十五、还有一个很容易忽略的问题:超级管理员
ShiyuAdmin 里面还有一个:
IsSuperAdmin bool
中间件直接处理:
if claims.IsSuperAdmin {
c.Next()
return
}
这其实非常重要。
否则超级管理员每次访问接口:
超级管理员
↓
角色
↓
菜单
↓
权限
不仅没必要,还会让超级管理员权限受到 RBAC 配置错误影响。
所以可以设计:
普通用户
↓
RBAC
超级管理员
↓
直接放行
当然,超级管理员一定要严格控制。
尤其不能让普通用户通过修改请求参数把:
is_super_admin
设置为:
true
真正的判断必须完全由服务端控制。
十六、前端隐藏按钮不等于权限控制
很多后台还有一个非常危险的误区:
没有权限
↓
前端不显示删除按钮
然后就觉得权限做好了。
这是错的。
前端:
{hasPermission('system:user:delete') && (
<Button danger>
删除
</Button>
)}
只能改善用户体验。
攻击者完全可以绕过前端直接请求:
curl -X DELETE \
http://localhost:18000/api/v1/system/users/user001 \
-H "Authorization: Bearer xxx"
所以真正的安全边界必须在:
Backend
也就是:
middleware.RequirePermission(
permissionSvc,
"system:user:delete",
)
前端负责:
看不看得到
后端负责:
能不能执行
这是两个完全不同的问题。
十七、ShiyuAdmin 现在还有一个值得继续优化的地方
看当前实现会发现,项目里实际上已经存在:
CachedService
里面已经实现:
GetUserPermissions
CheckPermission
InvalidateUserCache
InvalidateRoleCache
但是目前启动服务时,实际创建的是基础权限服务:
permissionSvcVar =
permissionsvc.New(
userRoleRepo,
roleMenuRepo,
)
也就是说:
缓存这一层已经写出来了,但还没有完全接到正式请求链路里。
这是一个挺典型的工程开发过程。
第一阶段:
RBAC 能正确工作
第二阶段:
加入 Redis Cache
第三阶段:
解决缓存失效
第四阶段:
解决高并发缓存问题
实际项目基本都是这么一步步演进出来的。
如果继续改,我会把结构调整成:
basePermissionSvc :=
permissionsvc.New(
userRoleRepo,
roleMenuRepo,
)
permissionSvcVar = basePermissionSvc
if redisClient != nil {
permissionSvcVar =
permissionsvc.NewCachedService(
basePermissionSvc,
redisClient,
5*time.Minute,
)
}
最终形成经典的装饰器结构:
PermissionService
▲
CachedPermissionService
▲
Middleware
这样业务层完全不知道底下有没有 Redis。
这也是我非常推荐的一种代码设计:
缓存应该作为能力增强层,而不是侵入所有业务代码。
最后
权限系统看起来只是:
if hasPermission
但真正做进生产环境以后,会逐渐遇到:
JWT
RBAC
用户角色
角色权限
菜单权限
数据权限
超级管理员
Redis 缓存
缓存失效
缓存一致性
缓存雪崩
接口鉴权
前后端权限同步
这也是为什么一个成熟后台系统的权限模块,看起来往往比 CRUD 复杂很多。
如果你正在学 Go 后端,建议不要只写:
用户增删改查
可以尝试真正实现一次:
User
↓
Role
↓
Menu
↓
Permission
↓
Gin Middleware
↓
Redis Cache
把这一整条链路跑通。
ShiyuAdmin 本身就是一个可以直接拿来拆代码的项目:
https://github.com/Rodert/ShiyuAdmin
后面我也准备继续从这个项目里拆一些真实后台系统经常遇到的技术问题。
比如:
部门树怎么设计
数据权限怎么实现
操作日志怎么做
Redis 缓存管理怎么做
Gin 中间件怎么分层
Gorm 如何设计 Repository
Docker Compose 如何一键部署完整后台
这些东西,比单纯写几个 CRUD,更接近真正的后端项目。

浙公网安备 33010602011771号