PostHog ClickHouse 数据删除覆盖机制解析:从deletion_targets.py到跨集群清扫(Sweep)设计
【免费下载链接】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
导读:删除某个用户的数据并不是 events 表的属性,而是每一张“存有可归属到该用户的行”的表的属性。PostHog 用posthog/models/deletion_targets.py维护了一张唯一的“个人数据目标表”清单,并在其上构建了一整套按集群分片清扫(sweep)、能力位(capability flag)、跨集群词典(dictionary)暂存与“拒绝优于静默欠删”的校验门。本文以 docs/internal/clickhouse-deletion-coverage.md 为骨架,结合源码实现,讲清六类删除清扫如何作用于每一张表、哪些表只依赖 TTL、已知缺口在哪里,以及如何合规地注册一张新表。
为什么删除不是 events 表的事
在 PostHog 中,一条AsyncDeletion请求(删除个人、删除团队、按 uuid 排空的队列)最终要落到 ClickHouse。而“删除”这一动作的语义必须贯穿所有存有个人数据的物理表,否则会出现“请求标记成功,但行仍在另一张表上存活”的静默欠删。
这份文档的答案是:
- 所有可删除的物理表统一注册在
posthog/models/deletion_targets.py的PERSONAL_DATA_TARGETS中; - 每张表都声明自己的能力字段(是否支持属性改写、是否存有 person_properties、位于哪个集群等),供调用方在需要“比 schema 无关谓词更多”的能力时,对无法承载的表响亮地失败,而不是静默跳过;
- 只靠 TTL 回收的表单独登记在
TTL_ONLY_TABLES,接受“删除可能滞后一个保留窗口”的决策; - 一张测试 posthog/clickhouse/test/test_deletion_coverage.py 强制这个决策必须被显式做出:任何声明了
person_properties列、却既不在PERSONAL_DATA_TARGETS也不在TTL_ONLY_TABLES中的存储表都会让测试失败。
# 测试的核心断言(posthog/clickhouse/test/test_deletion_coverage.py) # 找出所有声明 person_properties 的 MergeTree 存储表(排除 tmp_ 暂存表), # 要求它们要么被扫描(PERSONAL_DATA_TARGETS),要么被豁免(TTL_ONLY_TABLES)。 accounted_for = {target.data_table for target in PERSONAL_DATA_TARGETS} | TTL_ONLY_TABLES unaccounted = sorted(storage_tables - accounted_for) assert not unaccounted, "..."六类清扫(sweep)与它们的能力边界
文档给出了一张清扫总表。前四类清扫的谓词只使用所有目标表都会声明的列(team_id、person_id、timestamp、uuid、event等),因此可以原样作用于任何已注册表;后两类需要更多列,这正是DeletionTarget能力字段存在的原因。
| 清扫 | 入口 | 谓词列 |
|---|---|---|
| 个人删除(异步) | deletes_job→delete_events | team_id,person_id,timestamp |
| 团队删除 | deletes_job→delete_events | team_id |
| 排队 uuid 排空 | deletes_job→delete_events | team_id,uuid |
| 个人移除请求 | delete_person_events_op | team_id,person_id,timestamp |
| 事件移除请求 | execute_event_deletion | team_id,timestamp,event, + HogQL |
| 属性移除请求 | process_property_removal_shard | properties,person_properties, + HogQL |
这些入口全部位于 posthog/dags/data_deletion_requests.py(个人/事件/属性移除)与 posthog/dags/deletes.py(deletes_job)中。
一个值得注意的细节:团队删除在“复制(replicated)而非分片(sharded)”的表上走另一条独立的按表循环—— deletes.py 中的delete_team_data_from,它只派发到单个 host。分片表绝不能注册到这条路径,否则只会清扫一个分片;分片表的团队删除由delete_events内的 team 分支覆盖。
Reach:一个 handle 只对应一个集群
文档反复强调一个不对称性:
ClickhouseClusterhandle 恰好只寻址一个集群。hosts 来自clusterAllReplicas(<name>, system.clusters) WHERE name = <name> AND is_local,并且只有hostClusterRole宏为data的 host 才被赋予分片号;cluster.shards、map_one_host_per_shard、map_any_host_in_shards都只枚举这些 host。传入data_cluster=是替换分片映射而不是合并第二个映射,因此单个 handle 无法横跨两个集群。- Distributed 表没有这个限制,它路由到引擎指定的任意集群。这就是“集群外存储表”的危险所在:清扫发现无表可改并报告成功,而验证所经的代理(proxy)仍然能读到这些行。
placement_for(见 deletion_targets.py)就是防止这种静默成功的第一个门:如果一个已注册目标的存储表不在本 handle 可达的任何 data 节点上,而其 Distributed 代理仍返回行,则抛出UnreachableTargetError。表完全不存在且为空则被视为“尚未迁移”,这是滚动上线前的正常状态。
两个校验门与代理计数
assert_sweep_complete在个人移除与事件移除清扫完成后立即运行,通过代理统计存活行;任何未被变更触达的行都会让请求失败(UnsweptRowsError)而不是完成。deletes_job在自己的清扫后用同样的方式计数,但只记录日志并把请求标记为已验证—— 因为它的变更已经执行,此时让操作失败只会搁浅运行而无法撤销任何东西。证明存活为零是全表扫描,因此该计数是限时的,超时报告unknown而非0。- 代理只读取其引擎指定的集群,而
sql.py构建的events_json代理针对CLICKHOUSE_CLUSTER。所以存储在另一个集群的目标会被计数两次:一次经代理,一次经持有它的 handle 在存储表上计数。缺少第二次计数时,一个“存储已迁移但代理未跟随”的部署会在空表上报告一次“干净的清扫”。
两个门都探测 host 而不是比较集群名:两个集群名可能覆盖相同的节点(开发栈与 CI 正是如此),比较名字会拒绝实际上可以清扫表的部署。DeletionTarget.cluster_setting只指名存储表所在集群,ClickhouseCluster.sibling把这个名字变成 handle,两者都不决定可达性。
派发到目标自己的集群
resolve_placements把每个目标与“其分片承载它的 handle”配对:表在本集群就用 job 自己的 handle,否则派生 sibling。当前 handle 总是先被探测,所以全部表都在一个集群上的部署不会构建第二个 handle。
遍历 placements 的清扫(会按placement.cluster.shards逐目标派发)包括:
delete_person_events_opexecute_event_deletion(立即模式)deletes_job→delete_events(还需要把词典放到第二个集群,见下)
其余清扫绑定单个 handle,目标移出该集群时拒绝而不是跳过(dispatchable_here→UnreachableTargetError):
- 属性移除:其暂存表是 host 本地的,fan-out 是“一个集群每个分片一个 op”。
- 延迟队列填充:
INSERT的两半都是 host 本地的 —— 读取的源表与写入的adhoc_events_deletion队列。
把词典放到第二个集群:S3 暂存
delete_events不指名它删除的行,其谓词连接两个词典pending_deletes_<timestamp>_dictionary与adhoc_events_deletion_dictionary,因此变更无法在词典缺席的任何 host 上运行。
词典的源表通过复制到达某个集群的每个 host,而复制恰好止步于集群边界:拥有自己 Keeper 的集群永远无法加入那个副本集。adhoc_events_deletion更进一步,它由迁移管理、只存在于主集群。
解决方案是把行暂存起来(posthog/dags/common/staged_dictionary.py):
- 每次运行每个词典写一个 Parquet 对象,由集群自己用
INSERT INTO FUNCTION s3(...)写出(s3_truncate_on_insert=1,重试不会残留旧行); - 另一个集群的每个 host 通过
SOURCE(CLICKHOUSE(QUERY 'SELECT ... FROM s3(...)'))加载它 —— ClickHouse 没有 S3 词典源,但该源在本地执行其查询,而查询可以读取服务器能读的任何东西。
关键性质:
- 单集群部署零开销:handle 先被探测,不写任何对象。
- 源表不动:
pending_deletes_<timestamp>还承载mark_deletions_verified回读所需的 PostgresAsyncDeletion行 id,这些从不进入词典。 - 校验门:
load_and_verify_deletes_dictionary在每个集群的每个 host加载,除非所有 host 校验和一致否则使运行失败(deletes.py)。这能捕获陈旧或缺失的对象 —— 否则那里的变更连接空词典、删除零行、报告成功。 - 保留交给桶生命周期策略,由
DICTIONARY_STAGING_S3_*配置;没有任何代码删除这些对象。
同一个暂存机制还承载person-overrides 压缩(squash):squash_person_overrides通过一个连接快照词典的变更在sharded_events与sharded_events_json上重写person_id,然后删除刚应用的 overrides。跳过第二张表比欠删更糟:记录正确person_id的 overrides 在下一个 op 中消失,分歧永久化。posthog/dags/common/staged_dictionary.py 是两个任务共享的片段。
被覆盖的表与只靠 TTL 的表
被扫描(Covered):
sharded_events—— 所有清扫。sharded_events_json—— 所有清扫。可选:仅原生 JSON 迁移后才存在。sharded_flag_evaluations—— 个人、团队、排队 uuid 与事件移除。不含属性移除(见已知缺口)。可选。
只靠 TTL(TTL_ONLY_TABLES),每一条都是一个显式决策:删除可能滞后一个保留窗口:
sharded_events_recent—— 最近几天事件的瞬态镜像,7 天 TTL 键在inserted_at。它按天分区且ttl_only_drop_parts = 1,所以一个 part 只有在其最新一行过期后才会被丢弃:真实最坏情况约为 8 天加 TTL-merge 延迟,而非整 7 天。足够短可接受为擦除上界;清扫会与 TTL 赛跑且收益甚微。person_property_mutation_log_data—— 保留已提交的用户属性更新 30 天(以 Kafka 消息时间戳计)。它只存team_id、event_uuid、properties、ingested_at,基于人的清扫无法直接瞄准它。每日分区在新行过期后丢弃,加 TTL-merge 延迟。
会话录制、死信队列与日志同样由 TTL 回收。这一决定早于本文档;旧的 posthog/models/async_deletion/delete_events.py 中有一条注释记录了它,但该模块是 legacy,不是这里的真相来源。
已知缺口一:属性移除到不了flag_evaluations
flag_evaluations表(定义见 posthog/models/flag_evaluations/sql.py)只存$feature_flag_called事件(FLAG_EVALUATIONS_SOURCE_EVENT)。person_properties与group0..group4_properties已不在表上:没有任何 Insight 或 Hog 函数用它们做 breakdown 或 filter,ClickHouse 团队直接在两个 prod 集群上删掉了它们,sql.py也不再声明它们,因此任何从迁移构建的环境行为一致。事件properties与person_id仍会发送。
由于表不再持有 person 属性,只有请求中“事件 properties”这一半能在表上匹配到行。事件属性移除路径在暂存表中重写行,并用ALTER TABLE … UPDATE <col> = ''重置每个受影响的物化列 —— 这能工作是因为materialize()创建DEFAULT <expr>列,而 DEFAULT 列可被赋值。
所有这些机制(列发现、暂存重写、分片遍历)都限定在events内,到不了flag_evaluations。在那之前:
get_property_removal_shards在表持有匹配请求事件properties的行时拒绝启动,因此这类请求在它命名的数据仍存活时无法完成;表为空时该检查零成本。- person 属性那一半不同:
DeletionTarget.stores_person_properties在FLAG_EVALUATIONS上是False,门根本不对该表构建person_properties谓词,那一半请求无论列里有什么都完成。这对 producer 停止发送person_properties之后(2026-09-05,#95693)写入的行是准确的;对更早写入的行则是一个故意的盲区,直到该行 TTL 过去。
迁移 0301 后 DEFAULT 列的实测行为
迁移0301_flag_evaluations_default_columns把九个类型化列重建为DEFAULT <expr>(此前是真正的 ClickHouseMATERIALIZED,完全不可赋值)。对照 ClickHouse 26.6.2 的DEFAULT形态实测:
CREATE TABLE接受一个从properties推导的DEFAULT列(flag_key)放进排序键;省略类型化列的插入会从properties计算它们。- 对非键类型化列赋值被接受:
ALTER TABLE … UPDATE session_id = ''完成。MATERIALIZED时代被拒绝(Cannot UPDATE materialized column 'session_id')。 - 更新
properties被接受,单独更新与“events 路径式”在同一变更中重置受影响类型化列均可。MATERIALIZED时代的拒绝(Updated column 'properties' affects MATERIALIZED column 'flag_key', which is a key column)对DEFAULT依赖列不再触发。 - 更新
properties不会重算类型化列;行保留存储值。重写必须像 events 路径那样显式重置每个受影响列。 flag_key本身永远无法被重置:ALTER TABLE … UPDATE flag_key = ''以Cannot UPDATE key column 'flag_key'(CANNOT_UPDATE_COLUMN)被拒绝,无论列类型。因此命名$feature_flag的请求仍无法通过变更完成 —— 该属性需要拒绝,或更重的重写:INSERT … SELECT清洗后的行、省略类型化列让分片重算,再轻量删除原行。
切换还改变了两处行为(同一形态实测):SELECT *现在包含九个类型化列(MATERIALIZED会隐藏它们,仓库无依赖);指名类型化列的插入会存储给定值即使与properties矛盾(MATERIALIZED拒绝此类插入),producer 必须省略这些列,Kafka 路径通过writable_flag_evaluations不声明它们来强制。
剩余修复:把 events 重写机制指向这张表,并把$feature_flag限制内建到其行为中。flag_evaluations在修复落地前故意缺席MATERIALIZATION_VALID_TABLES:新的materialize()铸造列只会加宽未被修复路径静默遗留的东西。
如果请求在修复前到达
目前拒绝零成本,因为表为空。一旦有行,带delete_all_events的属性移除只要有一条 flag-evaluation 行携带命名的属性就会拒绝,且操作者无路可走:delete_all_events与events在模型上互斥,无法收窄请求排除$feature_flag_called,admin 的 Retry 按钮只会重放同样的失败。唯二出口是等待 TTL 或上线修复。表按月分区且ttl_only_drop_parts = 1,part 只有在最新一行过期后才被丢弃:真实等待最多约120 天而非 90 天 TTL(sql.py在分区子句旁有同样注释)。纯 person 属性请求不会被卡住:门根本不检查flag_evaluations。
拒绝优于静默欠删,因此门是正确默认。若真实流量到来时修复未落地,较便宜的止损方案是:允许请求排除事件名,让操作者绕开该表;或在请求上记录显式的、可审计的确认,让操作者接受残留而不被困住。什么都不做意味着第一例受影响的 GDPR 请求变成一次升级事故。
已知缺口二:带 HogQL 谓词的事件移除到不了flag_evaluations
compile_hogql_predicate(posthog/models/data_deletion_request.py)把每个谓词都解析到 events HogQL 表并发射 events 特有的物理列。它唯一的差异轴是 legacy 对 native-JSON events,因此 dag 虽为每个目标编译一个片段,但没有目标选择不同的表根。某一片段能否在flag_evaluations上运行取决于谓词与团队修饰符:只命名event或distinct_id的可以;触及mat_*列或属性组映射的不能;且没有东西校验这一点 ——dag 选择拒绝而非猜测。为flag_evaluations提供 HogQL 表定义也不会改变这一点,因为没有东西把编译路由到表。无谓词的请求正常清扫;带谓词的请求在表持有匹配行时被拒绝。
Producer 前提:person_id 对齐
每个基于人的清扫都以person_id为键,flag_evaluations与 events 相同。这只有因为 producer 为每一行填充person_id才是正确的,与 events 摄取管线一致:解析后的Person.uuid,或未解析人时按distinct_id的确定性 uuid。shadow-routing producer(posthog-code/flag-evaluations-shadow-routing)在 person 解析下游派生 enriched 事件,因此免费继承该值。
如果未来 producer 在 person 解析前发射行、留下未设置的person_id,个人删除会搁浅它们 —— 修复属于 producer 而非 scanner。把 fork 保持在 person 解析下游即契约(跟踪于 #81002)。
写时对齐本身还不够,因为后续合并会移动清扫要找的人:squash_person_overrides只在EVENTS_TARGETS上重写person_id,所以人 A 合并进 B 后 events 行携带 B、flag-evaluation 行仍携带 A。删除 B 以 B 的 uuid 排队,错过这些行,它们(连同事件properties和过期的person_id)存活到 TTL 丢弃 part。squash 在应用后立即删除 overrides,之后无法调和分歧。把 squash 扩展到FLAG_EVALUATIONS是修复,属于 posthog/dags/person_overrides.py(跟踪于 #93035)。
相关但刻意不变的部分
_fetch_stats只统计 events 表。它供给AUTO_APPROVE_MAX_EVENTS—— 一个成本启发而非完整性声明,因此被自动批准为“小”的请求实际移动的行可能略多于测得值。cleanup_old_events_by_partition保持 events-only。它为指定的团队集合强制多年保留下限,其他所有个人数据表已在各自 TTL 下更早过期。
如何添加一张表
在PERSONAL_DATA_TARGETS注册它,能力位要如实反映 schema 能承受什么、清扫代码实际实现了什么:
accepts_property_rewrite需要重写机制真正触达该表,而不只是列可赋值;stores_person_properties需要表的person_properties列真正存有可达数据,而不只是 schema 中存在 ——FLAG_EVALUATIONS正是两者背离的案例;- 不打算被清扫就加入
TTL_ONLY_TABLES并注明接受的窗口; - 存储位于删除任务所连集群之外,就给
cluster_setting命名那个集群并标记optional;见上文 “Reach” 与 “Dispatching”,确认哪些清扫会触达它、哪些会拒绝。
DeletionTarget还支持node_role(表分片所在节点的角色)、queue_uuid_candidates(该表的 uuid 是否应进入延迟删除队列;EVENTS_JSON因与 legacy 表双写、uuid 重复而设为False)、stored_events(表能持有的事件名集合,让命名其他事件的请求免查询直接跳过该表)等字段,逐一对应 deletion_targets.py 中的文档字符串。
最后,posthog/clickhouse/test/test_deletion_coverage.py 会对任何声明person_properties却不在两张清单之列的存储表失败 —— 决策必须被做出,而不能被跳过。
结语
PostHog 的删除覆盖设计可以概括为一句话:所有存个人数据的表显式注册,所有能力显式声明,所有不可达显式拒绝,所有存活显式计数。分布式表能指向任意集群这一不对称性,被 host 探测门、代理计数和 S3 词典暂存三层机制兜住;而flag_evaluations的两个已知缺口展示了“拒绝优于静默欠删”原则在真实演进中的取舍。对希望理解 GDPR/隐私数据擦除如何在多集群 ClickHouse 上正确落地的读者,这份文档与deletion_targets.py、staged_dictionary.py的组合是一份可直接研读的参考实现。
【免费下载链接】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),仅供参考