Skip to content

设计一个权限系统(RBAC/ABAC)

问题

用户/角色/权限模型怎么设计?千万级权限判断的缓存策略怎么做?权限变更后如何实时生效?

权限系统的核心模型

权限系统本质上解决的是三个问题:谁(Who)能对什么(What)做什么(Action)。市面上的权限模型基本就两种主流——RBAC 和 ABAC。

RBAC:基于角色的权限控制

RBAC 是 Permission 这个名字最直观的展开:用户 → 角色 → 权限,三层映射。经典表结构长这样:

sql
-- 用户表
CREATE TABLE user (
    id BIGINT PRIMARY KEY,
    name VARCHAR(64),
    dept_id BIGINT
);

-- 角色表
CREATE TABLE role (
    id BIGINT PRIMARY KEY,
    name VARCHAR(64),        -- 如 "admin", "editor", "viewer"
    description VARCHAR(256)
);

-- 用户-角色关联表
CREATE TABLE user_role (
    user_id BIGINT,
    role_id BIGINT,
    PRIMARY KEY (user_id, role_id)
);

-- 权限表(资源+操作)
CREATE TABLE permission (
    id BIGINT PRIMARY KEY,
    resource VARCHAR(128),    -- 如 "order", "user", "report"
    action VARCHAR(32),       -- 如 "create", "read", "update", "delete"
    description VARCHAR(256)
);

-- 角色-权限关联表
CREATE TABLE role_permission (
    role_id BIGINT,
    permission_id BIGINT,
    PRIMARY KEY (role_id, permission_id)
);

权限判断逻辑:用户登录后,查 user_role 拿到角色列表,再查 role_permission 拿到权限集合,判断 (resource, action) 是否在集合内。简单直观,适合大多数企业应用。

ABAC:基于属性的权限控制

ABAC 的权限判断不是靠角色,而是靠属性表达式。属性分三类:

  • 用户属性:部门、职级、地区、入职时间
  • 资源属性:创建者、所属部门、数据状态、敏感等级
  • 环境属性:时间、IP 段、设备类型、网络区域

ABAC 策略定义示例:

json
{
  "policyId": "p001",
  "effect": "allow",
  "condition": {
    "user.deptId == resource.ownerDeptId",
    "user.role == 'manager'",
    "environment.time BETWEEN '09:00' AND '18:00'"
  }
}

只有所有条件都满足,权限才放行。

RBAC vs ABAC 对比

维度RBACABAC
控制粒度功能级(模块/操作)属性级(行/字段/条件)
策略复杂度低(JOIN 查表)高(表达式引擎 + 属性解析)
单次判断耗时0.5-2ms(缓存命中)5-50ms(表达式求值)
运维成本低(CRUD 页面即可管理)高(需策略编辑器 + 测试工具)
扩展性用户数增长线性扩展策略数增长=指数级检查路径
适用场景80-90% 的通用场景10-20% 的行级/条件级场景
典型系统大部分后台管理AWS IAM、Kubernetes RBAC+ABAC

实际落地:RBAC 兜底 + ABAC 补充

绝大多数场景下,90% 的权限用 RBAC 就够了——"管理员能操作订单,运营只能查看"。剩下 5%-10% 的复杂场景(行级数据权限、审批流权限)才需要 ABAC。一个务实的架构是:

  • RBAC 管理功能级权限:能不能访问某个模块、能不能执行某个操作
  • ABAC 管理数据级权限:能不能看到某个部门的订单、能不能审批某个人的报销单
java
// 权限判断伪代码
public boolean checkPermission(Long userId, String resource, String action, Map<String, Object> context) {
    // 1. 先查 RBAC 缓存 - 功能级权限,O(1)
    Set<String> rbacPerms = getCachedPermissions(userId);
    String permKey = resource + ":" + action;
    if (rbacPerms.contains(permKey)) {
        return true;
    }
    
    // 2. RBAC 不通过,再看看 ABAC 规则
    // 常见场景:数据级权限(如"只看自己部门的订单")
    if (abacPolicyEngine != null) {
        return abacPolicyEngine.evaluate(userId, resource, action, context);
    }
    
    return false;
}

千万级权限判断的缓存策略

权限判断是高频操作,用户每次请求后端接口几乎都要做。千万级用户下,如果每次判断都查数据库,系统扛不住。

权限判断的性能数据(来自生产环境实测)

缓存层延迟(P99)QPS 单机内存开销(1000万用户)
MySQL 直查15-50ms~500
Redis Hash1-3ms~50000约 8-12GB(Hash 结构 + 元数据)
本地 Caffeine0.01-0.1ms~500000+约 200-500MB(只缓存热点用户,1万条)
三级全链路0.05-2ms百万级合计约 12GB

缓存层次

text
┌─────────────────────────────────────┐
│  Application Layer                  │
│  ┌──────────────┐  ┌──────────────┐ │
│  │  Caffeine    │  │  Guava Cache │ │
│  │  本地缓存     │  │  本地缓存     │ │
│  └──────────────┘  └──────────────┘ │
│         ↕ miss / evict              │
│  ┌──────────────────────────────┐   │
│  │  Redis Cluster               │   │
│  │  Hash结构: user:perm:{uid}   │   │
│  │  field=resource:action       │   │
│  │  value=true/false            │   │
│  └──────────────────────────────┘   │
│         ↕ miss / refresh            │
│  ┌──────────────────────────────┐   │
│  │  MySQL (分表)                │   │
│  │  user_role + role_permission │   │
│  └──────────────────────────────┘   │
└─────────────────────────────────────┘

Redis 缓存结构

text
key: user:perm:10086
field: "order:create"  →  "true"
field: "order:read"    →  "true"
field: "report:read"   →  "false"

权限判断逻辑:HEXISTS user:perm:10086 order:create → O(1)。缓存加载时机:用户登录时一次性加载全部权限到 Redis;缓存 TTL 设为 1-5 分钟(短 TTL 保证一致性)。

踩坑:Redis Hash 大 key 问题。如果某个超级管理员绑了 500+ 个角色,一个 user:perm:admin 的 Hash 可能包含 5000+ 个 field,单次 HGETALL 就是几十 KB 的返回数据。解决方案:给超级管理员单独走本地缓存直路,不对 Redis 做全量 load。

本地缓存优化热点用户

Redis 虽然快,但在几十万 QPS 下也有网络开销。对于高频操作的用户(如管理员、运营人员),在应用本地用 Caffeine 缓存一份权限快照,LRU 淘汰,最大 10000 条。权限判断链路变成:本地缓存 → Redis → DB,98% 的请求在第一层就返回了。

Caffeine 配置参考

java
Cache<String, Set<String>> permCache = Caffeine.newBuilder()
    .maximumSize(10_000)               // 最多缓存 1 万个用户
    .expireAfterWrite(5, TimeUnit.MINUTES)  // 写后 5 分钟过期
    .recordStats()                     // 监控命中率
    .build();

踩坑:本地缓存不一致。用户 A 在 Server 1 上改了权限,Server 2 的本地缓存还在用旧数据。必须通过 MQ 广播清除事件,所有节点收到后 invalidate(key)

权限变更的实时生效

这是权限系统最容易被问崩的点:"我改了用户权限,为什么他还能访问?"

方案一:缓存主动失效(推荐)

java
public void updateUserRole(Long userId, List<Long> newRoleIds) {
    // 1. 更新数据库 user_role 表
    userRoleDao.updateByUserId(userId, newRoleIds);
    
    // 2. 清除 Redis 缓存
    redisTemplate.delete("user:perm:" + userId);
    
    // 3. 广播变更事件(MQ)
    mqTemplate.send("perm.change", new PermissionChangeEvent(userId));
    
    // 4. 各服务监听事件,清除本地缓存
}
java
// 服务监听者
@Component
public class PermissionChangeListener {
    @RabbitListener(queues = "perm.change")
    public void onPermissionChange(PermissionChangeEvent event) {
        // 清除本地缓存中的该用户权限
        localCache.invalidate("perm:" + event.getUserId());
    }
}

踩坑:竞态条件。用户 A 并发请求,权限被修改后,请求 1 带着旧权限进了业务逻辑,请求 2 基于新权限被拒绝。如果业务对一致性要求高(如金融风控),需要在业务方法入口处重新校验权限,不能依赖请求进入时的 token 快照。

方案二:短 TTL 兜底

即使广播消息丢了,短 TTL(1-5 分钟)也能保证最终一致性。对于大多数企业应用,权限变更后 5 分钟内生效是可接受的 SLA。

方案三:长连接推送(高要求场景)

对于金融、风控等实时性要求高的场景,用 WebSocket 或 Server-Sent Events 推送消息给客户端,要求用户重新登录获取新的 token(token 中携带权限快照,服务端验证 token 时解析)。

延伸思考:数据权限(行级权限)

RBAC 只能控制"能不能访问订单模块",但控制不了"能看哪些订单"。这是数据权限的范畴,也是面试中 P7/P8 级别才会深入追问的。

方案一:SQL 改写

在 MyBatis 拦截器或 ORM 层自动注入数据权限条件:

java
@Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})})
public class DataPermissionInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 获取当前用户可见的部门列表
        List<Long> deptIds = getVisibleDeptIds();
        
        // 改写 SQL:追加 WHERE dept_id IN (...)
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        BoundSql boundSql = ms.getBoundSql(invocation.getArgs()[1]);
        String newSql = boundSql.getSql() + " AND dept_id IN (" 
            + String.join(",", deptIds.stream().map(String::valueOf).collect(Collectors.toList())) 
            + ")";
        // ... 替换 SQL
        return invocation.proceed();
    }
}

方案二:权限表达式引擎

定义权限表达式,在 ORM 查询时解析注入:

text
# 表达式示例
dept_id == currentUser.deptId          # 只能看本部门
create_time >= NOW() - INTERVAL 7 DAY  # 只能看最近 7 天
owner_id == currentUser.id             # 只能看自己创建的

踩坑:SQL 改写 + 分页 = 结果错乱COUNT(*) 查询也要注入权限条件,否则分页的总条数会多算。如果用的是 MyBatis-Plus,Page 对象的 optimizeCountSql 需要关闭,或者自己实现 count 拦截器。

方案三:数据权限缓存预热

对于行级权限频繁查询的场景(如"可以看到 200 个部门的所有订单"),每次查询都去解析表达式性能太差。可以在用户登录时,预计算好该用户的可见部门 ID 列表、可见客户 ID 列表,直接缓存到 Redis Set 里:

text
key: user:dept:10086
members: 1001, 1002, 1003, 1010, 2001, 3003

查询时只要 SISMEMBER user:dept:10086 order.dept_id 即可,O(1) 判断,不需要走表达式引擎。

延伸思考:角色继承与权限叠加

面试中另一个高频追问:用户有多个角色,角色之间有继承关系,权限怎么合并?

正向叠加(Permission Union)

A 角色有 order:read,B 角色有 order:create,用户同时拥有 A 和 B → 同时拥有 order:read + order:create。这是默认策略,取其并集

反向拒绝(Deny Override)

有些场景需要"黑名单"角色:如果用户被赋予 suspended 角色,即使有 admin 角色,所有操作权限都被拒绝。实现方式:检查 Deny 角色先于 Allow 角色

java
public boolean checkPermission(Long userId, String resource, String action) {
    // 1. 先检查拒绝权限
    if (denyCache.contains(userId, resource, action)) {
        return false;
    }
    // 2. 再检查允许权限
    return allowCache.contains(userId, resource, action);
}

角色继承(Role Hierarchy)

角色可以继承:super_admin 继承 adminadmin 继承 editor。实现方式有两种:

  • 数据库展开:创建角色时,递归展开上级角色的所有权限,存到 role_permission 表。优点是查询快,缺点是角色变更时需要重算。
  • 运行时展开:查询时递归查父角色权限。优点是维护简单,缺点是递归深度多了性能差(3 层以内可控)。

总结

  1. 模型选择:RBAC 兜底(功能级权限),ABAC 补充(数据级权限),不要一上来就上 ABAC 引擎
  2. 缓存设计:两级缓存(本地 Caffeine + Redis Hash),用户登录时加载,短 TTL(1-5 分钟)+ 变更主动清除
  3. 实时生效:MQ 广播变更事件 + 本地缓存清除,短 TTL 兜底,高要求场景用 WebSocket 推送
  4. 数据权限:SQL 改写或权限表达式引擎,动态注入行级过滤条件;高频场景预计算可见列表放 Redis Set
  5. 角色继承:正向叠加(并集),Deny Override(黑名单优先),继承可在数据库展开或运行时展开
  6. 审计:所有权限变更和权限校验失败的操作都要记录,用于安全审计和合规检查

权限系统的设计口诀:选对模型、缓存加速、变更清缓存、数据权限单独处理。实现起来不难,但要在千万级 QPS 下稳定运行,每一层都要做充分的性能压测。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。