PostHog 查询性能优化实战:PostgreSQL 与 ClickHouse 双引擎的规模化调优指南
2026/9/12 15:24:17 网站建设 项目流程

PostHog 查询性能优化实战:PostgreSQL 与 ClickHouse 双引擎的规模化调优指南

【免费下载链接】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 在规模化运行中能否保持快速响应,直接关系到产品体验。本文以 PostHog 工程手册中的查询性能优化文档 为核心,系统梳理其两大存储引擎(PostgreSQL 与 ClickHouse)的查询性能最佳实践:从编码规范、索引设计、慢查询定位与修复,到索引回收、锁规避等生产级实操,并结合当前仓库的真实源码与迁移文件进行佐证。读完本文,你将掌握一套可直接复用的 PostHog 数据库调优方法论。

存储引擎选型:什么时候用 PostgreSQL,什么时候用 ClickHouse

PostHog 同时使用两种不同类型的数据库,它们面向完全不同的访问模式。理解二者的边界是性能优化的第一步:

  • PostgreSQL:行式存储的 OLTP 数据库,主要用于以可预测的查询条件访问和查询数据集。它更可能是你的最佳选择,如果:

    • 访问数据集时的查询模式是可预测的;
    • 数据集规模预计不会超过 1 TB
    • 数据集需要频繁变更DELETE/UPDATE);
    • 查询模式需要在多个表之间进行 JOIN
  • ClickHouse:列式存储的 OLAP 数据库,用于存储大规模数据集并对其执行分析型查询。它更可能是你的最佳选择,如果:

    • 访问数据集时的查询模式是不可预测的;
    • 数据集规模预计会增长到 1 TB 以上
    • 数据集不需要频繁变更DELETE/UPDATE);
    • 查询模式不需要跨多表 JOIN

从源码结构看,这一分工在仓库中体现得非常清晰:PostgreSQL 侧由 Django ORM 管理 posthog/models 目录下的模型,承担团队、用户、Person、事件定义等业务数据存储;而 ClickHouse 侧则由 posthog/clickhouse 目录承载大规模事件分析查询,两者职责边界明确。

PostgreSQL 查询优化

编码最佳实践

对于使用 Django 的应用层,PostHog 的工程实践总结了 7 条核心编码规范:

  1. 只请求需要的字段SELECT name, surname优于SELECT *(后者仅在少数边界场景下有用)。

  2. 只请求需要的行:在查询末尾使用LIMIT条件。

  3. (尽可能)避免显式事务:如果无法避免,务必保持事务短小——事务会锁住正在处理的数据表,并可能导致死锁(强烈不建议在应用热路径中使用事务)。

  4. (尽可能)避免JOIN

  5. 避免使用子查询:子查询是嵌入在另一条 SQL 语句某个子句中的SELECT语句,写起来更简单,但JOIN通常能被数据库引擎优化得更好。

  6. 使用合适的数据类型:并非所有类型占用空间相同,使用具体类型时还应按存储内容限制其大小。例如VARCHAR(4000)VARCHAR(40)完全不同。应始终根据字段将要存储的内容来调整,避免在数据库中占用不必要空间(并应在应用代码中强制该限制,避免查询报错)。

  7. 仅在必要时使用LIKE运算符:如果你确切知道要找什么,请使用=运算符。

注:对于 Django 应用,PostHog 目前依赖 Django ORM 作为数据与关系数据库之间的接口。虽然此时不直接编写 SQL 查询,但上述最佳实践仍应予以考虑。

打印执行查询的调试技巧

在 Django 中,若想以DEBUG模式运行并打印已执行的查询,可以执行:

from django.db import connection print(connection.queries)

对于单条查询,可以执行:

print(Model.objects.filter(name='test').query)

索引设计

如果你以编程方式对某列进行排序(ordering)、排序(sorting)或分组(grouping),那么很可能应该在该列上建立索引。注意事项:索引会拖慢表的写入速度并占用磁盘空间(请务必删除未使用的索引)。

复合索引在需要针对多个非条件列进行查询优化时非常有用。关于单列索引和多列索引的更多信息,可参阅 PostgreSQL 官方文档。

从源码看 PostHog 的索引实践

PostHog 在真实迁移中大量使用复合索引来支撑高频查询路径。例如在 posthog/migrations/0532_taxonomy_unique_on_project.py 中,就为propertydefinition构建了包含coalesce(project_id, team_id)typegroup_type_indexquery_usage_30_day降序排序的多列索引(index_property_def_query_proj),直接服务属性定义的检索与热度排序查询。

如何发现慢查询

在生产环境中查找并调试慢查询,有几个可选方案:

  • AWS Console > Aurora 和 RDS > Performance insights:AWS 托管的性能洞察面板;
  • pganalyze query performance:专业的 Postgres 查询性能分析工具。

如何修复慢查询

修复慢查询通常是一个3 步过程

  1. 定位生成慢查询的代码位置:将堆栈跟踪(stacktrace)作为查询注释(query comments)附加,通常有助于将查询映射到代码。

  2. EXPLAIN重新执行查询获取查询计划

    EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON)

    查询计划并不容易阅读——它信息量巨大,更接近机器可解析而非人类可读。Postgres Explain Viewer 2(pev2)是简化阅读查询计划的工具:它以水平树展示每个节点(对应查询计划中的一个节点),包含时序信息、计划时间与实际时间的误差量,并为“成本最高(costliest)”或“估算偏差(bad estimate)”等有趣节点提供徽章标记。

  3. 修复查询,修复后应当生成成本更低的EXPLAIN计划。

从源码看「查询注释」的落地方式

文档建议"将堆栈跟踪作为查询注释附加",这一实践在 ClickHouse 客户端中已有对应实现:在 posthog/clickhouse/client/execute.py 中,sync_execute会通过get_caller_source()捕获调用方的源文件与行号,并连同查询标签一起写入log_comment(JSON 格式),使得每条查询都能从 ClickHouse 的system.query_log中反查到对应的应用代码位置。

如何减少 IO

  1. 索引需要 IO:通过移除未使用的索引可以减少部分 IO。

  2. 检查写入 IO,例如用以下 SQL:

SELECT total_time, blk_write_time, calls, query FROM pg_stat_statements ORDER BY (blk_write_time) DESC LIMIT 10;
  1. SELECT 也可能产生写入 IO:由于 MVCC 机制,PostgreSQL 中的 SELECT 查询在特定场景下(如 HOT 更新、同步复制、页面清理)同样可能触发磁盘写入。
移除外键字段上未使用的索引

假设你在team_idperson_id上建了复合索引。如果team_idperson_id是 Django 外键,Django 会自动为team_idperson_id各自创建独立索引。但根据 PostgreSQL 多列索引文档,复合索引可以同时覆盖team_idperson_id的查询,因此我们可以通过添加db_index=False来避免额外建立这两个索引。

这一点在仓库中有直接印证:在 posthog/models/person/person.py#L429-L436 中,PersonDistinctId.team显式声明了db_index=False,而复合外键(team_id, person_id)的约束在数据库层手动管理,既避免了冗余单列索引,又利用了分区裁剪。

移除外键字段

不要立即移除外键字段——这是向后不兼容的操作。应先做一次弃用(deprecation)流程,让收益先落地:先获得"不再有索引和约束"的好处,再逐步移除。

操作步骤:

  1. 将例如foreign_key_field重命名为__deprecated_foreign_key_field,并添加db_column=foreign_key_field,使得模型外部的引用必须使用完整限定名(保留该字段是为了让 Django 不会尝试创建删除迁移);
  2. 等待一个发布周期的字段弃用期;
  3. 在下个发布版本中彻底移除字段,并提示用户通过弃用版本进行升级,以保证运行中的代码兼容。

原文档注记:TODO——想办法让 SELECT 查询不再请求该字段(即最终能够真正 drop 列)。

查找并移除未使用的索引

如何知道索引是否被使用?可以执行类似下面的 SQL:

SELECT s.schemaname, s.relname AS tablename, s.indexrelname AS indexname, pg_relation_size(s.indexrelid) AS index_size FROM pg_catalog.pg_stat_user_indexes s JOIN pg_catalog.pg_index i ON s.indexrelid = i.indexrelid WHERE s.idx_scan = 0 -- has never been scanned ORDER BY pg_relation_size(s.indexrelid) DESC;

如果索引确实未被使用,可以通过移除db_index=False(即恢复为默认建索引行为,配合删除对应索引声明)并运行./manage.py makemigration来安全移除。

这会生成一个迁移,但如果你查看./manage.py sqlmigrate的输出,会发现它可能不是并发(CONCURRENTLY)删除索引,而是一次阻塞性操作。要解决这个问题,需要修改迁移:

  1. 使用SeparateDatabaseAndState让 Django 在状态层面跟踪模型的数据库结构,同时允许我们自行控制索引的创建方式;
  2. 使用RemoveIndexConcurrently以非阻塞方式删除索引。

PostHog 仓库中有两个非常典型的真实案例:

案例一:外键自动索引的非并发删除(0212 迁移)

在 posthog/migrations/0212_alter_persondistinctid_team.py 中,原生成的AlterField迁移会执行阻塞式的DROP INDEX(如文件注释中展示的sqlmigrate输出)。工程团队将其改写为SeparateDatabaseAndStatestate_operations中声明db_index=False保持 Django 状态同步,database_operations中使用DROP INDEX CONCURRENTLY IF EXISTS执行真正的非阻塞删除。注释还指出:django.contrib.postgres.operations.RemoveIndexConcurrently似乎只对显式索引生效,对ForeignKey自动生成的索引并不适用,因此这里改用RunSQL

案例二:大规模索引调整(0532 迁移)

在 posthog/migrations/0532_taxonomy_unique_on_project.py 中,迁移以atomic = False声明(这是并发索引操作的前提),先后使用RemoveIndexConcurrently移除 4 个冗余的project_id单列索引,再用AddIndexConcurrently创建基于coalesce(project_id, team_id)的新复合索引,全程不阻塞线上读写。ee/migrations/0035_conversation_slack_index.py中则展示了另一条路径:用SeparateDatabaseAndState配合手写CREATE UNIQUE INDEX CONCURRENTLY创建部分唯一索引(partial unique index),并同时保证 Django 状态与原始 SQL 同步。

避免相关表上的锁

例如在批量插入(bulk insert)时,可能需要从被引用表中选出大量主键。当我们并不真正关心这些关联约束时,可以指定db_constraint=False;如果正在更新已有字段,则需要同步生成必要的迁移。

这一实践在仓库中同样有据可查:PersonDistinctIdteam字段使用on_delete=models.DO_NOTHING, db_constraint=False(团队删除由人工处理,可能跨数据库);person字段也使用db_constraint=False,其复合外键约束在数据库层手动管理,见 posthog/models/person/person.py#L431-L436。此外 posthog/models/user_facet_settings.py 中team外键同样组合使用db_constraint=False, db_index=False

ClickHouse 查询优化

如何发现慢查询

在生产环境中查找并调试慢查询,有以下几个可选方案:

Grafana

ClickHouse queries - by endpoint仪表盘提供了可靠性与性能维度的拆分视图。高频使用且缓慢/不可靠的端点,往往暗示其背后的查询存在问题。

PostHoginstance/status仪表盘

instance/status的内部指标页面下可以找到各种指标与查询日志。如果你是 staff 用户,还可以通过点击(或复制自己的查询)来分析查询。

分析输出包含:

  • 查询运行时间(Query runtime)
  • 读取的行数 / 字节数(Number of rows read / Bytes read)
  • 内存使用量(Memory used)
  • CPU、时间与内存的火焰图(Flamegraphs for CPU, time and memory)

这些信息对定位查询为什么变慢非常有用。

Metabase

如果以上仪表盘提供的查询粒度不够细,可以使用 Metabase 查询。ClickHouse 的system表(例如system.query_log)提供了大量用于识别和诊断慢查询的有用信息。

从源码看,system.query_log的定位价值在 PostHog 中已被工程化:如 posthog/clickhouse/client/execute.py 通过log_comment注入来源文件与行号,正是为了能在system.query_log中快速把慢查询映射回代码调用点。

如何修复慢查询

修复 ClickHouse 慢查询的方法与技巧,可参阅 PostHog 工程手册中的 ClickHouse 专题文档(clickhouse 手册 相关的工程实践)。

小结:一套可落地的性能优化流程

综合文档与仓库实践,PostHog 的查询性能优化可以沉淀为一条可重复执行的流水线:

  1. 选对引擎:OLTP 可预测查询走 PostgreSQL,OLAP 大规模分析走 ClickHouse;
  2. 写对查询:只取所需字段与行、避免事务与子查询、选择合适数据类型、善用复合索引;
  3. 找对慢查询:生产环境用 Performance Insights / pganalyze / Grafana /system.query_log/ 内部指标面板定位问题查询;
  4. 修对慢查询:用EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON)+ pev2 分析执行计划,定位代码调用点,针对性改写;
  5. 持续治理 IO 与索引:周期性清理未使用索引(用pg_stat_user_indexes排查),以SeparateDatabaseAndState+RemoveIndexConcurrently非阻塞迁移,以db_index=False/db_constraint=False消除冗余索引与不必要的锁。

这套方法论既有文档层面的最佳实践,又有仓库中真实迁移文件(如021205320035)与模型定义(如PersonDistinctId)的代码级印证,可直接用于 PostHog 自托管部署或贡献者开发环境中的数据库性能治理。

【免费下载链接】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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询