PostHog 仪表盘查询、缓存与规模化设计:从刷新契约到可观测性的完整工程指南
仪表盘是 PostHog 中"一次渲染多张洞察卡片"的高并发场景,其查询路径涉及缓存命中、强制刷新、共享渲染限流与前端并发控制等多层约束。本文以 .agents/skills/managing-dashboards/references/querying-caching-and-scale.md 为骨架,结合仓库中 refresh_policy.py、query_runner.py 与 dashboardLogic.tsx 等源码实现,完整讲解 PostHog 仪表盘的查询缓存契约、工作量边界、规模化测试与可观测性设计,帮助你在改查询路径时不会踩坏缓存语义与并发上限。
查询与缓存契约(Query and Cache Contract)
在 PostHog 中,仪表盘加载(dashboard load)与仪表盘刷新(dashboard refresh)是两种完全不同的操作,它们走不同的执行路径、携带不同的刷新默认值,也拥有独立的缓存与错误语义。理解这条契约是改动任何查询路径的前提。
三层执行模式解析:Surface 默认 → 客户端显式刷新 → 共享渲染钳制
所有渲染洞察图块的 HTTP 入口(洞察详情/列表端点、仪表盘详情/流式/run_insights 动作、共享与嵌入渲染)最终都会汇聚到 InsightSerializer.insight_result,由它计算出一个 ExecutionMode。历史上每个入口的默认行为(客户端不带 ?refresh= 时发生什么)取决于该客户端恰好发送了哪些查询参数,在路由层完全不可见。为此仓库引入了 refresh_policy.py,把默认行为显式化、按入口(Surface)整理成一张表:
ComputeSurface 枚举(见 refresh_policy.py):
| ComputeSurface | 含义 |
|---|---|
INSIGHT_DETAIL |
独立洞察编辑器中的洞察详情 |
INSIGHT_LIST |
洞察列表 |
DASHBOARD_TILE |
作为仪表盘图块的洞察渲染(区别于独立编辑器,允许不同的刷新默认值) |
DASHBOARD_DETAIL |
仪表盘详情加载 |
DASHBOARD_STREAM |
仪表盘流式加载 |
DASHBOARD_RUN_INSIGHTS |
run_insights 动作 |
DASHBOARD_MUTATE |
仪表盘变更动作 |
SHARED |
共享/嵌入渲染 |
LEGACY_UNKNOWN |
无法识别的历史调用方 |
ExecutionMode 枚举(见 query_runner.py)定义了洞察结果的七种(重)计算方式:
CALCULATE_BLOCKING_ALWAYS(force_blocking):总是同步重算;CALCULATE_ASYNC_ALWAYS(force_async):总是发起异步计算;RECENT_CACHE_CALCULATE_BLOCKING_IF_STALE(blocking):缓存命中且新鲜则直接用,缺失或过期则同步重算;RECENT_CACHE_CALCULATE_ASYNC_IF_STALE(async):命中缓存直接用,缺失或过期则发起异步计算;RECENT_CACHE_CALCULATE_ASYNC_IF_STALE_AND_BLOCKING_ON_MISS(async_except_on_cache_miss):过期走异步,但缓存缺失时同步阻塞;EXTENDED_CACHE_CALCULATE_ASYNC_IF_STALE(lazy_async):延长缓存有效期,过期或缺失才发起异步计算;CACHE_ONLY_NEVER_CALCULATE(force_cache):只读缓存,绝不主动发起计算。
SURFACE_DEFAULT_EXECUTION_MODE 表(refresh_policy.py)当前将每一个 surface 的默认值都设为 CACHE_ONLY_NEVER_CALCULATE——这精确复刻了历史行为(缺失 ?refresh= 即只读缓存),并刻意逐条列出(而非用 dict.fromkeys)以便未来翻转某一个入口的默认值时,只是一行可独立度量、可 grep 的改动。
三层优先级在 resolve_execution_mode 中落地(refresh_policy.py):
- 客户端显式
?refresh=永远胜出:只要请求(query string 或 body)中出现refresh参数——哪怕值是false/0/no——就通过execution_mode_from_refresh解析;显式refresh=false与"根本没传"被_refresh_param_present明确区分,前者依然落到CACHE_ONLY_NEVER_CALCULATE; - surface 默认值仅在无任何 refresh 参数时生效;
- 共享/嵌入渲染最后钳制:
shared_insights_execution_mode把force_blocking降级为"缓存新鲜则直接用、否则同步重算",并携带SHARED_FORCE_BLOCKING_STALENESS_WINDOW(源自与前端自动刷新间隔同一 schema,见 query_runner.py)作为节流时钟——匿名/共享流量绝不能强制阻塞重算。SharedExecutionSettings.cache_age_seconds不是装饰性字段,丢弃它会静默禁用共享强制刷新的节流。
run_insights 是独立端点,缓存与错误行为必须分离
文档强调:run_insights 是独立于仪表盘详情(dashboard detail)与仪表盘流式(dashboard stream)的端点,必须保持各自的缓存与错误行为。这在 dashboard.py 中得到印证:run_insights 一次"运行仪表盘上所有洞察并返回结果",其 context 中显式写入 ComputeSurface.DASHBOARD_RUN_INSIGHTS,且由于 _format_insight_for_llm 需要消费 Python 数据,还设置了 require_parsed_results = True(不能用原始缓存字节)。它支持的参数包括:
| 参数 | 取值 | 说明 |
|---|---|---|
refresh |
force_cache / blocking / force_blocking |
force_cache(默认)过期也读缓存;blocking 缓存新鲜则用、否则重算;force_blocking 总是重算 |
output_format |
optimized / json |
optimized(默认)返回 LLM 友好的格式化文本;json 返回原始查询结果对象 |
对应测试见 test_run_insights.py:test_returns_one_result_per_insight_tile 验证每张洞察图块一个结果,test_json_format_returns_raw_query_results 验证 refresh="blocking" 下 json 格式返回原始结果。
改动查询路径前的六个必答问题
在动任何查询路径之前,先回答这份自检清单:
- 正常的仪表盘加载返回的是纯缓存结果、缓存数据,还是一次同步查询?
- 哪个调用方可以强制刷新?
- 缓存过期或为空时会发生什么?
- 一块图块的失败如何影响其余图块?
- 导航离开或加载新仪表盘后,什么机制取消未完成的工作?
- 哪个访问路径负责记录缓存命中与未命中?
保持工作量有界(Keep Work Bounded)
PostHog 把密集仪表盘(dense dashboard)视为默认的性能边界,即任何实现都必须能在大量图块同时存在时保持稳定。
不要为每个图块发一个无界请求
核心约束包括:
- 绝不每块图块一个无界请求:批量、节流与结果上限规则在 manage-dashboard-widgets 技能中有专门约定,若改动涉及 widget 查询,请先阅读该文档,不要在本路径重复造轮子;
- 预取序列化所需全部数据:仪表盘序列化中避免逐图块数据库查询,一次性批量预取(
run_insights中对 tag 的prefetch_related即是例子,见 dashboard.py); - 不要在任何模板列表响应里计算昂贵的模板诊断;
- 避免在单个图块结果陆续到达时做 N 次全网格渲染;
- 仪表盘身份、筛选器、变量或布局变化时取消在途工作。
源码印证:widget 的批量上限与节流
widget 查询路径把"批量"与"节流"落到了实处:
- 批量上限:
MAX_WIDGETS_BATCH_SIZE = 10(constants.py),run_widgets要求tile_ids参数为逗号分隔的整数列表、去重、非空且最多 10 个,超限直接返回校验错误(dashboard.py); - 多级节流:
run_widgets对每个 tile 依次检查产品访问权限、API scope、widget 查询节流(get_dashboard_widget_query_throttle_error)与 session replay 列表节流,任一命中都会把错误"局部化"到该 tile 的results_by_id[tile_id],而不是拖垮整个请求(dashboard.py); - 节流实现:widget_query_throttle.py 定义
DashboardWidgetQueryBurstRateThrottle(突发)与DashboardWidgetQuerySustainedRateThrottle(持续)两个限流类,命中时返回"Rate limit exceeded. Expected available in X seconds."这类客户端可读错误。
前端并发控制:dashboardLogic 的状态机
前端 dashboardLogic(dashboardLogic.tsx)负责刷新过期洞察图块,并跟踪每块图块的 queued、loading、refreshed(缓存/完成)与 errored(失败)四态,底层用 RefreshStatus 记录每条 shortId 的状态、错误与计时器(dashboardLogic.tsx)。
两个值得注意的工程细节:
- 批次的"X out of Y"分母在批次入队时即固定(
refreshTilesTotal),Y 不会随着图块逐个进入而增长;abortQuery只移除被取消的那块图块,而不是清空整个 map——否则在途的兄弟图块会被误判为完成,导致"X out of Y"虚高(dashboardLogic.tsx); - 进度 UI 由 DashboardReloadAction.tsx 渲染
refreshMetrics的completed/total,如"Refreshed 2 out of 2"。
刻意测试规模化(Test Scale Deliberately)
规模化测试必须使用有代表性的 fixtures 或测试辅助工具,绝不能依赖生产数据。以下是文档给出的核心测试矩阵:
| 场景 | 预期结果 |
|---|---|
| 空仪表盘 | 快速空状态,不触发任何图块查询 |
| 单块图块 | 内容与控件正确 |
| 多张洞察图块 | 请求有界、加载状态稳定、错误相互独立 |
| 混合图块 | 文本与按钮不进查询刷新路径 |
| 慢查询或失败的图块 | 其余图块保持可用,错误仅限该图块 |
| 重复刷新 | 同一刷新窗口内不产生重复请求 |
| 刷新期间导航 | 中止或忽略过期响应 |
这些场景与源码测试一一对应:后端 test_run_insights.py 与 test_run_widgets.py 覆盖端点级行为,前端 dashboardLogic.test.ts 断言 refreshTilesTotal 先固定、refreshMetrics 从 0/2 推进到 2/2 的完整周期。
可观测性(Observability)
改动查询路径时必须守住四条可观测性底线:
- 按访问方式保留仪表盘访问计数器:
dashboard_access_method区分四种方式——human(Web 事件源)、shared、embedded、api(其余一律归 API),record_dashboard_access每次命中累加对应 label 的计数器(access.py),并同步记录 OTel twin 指标; - 保留仪表盘洞察结果的缓存命中/未命中计数器:
posthog_query_cache_hit_total计数器带cache_hit与trigger两个 label,且缓存预热(cache warming)请求不计入(query_cache/metrics.py); - 保留仪表盘读取的端点监控:当新的交付或查询路径改变用户可见延迟时,评估 SLO 覆盖率(查询失败分类逻辑见 query_runner.py:用户错误/限流/取消计入 SUCCESS,超时与 OOM 等性能错误计入 FAILURE);
- 不要用埋点替代并发或缓存边界:instrumentation 只负责观察,真正的防护是前文的有界请求、节流与缓存钳制。
验证(Verification)
改动完成后,先跑最小相关集合:
hogli test products/dashboards/backend/api/test/test_run_insights.py
hogli test products/dashboards/backend/api/test/test_run_widgets.py
hogli test frontend/src/scenes/dashboard/dashboardLogic.test.ts
hogli test frontend/src/scenes/dashboard/DashboardItems.test.tsx
除此之外,还应运行变更涉及的 API、共享(sharing)、模板(template)或布局(layout)行为的聚焦测试。hogli 是仓库内置的测试运行入口(见根目录 hogli.yaml),以上命令可直接在仓库根目录执行。
小结
PostHog 仪表盘的查询与缓存设计可以总结为一条契约、一道边界、一组测试与一套指标:契约是"surface 默认 → 客户端显式刷新 → 共享渲染钳制"的三层执行模式解析(refresh_policy.py);边界是批量上限、多级节流与前端刷新状态机的有界并发;测试是文档化的小型测试矩阵加聚焦测试文件;指标是按访问方式与缓存命中/未命中打点的计数器。理解这条链路后,你就能安全地修改任何仪表盘查询路径——包括新增计算 surface、翻转某个入口的刷新默认值或调整共享渲染的节流窗口。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00