ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

在 PostHog 中为新产品编写 ClickHouse 查询:从 HogQL 到索引与性能优化的完整工程实践

在 PostHog 中为新产品编写 ClickHouse 查询:从 HogQL 到索引与性能优化的完整工程实践 在 PostHog 中为新产品编写 ClickHouse 查询从 HogQL 到索引与性能优化的完整工程实践【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog本指南围绕 PostHog 内部工程手册《Writing ClickHouse queries for new products》展开系统讲解在 PostHog 中为一个全新产品如 session replay、logs、error tracking 等编写 ClickHouse 数据查询时应遵循的全部最佳实践以 HogQL 而非裸 SQL 建模、以后端 QueryRunner 而非前端拼装查询、为行 ID 选择可时间排序的 UUIDv7 方案以及一套从物化列、跳过索引到 EXPLAIN 与 trace 日志的完整性能调试流程。读完本文你将掌握如何写出安全自动团队隔离、高效物化列与索引驱动且可测试、可观测的 ClickHouse 查询并能在进入生产前用可复现的六步工作流验证其性能。文档定位与前置阅读本文是对 clickhouse-queries-new-products.md 的完整展开。原文档是 PostHog 工程手册中面向内部工程师为新产品写 ClickHouse 查询的权威指南与以下文档配套阅读效果更佳Writing HogQL queries in Python —— HogQL 在 Python 端的调用方式与上下文构造Query performance optimization —— 更广义的查询性能优化方法论Materialized columns —— 物化列的完整原理与操作手册schema-changes.md —— ClickHouse 表结构变更迁移规范使用 HogQL 而非原始 ClickHouse SQL第一条原则贯穿全文永远使用 HogQL 编写查询而不是手写 ClickHouse SQL。HogQL 是构建在 ClickHouse SQL 之上的一层 AST 驱动的查询层PostHog 所有内部产品查询洞察、会话回放、日志、错误追踪等都以它为底座其核心价值在于自动提供安全与性能保证。为什么用 HogQL四项自动保障1. 自动 team_id 守卫每次查询在访问任何表时都会被自动注入team_id your_team_id过滤条件从根上防止跨团队数据泄露。该逻辑实现在 posthog/hogql/printer/clickhouse.py 的team_id_guard_for_table()它为每个表访问生成一个CompareOperationteam_id context.team_id并且如果上下文缺失team_id会直接抛出InternalHogQLError保证带团队上下文的查询必然带守卫。同文件还展示了同一设计模式的姊妹函数retention_floor_for_table()像 events 这类受数据保留期限制的表会在最底层强制注入timestamp now() - toIntervalMonth(retention_months)用户通过查询参数或 modifiers 也无法绕过保留期上限。这说明了 HogQL 架构的一个关键点——安全与数据治理约束是在 AST 层强制施加的而不是靠每个开发者自觉写 WHERE 条件。2. 物化属性优化properties.$browser这类属性访问会被自动重写为使用预先提取的物化列materialized column。物化列把 JSON 中的属性值作为独立列存储在磁盘上读取速度比查询时实时解析 JSON 快最多 25 倍。开发者完全不需要手动改查询写法照旧优化自动生效。3. Person join 优化Person-on-EventsPerson-on-EventsPoE模式由 HogQL 自动处理查询器会根据团队配置自动选择正确的 join 方式或列策略无需手工处理。从源码看PoE 在数据库层体现为 events 表上的一个虚拟子表poe见 posthog/hogql/database/schema/events.py 与 posthog/hogql/database/models.py 中reuses the parent for storage的嵌套表注释数据库初始化时会按团队 PoE 配置动态交换字段posthog/hogql/database/database.pyevents.person.*的访问路径随之自动解析到poe子表。4. 团队/查询级 settings 与 modifiers每个查询都可以通过 posthog/hogql/constants.py 中的 settings如MAX_SELECT_RETURNED_ROWS 50000等各类查询结果上限以及 posthog/schema.py 中的HogQLQueryModifiers精细调优 join 算法、物化模式materialization mode、投影优化等行为可按团队或按单个查询覆盖默认值。例如forceClickhouseDataSkippingIndexesmodifier 会在编译期把指定索引名写入 ClickHouse 的force_data_skipping_indices设置见 posthog/hogql/query.py强制 ClickHouse 在无法使用该索引时报错——这为生产环境提供了一张安全网。何时才考虑裸 SQLHogQL 并非银弹它面向 PostHog 的events、persons、sessions、logs等内置数据模型与 PostHog 自身的 ClickHouse 集群。若你的查询需要触碰 HogQL 尚未建模的底层细节如 EXPLAIN 调试、特殊 DDL 场景可以借助下文介绍的to_query()/EXPLAIN PLAN拿到编译后的 SQL 进行分析但作为产品功能正式落地的查询仍应以 HogQL QueryRunner 为唯一入口。使用后端 Query Runner而非前端定义查询第二条原则查询应定义在 Python 后端query runner而不是在前端拼装 HogQL 字符串。基类是 posthog/hogql_queries/query_runner.py 中的QueryRunner泛型基类参数化为QueryRunner[Q, R, CR]其中 Q 为查询模型、R 为响应模型、CR 为缓存响应模型其直接派生子类AnalyticsQueryRunner用于分析类查询。为什么用 Query Runner缓存内置缓存与可配置的刷新间隔。重写_refresh_frequency()控制结果刷新频率缓存键由get_cache_key()根据查询、团队、modifiers 与时区自动推导。以 TrendsQueryRunner 为例其_refresh_frequency()会根据时间粒度与查询跨度动态选择刷新间隔minute粒度走实时洞察间隔hour粒度或 7 天内的短周期洞察走缩短的间隔其余走基础间隔。可观测性查询执行自动埋点 Prometheus 指标QUERY_EXECUTION_TOTAL、QUERY_EXECUTION_DURATION并上报 PostHog 分析事件免费获得延迟直方图与错误分解。这意味着任何基于 QueryRunner 的新产品查询上线即纳入监控体系无需额外接入。可测试性QueryRunner 的单元测试非常简单——用 team 与查询 schema 实例化 runner调用calculate()对响应做断言即可完全不涉及 HTTP 层下文会给出实际测试模式的源码佐证。异步执行基类自动处理异步查询执行、限流rate limiting与查询状态跟踪新产品查询不需要自己实现这套基础设施。如何实现一个 Query Runner按原文档的四个步骤在 frontend/src/queries/schema/schema-general.ts或frontend/src/types.ts中定义查询与响应的 schema 类型——例如EventsQuery EventsQuery即在此定义见该文件 L89。注意Python 端的schema.py由这些 TS 类型自动生成不要直接手改 Python 端。创建继承QueryRunner或AnalyticsQueryRunner的 runner 类。实现_calculate()构建并执行 HogQL 查询如需获取编译后的 SQL 而不执行则实现/调用to_query()基类中两者分别位于 query_runner.py 与 query_runner.py。在 get_query_runner() 中注册你的 runner——该分发函数按kind字段路由到具体 runner 类并支持对DataTableNode/InsightVizNode等包装节点递归解包到其source。参考实现EventsQueryRunner最干净的参考示例是 posthog/hogql_queries/events_query_runner.py 的EventsQueryRunner它继承AnalyticsQueryRunner[EventsQueryResponse]在__init__中用HogQLHasMorePaginator处理分页分页上限由limit_context推导实现to_query()L262与_calculate()L524并在select_cols()中演示了*展开、person 列二次查询等真实细节。新产品的查询几乎都可以照葫芦画瓢。使用可时间排序的 IDUUIDv7如果你的产品要把数据写入 ClickHouse行 ID 应优先选择UUIDv7。PostHog 提供了 Python 与 TypeScript 两套实现Pythonposthog/uuidt.py中的uuid7()函数posthog/uuidt.py。其实现完全遵循 RFC 9562 布局高 48 位为 Unix 毫秒时间戳随后 4 位版本号ver 7、12 位随机位、2 位变体位var 0b10、低 62 位随机位时间戳部分支持传入 ISO 字符串或毫秒整数也支持注入随机源以便测试。文件开头的UUIDT类已被标记为 Deprecated明确注释新列/新模型/新功能应使用 UUIDv7。TypeScriptnodejs/src/utils/utils.ts中的UUID7类L260-L305 区间。对应的单元测试见 posthog/models/test/test_utils.py断言uuid7().version 7并验证传入固定时间戳与随机源时生成结果可复现。为什么这很重要ClickHouse 表只有一个主索引大致对应行在磁盘上的存储顺序没有传统关系型数据库意义上的二级索引。而你的产品几乎必然需要支持两种访问模式按 ID 查找——取某一行按时间范围聚合——按时间戳过滤的分析查询。由于 ClickHouse 只能高效地在主键顺序上进行过滤你的 ID 必须同时编码时间戳。UUIDv7 正好解决这个问题前 48 位是毫秒级 Unix 时间戳行天然按时间有序同一时间桶内的记录在物理存储上也彼此邻近两种访问模式都能吃到主索引红利。规范示例session IDssessions v3 表是这一模式的规范示例。原文档给出的字段定义-- Both UInt128 and UUID are imperfect choices here -- see https://michcioperz.com/wiki/clickhouse-uuid-ordering/ -- but also see https://github.com/ClickHouse/ClickHouse/issues/77226 and hope session_id_v7 UInt128,注意这里的类型是UInt128而不是UUID原因见下一节。ClickHouse 的 UUID 排序有问题——请使用 UInt128ClickHouse 目前对 UUID 的排序是不正确的其内部表示会交换高、低两个 64 位字导致ORDER BY uuid_column对 UUIDv7 无法产生时间顺序这是 ClickHouse 社区已知问题详见 ClickHouse issue #77226 与相关博客分析。解决办法是把 UUIDv7 以UInt128存储而不是UUID。转换方式有两种查询时转换reinterpretAsUInt128(toUUID(...))插入时转换用物化列在数据层完成转换。PostHog 在数据层落地的示例是迁移 0109_materialize_session_ids_uuid.pyALTER TABLE {table} ADD COLUMN IF NOT EXISTS $session_id_uuid Nullable(UInt128) MATERIALIZED toUInt128(JSONExtract(properties, $session_id, Nullable(UUID)))它把 events 表propertiesJSON 中的$session_id抽取为Nullable(UInt128)物化列同时服务于 ID 查找与时间排序两个目的。HogQL 侧还提供了成套的 UUID 表达式工具posthog/hogql/database/schema/util/uuid.pyuuid_string_expr_to_uuid_expr()、uuid_string_expr_to_uint128_expr()、uuid_expr_to_timestamp_expr()用UUIDv7ToDateTime从 UUIDv7 还原时间戳以及兼容 sessions v2 主键顺序的uuid_uint128_expr_to_timestamp_expr_v2()——它通过bitShiftRight(uuid, 80) / 1000手工从 UInt128 中切出毫秒时间戳。何时不使用 UUIDv7选择其他格式需要充分的理由。最大的例外是person IDs它们使用 UUIDv5通过uuidFromDistinctId()基于(team_id, distinct_id)确定性生成。这之所以关键是因为同一个用户在 identify 调用前后必须得到同一个 UUID——身份合并要求 ID 可重复推导这一确定性需求压倒了时间可排序性的收益。查询性能上线前的三道关确保相关列已物化如果你的产品频繁按某个属性过滤或分组应确保该属性拥有物化列。物化列把 JSON 属性值作为独立列存储在磁盘上读取最多快 25 倍。PostHog 有一个定时任务cron job见analyze.py会分析慢查询并自动物化属性但对新产品而言对于你已知会被高频查询的属性应主动通过 ClickHouse 迁移创建物化列原文档建议参考 migration 0147 这种同时添加物化列与 bloom filter 索引的迁移写法本仓库中0109迁移的ADD COLUMN ... MATERIALIZED语法是同一个模式的最小示例。更多细节见 materialized columns 手册页。考虑添加跳过索引skip indexesClickHouse 数据跳过索引让引擎可以跳过确定不匹配查询条件的 granule行块。常见类型minmax记录每个 granule 的最小/最大值适合时间戳或数值列。示例迁移 0237 为$session_id_uuid添加 minmax 索引0237_readd_session_id_uuid_minmax_index.pyALTER TABLE sharded_events ADD INDEX IF NOT EXISTS minmax_$session_id_uuid $session_id_uuid TYPE minmax GRANULARITY 1该迁移文件还记录了一个真实的运维教训原迁移 0222 因is_alter_on_replicated_table路由问题在生产环境只有 4/10 的分片真正加上了索引于是以IF NOT EXISTS 分片级执行的方式重跑——这正说明索引是否真的生效必须被显式验证见下一节。bloom_filter针对高基数列上等值与 IN 查找的概率性索引。示例migration 01840184_sharded_events_add_distinct_id_bloom_filter_index.py为distinct_id添加 bloom filter。Bloom filter 还支持 Map 列——可以分别索引mapKeys(my_map)与mapValues(my_map)以加速 map 类型列的查找Logs 表与 spans 表即采用此模式property_groups.py中封装了可复用的实现。ngrambf_v1面向substring/ILIKE部分匹配搜索的 n-gram bloom filter适合日志正文、邮箱、URL 等用户会做模糊搜索的文本列。Logs 表用ngrambf_v1(3, 25000, 2, 0)索引lower(body)spans 表索引 span name。对于物化属性列PostHog 提供了可复用的NgramLowerIndex辅助器用于规避 ClickHouse 在大小写不敏感必须包lower()与Nullable列必须包coalesce()上的限制。测试跳过索引确实被使用加了索引就要写测试断言它被用到——未被测试的跳过索引可能在 schema 变更后悄悄失效给你虚假的安全感。PostHog 为此提供了测试助手get_index_from_explain()实现在 posthog/test/base.py它对编译后的 HogQL 查询执行EXPLAIN PLAN indexes1,json1递归遍历执行计划中的所有Indexes数组按名称查找指定跳过索引并返回其信息找不到则返回None。配套的materialized()上下文管理器posthog/test/base.py则在一个受管块内物化指定属性可选附带 minmax / bloom_filter / ngram 索引退出时自动清理。原文档给出的标准测试模式出自test_printer.pyfrom posthog.test.base import get_index_from_explain, materialized from ee.clickhouse.materialized_columns.columns import get_minmax_index_name def test_skip_index_is_used(self): with materialized(events, test_prop, create_minmax_indexTrue) as mat_col: result execute_hogql_query( teamself.team, querySELECT distinct_id FROM events WHERE properties.test_prop target_value, ) index_name get_minmax_index_name(mat_col.name) assert get_index_from_explain(result.clickhouse, index_name), ( fExpected skip index {index_name} to be used )仓库中的真实测试印证了这一模式test_printer.py中对minmax_$session_id_uuid索引的断言Expected minmax_$session_id_uuid skip index to be used、对 minmax / bloom_filter / ngram 索引的多种比较算子,,ILIKE等用例都调用get_index_from_explain校验。此外还可以用forceClickhouseDataSkippingIndexesmodifier 让 ClickHouse 在无法应用指定索引时报错作为生产环境安全网from posthog.schema import HogQLQueryModifiers, MaterializationMode result execute_hogql_query( teamself.team, querySELECT distinct_id FROM events WHERE properties.test_prop foo, modifiersHogQLQueryModifiers( materializationModeMaterializationMode.AUTO, forceClickhouseDataSkippingIndexes[index_name], ), ) # ClickHouse will raise an error if the index cant be applied另外还有更低层的 EXPLAIN 分析工具explain.py中的find_all_reads()、guestimate_index_use()、execute_explain_get_index_use()及对应测试 posthog/clickhouse/test/test_explain.py后者对一组真实 EXPLAIN 计划做快照断言统计每个查询的ReadFromMergeTree读取数并逐项评估索引使用是否达标reads_use。调试查询性能六步工作流上线前应在接近真实的数据量下验证查询表现。以下是原文档给出的完整工作流。Step 1把你的产品加入 demo 数据生成generate_demo_data管理命令基于 Matrix 仿真框架生成逼真的数据。不要新建 Matrix 子类开销太大而是把产品的 events 与属性添加到现有的HedgeboxMatrix——它是默认仿真器已生成丰富的用户、会话与行为模式你只需把产品事件加进去。python manage.py generate_demo_data --n-clusters 10按需调整--n-clusters数值越大数据越多、耗时越长。结合命令源码posthog/management/commands/generate_demo_data.py还有一批值得了解的参数--days-past仿真从现在往前追溯的天数默认 120--days-future仿真延续到现在之后的天数默认 30--product选择仿真器默认hedgebox备选spikegpt--team-id写入指定已有项目默认新建用户与项目--seed/--now固定随机种子与仿真起始时间便于复现--skip-migration-check跳过迁移预检预检会在有未应用的 Postgres 迁移时拒绝执行。这一步能给你一个足以暴露性能问题而非只有几行数据的本地开发环境。Step 2获取编译后的 ClickHouse SQL调试性能你需要看 HogQL 实际编译出来的 SQL有三种途径从 query runner调用runner.to_query()拿到编译后的 SQL 而不执行从execute_hogql_query()返回的HogQLQueryResponse带有.clickhouse字段即编译后的 SQL从 PostHog UI在 SQL 编辑器中打开查询点击 Show ClickHouse SQL。Step 3用 EXPLAIN 检查索引与分区使用拿到编译 SQL 后交给 ClickHouse 的 EXPLAIN 看查询规划器将如何执行EXPLAIN PLAN indexes1, json1 SELECT ...your compiled query...关键选项indexes1显示使用了哪些索引主键、分区键、跳过索引以及各自过滤掉了多少 granulejson1输出结构化 JSON便于程序化解析。在输出中关注每个ReadFromMergeTree节点上的Indexes数组每个索引条目包含TypeMinMax、Partition、PrimaryKey或某个跳过索引名Condition实际应用的过滤条件若为true说明索引没有发挥作用Initial Granules应用该索引前的 granule 数Selected Granules应用后的 granule 数应远小于 Initial Granules。还可以运行管道分析查看执行计划与并行度EXPLAIN PIPELINE SELECT ...your compiled query...这能暴露诸如单线程聚合阶段之类的瓶颈。Step 4带 trace 日志运行查询要看 ClickHouse 执行期间的真实行为——读取了多少数据、哪些 part 慢、时间花在哪里——用clickhouse-client以 trace 级别日志运行clickhouse-client --send_logs_leveltrace --query SELECT ...your compiled query...trace 输出会展示从每个 part 读取了多少 granule 与行应用了哪些索引及其效果解压了多少数据每个管道阶段耗时。Step 5检查 query_log 的执行统计查询结束后可在system.query_log查看执行统计SELECT query_duration_ms, read_rows, read_bytes, result_rows, memory_usage, ProfileEvents[SelectedMarks] AS selected_marks, ProfileEvents[SelectedRanges] AS selected_ranges FROM system.query_log WHERE type QueryFinish ORDER BY event_time DESC LIMIT 1如果read_rows比result_rows大几个数量级说明过滤下推不理想大概率需要更好的索引。Step 6寻求第二意见至少把EXPLAIN PLAN indexes1, json1的输出与编译后的 SQL 交给一个 LLM 做 sanity check请它判断主键/分区键索引是否被有效使用是否存在本不该发生的全表扫描跳过索引是否被应用查询是否能从不同的排序或额外索引中获益。这是在问题进入生产前抓住明显性能缺陷的快捷方式。关键源码索引主题仓库路径team_id 守卫实现posthog/hogql/printer/clickhouse.pyQueryRunner 基类posthog/hogql_queries/query_runner.pyRunner 分发注册posthog/hogql_queries/query_runner.py参考实现 EventsQueryRunnerposthog/hogql_queries/events_query_runner.py刷新频率策略示例trends_query_runner.pyPython uuid7() 实现posthog/uuidt.pyUUID↔UInt128/时间戳表达式posthog/hogql/database/schema/util/uuid.pysession ID 物化列迁移0109_materialize_session_ids_uuid.pyminmax 跳过索引迁移0237_readd_session_id_uuid_minmax_index.py索引测试助手posthog/test/base.pyEXPLAIN 计划分析测试posthog/clickhouse/test/test_explain.pydemo 数据生成命令posthog/management/commands/generate_demo_data.py前端查询 schema 定义frontend/src/queries/schema/schema-general.ts小结新产品的查询开发清单把上述原则浓缩成一张可直接执行的检查清单语言一律用 HogQL获得自动team_id守卫、物化属性改写与 PoE 处理架构查询定义在 Python 后端 QueryRunner 中获取内置缓存、可观测性与可测试性ID新表行 ID 用 UUIDv7以UInt128存储规避 ClickHouse UUID 排序缺陷person 等需确定性 ID 的场景例外列对高频过滤/分组的属性主动建物化列索引按访问模式选择minmax/bloom_filter/ngrambf_v1并用get_index_from_explain写测试断言索引真的被使用验证上线前按六步工作流demo 数据 → 编译 SQL → EXPLAIN → trace → query_log → LLM 复核确认查询在真实数据量下表现良好。这套实践不是一次性动作而是 PostHog 每个新数据产品session replay、logs、error tracking 等迭代时的持续约束——它把安全、高效、可验证固化到了查询开发的每个环节里。【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表