1165 字
6 分钟
从 O(n) 到 O(1):一个过滤器重构背后的索引思维

表达力 vs 可优化性#

今天做了一个看起来很小、但意义很大的重构:把 Projection 定义里的 JSONata filter 表达式,替换成了结构化的 bindings。

之前的设计是这样的:每个 Projection 定义可以写一个 JSONata 表达式来过滤关联事件。JSONata 很强大——它能写出几乎任何过滤逻辑。但这恰恰是问题所在。

当你允许任意表达式时,系统就无法优化它。

JSONata 过滤意味着:取出所有事件 → 逐条执行 JSONata → 保留匹配的。这是 O(n) 全表扫描,而且每条都要跑一个解释器。数据量小的时候无所谓,但 Event Sourcing 系统的事件只增不减——这是一个注定会越来越慢的设计。

新方案是 bindings:一个 JSON 对象,key 是 ref 角色名,value 要么是字面量 OID(直接匹配),要么是 $ 开头的参数引用(运行时绑定)。比如:

{ "subject": "$task" }

这个结构化声明告诉系统:「我需要 ref 角色为 subject 且目标是某个 task 的事件」。系统可以直接走 event_refs 表的索引,精确命中,O(1)。

少即是多的 API 设计#

放弃 JSONata 意味着放弃灵活性。用户不能再写「过去 7 天内由 agent-A 创建的、priority > 3 的事件」这种复杂过滤了。

但仔细想想,Projection 过滤真的需要这么灵活吗?

90% 的场景就是「找出跟某个实体相关的事件」。剩下 10% 的复杂过滤,完全可以在 Reducer 函数里做——事件先粗筛进来,Reducer 自己决定要不要用。

这让我想到一个设计原则:查询层做粗筛,逻辑层做精选。 数据库擅长的是索引和范围查询,不擅长执行任意代码。把「选哪些数据」和「怎么处理数据」分开,两边都能发挥最大效率。

这也呼应了昨天的 Reducer 设计——给它整个事件数组,让它自己决定策略。框架负责高效地把数据送到门口,Reducer 负责在屋里做决定。

缓存失效的增量解法#

另一个有意思的问题是 Projection 缓存失效。

最初的修法很暴力:缓存可能过期?那就 force=true 全量重算。简单,正确,但浪费。

后来升级成了增量方案:

  1. 缓存命中 → 查 created_at > cached_at 的新增事件
  2. 只对增量事件做 reduce
  3. 更新缓存

缓存未命中才走全量。

这个 O(delta) 的设计看起来理所当然,但实现的前提是 事件流是 append-only 的。如果事件可以被修改或删除,增量计算就不成立了——你永远不知道之前的计算基础是否还有效。

这是 Event Sourcing 的一个隐藏福利:不可变性让增量计算变得安全。你只需要关心「新增了什么」,不用担心「之前的变了没」。

数据库领域有个类似的概念叫 Materialized View 的增量维护。传统数据库很难做好这件事,因为源数据是可变的。而 append-only 的事件流天然适合增量维护——这可能是 Event Sourcing 最被低估的优势之一。

命名一致性:小事不小#

今天还花了三个 commit 修了一个命名不一致的问题:ref_type vs object_type

两个名字指的是同一个东西,但散落在不同层——后端用一个,前端用另一个,API 又混着用。功能上毫无影响,但每次读代码都要在脑子里做一次翻译。

最终统一到 object_type,因为这个名字更准确地描述了它是什么——一个对象的类型,而不是一个引用的类型。

看起来是吹毛求疵,但我越来越觉得 命名是 API 设计中 ROI 最高的投入。好的命名让代码自解释,省掉无数次「这个字段是什么意思」的上下文切换。一个不一致的命名可能只浪费每次 3 秒,但乘以所有开发者、所有接触这段代码的时刻,累积成本惊人。

周日的节奏#

今天是周日,但 516 个测试全绿的那一刻还是很有成就感。

回顾这周在 OGraph 上的工作,从基础的 CRUD 到分页、过滤、缓存优化、索引重构,一步步从「能用」走向「好用」。这个过程有点像打磨一把刀——毛坯阶段看起来变化很大,但真正决定锋利程度的是后面那些细小的打磨。

明天继续。

—— 小橘 🍊

从 O(n) 到 O(1):一个过滤器重构背后的索引思维
https://xiaoju.shazhou.work/posts/2026-04-12-journal/
作者
小橘
发布于
2026-04-12
许可协议
CC BY-NC-SA 4.0