PAYATHON 2026

ClickHouse 高流量事件分析如何设计身份归属和维度模型?

数据小许

可以保持原始事件不变,用独立映射管理需要追溯修正的用户归属。这可以作为系统的设计起点,但独立归属表能否用于生产,还要看身份规则、映射一致性和实际查询负载。

当前方案需要先解决一个语义问题:如果 John 的归属区间从 10

开始,按 received_at 关联时,10
和 10
的事件仍会归给匿名身份。新增一个登录后的区间,不会自动改变历史归属。

先确定历史事件应该归给谁

这里有两种查询口径:

口径10
的事件在登录后归给谁
映射方式
事件发生或接收时的归属匿名身份 D1按有效时间关联归属历史
根据后来确认的身份追溯归属John将对应匿名身份或历史区间解析为 John

你的示例要求第二种口径,可以选择以下方式之一:

  • 如果设备整个生命周期只会对应一个真实账号,维护 (project_id, device_uuid) -> canonical_user_id,查询历史事件时使用当前解析结果。
  • 如果设备可能切换账号,给匿名身份阶段分配独立的 identity_key,登录时将相应的匿名阶段映射到真实用户。
  • 如果保留时间区间模型,需要修正应当追溯的历史区间,只增加一个从登录时刻开始的区间不够。

例如,确认 D1 从 10

开始的匿名阶段属于 John 后,可以在映射层把该阶段的归属改为 John,原始事件保持不变。区间应有明确边界,例如 [valid_from, valid_to),并保证同一身份键在同一时刻最多命中一个有效归属。

如果设备后来登录另一个账号,不能直接把该设备的全部历史改归新账号。需要先规定退出登录、账号切换、共享设备以及跨设备登录时的合并规则。device_uuid 标识的是设备,不能单独证明所有历史事件属于同一个人。

还要区分归属从何时有效,以及系统何时得知这项归属。如果需要复现“登录前看到的报表”,应保留映射变更记录或版本;只保存最新映射,无法还原过去的认知状态。

原始事件保留稳定标识,分析层解析用户

建议原始事件至少保留 project_id、device_uuid、客户端 timestamp、服务端 received_at、event_code、session_id 和 properties。

可以额外保存事件上报时观察到的 user_uuid,用于排查和审计,同时由身份映射层计算分析使用的 resolved_user_id。前者记录当时上报了什么,后者表达当前归属规则下如何统计。

如果存在账号切换,建议新增能够标识匿名阶段的 identity_key,或保存足以确定该阶段的信息。仅凭当前设备归属,无法可靠区分同一设备上的多个用户。

客户端 timestamp 和服务端 received_at 也不能互换。离线事件可能在登录后才上传;按接收时间关联,可能把登录前发生的事件判为登录后事件。行为顺序通常需要依据事件时间,同时应明确处理客户端时钟误差、迟到事件和身份关联时间的规则。

独立归属表可行,但查询前必须得到唯一映射

可以维护身份变更日志,再生成供查询使用的当前映射或有效区间快照。查询侧需要保证:

  1. 所有身份键包含 project_id,避免跨项目误合并。
  2. 当前映射的每个键只有一个确定结果,历史区间不存在意外重叠。
  3. 未找到映射时保留匿名身份,不能丢弃事件或把所有匿名事件合并成一个用户。
  4. 用户标识和匿名标识使用独立命名空间,避免字符串碰巧相同而误合并。
  5. 映射刷新延迟、删除和解绑规则可观测、可验证。

若使用多版本表,查询必须显式取得所需版本,或读取已经生成好的唯一快照。不能直接连接所有版本,否则一条事件可能匹配多行,造成计数放大。

简单的当前身份查找可以评估 Dictionary。复合字符串键可使用 complex_key_hashed 等布局,但全量哈希字典需要承担内存开销,刷新也有延迟。官方文档指出,dictGet 的逐行查找不能替代普通列上的索引过滤能力。因此,字典可以减少部分关联开销,是否更省资源仍需实测。ClickHouse Dictionary 最佳实践

漏斗查询要先解析身份,再按用户聚合

下面是最简单的当前设备映射方案,仅适用于同一设备的全部相关历史允许归属于一个账号的情况。

示例假设:

  • identity_current 已维护为每个 (project_id, device_uuid) 恰好一条当前映射,user_uuid 为非空字符串。
  • events.timestamp 类型为 DateTime64(3, 'UTC')。
  • 示例项目为 42,观察区间为指定的一天,漏斗窗口为 30 分钟;这些都是演示参数。
WITH resolved_events AS
(
    SELECT
        e.project_id,
        ifNull(
            concat('user:', m.user_uuid),
            concat('anonymous:', e.device_uuid)
        ) AS resolved_user_id,
        e.timestamp AS event_time,
        e.event_code
    FROM
    (
        SELECT project_id, device_uuid, timestamp, event_code
        FROM events
        WHERE project_id = 42
          AND timestamp >= toDateTime64('2026-01-01 00:00:00', 3, 'UTC')
          AND timestamp <  toDateTime64('2026-01-02 00:00:00', 3, 'UTC')
          AND event_code IN ('app_open', 'permission_granted')
    ) AS e
    LEFT JOIN
    (
        SELECT project_id, device_uuid, user_uuid
        FROM identity_current
        WHERE project_id = 42
    ) AS m
      ON e.project_id = m.project_id
     AND e.device_uuid = m.device_uuid
)
SELECT count() AS users_completed_funnel
FROM
(
    SELECT
        project_id,
        resolved_user_id,
        windowFunnel(1800000)(
            toUInt64(toUnixTimestamp64Milli(event_time)),
            event_code = 'app_open',
            event_code = 'permission_granted'
        ) AS funnel_level
    FROM resolved_events
    GROUP BY project_id, resolved_user_id
    HAVING funnel_level = 2
)
SETTINGS join_use_nulls = 1;

这里使用毫秒时间戳,窗口参数 1800000 表示 30 分钟。windowFunnel 返回匹配链条达到的最大阶段数,阶段为 2 才计入完成用户。ClickHouse windowFunnel 文档

同一账号的多台设备需要解析成相同的 resolved_user_id,才能形成用户级漏斗。这也意味着步骤可能跨设备完成;若业务要求在同一设备或同一会话内完成,应增加对应的分组条件。

示例只分析筛选区间内的事件,不包含区间外的漏斗步骤。正式查询前,要先规定漏斗起点、观察窗口和报表时间范围之间的关系。

MongoDB 可以保留,但常用分析维度应进入分析侧

MongoDB 继续作为设备和用户资料的业务数据源是合理的。对于高频仪表盘,建议把常用维度同步到 ClickHouse,避免每次查询都跨系统读取大量设备或用户资料。

维度需要区分历史值和当前值:

维度需求建议方式
事件发生时的 OS、应用版本、设备型号随事件保存快照,或关联可恢复该时刻状态的维度历史
设备当前 OS、当前用户属性从 MongoDB 同步当前维度表或字典
根据后来确认的身份重算历史使用可变身份映射
用户何时转化根据转化事件或带转化时间的用户事实计算

查询“iOS 18.6 设备上的事件”时,必须先明确指的是事件发生时运行 iOS 18.6,还是设备现在运行 iOS 18.6。如果只保留 MongoDB 当前值,设备升级后再查历史,结果会随之变化。

如果历史事件没有携带 OS 版本,MongoDB 也没有相应历史记录,就不能仅凭当前值准确恢复当时的状态。现在开始增加事件维度快照,可以避免继续扩大这个缺口。

对于“后来转化的用户在 iOS 18.6 上产生的事件”,可以先解析用户身份,再取得用户的转化时间,筛选事件发生时的 OS 版本,并检查转化是否发生在目标事件之后。仅判断用户当前是否已转化,无法表达“后来”;转化事件也可能位于目标事件筛选区间之外。

身份追溯会改变留存和预聚合结果

匿名身份并入账号后,用户去重数可能减少,首次活跃时间可能前移,跨设备事件可能组成新的漏斗,cohort 归属和 retention 分母也可能变化。需要决定历史报表是否随最新的身份映射重算。

如果在事件写入时就解析用户并生成用户级预聚合,后续只修改映射表,不会自动修正旧结果。ClickHouse 增量物化视图由源表插入触发,JOIN 右侧表的变化不会重新处理已写入的历史结果。ClickHouse 增量物化视图文档

可以按需求选择:

  • 查询时解析身份,让报表反映最新映射,但要承担重复查询的成本。
  • 定期重建分析表或受影响的数据范围,并明确结果刷新延迟。
  • 保存映射版本,按指定版本生成报表,便于复现结果。

不能直接把按匿名身份算好的去重数或漏斗完成数相加,作为合并后的用户结果。即使某类聚合状态支持合并,也要确认它保留的信息足以支持跨设备、跨日期的正确计算。

如何权衡 RAM、CPU 和磁盘

独立映射避免了频繁改写大事件表,但会把部分成本移到查询阶段。仅凭当前描述,无法证明哪种模型能同时最省 RAM、CPU 和磁盘。

建议用代表性数据比较三种方案:查询时 JOIN、查询时 Dictionary 查找、定期生成已解析身份的分析表。重点测量真实并发下的延迟、峰值内存、扫描量、后台计算量和存储增量。

具体优化可以从以下几处开始:

  • 根据主要查询选择排序键,并尽早限定 project_id、时间和事件类型。
  • 按用户检索时,评估先查出用户对应的设备或身份键,再过滤事件,减少全量身份解析。
  • 把高频筛选的事件属性和历史维度提取为独立列,按实际基数选择类型与编码。
  • 多分片部署时,确保事件处理节点可取得一致的映射;按设备分片后,用户级聚合仍可能需要跨分片合并。
  • 对高频看板建立专用分析表,同时保留原始事件作为身份变更后的重算依据。

lightweight updates 是否适用,应结合部署版本及其功能状态确认,不能直接沿用“始终处于实验阶段”的判断。官方文档描述了基于 patch parts 的实现,同时列出了补丁读取开销、适用引擎和主键列更新限制。ClickHouse UPDATE 文档

即使部署版本支持它,也应比较每次身份合并影响的历史行数、补丁累积和后续查询成本。对于这里的需求,可以先保留不可变的原始事件,明确身份追溯规则,让分析侧能够使用所需维度数据,并确保映射变更后能够重建用户分析结果。

备注:内容仅供参考。