0. 写在前面

本文主要是谈一谈OpenFGA在更新迭代一段时间后出现的一些有特色的性能优化点,近来时间有限,暂时无法非常细致的把每个优化点的剖析都整理过去,只能先大致理一理核心思想,来日方长吧。不过相信在AI的帮助下,理解这些优化点和代码细节其实并不难。

OpenFGA 的核心概念和基础设计思路就不再进行赘述了。建议在阅读本文之前,至少完整阅读以下关键资料:

  1. 官方文档:

    • https://openfga.dev/docs/fga
  2. 架构概览:

  3. Check 实现细节:

源码结构与追踪思路

模块路径 核心组件 职责概览
cmd openfga 服务启动入口,负责配置解析和组件初始化。
pkg storage, server, typesystem 核心服务层。storage 抽象数据接口;server 实现 API 协议;typesystem 处理模型静态分析。
internal graph, planner 性能与求解核心。graph 包含复杂的授权图遍历和求解器(Resolver)实现;planner 实现了自适应查询策略选择。

OpenFGA 的源码为追求灵活性和可测试性,大量采用了接口定义和装饰器(Decorator)模式。这使得查找实际的逻辑实现体成为一个挑战。

  1. 服务入口: GRPC Server 的启动代码位于 cmd/run.goRun 方法中。API 定义则依照 GitHub - openfga/api: Protocol Buffers used by OpenFGA 中的 Protocol Buffers 文件生成。

  2. 命令执行: GRPC Server 中定义的上层方法(如 Check, Write)的业务逻辑位于 `pkg/server包中,最外层处理请求的整体框架和流程控制,command包下放核心的OpenFGA功能逻辑,可以理解为Application Layer和Domain Layer的关系。

  3. 定位实现: Run 方法中包含了大量的组件初始化代码。在阅读源码时,应将注意力放在 datastoreplanner 等核心组件的具体实现上,千万不要一扫而过,在后续的方法执行中充斥着大量的接口直接调用,如果一开始没有理明白他是什么样的装饰器结构,那么等待着你的将会是再来一次。

1. 基础查询优化

1.1 预计算可达性

预计算可达性是指 OpenFGA 的类型系统在模型加载时,静态分析并确定每一个合法的Userset类型。在实际查询时,OpenFGA 利用这些预计算的结果,极大地缩小了数据库的搜索空间。

	if len(filter.AllowedUserTypeRestrictions) > 0 {
		orConditions := sq.Or{}
		for _, userset := range filter.AllowedUserTypeRestrictions {
			if _, ok := userset.GetRelationOrWildcard().(*openfgav1.RelationReference_Relation); ok {
				orConditions = append(orConditions, sq.Like{
					"_user": userset.GetType() + ":%#" + userset.GetRelation(),
				})
			}
			if _, ok := userset.GetRelationOrWildcard().(*openfgav1.RelationReference_Wildcard); ok {
				orConditions = append(orConditions, sq.Eq{
					"_user": userset.GetType() + ":*",
				})
			}
		}
		sb = sb.Where(orConditions)
	}
  1. 模型约束: 假设在授权模型中,我们定义了以下关系:

    type document
     relations
       define editor as [user, group#member] // 只能是 user 或 group#member
       define parent as [folder]            // 只能是 folder
    
  2. 静态分析: 类型系统在模型加载时就计算出,对于 document#editor 关系,其 AllowedUserTypeRestrictions 列表精确为:[user, group#member]。

  3. 查询优化: 当 OpenFGA 需要查询 document:X#editor 的元组时,这个预计算列表被传递给底层 Datastore。

    • 优化前: 数据库可能需要搜索所有对象类型(folder:Y#parentteam:Z#admin 等),看哪个能作为 editor

    • 优化后: Datastore 利用 AllowedUserTypeRestrictions 参数,在数据库查询层面直接添加 WHERE 子句约束,强制只查找用户部分是 usergroup#member的元组。无关的类型(如 folder)在 I/O 层面就被排除了。

这种优化将原本需要在应用层进行的类型校验,提前到了最高效的数据库过滤阶段。

1.2 解耦与过滤

在 OpenFGA 中,关系元组可以附加Condition,实现基于属性或时间戳的动态访问控制。OpenFGA 在这个模型功能上优化策略是将条件评估的计算复杂性从 Datastore 层解耦到应用层。

// BuildTupleKeyConditionFilter returns the TupleKeyConditionFilterFunc for which, together with the tuple key,
// evaluates whether condition is met.
func BuildTupleKeyConditionFilter(ctx context.Context, reqCtx *structpb.Struct, typesys *typesystem.TypeSystem) storage.TupleKeyConditionFilterFunc {
	return func(t *openfgav1.TupleKey) (bool, error) {
		// no condition on tuple or not found gets handled by eval.EvaluateTupleCondition
		cond, _ := typesys.GetCondition(t.GetCondition().GetName())

		return eval.EvaluateTupleCondition(ctx, t, cond, reqCtx)
	}
}
  1. 条件定义: 假设我们定义了一个限制访问时间段的条件:

    type document
     relations
       define restricted_viewer as [user with is_business_hours]
     conditions
       define is_business_hours(request: {hour: int}) {
         request.hour >= 9 && request.hour < 17
       }
    
  2. 存储与I/O: 数据库存储了元组 (document:X, restricted_viewer, user:anne, ConditionName: is_business_hours)。数据库的职责仅仅是快速检索这个元组,不对条件表达式进行解析或求值。

  3. 应用层过滤: 上述代码片段展示了对底层迭代器 (iter) 的封装。storage.NewConditionsFilteredTupleKeyIterator 充当了后置过滤器的角色。

    • 它接收到元组后,结合请求的上下文(例如 request = { "hour": 20 }),在内存中利用 CEL 评估器执行表达式求值。

    • 这种将 计算与I/O分离的架构,避免了在每一次 Check 中都将 CEL 评估负载推给 Datastore,确保了存储层的I/O吞吐量不受条件复杂性的影响。

2. 自适应查询规划器

OpenFGA 比较有意思的一点是在运行时通过机器学习方法自适应地选择最优查询策略。这套机制由 internal/planner 包实现,它将查询优化视为一个Multi-Armed Bandit问题,并采用 Thompson Sampling算法进行求解。

2.1 策略配置

Thompson Sampling 是一种贝叶斯在线学习方法。Planner 不仅仅记录策略的平均延迟,而是为每个策略维护一个性能概率分布。这个分布由 λ \lambda λ α \alpha α β \beta β 三个参数共同决定。

var recursivePlan = &planner.PlanConfig{
    Name:         recursiveResolver,
    InitialGuess: 150 * time.Millisecond,
    Lambda: 5.0, // Medium Confidence in the mean
    Alpha: 2.0,  // Low expected precision
    Beta:  2.5,  // High expected variance
}

var defaultRecursivePlan = &planner.PlanConfig{
    Name:         defaultResolver,
    InitialGuess: 300 * time.Millisecond, // High initial guess
    Lambda: 1,    // Low Confidence
    Alpha: 0.5,   // Maximum uncertainty about variance
    Beta:  0.5,   
}
  • λ \lambda λ:代表对初始平均值(InitialGuess)的信心程度。较高的 λ \lambda λ(如 recursivePlan 的 5.0)意味着 Planner 相信初始的 150ms 估计是比较准确的,会减少初期的探索权重。

  • α \alpha α β \beta β:定义了策略性能的方差和精确度的信念分布。

    • recursivePlan 采用 α = 2.0 , β = 2.5 \alpha=2.0, \beta=2.5 α=2.0,β=2.5:这暗示着 Planner 预期性能会有较大波动(Bursty / Jittery),因此不会因为单次慢查询而过度惩罚该策略,鼓励继续使用和探索。

    • defaultRecursivePlan 采用极低的 α = 0.5 , β = 0.5 \alpha=0.5, \beta=0.5 α=0.5,β=0.5:这表示极度的不确定性,规划器会倾向于探索其他具有更高确定性信念的策略,除非它表现非常好。这种设置旨在将流量导向更优化的 recursivePlan

2.2 Thompson Sampling 决策规则

在每次执行授权检查时,Planner 不会简单地选择历史平均延迟最低的策略。相反,它会在每个策略的性能分布中进行Sampling,并选择当次采样延迟最低的策略。这完美地平衡了利用已知最优策略和探索潜在更优但数据不足的策略。

// Select implements the Thompson Sampling decision rule.
func (kp *keyPlan) Select(resolvers map[string]*PlanConfig) *PlanConfig {
    // ... 获取随机数生成器 (rng) ...

    bestResolver := ""
    var minSampledTime float64 = -1

    for k, plan := range resolvers {
        // 1. 获取或创建该策略的统计数据
        ts := kp.getOrCreateStats(plan)

        // 2. 从该策略的性能分布中抽取一个样本延迟时间
        sampledTime := ts.Sample(rng) 

        // 3. 选择样本延迟时间最短的策略
        if bestResolver == "" || sampledTime < minSampledTime {
            minSampledTime = sampledTime
            bestResolver = k
        }
    }

    return resolvers[bestResolver]
}
  1. 性能分布: 每个策略(例如 recursiveResolver)都有一个基于历史数据的性能概率分布(由 α , β , λ \alpha, \beta, \lambda α,β,λ 决定)。

  2. 采样决策: ts.Sample(rng) 从这个分布中随机抽取一个值。如果一个策略的历史性能稳定且优秀,其分布会很集中,抽到的值大概率较低;如果一个策略数据量少或波动大,其分布会很宽,偶尔会抽到极低的值。

  3. 选择: Planner 选择这次采样的 minSampledTime 对应的策略。这种随机性确保了即使是最优策略被选中,那些表现较差但潜力尚不明确的策略也有机会被选中并获得新的性能数据。

2.3 如何使用

Planner 机制并非独立运行,它被深度集成在 OpenFGA 的授权检查流程中,构成一个闭环的反馈系统。

Check的流程是:获取 Key → \rightarrow 选择策略 → \rightarrow 执行 → \rightarrow 更新统计。


        // ... 
        // 1. 根据当前查询构造一个唯一的 Key
        b.WriteString("infinite")
        key := b.String()

        // 2. 获取该 Key 对应的 Planner 选择器
        keyPlan := c.planner.GetPlanSelector(key)

        // 3. 定义可选策略
        possibleStrategies[defaultResolver] = defaultRecursivePlan
        possibleStrategies[recursiveResolver] = recursivePlan

        // 4. 动态选择本次执行的策略
        plan := keyPlan.Select(possibleStrategies)

        // 5. 将选定的策略包装进性能分析处理器中执行
        resolver := c.defaultUserset
        if plan.Name == recursiveResolver {
            resolver = c.recursiveUserset
        }
        return c.profiledCheckHandler(keyPlan, plan, resolver(ctx, req, directlyRelatedUsersetTypes, iter))(ctx)

在执行完选定的策略后,一个包装函数负责捕获实际的执行时间,并将反馈给 Planner,完成学习闭环。

func (c *LocalChecker) profiledCheckHandler(keyPlan planner.Selector, strategy *planner.PlanConfig, resolver CheckHandlerFunc) CheckHandlerFunc {
    return func(ctx context.Context) (*ResolveCheckResponse, error) {
        start := time.Now()
        res, err := resolver(ctx) // 执行选定的 Check 策略

        // ... 

        // 更新该策略的统计数据
        keyPlan.UpdateStats(strategy, time.Since(start)) 
        return res, nil
    }
}

每次调用 keyPlan.UpdateStats,都会更新策略的 α \alpha α β \beta β 参数,从而改变其性能分布,影响 Planner 在下一次请求中进行策略选择的倾向。这确保了 Planner 能够持续学习并适应数据库的负载变化和查询的冷热状态。

3. 运行时优化

运行时优化是 OpenFGA 确保在面对深度递归和高并发请求时依然保持低延迟的关键,这些机制作用于授权关系图的遍历阶段,旨在加速查询、控制并发,并对底层数据库 I/O 资源进行精细管理。当然,基础的一些并发优化就不再复述啦。

3.1 Cache Resolver

Cache Resolver 相对来说就比较常规了,没有什么特别的事情发生

    if tryCache {
        // ...
        if cachedResp := c.cache.Get(cacheKey); cachedResp != nil {
            res := cachedResp.(*CheckResponseCacheEntry)
            isValid := res.LastModified.After(req.LastCacheInvalidationTime) // 检查缓存有效性

            if isValid {
                // ... 命中并返回
                return res.CheckResponse.clone(), nil
            }
            // ... (缓存失效)
        }
    }
  1. 查询去重: OpenFGA 为每个查询子问题生成一个唯一的缓存键,这在Rewrite关系图中能高效地消除大量重复的子查询。

  2. 版本控制: 缓存有效性基于版本控制。通过比较缓存项的 LastModified 时间和请求的 LastCacheInvalidationTime,确保在数据写入后,所有受影响的 Check 请求能够绕过旧的缓存。

  3. 并发安全: 返回缓存结果时使用 clone() 方法,确保返回的是数据副本,避免不同并发的 goroutine 之间因共享数据而产生竞争。

3.2 Weight Two Resolver

Weight Two Resolver 是专门为解决两跳传递性关系(Two-Hop Transitivity,即TTU)而设计的优化求解器。它通过将两步查询转化为一次高效的集合交集操作,并利用短路机制快速返回结果。

模型示例:两跳关系 (Tuple-To-Userset)
type team
  relations
    define member as [user] // 团队成员

type document
  relations
    // 关系:文档的viewer是该文档所属team的member
    define viewer as team#member

查询:Is user:anne a viewer of document:roadmap?

  • 传统递归路径:

    1. document:roadmap#viewer -> 得到 team:design#member

    2. team:design#member -> 得到 user:anne

  • Weight Two Resolver 优化路径: 将其转化为集合交集问题。

// Right channel is the result set of the Read of ObjectID/Relation that yields the User's ObjectID.
// Left channel is the result set of ReadStartingWithUser of User/Relation that yields Object's ObjectID.
func (c *LocalChecker) weight2(ctx context.Context, leftChans []<-chan *iterator.Msg, iter storage.TupleMapper) (*ResolveCheckResponse, error) {
    // ...
    leftChan := iterator.FanInIteratorChannels(cancellableCtx, leftChans)
    rightChan := streamedLookupUsersetFromIterator(cancellableCtx, iter)
    rightSet := hashset.New()
    leftSet := hashset.New()
    // ...
ConsumerLoop:
    for leftOpen || rightOpen {
        // ... 同时消费 Left (反向查询结果) 和 Right (正向查询结果) 数据流 ...
    }
    return res, lastErr
}
  1. 查询转化: Weight Two Resolver 将两步递归查询转化为两个可并行的 I/O 数据流:

    • Right Channel: 正向查询 document:roadmap#viewer 的结果集(例如,得到 team:design)。

    • Left Channel: 反向查询 user:anne 能通过 member 关系到达的所有 team 对象集合(ReadStartingWithUser)。

  2. 并行交集与短路: ConsumerLoop 同时消费这两个流,在内存中维护集合并查找交集。一旦 team:design 同时出现在两个集合中,系统立即中断,返回 Allowed: true。这种 I/O 并行 + 内存交集 + 快速短路 的模式,极大地避免了两次慢速的数据库查找和应用层的复杂递归。

3.3 递归解析与并发 BFS

对于无法通过 Weight Two 优化的深度传递性关系,即关系路径长度不固定且可能很深,OpenFGA 采用并发BFS来处理,并实施严格的并发控制和去重。

考虑一个无限深度的组织架构(即父子关系):

type folder
  relations
    define parent as [folder]
    // 关系:folder的viewer是其自身viewer,或是其父folder的viewer
    define viewer as viewer or viewer from parent 

查询:Is user:bob a viewer of folder:deep_doc? 这可能需要遍历多层父文件夹。

func (c *LocalChecker) breadthFirstRecursiveMatch(...) {
    // ...
    if req.GetRequestMetadata().Depth == c.maxResolutionDepth { return } // 深度限制

    pool := concurrency.NewPool(ctx, c.concurrencyLimit) // 并发池

    for _, usersetInterface := range currentUsersetLevel.Values() {
        userset := usersetInterface.(string)
        _, visited := visitedUserset.LoadOrStore(userset, struct{}{}) // 去重与循环检查
        if visited {
            continue
        }
        // ... (在池中执行并发查询)
    }

    if err := pool.Wait(); errors.Is(err, ErrShortCircuit) { /* 短路返回 */ }
    c.breadthFirstRecursiveMatch(ctx, req, mapping, visitedUserset, nextUsersetLevel, usersetFromUser, checkOutcomeChan) // 递归到下一层
}
  1. BFS 优势: 相比 DFS,BFS 能将每一层的搜索工作分解,天然适合使用 concurrency.NewPool 进行并行处理,加速查询。

  2. 资源控制: concurrency.NewPool 严格限制了并发执行的 Goroutine 数量,防止在深度递归时因创建过多数据库连接而压垮 Datastore。

  3. 去重与短路: visitedUserset *sync.Map 确保任何一个 Userset 只会被访问一次,有效处理了菱形依赖并避免无限循环。一旦 pool.Wait() 捕获到 ErrShortCircuit,它会立即中断所有正在执行的查询,实现最短路径的快速返回。

3.4 Shared Iterator Datastore

Shared Iterator Datastore 是 OpenFGA 对底层数据库 I/O 资源(游标/连接)进行的精细管理优化。它确保了在复杂的查询树中,相同的查询请求可以共享游标,并保证其生命周期的正确性。

    // The producer function is called to create a new shared iterator when it is first accessed.
    newStorageItem.producer = func() (*sharedIterator, error) {
        it, err := sf.RelationshipTupleReader.Read(ctx, store, filter, options)
        if err != nil {
            return nil, err
        }
        si := newSharedIterator(it) // 包装底层迭代器
        sharedIteratorCount.Inc()
        return si, nil
    }

    // Load or store the new storage item in the internal storage map.
    value, loaded := sf.internalStorage.read.LoadOrStore(cacheKey, newStorageItem)
    if !loaded {
        sf.internalStorage.ctr.Add(1)
    }
    item, _ := value.(*storageItem)

    // Unwrap the storage item to get the shared iterator.
    // If this is the first time the iterator is accessed, it will call the producer function to create a new iterator.
    it, created, err := item.unwrap()
    if err != nil {
        sf.internalStorage.read.CompareAndDelete(cacheKey, newStorageItem)
        return nil, err
    }
  1. 按需创建: 只有当第一次访问时,producer 函数才会被调用来执行昂贵的底层数据库 Read() 操作,并返回一个被 newSharedIterator 包装的游标。

  2. 资源共享: sf.internalStorage.read.LoadOrStore(cacheKey, newStorageItem) 逻辑确保了相同的查询请求只会触发一次 I/O。后来的请求直接从内部存储获取已存在的 storageItem

  3. 引用与生命周期: item.unwrap() 负责将共享游标返回给上层调用者。Shared Iterator 内部维护了引用计数。只有当所有并发或递归路径对该游标的引用全部释放时,底层的数据库游标才会被安全地关闭和清理,从而防止资源泄漏和数据库过载。

4.聊聊实践

1. 模型即性能

OpenFGA 能处理深度递归,但这不代表它鼓励你这么做。深度递归是权限检查中的性能黑洞。

  • 如果你发现业务模型天然要求多层递归路径,千万别直接照搬。

  • 考虑是否能通过数据预处理或在写入时增加冗余元组的方式,将路径扁平化。越扁平,越能命中 Weight Two Resolver 这种高效的专项优化器。

2. 适配器的艺术

在业务代码和 OpenFGA 之间,一个合适的Adapter Layer是需要的。

  • 常规的代码防腐层:业务代码只跟适配层打交道,适配层负责处理 OpenFGA 的各种细节(比如请求上下文、模型 ID、甚至未来 OpenFGA 自身的升级)。这样业务代码就能保持“纯洁”和长期稳定。

  • 结构优化:利用适配层,你可以把业务中那些路径很深、或者命名很随意的模型概念,翻译成对 OpenFGA 最友好、最扁平的模型结构,让查询性能在进入 OpenFGA 前就获得了一次加速。

最后,没有白白浪费一天的周末时光!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐