应使用$graphLookup一次性展开角色继承链,避免应用层递归查询;权限字段用扁平字符串数组而非嵌套对象;角色变更需同步清理关联用户缓存;定期用脚本检测角色树成环。

用 $graphLookup 一次性展开角色继承链,别手写递归
MongoDB 本身不支持 SQL 那种自关联递归查询,硬要查“某角色的所有上级角色”或“某用户最终能访问哪些路由”,靠应用层循环查 db.roles.findOne() 再查父级,性能崩得很快,尤其树深 >5 层时延迟明显。
正确做法是用聚合管道的 $graphLookup —— 它专为这类图遍历设计,一次请求拿到完整路径:
db.roles.aggregate([
{ $match: { _id: "admin" } },
{
$graphLookup: {
from: "roles",
startWith: "$parent_id",
connectFromField: "parent_id",
connectToField: "_id",
as: "ancestors",
maxDepth: 10,
depthField: "level"
}
}
])注意三点:
• startWith 必须是数组或单值,如果字段可能为 null,先用 $ifNull 包一层;
• maxDepth 别设太大(比如 100),否则查错数据会拖垮整个分片;
• 返回的 ancestors 是扁平数组,含所有祖先节点,包括中间层级,直接 .map(r => r.route) 就能收集成权限白名单。
路由权限字段别存嵌套对象,用字符串数组更稳
有人把路由权限设计成这样:{ routes: { "/api/users": { read: true, write: false }, "/api/orders": { ... } } } —— 看着结构清晰,实际查起来反而是坑:没法用 $in 直接匹配,索引也建不高效,聚合时还得 $objectToArray 转,徒增 CPU 开销。
改成扁平字符串数组,简单直接:
routes: ["/api/users/read", "/api/orders/list", "/dashboard"]- 用户鉴权时,只要判断
req.path + "/" + method.toLowerCase()是否在该数组里,一行role.routes.includes(permissionKey)解决 - 建复合索引
{ "routes": 1, "status": 1 },查询速度基本不随数据量增长而变慢
额外提醒:避免用通配符如 "/api/*" 存进数组——MongoDB 不支持 glob 查询,得靠应用层解析,反而引入不一致风险。
角色变更后,缓存失效必须同步清理关联用户的权限快照
很多团队把用户完整权限列表(含继承后的所有路由)缓存在 Redis 里,键名类似 perm:u:123。但一旦管理员修改了某个中间角色的 routes 字段,或调整了父子关系,所有继承它的用户的缓存就全过期了——可没人主动去扫这些依赖关系。
解决方案只有两个,且必须二选一:
- 写角色时,用
$graphLookup反查出所有直系/间接子角色,再用keys perm:u:* | xargs redis-cli del清对应用户缓存(适合中小规模) - 彻底放弃“预计算权限快照”,每次鉴权都实时走聚合查
$graphLookup+$lookup关联用户角色,用 MongoDB 查询缓存顶住压力(适合权限变更极少、读远多于写的场景)
别信“加个版本号字段然后比对”的方案——跨集合更新版本号在分布式环境下极易不一致,线上出过不止一次漏清缓存导致越权访问的事故。
用 db.runCommand({ validate: "roles" }) 定期检查角色树是否成环
角色父子关系一旦出现闭环(A → B → C → A),$graphLookup 会卡死或返回截断结果,而且这种错误不会报错,只会静默少返回几层,极难排查。
MongoDB 没有外键约束,只能靠人工校验。最轻量的办法是定期跑验证命令:
db.runCommand({
validate: "roles",
full: true,
scanData: false
})它会检查文档结构合法性,但不查业务逻辑环。真正防环得自己写脚本:遍历每个角色,用 $graphLookup 查自身是否出现在自己的 ancestors 里。这个检查不能只上线前跑一次,得作为 CI 步骤或定时任务(比如每天凌晨)执行,因为运营后台可能绕过 API 直连数据库改数据。
树形权限看着简单,真正压测下来,90% 的问题都出在环检测缺失、缓存不同步、以及权限字段格式不统一这三处。其他地方再怎么优化,这三块没盯紧,迟早出事。



















