ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Zulip 服务端性能与可扩展性设计解析:从负载画像到核心端点优化

Zulip 服务端性能与可扩展性设计解析:从负载画像到核心端点优化 Zulip 服务端性能与可扩展性设计解析从负载画像到核心端点优化【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulipZulip 的docs/subsystems/performance.md系统性地阐述了其服务端性能与可扩展性的设计理念与工程实践哪些端点是系统稳态负载的真正来源、为什么这些端点占用了绝大多数 CPU 时间、以及 Zulip 是如何通过架构设计单请求加载全部数据、事件推送、软停用等在有限硬件上支撑大规模多组织部署的。读完本文你将理解 Zulip 的性能优化优先级是如何被客观数据驱动的并掌握其六大关键端点的内部实现路径与可扩展性策略可直接用于理解或评估 Zulip 自托管部署的容量规划。性能与可扩展性的核心哲学Zulip 把快视为实时协作工具用户体验的核心组成部分并为此确立了两条基本原则以数据预置换取即时渲染。Zulip Web 应用中的大量 UI 功能之所以能做到瞬间加载是因为渲染所需的数据几乎全部包含在首次 HTTP 响应page_params中Zulip 的 API 与 Web 应用整体架构都围绕这一策略设计。用设计工作替代运维扩容。Zulip 的数据库模型与服务端实现经过精心设计确保每个常见操作都足够高效并有自动化测试防止低效或过量的数据库查询被无意引入代码库。团队明确表示更愿意通过设计与实现让请求变快而不是为了应付同样负载去运行 25 倍的硬件。从源码中可以看到这种理念的具体落地例如 zerver/views/message_fetch.py 中的get_messages_backend对单次拉取的消息数量设有MAX_MESSAGES_PER_FETCH上限从 API 层就杜绝了客户端一次性索取海量消息导致数据库压力失控的可能。延伸阅读生产环境的硬件选型与容量建议见 docs/production/requirements.md#scalability该文档针对不同日活规模给出了 RAM、CPU、磁盘含 S3 上传后端与 SSD 数据库盘的保守建议并说明 Zulip 资源需求主要取决于三个参数日活用户数、总账号数、消息量。两类截然不同的负载画像分析可扩展性必须先理解生产环境的负载画像。Zulip 服务器的典型负载是两种差异极大的使用模式的混合开放社区型开源项目、在线课程等用户总数庞大但绝大多数处于空闲状态——许多人来问个问题、得到答案、然后一年内不再回来。Zulip 自己的开发社区就是典型例子总账号超过 1.5 万但最近几周内登录过的只有几百人。这类画像要求系统对海量闲置用户几乎零成本。全职团队型典型的企业自托管部署用户每天活跃数小时、人均消息发送量大。这一画像对自托管服务器最为关键因为多数自托管实例仅由运行该服务器的组织员工使用。而 zulip.com 的负载画像本质上就是成千上万个上述两类组织的叠加之和。针对闲置用户拖垮系统的问题Zulip 引入了**软停用soft deactivation**机制见 docs/subsystems/sending-messages.md#soft-deactivation 的说明。其实现位于 zerver/lib/soft_deactivation.pydo_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%9000GET /messages70ms3%2100GET /300ms0.3%900GET /events2ms44%880GET /user_uploads/*12ms5%600POST /messages/flags25ms1.5%375POST /messages40ms0.5%200POST /users/me/*50ms0.04%20表中的平均影响由请求占比 × 平均耗时计算而来粗略反映该端点在系统稳态 CPU 总负载中的相对贡献。它并不精确——等待网络请求与活跃 CPU 时间被等同对待但对于建立哪条代码路径最值得优化的直觉极其有用因为现实中的网络等待绝大部分是在等待 PostgreSQL 或 memcached 干活。由此可以清晰看到两类对可扩展性重要的端点极高请求量的端点presence、events以及中等请求量但单次昂贵的端点GET /、GET /messages。反之像POST /users/me/subscriptions这类端点即使再昂贵也无碍可扩展性因为其请求量可忽略不计。Tornado 实时事件推送GET /eventsZulip 基于 Tornado 的实时推送系统是请求量的大头GET /events约占生产服务器全部 HTTP 请求的 50%。尽管量级惊人单个典型请求仅需 13ms 处理且完全不访问数据库只会访问 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。PresencePOST /users/me/presencePOST /users/me/presence请求提交当前用户的在线状态、并返回组织内所有其他活跃用户的在线状态约占生产服务器 HTTP 请求的 36%是 Zulip 服务器稳态负载的头号来源对其他聊天服务器实现而言同样如此。Presence 之所以是任何聊天系统最棘手也最重要的可扩展性课题根源在于两点它不能被长时间缓存且在结构上是平方级问题——每次请求都要组织并回传所有活跃用户的状态。典型 presence 请求要消耗 1050ms 服务端处理时间叠加其超高请求量构成了服务器 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_paramsGET /生成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详见下文拉取消息历史。其成本主要随组织规模变化典型组织 90ms300ms而拥有数万用户的大型开放组织可能达到数秒个人数据状态也有影响——数万条未读消息会导致找出这些未读消息分布在哪些频道/主题的查询变得昂贵。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_registerzerver/lib/events.py拉取初始化状态数据再合并服务器设置、语言列表、已读时间戳、2FA 状态等字段最终注入名为page_params的 JavaScript 对象每个字段都有对应注释说明其用途并与前端base_page_params.ts的 schema 保持同步。一个值得注意的细节是当客户端因事件队列过期而触发重载时?state_datadeferred该函数会跳过昂贵的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 应用产生大量此类请求主要有三个原因用户在视图间点击切换时为避免某些微妙的 bugWeb 应用即使本地已有相关频道/主题的缓存历史仍会向服务器重新拉取内容浏览器打开 Web 应用时最终会拉取并缓存非静音上下文中、比最老未读消息更新的全部消息——对拥有数万条未读消息的用户而言这可能在总量上极其昂贵单个浏览器可能发出 100 次此类请求每次部署新版本服务器后所有浏览器会在 30 分钟内重载以运行最新代码。对于频繁部署的实例如 chat.zulip.org 与 zulip.com这会对/和GET /messages造成惊群效应。为此自动重载系统的设计投入了大量精力把惊群分散到几分钟内。典型请求消耗 20100ms其中大部分时间用于从数据库获取消息 ID、再从 memcached 取消息内容——这使其成为相对其他端点而言并不算大、但很昂贵的一类请求。全文搜索之类的请求对常用词可能更贵但因其绝对频次足够低对系统整体可扩展性无实质影响。该服务端代码路径已在单请求维度高度优化但文档指出存在两个方向的技术设计来优化客户端发起请求的总频次改进客户端缓存允许缓存用户当前会话内浏览过的 narrow视图避免同一会话内重复拉取消息内容调整拥有数万未读消息客户端的取数行为不再把大量旧消息历史拉进缓存。两项改动叠加预计将大幅削减拉取消息历史在整体可扩展性中的成本。用户上传文件GET /user_uploads/*获取上传文件含用户头像的请求约占全部 HTTP 请求的 5%。Zulip 处理单个此类请求约耗时 1015ms主要是授权逻辑随后将文件交付移交给nginxnginx 本身可能视配置的上传后端从 S3 拉取。也就是说Zulip 应用进程在此路径上的开销极低文件传输的带宽成本被合理地卸载给了反向代理与对象存储。发送与编辑消息POST /messages发送新消息含 incoming webhooks的请求量不到总请求量的 0.5%。这个数字之小并不令人意外——尽管发消息直观上是聊天服务的核心功能一条发给 50 个用户的消息会触发约 50 次GET /events请求因为每个在线接收者都需要通过事件推送得知新消息。典型消息发送请求耗时 2070ms更昂贵的请求通常源于更复杂语法的 Markdown 渲染。因此这类请求对 Zulip 整体可扩展性并不关键。编辑消息与添加 emoji 反应在性能/可扩展性特征上与发送非常相似——需要通知同一批客户端且请求量更低。但文档强调这些端点的性能是Zulip 用户体验中最重要的部分之一即使有本地回显local echo它们也是少数请求处理延迟高度可见的位置。此外输入状态通知typing notifications的请求量略高于发消息但单次极便宜约 3ms。其余端点订阅频道、修改设置、注册账号等其他 API 操作相比上述端点少得可以忽略——根本原因在于几乎没有人会在账号生命周期内把这些操作做上几十次以上而上述端点对应的行为在线上报、拉历史、收事件每个用户都会重复成千上万次。因此针对这些请求的性能工作通常只为延迟考虑而非优化 Zulip 服务器的整体可扩展性。队列处理器与 cron 任务生产 Zulip 服务器的工作远不止响应 HTTP 请求发送外发邮件、记录支撑 /stats 分析页面的数据等任务由队列处理器与 cron 任务完成而非在 HTTP 请求处理路径内执行。实践中所有这些任务都被设计成对总负载与架构可扩展性无实质影响不过偶尔仍需运维层面为高流量队列增加队列处理器实例。文档特别指出所有队列处理器的序列化要求至多按用户维度因此必要时按user_id或realm_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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进