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) | UserCount、StreamCount、RealmCount、InstallationCount四张表,存储时间序列数据 | 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 外键:指向
Realm、UserProfile、Stream,或没有。例如RealmCount持有指向Realm的外键; - value:整数计数值。例如
RealmCount表中"active_users_audit:is_bot:hour"的值表示某个 realm 在某个end_time时刻活跃人类/机器人的数量;UserCount表中"messages_sent:client:day"的值表示某用户某天通过某客户端发送的消息数。
四张表的粒度差异如下(analytics/models.py):
UserCount:按用户粒度,带user与realm外键;StreamCount:按频道粒度,带stream与realm外键;RealmCount:按组织粒度,带realm外键;InstallationCount:整个服务器安装的全局粒度,无外键。
它们之间是逐级汇总的关系:每个CountStat最初写入UserCount、StreamCount或RealmCount三者之一;UserCount与StreamCount中的数据再聚合进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 为空时)上唯一; UserCount与StreamCount上的(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); - frequency:
CountStat.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::day(LoggingCountStat,单位为 $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 附件占用字节数; - 消息已读类(
LoggingCountStat):messages_read::hour与messages_read_interactions::hour(后者近似统计“导致消息标记已读的 UI 交互次数”,对批量请求的放大效应不敏感); - 速率限制类(
LoggingCountStat,直接写RealmCount):invites_sent::day(限制组织发送邀请邮件数量)、mobile_pushes_sent::day; - 仅 ZILENCER_ENABLED 时的远程统计:
mobile_pushes_received::day、mobile_pushes_forwarded::day(写入RemoteRealmCount/RemoteInstallationCount,用于推送代理侧统计)。
模块底部还定义了COUNT_STATS(本地)、REMOTE_INSTALLATION_COUNT_STATS(远程)以及合并后的ALL_COUNT_STATS;BOUNCER_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)。它有两个关键用途:
- 增量续跑:
process_count_stat(analytics/lib/counts.py)从FillState读取“已填充到哪”,从该点开始以time_increment为步长循环推进,避免重复计算历史数据; - 崩溃恢复:若发现状态停留在
STARTED(说明上次填充中途失败),会先调用do_delete_counts_at_hour删除该时间点已写入的数据(回滚),再从上一个已完成时间点重试,保证不会产生半截数据。
首次运行时,若某统计没有FillState记录,则以installation_epoch()为起点——它是所有 realm 中最早创建时间的 UTC 日下界(analytics/models.py),意味着历史数据理论上可以一直回填到系统诞生。
process_count_stat内部对每个时间点执行:
- 将
FillState置为STARTED; - 调用
do_fill_count_stat_at_hour:先执行pull_function(对普通统计)把原始数据INSERT INTO目标子表,再调用do_aggregate_to_summary_table向上逐级聚合; - 将
FillState置回DONE并前进一个步长。
每一步都带logger.info计时(START ... DONE ... (%dms)),方便定位慢统计。
性能策略:让分析系统“轻”下来
针对“分析表不能比主库大、不能给服务器增加过多计算负载”的目标,docs/subsystems/analytics.md 总结了五条原则,均可从源码得到印证:
- 用
FillState避免重复劳动:只增量计算,不重算历史; - 预计算到
*Count表,避免端点直查大表:Message/UserMessage是 Zulip 应用库中仅有的两张超大表,某些查询直接执行可能要数分钟;把昂贵操作放到离线 cron 中,端点只读聚合好的小表,响应就能保持快速; - 把重活交给数据库:用原生 SQL 的
INSERT INTO ... SELECT(如do_aggregate_to_summary_table)在数据库内部完成聚合,而不是把数据取回 Python 再写回。由于 Django ORM 目前不支持这种写法,这里刻意使用了 Zulip 通常回避的 raw SQL; - 尽量聚合以减少对大表的查询:例如生成“每个用户的消息数”后直接求和得到 realm 总数,而不是分别对 realm 和 user 各查一次
Message表; - 不为 0 值建行:一个按小时的用户级统计如果不加控制,每年每个用户可能积累约 24×365 行、约 4GB 数据,且绝大多数值为 0。源码中
populate_analytics_db.py的insert_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_deploy与abort_unless_locked保护:部署期间自动中止、用锁避免并发运行。填充完成后若should_send_analytics_data()为真,还会按ZULIP_ORG_ID哈希出 0–10 分钟随机延迟,再向推送代理上报统计数据,避免所有服务器同时上报。check_analytics_state:Nagios 监控命令(analytics/management/commands/check_analytics_state.py),逐个检查ALL_COUNT_STATS的last_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 强调测试优先级:
- 最重要:测试“真正向分析表填充数据”的代码路径(
process_count_stat→pull_function→ 聚合)。这类 bug 发现成本极高,值得花时间设计边界用例; - 其次:测试后端视图从数据库抽取数据并返回给客户端的逻辑(如
get_chart_data各端点)。
手工调试时可用./manage.py dbshell直接查看各表内容核对结果;但文档建议:任何需要手工确认的断言都应沉淀进后端分析测试,避免重构后回归。
LoggingCountStats:处理不值得存全量数据的事件
上述体系适合“原始数据已存在数据库中、可随时回填”的统计(如Message、UserMessage)。但对于活动日志、请求性能等“每个数据点都不值得存储”的场景,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::hour、invites_sent::day、mobile_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(含用户shylock、bassanio与频道all)→ 用generate_time_series_data(定义于 analytics/lib/fixtures.py)为各统计生成 100 天、带工作日/非工作日基线、增长、自相关、尖峰与节假日效应的随机时序数据,并同步写入对应FillState。insert_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_data、get_chart_data_for_realm、get_chart_data_for_stream、get_chart_data_for_installation等端点(均为@typed_endpoint,支持chart_name、min_length、start、end参数)汇入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 的数据。访问前提是UserProfile的is_staff位为真,可通过manage.py shell直接修改UserProfile对象来设置。文档将其数据源清理与接口文档化列为值得做的后续项目。
小结:从数据到图表的完整链路
一个/stats图表的完整数据链路可以概括为:
- 收集:cron 每小时运行
update_analytics_counts,或业务事件触发do_increment_logging_stat,把原始计数写入UserCount/StreamCount/RealmCount; - 聚合:
do_aggregate_to_summary_table用原生 SQL 逐级汇总到RealmCount、InstallationCount; - 记账:
FillState记录进度,支持增量续跑与失败回滚; - 供给:
analytics/views/stats.py的do_get_chart_data按chart_name读取聚合表,生成对齐的时间序列 JSON; - 展示:
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),仅供参考