Skip to content

优化 EventStorage 查询,批量加载事件关联数据#182

Merged
4thfever merged 1 commit into4thfever:mainfrom
RealityError:event-storage-batched-association-loading
Apr 5, 2026
Merged

优化 EventStorage 查询,批量加载事件关联数据#182
4thfever merged 1 commit into4thfever:mainfrom
RealityError:event-storage-batched-association-loading

Conversation

@RealityError
Copy link
Copy Markdown
Contributor

@RealityError RealityError commented Apr 4, 2026

Summary

先说说这个问题是怎么被注意到的:

一开始我想先找一个边界清楚、收益能量化、而且 review 成本不会太高的点做掉。

EventStorage 的时候,很快就发现:事件列表分页、角色筛选、宗门筛选、长短期记忆相关查询,基本都要走这里。只要这里有性能瓶颈,那全局都会受影响。

然后顺着代码往下看,问题就出来了。

get_events() 这种方法会先查出一页 events,这一步本身没什么问题。问题在后面。每条事件在组装成 Event 对象的时候,还会各自再去查一次:

  • event_avatars
  • event_sects

也就是说,旧链路实际上是“先查一页主表,再按页内每条事件回两次关联表”。这就是很典型的 N+1,而且还是双倍的。

limit=100 来说,旧逻辑理论上就是:

  • 主查询 1 次
  • event_avatars 100 次
  • event_sects 100 次

合起来刚好 201 次 SQL。

看到这里时,我基本就觉得这个点值得单独提一个 PR 了。

排查过程本身倒没有什么玄学,就是顺着调用链老老实实往下看。

先看 get_eventsget_major_events_by_avatar 这些入口,确认它们都是“先拿一批 row,再逐条组装事件对象”的模式。然后再看 _row_to_event(),确认它在组装单条事件时会额外查询 event_avatarsevent_sects。最后再用 SQLite trace callback 把 SQL 条数数出来,看和代码推导是不是对得上。

旧逻辑的核心长这样:

for row in rows:
    event = self._row_to_event(row)
    events.append(event)

_row_to_event() 里面会做这两次回表:

SELECT avatar_id FROM event_avatars WHERE event_id = ?
SELECT sect_id FROM event_sects WHERE event_id = ?

页内有多少条事件,这两类查询就跟着放大多少次。

而且受影响的不只是 get_events()。一起会踩到这套模式的还有:

  • get_major_events_by_avatar
  • get_minor_events_by_avatar
  • get_major_events_between
  • get_minor_events_between

所以这就是 EventStorage 内部一个重复出现的查询模式问题,而不是某一个接口的小问题。


改动本身反而挺朴素的。

这次没有改 API,没有改 schema,也没有借机去拆大文件。我只做了一件事:把“逐条组装时逐条回表”改成“这一页先把关联数据一次拉出来,再统一组装”。

现在的流程是:

  1. 先拿到当前页的全部 event_id
  2. 批量查询这一页对应的 event_avatars
  3. 批量查询这一页对应的 event_sects
  4. event_id 分组后统一组装 Event

内部新加了 3 个 helper:

  • _load_avatar_map_for_events
  • _load_sect_map_for_events
  • _build_events_from_rows

然后把下面这些读取路径接到了新的批量组装逻辑上:

  • get_events
  • get_major_events_by_avatar
  • get_minor_events_by_avatar
  • get_major_events_between
  • get_minor_events_between

另外 _row_to_event() 的 fallback 语义我保留了。也就是说,如果别的地方还是单独拿一条 row 来组装事件,它仍然按原来的方式工作。这一点我觉得很重要,因为这能把这次改动的影响面压在 EventStorage 内部,不会莫名其妙把别的调用方一起拖下水。


benchmark 这块,我先拿真实库做确认。

如果连真实存档上都看不出这个问题,那这个 PR 的优先级其实就没那么高。

这次用的是我自己在服务器上跑了半个小时产出的数据库:

  • save_20260404_1347_events.db

它当前的数据量不算夸张,但足够说明问题:

  • events = 320
  • event_avatars = 257
  • event_sects = 105

在这份真实库上,我对比了两种模式:

  • 当前实现:页内批量装载
  • 旧实现模拟:强制退回逐条 _row_to_event() 装载

结果如下:

场景 当前实现 SQL 旧实现 SQL 当前均值 旧均值
all_events limit=100 3 201 1.79 ms 9.09 ms
avatar_filter limit=50 3 85 0.86 ms 3.93 ms
sect_filter limit=50 3 59 0.65 ms 2.61 ms
major_events_by_avatar limit=10 3 11 0.34 ms 0.57 ms

这一步已经足够说明问题是真实存在的。


然后我开始伪造数据库看看能不能更深入证明问题的影响规模。

主要是想回答两个更实际的问题:

  1. 这个收益是不是只在几百条事件的真实库上成立?
  2. 如果事件规模继续往上走,结论会不会很快反过来?

这里没有用那种“一个热点 key 命中整库”的极端模型,因为那个模型太容易把主查询本身的扫描成本也一起放大,最后很难看清到底是谁在拖后腿。
后面把数据做成了更接近真实筛选负载的分布:

  • 总事件量分别生成 1k / 10k / 100k
  • 只有一部分事件命中热点 avatar_hot
  • 只有一部分事件命中热点 sect=1
  • 重大事件的 observation 也按固定比例生成
  • 每条事件依然带 2 到 3 条关联 avatar / sect,不让数据过薄

伪造数据的关键生成逻辑大概是这样:

for i in range(scale):
    event_id = f"sel_evt_{i:06d}"
    month_stamp = 20_000_000 - i
    is_major = 1 if i % 8 == 0 else 0
    is_story = 1 if i % 32 == 0 else 0

    primary_avatar = "avatar_hot" if i % 20 == 0 else f"avatar_{i % 1000}"
    primary_sect = 1 if i % 15 == 0 else (i % 60) + 2

    events.append(
        (
            event_id,
            month_stamp,
            f"Selective synthetic event {i}",
            is_major,
            is_story,
            "benchmark",
            None,
            None,
            "2026-04-04 00:00:00.000000",
        )
    )

    avatars.append((event_id, primary_avatar))
    avatars.append((event_id, f"avatar_peer_{(i + 7) % 1000}"))
    sects.append((event_id, primary_sect))
    sects.append((event_id, ((i + 11) % 60) + 2))

    if is_major and not is_story:
        observations.append(
            (
                f"obs_hot_major_{i:06d}",
                event_id,
                "observer_hot",
                primary_avatar,
                "heard",
                None,
                "2026-04-04 00:00:00.000000",
            )
        )

benchmark 的跑法很直接:同一份数据库上,分别跑当前实现和旧实现模拟,然后统计 SQL 数和平均耗时。

结果如下。

1k

场景 当前实现 SQL 旧实现 SQL 当前均值 旧均值
all_events limit=100 3 201 1.87 ms 9.89 ms
avatar_filter limit=50 3 101 1.36 ms 4.87 ms
sect_filter limit=50 3 101 1.15 ms 4.77 ms
major_events_by_avatar limit=10 3 21 0.51 ms 1.13 ms

10k

场景 当前实现 SQL 旧实现 SQL 当前均值 旧均值
all_events limit=100 3 201 1.99 ms 11.55 ms
avatar_filter limit=50 3 101 5.57 ms 13.00 ms
sect_filter limit=50 3 101 2.07 ms 6.69 ms
major_events_by_avatar limit=10 3 21 1.73 ms 2.63 ms

100k

场景 当前实现 SQL 旧实现 SQL 当前均值 旧均值
all_events limit=100 3 201 2.38 ms 11.71 ms
avatar_filter limit=50 3 101 47.54 ms 51.08 ms
sect_filter limit=50 3 101 45.89 ms 51.00 ms
major_events_by_avatar limit=10 3 21 51.36 ms 49.97 ms

看完这些数字之后,可以得出判断了。

get_events 这条主链路的收益非常稳。这个几乎不用多解释了,不管是真实库还是 1k / 10k / 100k,SQL 数都稳定从线性增长压到了常数级 3,而且耗时也明显更好。这部分就是这次 PR 最核心的价值。

avatar_filtersect_filter 也都是真实受益的。在中小规模下提升很直接;到了 100k,墙钟时间优势开始收窄,但 SQL 数的下降依然很明显。这说明这次去掉的 N+1 是真问题,不是统计噪音。

major_events_by_avatar 这条路径稍微有点不同。它的 N+1 同样被去掉了,SQL 数也确实从 21 降到了 3,但在 100k 规模下,耗时已经不再明显占优。我不觉得这是坏消息,反而说明这条路径下一步该优化的点已经变了。现在更像是 event_observations 这条主查询本身在吃掉主要成本,而不是页内事件组装还在拖后腿。

换句话说,这次优化把第一层已经确认的热点处理掉之后,下一层真正的瓶颈也跟着露出来了。


测试这边,除了 benchmark,我还补了针对查询效率的回归测试,主要就是防止后面有人不小心又把它改回逐条回表。

本次执行结果:

  • pytest tests/test_event_storage.py tests/test_api_events.py
  • 74 passed

至此我决定停下来了,至于为什么:

如果只看 100k 的 benchmark,当然还能继续往下挖,尤其是 event_observations 的筛选、排序和索引命中,后面大概率还有空间。但我不想把这件事继续塞进同一个 PR 里,原因也很简单。

第一,正常项目使用场景里,短期内其实不太容易碰到 100k 级别的事件量。
第二,这次 PR 的目标已经很明确了,就是先把已经确认存在的 N+1 去掉,而且真实库和伪造库都已经把收益说明白了。
第三,如果继续把下一层 observation 查询优化也叠进来,这个 PR 就会开始同时回答两类问题,review 会变重,结论也会变得没那么干净。

所以这次先收住:

  • 已经解决的问题:页内事件关联装载的 N+1
  • 暂时不继续展开的问题:超大 observation 规模下的下一层查询瓶颈

如果后面真的出现这类负载需求,完全可以再提一个独立 PR,专门看 event_observations 这条查询链路。看看大家后面愿不愿意继续讨论这个问题了。

好的,已经很晚了,就写到这里。如果后续有什么问题也可以继续讨论。这个项目真的很好玩,希望大家也能长久坚持下去吧~

Test Plan

  • 运行 pytest tests/test_event_storage.py tests/test_api_events.py
  • 在真实数据库 save_20260404_1347_events.db 上对比当前实现与旧逐条装载模式
  • 生成 1k / 10k / 100k 的选择性命中伪造数据库,统计 SQL 数和平均耗时

@RealityError RealityError marked this pull request as ready for review April 4, 2026 17:07
@RealityError
Copy link
Copy Markdown
Contributor Author

补两张 benchmark 图,放在评论里方便直接看。

选择性命中型伪造库 · 平均耗时(ms)

选择性命中型伪造库 · SQL 条数

@4thfever
Copy link
Copy Markdown
Owner

4thfever commented Apr 5, 2026

LGTM。看起来蛮solid的。

@4thfever 4thfever merged commit a583932 into 4thfever:main Apr 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants