设计)
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_eventsteam_id,person_id,timestamp团队删除deletes_job→delete_eventsteam_id排队 uuid 排空deletes_job→delete_eventsteam_id,uuid个人移除请求delete_person_events_opteam_id,person_id,timestamp事件移除请求execute_event_deletionteam_id,timestamp,event, HogQL属性移除请求process_property_removal_shardproperties,person_properties, HogQL这些入口全部位于 posthog/dags/data_deletion_requests.py个人/事件/属性移除与 posthog/dags/deletes.pydeletes_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_insert1重试不会残留旧行另一个集群的每个 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 压缩squashsquash_person_overrides通过一个连接快照词典的变更在sharded_events与sharded_events_json上重写person_id然后删除刚应用的 overrides。跳过第二张表比欠删更糟记录正确person_id的 overrides 在下一个 op 中消失分歧永久化。posthog/dags/common/staged_dictionary.py 是两个任务共享的片段。被覆盖的表与只靠 TTL 的表被扫描Coveredsharded_events—— 所有清扫。sharded_events_json—— 所有清扫。可选仅原生 JSON 迁移后才存在。sharded_flag_evaluations—— 个人、团队、排队 uuid 与事件移除。不含属性移除见已知缺口。可选。只靠 TTLTTL_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_evaluationsflag_evaluations表定义见 posthog/models/flag_evaluations/sql.py只存$feature_flag_called事件FLAG_EVALUATIONS_SOURCE_EVENT。person_properties与group0..group4_properties已不在表上没有任何 Insight 或 Hog 函数用它们做 breakdown 或 filterClickHouse 团队直接在两个 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_keyCANNOT_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_calledadmin 的 Retry 按钮只会重放同样的失败。唯二出口是等待 TTL 或上线修复。表按月分区且ttl_only_drop_parts 1part 只有在最新一行过期后才被丢弃真实等待最多约120 天而非 90 天 TTLsql.py在分区子句旁有同样注释。纯 person 属性请求不会被卡住门根本不检查flag_evaluations。拒绝优于静默欠删因此门是正确默认。若真实流量到来时修复未落地较便宜的止损方案是允许请求排除事件名让操作者绕开该表或在请求上记录显式的、可审计的确认让操作者接受残留而不被困住。什么都不做意味着第一例受影响的 GDPR 请求变成一次升级事故。已知缺口二带 HogQL 谓词的事件移除到不了flag_evaluationscompile_hogql_predicateposthog/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 producerposthog-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),仅供参考