news 2026/9/12 14:21:48

Zulip 通知系统架构深度解析:邮件与移动推送的完整代码路径

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Zulip 通知系统架构深度解析:邮件与移动推送的完整代码路径

Zulip 通知系统架构深度解析:邮件与移动推送的完整代码路径

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

本指南围绕 Zulip 仓库中的设计文档 docs/subsystems/notifications.md 展开,系统讲解 Zulip 的邮件通知与移动推送通知(mobile push notifications)从消息发送到最终触达用户的全链路设计:包括"何时该通知用户"的业务判定逻辑、离线/闲置用户的两个经典难题及其解决方案、Tornado 事件队列与后台队列进程的分工,以及消息编辑与通知的交互。读完本文,你将掌握 Zulip 通知子系统的核心设计决策、关键代码入口与测试验证方式,能够直接定位相关源码继续深入。

前置阅读与文档定位

本文是 Zulip 通知子系统的设计文档,面向在该代码路径上工作的开发者。建议先阅读 消息发送子系统 以了解消息如何被创建与投递;本文在其基础上展开邮件通知与移动推送通知的细节。同时,通知系统的行为依赖于 Tornado 事件队列系统,后者负责将消息事件推送给在线的客户端。

整个通知链路的代码分布非常清晰,可用如下目录快速索引:

  • 通知业务判定逻辑:zerver/lib/notification_data.py
  • 消息发送路径(构造各类 user_ids 集合):zerver/actions/message_send.py
  • Tornado 事件处理与队列投递:zerver/tornado/event_queue.py
  • 邮件通知队列进程:zerver/worker/missedmessage_emails.py
  • 移动推送队列进程:zerver/worker/missedmessage_mobile_notifications.py
  • 邮件渲染与发送:zerver/lib/email_notifications.py
  • 推送服务对接(APNs/FCM/Bouncer):zerver/lib/push_notifications.py

设计通知系统必须先理解的两个极端场景

在设计任何"离线通知"系统之前,文档先点名了两个容易踩坑的边界情况,Zulip 的整个通知判定逻辑都是围绕它们展开的:

  • 闲置桌面问题(idle desktop problem):用户办公室里放着一台"在线"的桌面电脑,但他已经 3 天没碰过它。如果系统仅仅因为"有客户端在线"就不发通知,那么这台桌面机会吞掉所有通知。Zulip 的结论是:在线 ≠ 活跃,判定"是否通知"必须基于用户的真实使用状态,而不是客户端连接状态。

  • 硬断连问题(hard disconnect problem):客户端随时可能断网(或笔记本被合上、系统被挂起),这是日常会反复发生的情况。系统必须保证:当一条可通知的消息发出后,用户紧接着合上笔记本,不会因为系统误以为"客户端已经收到"而让用户永远收不到通知。

这两个问题直接决定了presence_idle_user_ids集合与missedmessage_hook机制的存在意义,下文会逐一展开。

整体流程:从do_send_messages到队列进程

第一步:同步发送路径收集通知所需的全部数据

do_send_messages是同步的消息发送代码路径(位于zerver/actions/message_send.py)。在它调用send_event_on_commit时,会随事件字典携带三类与通知相关的数据:

  1. 消息内容相关数据:包括 @ 提及(mentions)、通配符提及(wildcard mentions)、提醒词(alert words)等,这些信息被编码进UserMessage表的flags结构,并通过send_event_on_commit随每个收件人的事件一起传递。

  2. 用户配置相关数据online_push_user_idsstream_notify_user_ids等集合被放入事件字典的主结构中。以 message_send.py 中的get_ids_for辅助函数为例,它从批量查询到的收件人行中按条件筛出集合,例如online_push_user_ids只包含enable_online_push_notifications=True的用户,dm_mention_email_disabled_user_ids只包含关闭了离线邮件通知的用户——注意这里刻意只记录"关闭了默认设置"的用户,因为这样的集合通常比"开启者"小得多,有利于控制事件体量。

  3. presence_idle_user_ids集合:包含"潜在可接收通知、但最近几分钟没有与 Zulip 客户端交互"的收件人子集。文档特别强调:这个集合会忽略消息本身不可通知的用户——否则给大型频道发消息时,这个集合会膨胀成数千个 user_id。具体实现见 message_send.py 的get_active_presence_idle_user_ids:它先对每个收件人调用is_notifiable(sender_id, idle=True)判断"如果此人真的闲置,这条消息是否可通知",只把满足条件的用户拿去查UserPresence表;随后filter_presence_idle_user_ids通过last_active_timeOFFLINE_THRESHOLD_SECS的对比筛出真正闲置的用户。这是一种非常重要的性能优化:对于大量不活跃成员的开源社区,避免了为所有人做 presence 查询。

第二步:Tornado 事件队列系统做"是否通知"的初步判定

Tornado 事件队列系统(见 events-system.md)拿到上述数据以及每个用户当前活跃的事件队列信息后,做两件事:

  1. 把消息事件推送到每个需要它的队列;
  2. 对于可通知的消息,把事件推送到missedmessage_mobile_notifications和/或missedmessage_emails队列。

这个过程的核心入口是 event_queue.py 的process_message_event。与普通事件相比,消息事件的处理有额外逻辑,例如按用户"拼接(splicing)flags"以定制每个用户的事件负载——同一个消息事件,对不同用户携带不同的 flags(如mentionedstream_wildcard_mentioned),这在为每个客户端构造 payload 时通过extra_user_data完成。

随后对每个收件人调用UserMessageNotificationsData.from_user_id_sets(见zerver/lib/notification_data.py),把事件字典里的一堆 user_ids 集合与用户的 flags 组合成一个完整的"通知判定对象",用于回答"这条消息对该用户是否可通知、触发类型是什么"。

第三步:队列进程做最终裁决并真正发送

这两个队列的进程会做最终裁决,并承担真正的发送工作。消息在队列里停留几分钟(未来可能数小时)是完全正常的,因为:

  • 已标记为已读的消息永远不触发通知;
  • 已删除的消息永远不触发通知;
  • 用户级"是否禁用邮件/推送通知"的设置会被重新检查——因为消息在队列等待期间,用户可能已经改掉了这些设置。

"何时通知"的核心判定逻辑:zerver/lib/notification_data.py

文档强调:"何时通知"的业务逻辑被刻意收敛在zerver/lib/notification_data.py的类方法中,该模块在zerver/tests/test_notification_data.py中有覆盖所有可能场景的单元测试。测试类TestNotificationData的 docstring 点明了这个模块的设计前提:UserMessageNotificationsData不做任何数据库查询,因此测试中的 user_id 都是任意的,不表示真实用户。

数据结构:UserMessageNotificationsData

这是一个 dataclass,字段可分为几组:

  • 基本身份:user_id
  • 各类通知开关:dm_email_notify/dm_push_notify(私信)、mention_email_notify/mention_push_notify(提及)、topic_wildcard_mention_*/stream_wildcard_mention_*(通配符提及)、stream_push_notify/stream_email_notify(频道消息)、followed_topic_push_notify/followed_topic_email_notify(已关注主题)以及这两者在"已关注主题中的通配符提及"变体
  • 特殊状态:online_push_enabledsender_is_muted(发送者是否被静音)、disable_external_notifications(是否禁用外部通知)

__post_init__里有一组assert校验数据一致性:私信通知与频道/已关注主题通知不能同时为真,反之亦然。

from_user_id_sets:把集合翻译成布尔开关

这是把事件字典中的各类 user_ids 集合翻译成"每个用户是否开启某类通知"的工厂方法,其中几个关键点:

  • 机器人直接被排除:如果user_id in all_bot_user_ids,直接返回一个全部为 False 的对象——机器人不接收任何通知。
  • dm_mention_*_disabled_user_ids的语义:私信与提及类通知使用"不在禁用集合中"作为条件,与发送路径"只记录关闭者"的策略保持一致。
  • 通配符提及集合的语义stream_wildcard_mention_user_ids/topic_wildcard_mention_user_ids(及其 followed-topic 变体)表示"这些用户对通配符提及应遵循个人提及的通知设置",它本身不是一个独立的通知设置,而是包装器。
  • push_device_registered_user_ids:推送类通知要求用户至少注册了一个推送设备。该参数为None时(老版本事件)硬编码push_device_registered = True以保证兼容升级路径,代码注释说明这是 11.x 直接升级到 main 时的过渡代码。
  • flags的角色"mentioned" in flags"topic_wildcard_mentioned" in flags"stream_wildcard_mentioned" in flags分别对应个人提及与两种通配符提及。

触发类型判定:优先级就是"显著性"排序

get_push_notification_triggerget_email_notification_trigger返回NotificationTriggers常量(定义在zerver/models/scheduled_jobs.py),其判定顺序刻意保持一致性——先判定的类型在通知语义上更显著

DIRECT_MESSAGE > MENTION > TOPIC_WILDCARD_MENTION_IN_FOLLOWED_TOPIC > STREAM_WILDCARD_MENTION_IN_FOLLOWED_TOPIC > TOPIC_WILDCARD_MENTION > STREAM_WILDCARD_MENTION > FOLLOWED_TOPIC_PUSH/EMAIL > STREAM_PUSH/EMAIL

例如,若mention_push_notifystream_push_notify同时为 True,则归类为提及(MENTION),因为"有人 @ 我"比"频道里来了一条消息"更值得用户注意。触发常量列表可在 scheduled_jobs.py 查看,包括direct_messagementionedtopic_wildcard_mentionedstream_wildcard_mentionedstream_push_notifystream_email_notifyfollowed_topic_push_notifyfollowed_topic_email_notify等。

邮件与推送的判定差异is_push_notifiable允许"在线但开启了enable_online_push_notifications"的用户(idle=False时仍可推送),而is_email_notifiable严格要求idle=True——邮件通知从不发给正在活跃使用 Zulip 的用户。两者的共同前置是trivially_should_not_notify:发送者是自己、发送者被静音、或禁用了外部通知,均直接不通知。

设置优先级层级:user_allows_notifications_in_StreamTopic

该模块还提供了通知设置的层级解析函数(zerver/lib/notification_data.py#L292):主题级静音 > 频道静音 > 频道特定设置 > 全局设置visibility_policy == MUTED的主题无论其他设置如何都抑制通知;被静音的频道中,只有当channel_specific_setting_overrides_mute(目前用于wildcard_mentions_notify)为真且频道特定设置存在时才可能放行;其余情况按"频道特定设置优先、全局设置兜底"的规则返回。这个函数被 message_send.py 等调用,用于在发送路径计算stream_push_user_idsstream_email_user_ids等集合。

用户组提及的辅助逻辑

  • get_user_group_mentions_data:当多个用户组被同时提及且用户同时属于多个组时,优先选择成员数最少的用户组作为通知触发来源。
  • get_mentioned_user_group:在邮件通知中展示用户组名称时实现同一算法(该函数位于zerver/lib/notification_data.py,但注释说明它接收的是消息列表而非 user_ids 集合),同样取成员最少的组。

在线/闲置判定:如何解决两个难题

presence_idle_user_ids解决闲置桌面问题

Tornado 系统需要判定用户是否"离线/闲置"。presence_idle_user_ids中的用户一律被视为闲置——变量名的含义正是"因 presence(在线状态)而闲置的用户"。这让闲置的桌面用户与未登录用户在此检查中享受同等对待:他们都会收到通知。这就是对"闲置桌面问题"的解答。

receiver_is_off_zulip处理软断连

但 presence 检查解决不了硬断连问题:如果用户在消息发送前 1 分钟还在线,然后合上笔记本,由于 Zulip 客户端需要闲置几分钟才会向服务器声明"我真的闲置了"(见 update-presence API 的相关说明),该用户不会出现在presence_idle_user_ids中。若没有额外机制,用户离开后不久到达的消息将永远不触发通知。

解决方案是同时检查receiver_is_off_zulip(event_queue.py):它检查用户当前是否有任何注册接收message事件的活跃事件队列,若一个都没有,说明用户不在 Zulip 上,需要通知。这个检查是立即执行的,因此能处理软断连场景——例如用户关掉最后一个 Zulip 标签页时,服务器收到DELETE /events/{queue_id}请求,事件队列被立即移除。

missedmessage_hook处理硬断连

receiver_is_off_zulip无法覆盖硬断连:如果客户端直接断网,队列要等到超时被垃圾回收时才会被认为"离线"。为此,事件队列被垃圾回收时会调用missedmessage_hook(event_queue.py),检查被回收的队列是否是该用户最后一个队列:

  • 若是最后一个,则遍历该队列中缓存的message事件,从事件的internal_data中还原出该用户的UserMessageNotificationsData(注意这里online_push_enabled硬编码为 False、idle硬编码为 True——既然最后一个队列都被回收了,用户必然是闲置的),再通过maybe_enqueue_notifications补发通知;
  • 若队列此前已因超时被标记 offline 并处理过通知(长生命周期队列如移动端在EVENT_QUEUE_OFFLINE_TIMEOUT_SECS闲置后就会触发一次),则跳过以避免重复通知。

EVENT_QUEUE_OFFLINE_TIMEOUT_SECS = 60 * 10(定义于 event_queue.py),这解释了文档中的观察:硬断连场景的通知通常会迟到 10 分钟

真正把事件变成通知的两个队列进程

邮件队列:MissedMessageWorker

zerver/worker/missedmessage_emails.py中的MissedMessageWorker(注册到missedmessage_emails队列)实现了"等待 + 批处理"策略:

  • 按用户配置的批处理周期:消费事件时读取user_profile.email_notifications_batching_period_seconds(模型默认值 120 秒,见 users.py),把ScheduledMessageNotificationEmail行写入数据库,scheduled_timestamp = now + batch_duration。若该用户已有待发送的邮件,则沿用已有的时间戳。
  • 双线程结构:主线程处理 RabbitMQ 连接并写数据库行;后台worker_thread通过条件变量(cv)在三种状态间切换——显式停止、无待发邮件时被 notify 唤醒、有待发邮件时以CHECK_FREQUENCY_SECONDS = 5的周期轮询。
  • 批处理发送maybe_send_batched_emails在事务中用select_for_update(skip_locked=True)锁定所有已到期的行(避免与其他 worker 或消息删除产生死锁),按用户分组,每组调用handle_missedmessage_emails合并为一封邮件,最后删除已处理的行。

为什么邮件要批处理而推送不需要?文档给出的理由是:推送通知可以随时用后续通知"补丁式更新"细节,而邮件一旦发出就无法轻易更新,所以必须等一等、把多条消息合并成一封邮件(文档同时指出,邮件等待的 2 分钟硬编码希望未来变成可配置项)。此外,Zulip 的邮件通知样式刻意模仿 GitHub 邮件通知的简洁设计,并支持直接回复邮件来回复消息(通过 incoming email integration 邮件网关实现)。

邮件发送的核心实现在zerver/lib/email_notifications.py

  • handle_missedmessage_emails(email_notifications.py):重新校验enable_offline_email_notifications;通过Message.objects.filter(...)的查询结构自动过滤已永久删除的消息(已删除消息已移入ArchivedMessage表);排除已读消息(~UserMessage.flags.read)与content="(deleted)"的消息。随后按(recipient_id, topic)分桶,同一桶内取最早的消息,若该频道消息含提及则附带上下文消息,最后按最近活跃时间从旧到新排序,每个桶发送一封邮件。
  • do_send_missedmessage_events_reply_in_zulip(email_notifications.py):构造邮件的 Reply-To 地址(create_missed_message_address生成的受限邮箱地址,仅当配置了EMAIL_GATEWAY_PATTERN时启用回复功能)、计算提及类型(个人提及 / 主题通配符 / 频道通配符等)并渲染模板上下文。

推送队列:PushNotificationsWorker

zerver/worker/missedmessage_mobile_notifications.py中的PushNotificationsWorker(注册到missedmessage_mobile_notifications队列)是push_notifications.py代码的简单包装,但有几处自身的复杂性:

  • 消息类型分发consume根据event["type"]分发——register_push_device_to_bouncer注册设备到 Bouncer、remove移除通知(用于消息被删除或提及被移除时更新移动端)、其余类型调用handle_push_notification
  • 推送设备注册检查与 Badge 计数:发送逻辑需要跟踪未读推送通知数量以显示在移动应用角标(badge)上,见zerver/lib/push_notifications.pyhandle_push_notificationhandle_remove_push_notification
  • 自托管系统的 Bouncer 服务:自托管 Zulip 使用 mobile push notifications service 中继推送;当 Bouncer 返回PushNotificationBouncerRetryLaterError时,事件通过retry_event进入重试队列。
  • 分片支持:当MOBILE_NOTIFICATIONS_SHARDS > 1时(配置见zproject/computed_settings.py),队列按user_id % MOBILE_NOTIFICATIONS_SHARDS拆分为missedmessage_mobile_notifications_shardN(见zerver/lib/queue.py),每个 worker 进程只处理一个分片。
  • 推送发送底层对接 APNs(Apple)与 FCM(Android),涉及dedupe_device_tokenssend_apple_push_notificationsend_android_push_notification等函数;由于使用了 aioapns,MAX_CONSUME_SECONDS被设为None,因为 SIGALRM 与 asyncio 不兼容。

桌面通知:最简路径

桌面通知的实现最为简单:完全由 Web/桌面应用的客户端逻辑完成(见web/src/message_notifications.tsweb/src/desktop_notifications.ts等模块)。客户端检查 Tornado 系统拼接进message事件的flags字段(如mentionedstream_wildcard_mentioned),结合用户的本地通知设置(desktop_notifypm_content_in_desktop_notifications等)决定是否弹出系统通知。服务端不参与桌面通知的"是否发送"裁决,这正是它简单的原因。

消息编辑与通知的交互

Zulip 支持消息编辑,这给通知系统带来了需要小心处理的交互:

  • 通知应包含最新编辑后的内容:用户常常在发出消息 30 秒后修改错别字,通知不能基于旧内容生成;
  • 编辑时新增提及应通知被新提及的用户
  • 删除消息应取消其未发送的通知handle_remove_push_notification处理移动端已发送通知的移除)。

Tornado 内部的对应实现是maybe_enqueue_notifications_for_message_update(event_queue.py),它与消息发送路径的判定逻辑并行,但有几处刻意不同的规则:

  1. 发送者被静音则直接返回;
  2. 私信不处理编辑通知——原始消息已经通知过用户;
  3. prior_mentioned时不重复通知——避免对同一用户重复发送提及通知(考虑到大多数编辑只是修正错别字);文档同时指出理想情况下应区分wildcard_mentions_notify=False的用户,但当前数据模型无法还原原始消息时的设置,故暂不处理;
  4. 若用户原本就设置了stream_push_notify/stream_email_notify/followed_topic_*,则假定用户已收到过原消息的通知,直接短路(未来可能用类似AlreadyNotified的模型更严格地处理);
  5. 闲置判定为presence_idle or receiver_is_off_zulip,随后复用maybe_enqueue_notifications

编辑通知暂不支持用户组提及的定制化展示(mentioned_user_group_id = None),被提及的用户仍会收到通知,但效果等同于个人提及。

该路径的测试非常完善:test_message_edit_notifications.py 覆盖了编辑添加/移除提及的各类情况,包括test_second_mention_is_ignored(第二次提及被忽略)、test_clear_notification_when_mention_removed(移除提及时清除通知)、test_not_clear_notification_when_mention_removed_but_stream_notified(虽然移除了提及但频道已通知故不重复处理)等用例。

系统结构约束:设计变更时必须牢记的四条铁律

文档最后总结了理解本系统结构时必须知道的四条约束,任何改动都应在其框架内进行:

  1. 批量数据库查询效率远高:像"哪些收件人当前在线"这类数据库细节检查,用批量查询(而非逐用户查询)高效得多。
  2. 支持数千用户:Zulip 面向数千用户的组织,必须避免send_event_on_commit()通过 RabbitMQ 向 Tornado 推送大量逐用户数据——这就是事件字典中大量使用 user_ids 集合(而非逐用户字典)的原因。
  3. Tornado 不做数据库查询:Tornado 是异步事件驱动框架,而 Django 数据库库是同步的,数据库查询代价极高。因此 presence 等数据必须在do_send_messages或队列进程中查询,绝不放在 Tornado 中(这解释了为什么presence_idle_user_ids在发送路径就已算好、随事件传入)。
  4. 未来配置的扩展空间:通知设置只会有增无减,例如计划中的"关注主题"功能(只接收某个主题内消息的通知)。Zulip 的线程模型衍生出大量不同的工作流,让用户便捷地把通知配置成贴合自己的工作流,是持续的设计目标。
  5. 消息编辑:如上节所述,编辑与通知的交互需要小心处理,且通知应携带最新内容。

测试与验证路径

文档明确指出zerver/lib/notification_data.py的单元测试位于 test_notification_data.py,覆盖"所有可能情况";消息编辑通知路径的自动化测试位于 test_message_edit_notifications.py。

TestNotificationData中的典型用例(如test_is_push_notifiable)验证了触发类型判定的优先级:直接私信返回DIRECT_MESSAGE、提及返回MENTION、主题/频道通配符提及分别返回TOPIC_WILDCARD_MENTION/STREAM_WILDCARD_MENTION、频道通知返回STREAM_PUSH、已关注主题返回FOLLOWED_TOPIC_PUSH。由于UserMessageNotificationsData不做数据库查询,这些测试无需真实用户数据即可运行,非常适合作为理解各通知开关组合效果的入门读物。

此外,maybe_enqueue_notifications在源码注释中声明"在test_enqueue_notifications中拥有完整的单元测试套件",文档也提示未来可能考虑增加"让用户查看历史通知"的机制,以帮助解释和调试这个因复杂度而著称的系统——这从一个侧面说明了该子系统确实值得一个可视化调试入口。

未来演进方向

文档提到的未来方向包括:把邮件批处理的 2 分钟等待变成可配置设置、增加"已关注主题"的通知维度、以及(可能出现的)历史通知查看/调试界面。对开发者而言,zerver/lib/notification_data.py是扩展"何时通知"逻辑的首选落点,zerver/tornado/event_queue.py与两个队列进程则承载了"如何投递"的实现细节。

【免费下载链接】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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 14:20:22

LLM核心技术解析:Function Calling、MCP与A2A实战

1. 项目概述 "深入理解LLM三大核心技术:Function Calling、MCP与A2A实战指南"这个标题直指当前大语言模型(LLM)应用开发中最核心的三大技术方向。作为一名长期从事AI应用开发的工程师,我发现很多团队在接入LLM时都会遇到…

作者头像 李华
网站建设 2026/9/12 14:19:52

Pandas数据排序实战:sort_values、sort_index与rank全解析

用 pandas 做数据排序这事儿,看起来就是个sort_values的事,但真到了实战里,单列排序、多列排序、按索引排、缺失值怎么放、字符串怎么按规则排、排序后索引乱不乱……每个点都能卡你一下。我自己刚用 pandas 处理数据那会儿,就被“…

作者头像 李华
网站建设 2026/9/12 14:17:34

电动汽车充电调度优化:双层模型与MATLAB实践

1. 电动汽车时空调度问题的现实挑战 作为一名长期从事能源系统优化的工程师,我深刻理解电动汽车规模化接入电网带来的调度难题。去年参与某充电站集群项目时,我们遇到了一个典型场景:下午6点下班高峰时段,30辆电动网约车同时返回充…

作者头像 李华
网站建设 2026/9/12 14:14:27

ESLint max-classes-per-file 规则详解:限制单文件中的类数量

ESLint max-classes-per-file 规则详解:限制单文件中的类数量 【免费下载链接】eslint Find and fix problems in your JavaScript code. 项目地址: https://gitcode.com/GitHub_Trending/es/eslint 导读 max-classes-per-file 是 ESLint 内置的一条代码风格…

作者头像 李华