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 条核心编码规范:
只请求需要的字段:
SELECT name, surname优于SELECT *(后者仅在少数边界场景下有用)。只请求需要的行:在查询末尾使用
LIMIT条件。(尽可能)避免显式事务:如果无法避免,务必保持事务短小——事务会锁住正在处理的数据表,并可能导致死锁(强烈不建议在应用热路径中使用事务)。
(尽可能)避免
JOIN。避免使用子查询:子查询是嵌入在另一条 SQL 语句某个子句中的
SELECT语句,写起来更简单,但JOIN通常能被数据库引擎优化得更好。使用合适的数据类型:并非所有类型占用空间相同,使用具体类型时还应按存储内容限制其大小。例如
VARCHAR(4000)与VARCHAR(40)完全不同。应始终根据字段将要存储的内容来调整,避免在数据库中占用不必要空间(并应在应用代码中强制该限制,避免查询报错)。仅在必要时使用
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)、type、group_type_index与query_usage_30_day降序排序的多列索引(index_property_def_query_proj),直接服务属性定义的检索与热度排序查询。
如何发现慢查询
在生产环境中查找并调试慢查询,有几个可选方案:
- AWS Console > Aurora 和 RDS > Performance insights:AWS 托管的性能洞察面板;
- pganalyze query performance:专业的 Postgres 查询性能分析工具。
如何修复慢查询
修复慢查询通常是一个3 步过程:
定位生成慢查询的代码位置:将堆栈跟踪(stacktrace)作为查询注释(query comments)附加,通常有助于将查询映射到代码。
用
EXPLAIN重新执行查询获取查询计划:EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON)查询计划并不容易阅读——它信息量巨大,更接近机器可解析而非人类可读。Postgres Explain Viewer 2(pev2)是简化阅读查询计划的工具:它以水平树展示每个节点(对应查询计划中的一个节点),包含时序信息、计划时间与实际时间的误差量,并为“成本最高(costliest)”或“估算偏差(bad estimate)”等有趣节点提供徽章标记。
修复查询,修复后应当生成成本更低的
EXPLAIN计划。
从源码看「查询注释」的落地方式
文档建议"将堆栈跟踪作为查询注释附加",这一实践在 ClickHouse 客户端中已有对应实现:在 posthog/clickhouse/client/execute.py 中,sync_execute会通过get_caller_source()捕获调用方的源文件与行号,并连同查询标签一起写入log_comment(JSON 格式),使得每条查询都能从 ClickHouse 的system.query_log中反查到对应的应用代码位置。
如何减少 IO
索引需要 IO:通过移除未使用的索引可以减少部分 IO。
检查写入 IO,例如用以下 SQL:
SELECT total_time, blk_write_time, calls, query FROM pg_stat_statements ORDER BY (blk_write_time) DESC LIMIT 10;- SELECT 也可能产生写入 IO:由于 MVCC 机制,PostgreSQL 中的 SELECT 查询在特定场景下(如 HOT 更新、同步复制、页面清理)同样可能触发磁盘写入。
移除外键字段上未使用的索引
假设你在team_id、person_id上建了复合索引。如果team_id和person_id是 Django 外键,Django 会自动为team_id和person_id各自创建独立索引。但根据 PostgreSQL 多列索引文档,复合索引可以同时覆盖team_id与person_id的查询,因此我们可以通过添加db_index=False来避免额外建立这两个索引。
这一点在仓库中有直接印证:在 posthog/models/person/person.py#L429-L436 中,PersonDistinctId.team显式声明了db_index=False,而复合外键(team_id, person_id)的约束在数据库层手动管理,既避免了冗余单列索引,又利用了分区裁剪。
移除外键字段
不要立即移除外键字段——这是向后不兼容的操作。应先做一次弃用(deprecation)流程,让收益先落地:先获得"不再有索引和约束"的好处,再逐步移除。
操作步骤:
- 将例如
foreign_key_field重命名为__deprecated_foreign_key_field,并添加db_column=foreign_key_field,使得模型外部的引用必须使用完整限定名(保留该字段是为了让 Django 不会尝试创建删除迁移); - 等待一个发布周期的字段弃用期;
- 在下个发布版本中彻底移除字段,并提示用户通过弃用版本进行升级,以保证运行中的代码兼容。
原文档注记: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)删除索引,而是一次阻塞性操作。要解决这个问题,需要修改迁移:
- 使用
SeparateDatabaseAndState让 Django 在状态层面跟踪模型的数据库结构,同时允许我们自行控制索引的创建方式; - 使用
RemoveIndexConcurrently以非阻塞方式删除索引。
PostHog 仓库中有两个非常典型的真实案例:
案例一:外键自动索引的非并发删除(0212 迁移)
在 posthog/migrations/0212_alter_persondistinctid_team.py 中,原生成的AlterField迁移会执行阻塞式的DROP INDEX(如文件注释中展示的sqlmigrate输出)。工程团队将其改写为SeparateDatabaseAndState:state_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;如果正在更新已有字段,则需要同步生成必要的迁移。
这一实践在仓库中同样有据可查:PersonDistinctId的team字段使用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 的查询性能优化可以沉淀为一条可重复执行的流水线:
- 选对引擎:OLTP 可预测查询走 PostgreSQL,OLAP 大规模分析走 ClickHouse;
- 写对查询:只取所需字段与行、避免事务与子查询、选择合适数据类型、善用复合索引;
- 找对慢查询:生产环境用 Performance Insights / pganalyze / Grafana /
system.query_log/ 内部指标面板定位问题查询; - 修对慢查询:用
EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON)+ pev2 分析执行计划,定位代码调用点,针对性改写; - 持续治理 IO 与索引:周期性清理未使用索引(用
pg_stat_user_indexes排查),以SeparateDatabaseAndState+RemoveIndexConcurrently非阻塞迁移,以db_index=False/db_constraint=False消除冗余索引与不必要的锁。
这套方法论既有文档层面的最佳实践,又有仓库中真实迁移文件(如0212、0532、0035)与模型定义(如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),仅供参考