Zulip Analytics 子系统全解析:从 `*Count` 时序表到 `/stats` 页面的数据链路
2026/9/12 20:03:42 网站建设 项目流程

Zulip Analytics 子系统全解析:从*Count时序表到/stats页面的数据链路

【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip

Zulip 内置了一套轻量级、无外部依赖的分析(Analytics)子系统,以一组精心设计的 PostgreSQL 时序表驱动/stats统计页面,并逐步为频道用量统计等功能提供数据支撑。本文以 docs/subsystems/analytics.md 为骨架,结合analytics/目录下的模型、统计定义与命令行工具源码,系统讲解数据模型、CountStat声明、FillState记账机制、性能策略、后端测试以及统计页面的开发与调试方法,帮助你理解并能动手扩展 Zulip 的分析能力。

设计目标:为什么 Zulip 要自研一套分析系统

Zulip 的分析系统围绕以下目标设计(见 docs/subsystems/analytics.md):

  • 对可扩展性与服务复杂度影响最小:不需要额外引入 Hadoop 之类的大数据组件;
  • 结果可信:通过完善的自动化测试保证统计数值正确;
  • 查询高效:能够在应用内(如频道页面)展示数据,而对页面整体性能影响极小;
  • 存储可控:分析表的总大小小于核心的Message/UserMessage表,因此可以直接存放在主 PostgreSQL 数据库中,无需专门的数据库平台。

这一设计意味着:分析数据的生成(离线、批处理)与消费(在线、低延迟)被彻底解耦——昂贵的数据计算在后台 cron 中完成,而面向用户的查询只读预先算好的小表。

三大核心组件:模型、统计定义与记账

要理解并修改这套系统,需要先掌握三个组成部分及其在仓库中的位置:

组件职责源码位置
模型(models)UserCountStreamCountRealmCountInstallationCount四张表,存储时间序列数据analytics/models.py
统计定义(stat definitions)COUNT_STATS字典中的CountStat对象,声明 Zulip 收集哪些统计analytics/lib/counts.py
记账(accounting)FillState表,记录每个CountStat已收集到哪个时间点analytics/models.py

FillState是这套系统的“水位线”:默认生产配置下,每小时运行一次 cron 任务,把各CountStat从上次成功更新的end_time推进到当前时刻。它同时支持系统出错后重试,也用于监控 cron 是否正常跑完。

*Count数据库表:时序数据的统一形态

四张计数表全部继承自抽象基类BaseCount(analytics/models.py),统一包含以下字段:

  • property:人类可读的字符串,唯一标识一个CountStat。例如"active_users_audit:is_bot:hour""messages_sent:client:day"
  • subgroup:绝大多数统计会按子分组切分。对"active_users_audit:is_bot:day",该列为False(人类)或True(机器人);对"messages_sent:client:day",该列为对应的client_id。该列允许为NULL,用于没有子分组的统计;
  • end_time:时间区间结束时刻的datetime。按小时(或 UTC 日)频率收集的统计,取值落在整点(或 UTC 日)边界上;区间长度由CountStat决定;
  • 各种 id 外键:指向RealmUserProfileStream,或没有。例如RealmCount持有指向Realm的外键;
  • value:整数计数值。例如RealmCount表中"active_users_audit:is_bot:hour"的值表示某个 realm 在某个end_time时刻活跃人类/机器人的数量;UserCount表中"messages_sent:client:day"的值表示某用户某天通过某客户端发送的消息数。

四张表的粒度差异如下(analytics/models.py):

  • UserCount:按用户粒度,带userrealm外键;
  • StreamCount:按频道粒度,带streamrealm外键;
  • RealmCount:按组织粒度,带realm外键;
  • InstallationCount:整个服务器安装的全局粒度,无外键。

它们之间是逐级汇总的关系:每个CountStat最初写入UserCountStreamCountRealmCount三者之一;UserCountStreamCount中的数据再聚合进RealmCount;所有统计最终从RealmCount聚合进InstallationCount。例如"messages_sent:client:day":先在UserCount中存(user, end_time, client)三元组,求和成RealmCount中的(realm, end_time, client)三元组,再求和成InstallationCount中的(end_time, client)对。

唯一性约束与索引

为了保证数据一致性,各表都定义了条件唯一约束与聚合用索引:

  • 每条记录在(id, property, subgroup, end_time)(subgroup 非空时)或(id, property, end_time)(subgroup 为空时)上唯一;
  • UserCountStreamCount上的(property, realm, end_time)索引显著加速“从用户/频道聚合到 realm”的查询(源码注释明确说明这一点)。

聚合逻辑在 analytics/lib/counts.py 的do_aggregate_to_summary_table中实现:使用原生 SQLINSERT INTO ... SELECT ... GROUP BY,将子表按 realm/子分组求和写入上级表。注意,聚合到InstallationCount只在realm is None(即一次性处理所有 realm)时执行。

CountStat:统计项声明

CountStat类与其全部实例定义在 analytics/lib/counts.py 中。它声明了系统应向哪些表填充什么数据,构造参数包括:

  • property:统计属性名(对应表中的property列);
  • data_collector:一个DataCollector,指定输出表与数据拉取函数(pull_function);
  • frequencyCountStat.HOUR("hour")或CountStat.DAY("day"),决定time_increment是 1 小时还是 1 天;
  • interval:可选,覆盖默认的时间窗口长度(例如活跃用户统计使用timedelta(days=N) - MIN_INTERVAL_LENGTH)。

此外还有两个子类:

  • LoggingCountStat:不通过后台批量拉取数据,而是在事件发生时由业务代码实时INSERT ... ON CONFLICT DO UPDATE累加(见do_increment_logging_stat,analytics/lib/counts.py);
  • DependentCountStat:依赖其他统计先完成(如realm_active_humans::day依赖15day_actives::day),处理时会取依赖项最近一次成功填充时间与目标时间的最小值。

当前定义的统计项清单

get_count_stats()(analytics/lib/counts.py)按依赖顺序声明了全套统计,主要分组如下:

  • 消息发送类(读Message表)
    • messages_sent:is_bot:hour—— 按是否机器人子分组的小时消息数(写入UserCount);
    • messages_sent:message_type:day—— 按消息类型(public_stream/private_stream/private_message/huddle_message)分组的日消息数;
    • messages_sent:client:day—— 按客户端分组的日消息数;
    • messages_in_stream:is_bot:day—— 单个频道内按是否机器人分组的日消息数(写入StreamCount);
  • AI 用量类ai_credit_usage::dayLoggingCountStat,单位为 $1/10^9,适合用 bigint 聚合);
  • 活跃用户类(读RealmAuditLog/UserActivityInterval
    • active_users_audit:is_bot:day—— 按审计日志判定当前活跃(created/activated/reactivated 且未被 deactivated)的用户数;
    • 1day_actives::day/7day_actives::day/15day_actives::day—— 最近 N 天内有活动的用户数(区间为N天 - MIN_INTERVAL_LENGTH);
    • minutes_active::day—— 用户活跃分钟数(由 Python 遍历UserActivityInterval计算后bulk_create);
    • realm_active_humans::day——DependentCountStat,依赖15day_actives::day计算活跃人类数;
  • 上传用量upload_quota_used_bytes::day—— 各 realm 附件占用字节数;
  • 消息已读类(LoggingCountStatmessages_read::hourmessages_read_interactions::hour(后者近似统计“导致消息标记已读的 UI 交互次数”,对批量请求的放大效应不敏感);
  • 速率限制类(LoggingCountStat,直接写RealmCountinvites_sent::day(限制组织发送邀请邮件数量)、mobile_pushes_sent::day
  • 仅 ZILENCER_ENABLED 时的远程统计mobile_pushes_received::daymobile_pushes_forwarded::day(写入RemoteRealmCount/RemoteInstallationCount,用于推送代理侧统计)。

模块底部还定义了COUNT_STATS(本地)、REMOTE_INSTALLATION_COUNT_STATS(远程)以及合并后的ALL_COUNT_STATSBOUNCER_ONLY_REMOTE_COUNT_STAT_PROPERTIES防止远端服务器篡改代理侧数据,LOGGING_COUNT_STAT_PROPERTIES_NOT_SENT_TO_BOUNCER则排除尚不完整、价值较低的日志型统计上报。

FillState与增量填充流程

FillState(analytics/models.py)以property为主键记录每个统计的end_time与状态(DONE = 1/STARTED = 2)。它有两个关键用途:

  1. 增量续跑process_count_stat(analytics/lib/counts.py)从FillState读取“已填充到哪”,从该点开始以time_increment为步长循环推进,避免重复计算历史数据;
  2. 崩溃恢复:若发现状态停留在STARTED(说明上次填充中途失败),会先调用do_delete_counts_at_hour删除该时间点已写入的数据(回滚),再从上一个已完成时间点重试,保证不会产生半截数据。

首次运行时,若某统计没有FillState记录,则以installation_epoch()为起点——它是所有 realm 中最早创建时间的 UTC 日下界(analytics/models.py),意味着历史数据理论上可以一直回填到系统诞生。

process_count_stat内部对每个时间点执行:

  1. FillState置为STARTED
  2. 调用do_fill_count_stat_at_hour:先执行pull_function(对普通统计)把原始数据INSERT INTO目标子表,再调用do_aggregate_to_summary_table向上逐级聚合;
  3. FillState置回DONE并前进一个步长。

每一步都带logger.info计时(START ... DONE ... (%dms)),方便定位慢统计。

性能策略:让分析系统“轻”下来

针对“分析表不能比主库大、不能给服务器增加过多计算负载”的目标,docs/subsystems/analytics.md 总结了五条原则,均可从源码得到印证:

  1. FillState避免重复劳动:只增量计算,不重算历史;
  2. 预计算到*Count表,避免端点直查大表Message/UserMessage是 Zulip 应用库中仅有的两张超大表,某些查询直接执行可能要数分钟;把昂贵操作放到离线 cron 中,端点只读聚合好的小表,响应就能保持快速;
  3. 把重活交给数据库:用原生 SQL 的INSERT INTO ... SELECT(如do_aggregate_to_summary_table)在数据库内部完成聚合,而不是把数据取回 Python 再写回。由于 Django ORM 目前不支持这种写法,这里刻意使用了 Zulip 通常回避的 raw SQL;
  4. 尽量聚合以减少对大表的查询:例如生成“每个用户的消息数”后直接求和得到 realm 总数,而不是分别对 realm 和 user 各查一次Message表;
  5. 不为 0 值建行:一个按小时的用户级统计如果不加控制,每年每个用户可能积累约 24×365 行、约 4GB 数据,且绝大多数值为 0。源码中populate_analytics_db.pyinsert_fixture_data也遵守了if value != 0的过滤(analytics/management/commands/populate_analytics_db.py)。同时要注意:新增统计时应优先考虑“通常非 0”的查询,避免引入大量稀疏行。

数据生成与维护命令行

analytics/management/commands/下提供了四个可直接使用的管理命令:

  • update_analytics_counts:每小时 cron 的填充入口(analytics/management/commands/update_analytics_counts.py)。参数包括:

    • --time / -t:填充到指定时刻,默认当前时间;
    • --utc:将--time解释为 UTC 时间(未带时区信息的--time会直接报错);
    • --stat / -s:只处理单个CountStat,缺省处理全部;
    • --verbose:打印每个统计的耗时。

    命令受abort_cron_during_deployabort_unless_locked保护:部署期间自动中止、用锁避免并发运行。填充完成后若should_send_analytics_data()为真,还会按ZULIP_ORG_ID哈希出 0–10 分钟随机延迟,再向推送代理上报统计数据,避免所有服务器同时上报。

  • check_analytics_state:Nagios 监控命令(analytics/management/commands/check_analytics_state.py),逐个检查ALL_COUNT_STATSlast_successful_fill():日频率统计超过 26 小时未更新告警 WARNING、超过 50 小时告警 CRITICAL;小时频率统计超过 90 分钟告警 WARNING、超过 150 分钟告警 CRITICAL;同时校验FillState是否在 UTC 且落在正确的频率边界上,结果通过atomic_nagios_write输出。

  • populate_analytics_db:生成随机演示数据(见下文 UI 调试一节)。

  • clear_analytics_tables/clear_single_stat:清空全部/单个统计的数据(对应do_drop_all_analytics_tables/do_drop_single_stat)。

后端测试策略

由于“填表逻辑出错几乎需要重算全部历史数据”,docs/subsystems/analytics.md 强调测试优先级:

  1. 最重要:测试“真正向分析表填充数据”的代码路径(process_count_statpull_function→ 聚合)。这类 bug 发现成本极高,值得花时间设计边界用例;
  2. 其次:测试后端视图从数据库抽取数据并返回给客户端的逻辑(如get_chart_data各端点)。

手工调试时可用./manage.py dbshell直接查看各表内容核对结果;但文档建议:任何需要手工确认的断言都应沉淀进后端分析测试,避免重构后回归。

LoggingCountStats:处理不值得存全量数据的事件

上述体系适合“原始数据已存在数据库中、可随时回填”的统计(如MessageUserMessage)。但对于活动日志、请求性能等“每个数据点都不值得存储”的场景,Zulip 提供了LoggingCountStat作为参考实现:它不设pull_function,而是由业务代码在事件发生时通过do_increment_logging_stat直接对对应*Count表执行原子化的INSERT ... ON CONFLICT ... DO UPDATE SET value = value + EXCLUDED.value(analytics/lib/counts.py)。

该函数按stat.frequency把事件时间向上取整到小时/日边界作为end_time,并按输出表类型写入对应的 id 字段与冲突列;subgroup会被统一转换为字符串(例如布尔False变成"False",以兼容历史上get_or_create的行为与跨服务器交换数据时的大小写一致性)。messages_read::hourinvites_sent::daymobile_pushes_sent::day等都是这类统计的实际用例。

Analytics UI 的开发与测试

测试环境搭建

/stats页面 UI 的主要测试方式是手工测试:

  • 以 Iago(服务器管理员)身份访问/stats/realm/analytics,查看某个 realm 的统计(服务端管理视角)。唯一无法测试的是 “Me” 按钮(当前登录用户自己的数据);
  • shylock@analytics.ds身份登录analyticsrealm 并访问/stats,即可测试 “Me” 视图。注意该 realm 是空壳(没有频道),只适合测试图表。

新增统计或数据表时,需要编辑 analytics/management/commands/populate_analytics_db.py 添加对应形式的假数据生成代码,然后运行./manage.py populate_analytics_db再刷新图表。该命令会:清空所有分析表 → 重建analyticsrealm(含用户shylockbassanio与频道all)→ 用generate_time_series_data(定义于 analytics/lib/fixtures.py)为各统计生成 100 天、带工作日/非工作日基线、增长、自相关、尖峰与节假日效应的随机时序数据,并同步写入对应FillStateinsert_fixture_data会跳过值为 0 的行,与生产写入策略保持一致。

添加/编辑/stats图表

相关文件(文档原样列出,均为仓库内相对路径):

  • analytics/views/stats.py:/stats页面的所有图表数据请求都汇聚到do_get_chart_data
  • web/src/stats/stats.ts:前端 JavaScript 与 Plotly 绘图代码;
  • templates/analytics/stats.html:页面模板;
  • web/styles/stats.css 与 web/styles/legacy_portico.css:样式(页面正在从 portico CSS 重构为应用内 CSS,目前仍受 portico 影响);
  • analytics/urls.py:URL 路由,即使新增图表也通常无需修改。

新增图表的捷径是“照抄现有图表”。源码层面,图表数据请求经由get_chart_dataget_chart_data_for_realmget_chart_data_for_streamget_chart_data_for_installation等端点(均为@typed_endpoint,支持chart_namemin_lengthstartend参数)汇入do_get_chart_data;其内部按chart_name决定使用哪些CountStat、读取哪张聚合表、如何映射 subgroup 标签,再借助 analytics/lib/time_utils.py 的time_range()生成对齐到 UTC 小时/日边界的时间轴(min_length用于向左补齐最少数据点数),最后把各 subgroup 的时间序列封装成{end_times, frequency, everyone/user, display_order}的 JSON 返回。messages_sent_by_client还会经过rewrite_client_arrays把相似客户端(如各种 Webhook 名称)合并并改名。

文档给出的一些 Plotly 调试技巧(保留原文要点):

  • $.get从后端拉数据,可在stats.ts中 grep 到现成用法;
  • 除非图表数据量极大,否则数据变化时(如点击聚合按钮)直接整图重绘,比使用 retrace/relayout 更稳妥——后两者引入过不少小 bug;
  • 可通过 Plotly 间接访问原始 d3 功能(文档化程度不高);
  • Plotly 选项中的'paper'指图形的包围盒(bounding box)相关对象;
  • Plotly 图形上有一层交互层,无法直接右键检查元素(如图表中的柱体),但可以在文档树中搜索定位。

/activity页面

服务端还有一个成熟度稍低的/activity页面,面向服务器管理员展示服务器上所有 realm 的数据。访问前提是UserProfileis_staff位为真,可通过manage.py shell直接修改UserProfile对象来设置。文档将其数据源清理与接口文档化列为值得做的后续项目。

小结:从数据到图表的完整链路

一个/stats图表的完整数据链路可以概括为:

  1. 收集:cron 每小时运行update_analytics_counts,或业务事件触发do_increment_logging_stat,把原始计数写入UserCount/StreamCount/RealmCount
  2. 聚合do_aggregate_to_summary_table用原生 SQL 逐级汇总到RealmCountInstallationCount
  3. 记账FillState记录进度,支持增量续跑与失败回滚;
  4. 供给analytics/views/stats.pydo_get_chart_datachart_name读取聚合表,生成对齐的时间序列 JSON;
  5. 展示web/src/stats/stats.ts用 Plotly 渲染成图表。

理解这五步,就能在 Zulip 中安全地新增统计项、调整频率、扩展图表,甚至复刻这套“小表预计算 + 增量水位线”的模式到其他需要内嵌分析能力的项目中。

【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询