Zulip 服务端性能与可扩展性设计解析:从负载画像到核心端点优化
2026/9/12 11:52:08 网站建设 项目流程

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/performance.md系统性地阐述了其服务端性能与可扩展性的设计理念与工程实践:哪些端点是系统稳态负载的真正来源、为什么这些端点占用了绝大多数 CPU 时间、以及 Zulip 是如何通过架构设计(单请求加载全部数据、事件推送、软停用等)在有限硬件上支撑大规模多组织部署的。读完本文,你将理解 Zulip 的性能优化优先级是如何被客观数据驱动的,并掌握其六大关键端点的内部实现路径与可扩展性策略,可直接用于理解或评估 Zulip 自托管部署的容量规划。

性能与可扩展性的核心哲学

Zulip 把"快"视为实时协作工具用户体验的核心组成部分,并为此确立了两条基本原则:

  • 以数据预置换取即时渲染。Zulip Web 应用中的大量 UI 功能之所以能做到"瞬间加载",是因为渲染所需的数据几乎全部包含在首次 HTTP 响应(page_params)中,Zulip 的 API 与 Web 应用整体架构都围绕这一策略设计。
  • 用设计工作替代运维扩容。Zulip 的数据库模型与服务端实现经过精心设计,确保每个常见操作都足够高效,并有自动化测试防止"低效或过量的数据库查询"被无意引入代码库。团队明确表示"更愿意通过设计与实现让请求变快,而不是为了应付同样负载去运行 2~5 倍的硬件"。

从源码中可以看到这种理念的具体落地:例如 zerver/views/message_fetch.py 中的get_messages_backend对单次拉取的消息数量设有MAX_MESSAGES_PER_FETCH上限,从 API 层就杜绝了客户端一次性索取海量消息导致数据库压力失控的可能。

延伸阅读:生产环境的硬件选型与容量建议见 docs/production/requirements.md#scalability,该文档针对不同日活规模给出了 RAM、CPU、磁盘(含 S3 上传后端与 SSD 数据库盘)的保守建议,并说明 Zulip 资源需求主要取决于三个参数:日活用户数、总账号数、消息量。

两类截然不同的负载画像

分析可扩展性必须先理解生产环境的负载画像。Zulip 服务器的典型负载是两种差异极大的使用模式的混合:

  1. 开放社区型(开源项目、在线课程等):用户总数庞大但绝大多数处于空闲状态——许多人"来问个问题、得到答案、然后一年内不再回来"。Zulip 自己的开发社区就是典型例子:总账号超过 1.5 万,但最近几周内登录过的只有几百人。这类画像要求系统对"海量闲置用户"几乎零成本。
  2. 全职团队型(典型的企业自托管部署):用户每天活跃数小时、人均消息发送量大。这一画像对自托管服务器最为关键,因为多数自托管实例仅由运行该服务器的组织员工使用。

而 zulip.com 的负载画像,本质上就是成千上万个上述两类组织的叠加之和。

针对"闲置用户拖垮系统"的问题,Zulip 引入了**软停用(soft deactivation)**机制,见 docs/subsystems/sending-messages.md#soft-deactivation 的说明。其实现位于 zerver/lib/soft_deactivation.py:do_soft_deactivate_users将长期不活跃的用户标记为软停用(zerver/lib/soft_deactivation.py#L272-L300),使其不再接收消息事件等实时负载;当用户重新活跃时,reactivate_user_if_soft_deactivated 会将其重新激活并补齐遗漏数据,该函数在事件注册流程 zerver/lib/events.py 中会被自动调用。此外还有配套的 cron 式管理命令do_auto_soft_deactivate_userszerver/lib/soft_deactivation.py#L303)负责批量处理。通过这一机制,闲置用户在服务端可扩展性与请求延迟两个维度上的影响都被压到最小。

主导负载的"少数派端点"

理解 Zulip 可扩展性的关键前提是:只有极少数端点是系统绝大部分负载的来源,其余端点无论多慢都对整体可扩展性无足轻重。但这并不意味着其余端点可以被忽略——它们仍会因为延迟影响用户体验而值得优化;只是如果一个优化只能节省终端用户看不见的几毫秒、却牺牲代码可读性,那么只有它作用于下述主要端点时才值得考虑。

Zulip 文档写作遵循"记录长期为真的事实"的原则:下表数字会随硬件、使用模式和时间波动(一天内就有明显振荡),但其量级以及"哪些端点是重要的"这一结论在相当长时间内不会改变。

端点平均耗时请求占比平均影响
POST /users/me/presence25ms36%9000
GET /messages70ms3%2100
GET /300ms0.3%900
GET /events2ms44%880
GET /user_uploads/*12ms5%600
POST /messages/flags25ms1.5%375
POST /messages40ms0.5%200
POST /users/me/*50ms0.04%20

表中的"平均影响"由请求占比 × 平均耗时计算而来,粗略反映该端点在系统稳态 CPU 总负载中的相对贡献。它并不精确——等待网络请求与活跃 CPU 时间被等同对待,但对于建立"哪条代码路径最值得优化"的直觉极其有用,因为现实中的网络等待绝大部分是在等待 PostgreSQL 或 memcached 干活。

由此可以清晰看到两类对可扩展性重要的端点:极高请求量的端点(presence、events),以及中等请求量但单次昂贵的端点(GET /、GET /messages)。反之,像POST /users/me/subscriptions这类端点即使再昂贵也无碍可扩展性,因为其请求量可忽略不计。

Tornado 实时事件推送(GET /events)

Zulip 基于 Tornado 的实时推送系统是请求量的大头:GET /events约占生产服务器全部 HTTP 请求的 50%。尽管量级惊人,单个典型请求仅需 1~3ms 处理,且完全不访问数据库(只会访问 memcached 与 redis),因此对服务器总 CPU 的贡献并不突出——从源码看,GET /events的核心工作只是从内存事件队列中取出事件,zerver/tornado/event_queue.py 中的create_heartbeat_event就是典型例子。

正因为这些请求在总 CPU 消耗上如此高效,Tornado 服务在整个 Zulip 安装的 CPU 使用中的重要性远低于 Presence 与消息历史拉取。

值得注意的细节是:约 80% 的 Tornado 请求是以heartbeat事件结束长轮询的——这些 heartbeat 在连接空闲约一分钟后发出(源码中HEARTBEAT_MIN_FREQ_SECS = 45,即 zerver/tornado/event_queue.py 定义的最大超时值设为 55 秒,以应对那些会掐断空闲 HTTP 连接的劣质家用无线路由器)。这些 heartbeat 除规避"配置不当的 NAT/代理会杀死空闲连接"问题外别无用途;文档推断,若采用某种检测此类场景的策略,可以大幅削减其数量、从而显著降低 Tornado 的整体负载。

部署层面,Tornado 目前按realm 分片,且可选在 realm 内进一步按user-id 分片(配置见 docs/production/system-configuration.md#tornado_sharding)。按 realm 分片足以支撑 zulip.com 这类多租户系统的组织数量任意扩展;而按 user-id 分片则是为了同时拥有数千活跃用户的大型组织准备的。事件队列的生存期管理同样值得注意:默认队列超时为DEFAULT_EVENT_QUEUE_TIMEOUT_SECS = 600秒(10 分钟),上限MAX_QUEUE_TIMEOUT_SECS为 7 天,移动客户端则放宽到 12 小时(zerver/tornado/event_queue.py#L42-L60)。

Presence(POST /users/me/presence)

POST /users/me/presence请求提交当前用户的在线状态、并返回组织内所有其他活跃用户的在线状态,约占生产服务器 HTTP 请求的 36%,是 Zulip 服务器稳态负载的头号来源(对其他聊天服务器实现而言同样如此)。

Presence 之所以是任何聊天系统最棘手也最重要的可扩展性课题,根源在于两点:它不能被长时间缓存,且在结构上是平方级问题——每次请求都要组织并回传所有活跃用户的状态。典型 presence 请求要消耗 10~50ms 服务端处理时间,叠加其超高请求量,构成了服务器 CPU 的最大固定开支。此外,presence 的不可缓存性还意味着它绕过了 Zulip 最擅长的"缓存一切"策略。

从源码看,对应的处理函数是 zerver/views/presence.py 的update_active_status_backend:它先通过update_user_presence写入当前用户状态,再调用get_presence_response组织全部活跃用户数据返回。现代客户端(提交last_update_id参数)会启用slim_presence模式以减小响应体积;ping_only模式则只上报心跳、不取回全量状态,进一步降低每次请求的代价。

拉取 page_params(GET /)

生成GET /page_params部分的请求(等价于移动端/终端使用的GET /api/v1/register接口)是 Zulip最复杂、最昂贵的请求之一。

Zulip 在 Web 应用中有个相当独特的做法:在单个请求里发送整个 Web 应用所需的全部数据——组织内其他用户、频道(channels)、支持的 emoji、自定义资料字段等全部包含在内。这正是 Zulip Web 应用加载极快的原因:除可缓存静态资源(头像、图片、JS、CSS)外,只需要一次网络往返。该模型的优势在于,客户端几乎每个 UI 元素都能立刻渲染、无需等待服务器,这对高延迟网络环境下的体验至关重要。

仅有少数例外会在页面加载后通过独立 AJAX 请求补取数据:

  • 消息历史单独管理——这就是为什么 Zulip Web 应用会先渲染除中间面板外的整个站点,片刻后才渲染显示消息历史的中间面板;
  • 极少数低频数据(如消息编辑历史)按需获取;
  • 管理设置页面所需的部分数据集,仅在加载对应 UI 时获取。

GET //api/v1/register的请求量仅约 0.3%,但仍对可扩展性重要,原因有二:(1) 它们是 Zulip API 支持的最昂贵读请求;(2) 服务器重启后可能形成惊群效应(thundering herd,详见下文"拉取消息历史")。

其成本主要随组织规模变化:典型组织 90ms~300ms,而拥有数万用户的大型开放组织可能达到数秒;个人数据状态也有影响——数万条未读消息会导致"找出这些未读消息分布在哪些频道/主题"的查询变得昂贵。Zulip 将"任何组织正常page_params拉取超过 1 秒"视为 bug,并有持续修复工作。

一个有用的思考模型是:把page_params想象成另一个 Web 应用中的约 25 个 HTTP GET 请求(分别拉取用户、频道、自定义 emoji 等),而 Zulip 只是把它们合并成了单个 API 请求。未来 Zulip 很可能转向"并行执行不同特性的数据库拉取工作"的设计来进一步降低延迟。对于 1 万+ 用户且默认频道众多的组织,构造page_params的大部分时间花在编组"哪些用户订阅了哪些频道"的数据上,这也是当前优化工作的活跃方向。

源码佐证:page_params 的组装逻辑在 zerver/lib/home.py 的build_page_params_for_home_page_load中——它调用do_events_register(zerver/lib/events.py)拉取初始化状态数据,再合并服务器设置、语言列表、已读时间戳、2FA 状态等字段,最终注入名为page_params的 JavaScript 对象;每个字段都有对应注释说明其用途,并与前端base_page_params.ts的 schema 保持同步。一个值得注意的细节是:当客户端因事件队列过期而触发重载时(?state_data=deferred),该函数会跳过昂贵的do_events_register()调用、改为让客户端通过/json/register获取状态,以避免 HTML 响应过大及弱网络下部分传输失败的问题(见 zerver/lib/home.py 的注释)。

拉取消息历史(GET /messages)

批量获取消息内容与元数据的GET /messages约占全部 HTTP 请求的 3%,其处理函数为 zerver/views/message_fetch.py 的get_messages_backend。Zulip Web 应用产生大量此类请求,主要有三个原因:

  • 用户在视图间点击切换时,为避免某些微妙的 bug,Web 应用即使本地已有相关频道/主题的缓存历史,仍会向服务器重新拉取内容;
  • 浏览器打开 Web 应用时,最终会拉取并缓存"非静音上下文中、比最老未读消息更新的全部消息"——对拥有数万条未读消息的用户而言,这可能在总量上极其昂贵,单个浏览器可能发出 100 次此类请求;
  • 每次部署新版本服务器后,所有浏览器会在 30 分钟内重载以运行最新代码。对于频繁部署的实例(如 chat.zulip.org 与 zulip.com),这会对/GET /messages造成惊群效应。为此,自动重载系统的设计投入了大量精力,把惊群分散到几分钟内。

典型请求消耗 20~100ms,其中大部分时间用于从数据库获取消息 ID、再从 memcached 取消息内容——这使其成为相对其他端点而言"并不算大、但很昂贵"的一类请求。全文搜索之类的请求对常用词可能更贵,但因其绝对频次足够低,对系统整体可扩展性无实质影响。

该服务端代码路径已在单请求维度高度优化,但文档指出存在两个方向的技术设计来优化客户端发起请求的总频次

  • 改进客户端缓存,允许缓存用户当前会话内浏览过的 narrow(视图),避免同一会话内重复拉取消息内容;
  • 调整拥有数万未读消息客户端的取数行为,不再把大量旧消息历史拉进缓存。

两项改动叠加,预计将大幅削减拉取消息历史在整体可扩展性中的成本。

用户上传文件(GET /user_uploads/*)

获取上传文件(含用户头像)的请求约占全部 HTTP 请求的 5%。Zulip 处理单个此类请求约耗时 10~15ms(主要是授权逻辑),随后将文件交付移交给nginx(nginx 本身可能视配置的上传后端从 S3 拉取)。也就是说,Zulip 应用进程在此路径上的开销极低,文件传输的带宽成本被合理地卸载给了反向代理与对象存储。

发送与编辑消息(POST /messages)

发送新消息(含 incoming webhooks)的请求量不到总请求量的 0.5%。这个数字之小并不令人意外——尽管发消息直观上是聊天服务的核心功能:一条发给 50 个用户的消息,会触发约 50 次GET /events请求(因为每个在线接收者都需要通过事件推送得知新消息)。

典型消息发送请求耗时 20~70ms,更昂贵的请求通常源于更复杂语法的 Markdown 渲染。因此这类请求对 Zulip 整体可扩展性并不关键。编辑消息与添加 emoji 反应在性能/可扩展性特征上与发送非常相似——需要通知同一批客户端,且请求量更低。

但文档强调,这些端点的性能是Zulip 用户体验中最重要的部分之一:即使有本地回显(local echo),它们也是少数"请求处理延迟高度可见"的位置。此外,输入状态通知(typing notifications)的请求量略高于发消息,但单次极便宜(约 3ms)。

其余端点

订阅频道、修改设置、注册账号等其他 API 操作,相比上述端点"少得可以忽略"——根本原因在于:几乎没有人会在账号生命周期内把这些操作做上几十次以上,而上述端点对应的行为(在线上报、拉历史、收事件)每个用户都会重复成千上万次。因此,针对这些请求的性能工作通常只为延迟考虑,而非优化 Zulip 服务器的整体可扩展性。

队列处理器与 cron 任务

生产 Zulip 服务器的工作远不止响应 HTTP 请求:发送外发邮件、记录支撑 /stats 分析页面的数据等任务,由队列处理器与 cron 任务完成,而非在 HTTP 请求处理路径内执行。实践中,所有这些任务都被设计成对总负载与架构可扩展性无实质影响;不过偶尔仍需运维层面为高流量队列增加队列处理器实例。文档特别指出:所有队列处理器的序列化要求至多按用户维度,因此必要时按user_idrealm_id分片是直截了当的。

基础设施服务的可扩展性

除了聚焦总 CPU 工作量,还应关注基础设施服务(memcached、redis、rabbitmq,以及最重要的 postgres)与队列处理器(可能出现积压)上的负载。

实践中,让单个端点变快的努力通常也会连带降低这些服务的负载。但要牢记一点:数据库时间比 Python/CPU 时间珍贵得多——因为它更难水平扩展。因此:

  • 优化端点的第一步通常是优化数据库查询与/或启用缓存,然后按需继续对 Python 代码与 memcached 查询做性能剖析;
  • 对于上述少数关键代码路径,Zulip 还会在局部跳过 Django ORM(其本身开销可观),直接执行更精简的查询,通常足以让数据库查询时间主导 Python 应用进程的耗时;
  • Zulip 的服务器日志专门设计为能在请求消耗显著数据库或 memcached 资源时提供洞察,开发与生产环境均可使用。

结语:把优化资源投向正确的端点

Zulip 的性能与可扩展性方法论可以浓缩为一句话:用请求占比 × 单次耗时的乘积找出真正决定系统稳态 CPU 负载的少数端点,把架构级优化资源集中投放在它们身上,同时用"数据一次性随页面下发 + 事件推送 + 缓存 + 软停用"等系统性设计消灭结构性的低效。无论是自托管容量规划(可参考 docs/production/requirements.md#scalability 的硬件建议),还是想理解实时协作系统的服务端设计,这份文档与其对应的源码实现(zerver/lib/home.py、zerver/views/presence.py、zerver/views/message_fetch.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),仅供参考

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

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

立即咨询