news 2026/10/1 14:14:46

从通知泛滥到统一收件箱:自建轻量级实时推送服务buzz实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从通知泛滥到统一收件箱:自建轻量级实时推送服务buzz实战

我一开始做buzz这个项目,原因特别朴素:我自己的消息通道炸了。手机里微信、邮件、Slack、GitHub、监控告警、客户群、服务器报警……每个渠道都在说话,可是真正需要我看的那条消息,经常被淹没在几百条“好的”“收到”和自动化通知里。我把所有渠道的通知折腾到统一入口,做了段时间后,意识到这本身就是一个标准的技术项目:实时事件接入、消息路由、推送分发、可靠性兜底。于是buzz从一个脚本变成了一套带连接管理、事件通道、离线补推的独立服务。

如果你也在做消息推送、实时通知、事件分发这类事情,或者只是想把零散的通知源收敛到一个系统里,这篇就是我把它从原型跑到生产环境的完整记录,包括为什么这么设计、踩过的坑是哪个环节埋下的、以及现在半年跑下来哪些决策被验证是对的。

1. 从没完没了的通知说起:buzz 想解决的真实问题

1.1 我的痛点不只在“消息太多”

先说个现象。很多人以为通知泛滥是“消息数量”的问题,其实不是。数量只是表面,真正让我难受的是三件事:

  • 上下文断裂:同一个项目的部署告警在钉钉群,代码评审提醒在邮件,用户反馈在另一个平台。我想搞清楚“这次发布到底有没有问题”,得开四个页面来回切。
  • 重要度和时效性被抹平:服务器磁盘告警和“有人给你点了赞”在视觉上是一样重的,我无法按规则把它们分流。
  • 消息没有状态:看过就找不到了,想追溯“昨晚那条告警最后怎么处理的”,没有记录。

于是我想做一个统一收件箱,它不只是一个转发管道,而是一个能理解“事件从哪来、该去哪、需不需要立刻引起注意”的系统。buzz这个名字就是这么来的——我希望它是那种“贴身振动一下”的提示,而不是“没完没了响个不停的喇叭”。

1.2 调研过现成方案之后,为什么还要自己写

决定动手之前,我把现成选项认真过了一遍:

  • 用 Slack/Discord/钉钉的机器人 Webhook:接入最快,但通知永远往聊天记录里混,检索和状态管理等于没有。
  • 用现成开源推送系统(比如各类即时通讯框架):功能强,但部署重量级,依赖一堆组件,对于个人项目和中小团队场景来说,运维成本不划算。
  • 用云厂商的推送服务(比如各类厂商的移动推送、消息服务):适合 App 端,但我的场景里还要接 Webhook、网页端实时流、脚本回调,它不够通用。

我真正想要的是一个轻量、自托管的“事件分发中枢”:接收 HTTP 请求里的事件,按用户规则做过滤和路由,推送到网页端或移动端,同时保留历史记录,允许事后查询。

这个定位刚好卡在“聊天机器人太薄”和“完整消息平台太重”之间,所以自己写一个并不算重复造轮子,更准确地说,是做一个贴合我用法的最小系统。

1.3 buzz 定位一句话版本

如果你只记一句话,可以这样理解buzz:

一个带连接管理、事件路由、历史存储的实时推送服务。上游任何系统只要发一个 HTTP 请求,buzz 就能把事件按规则推给对应终端,并且在终端离线时把消息存下来,等它回来再补推。

它在技术上解决的核心问题是:如何在不同网络状况、不同终端类型、不同消息重要度下,把一条事件可靠送到需要被通知的人面前。

2. buzz 的核心架构:一个尽量简单的实时事件分发模型

2.1 最小可用架构长什么样

我不喜欢过度设计,第一版架构就四块:

事件入口(HTTP API / Webhook) ↓ 路由与过滤引擎(按用户、级别、标签匹配) ↓ 连接管理层(WebSocket 在线终端 + 状态管理) ↓ 存储层(事件历史 + 离线消息队列)

这里没有引入独立消息队列组件,没有用 Redis 做缓冲,没有拆微服务。原因很简单:buzz的目标并行度是“几百个在线连接、每秒几十到几百条事件”,这个量级用单进程加内置队列完全能撑住。加了消息队列反而会让问题复杂化,因为你还要考虑队列积压监控、消费者失败重试、消息顺序问题。

架构上的核心原则就一条:能在一个进程里完成的事情,绝不拆出去。

2.2 为什么选 WebSocket 作为主通道

终端要收到“实时”推送,主通道我只考虑了两个方案:轮询和长连接。

轮询最简单,但问题很明显:为了达到准实时效果,客户端得几秒请求一次,服务器压力随客户端数量线性膨胀,而且事件延迟没法稳定控制。我最初测试时,20个客户端、3秒间隔轮询,服务端没垮,但日志刷得很烦躁。更关键的是,轮询无法做“服务器主动向下推送”,很多状态同步要靠客户端猜。

所以我选了 WebSocket 作为在线终端的主通道。理由不只是实时性,而是它天然维护了一个双向、有状态的长连接。服务器端能精确知道这个客户端活着、断线、重连,连接上还能附加用户身份、订阅标签等信息。

不过说实话,WebSocket 也不是没有代价:连接要保活、要处理心跳、要应对网络代理的超时问题。我在后面会单独讲几个我自己踩过的连接坑。

2.3 推和拉必须共存,不能赌单一策略

如果只看实时通道,很多人会以为“只要 WebSocket 够稳,就不需要拉取”。我的经验是:推送和拉取从来不是二选一,而是互为兜底。

原因很现实:

  • WebSocket 长连接可能被中间网络设备静默断掉,客户端以为自己还连着,实际已经死掉。这种问题不是服务端能完全感知的。
  • 某些网络环境(比如用户从内网切到外部网络、手机锁屏一段时间)会导致连接根本无法维持。
  • 客户端进程被系统杀掉重开,需要同步断开期间丢失的消息。

所以我在客户端 SDK 里做了“推拉结合”:正常情况下靠 WebSocket 收实时消息,同时每 30 秒做一次轻量同步请求,把上次收到的最后一条消息 ID 传给服务端,服务端把之后的新消息一次性补回来。这样即使连接被静默掐断,最多丢失 30 秒内的事件,而且事件历史在服务端都有存储,拉取就能补上。

这背后的思维是:实时性靠推,可靠性靠拉,两个通道共存才能处理真实网络环境的渣滓。

2.4 通道适配层:把“通知源”变成统一事件

buzz要接的上游系统非常多,从 GitHub webhook、监控告警、CI/CD 流水线,到自定义脚本里的一行curl。如果不做一层统一抽象,每接一个来源就要写一套解析逻辑,迟早乱套。

我的做法是定义一个最小事件模型:

{ "id": "evt_20250101_0001", "source": "monitor", "type": "disk.usage.warning", "level": "warning", "title": "磁盘使用率超过 85%", "body": "主机 prod-01,分区 /,当前使用率 87%", "tags": ["prod", "alert"], "timestamp": "2025-01-01T10:00:00Z" }

所有上游系统不管原始格式是什么,接入时都映射成这个结构。其它字段可以塞进ext扩展字段里,但核心字段必须统一。这个模型的好处是:路由引擎只需要基于source、type、level、tags做匹配,不需要关心某个来源的特殊细节。新接入一个推送源,通常只需要写一个几十行的适配函数。

3. 从第一版到能上线:关键坑位与完整排查链路

3.1 连接一多就断线:先猜是网络,其实是文件描述符

第一次压力测试就翻车了。我用脚本模拟 200 个 WebSocket 客户端同时连接,前 100 个很顺利,到 120 左右开始大量连接超时,已经连上的也开始不稳定断开。

我第一反应是网络带宽或者端口不够,连续检查了 TCP 连接状态、netstat、防火墙规则。排查了半天没结论,直到我无意中看了进程的文件描述符数量。

问题根因很简单也很有代表性:Linux 单个进程默认的文件描述符上限是 1024,我的进程还开了日志文件、数据库连接等,所以实际能承载的 socket 连接数远低于 200。每个 WebSocket 连接至少占用 1 个 fd,系统预留资源和日志句柄再占掉一批,阈值就出现在 120 附近。

修复分两步:

# 临时调整个进程的 ulimit ulimit -n 65535 # 持久化配置 echo "username soft nofile 65535" >> /etc/security/limits.conf echo "username hard nofile 65535" >> /etc/security/limits.conf

还要注意,ulimit调了不代表立刻对所有连接生效,已经启动的服务需要重启。我当时忘记重启,又浪费了十分钟才意识到为什么配置没生效。

提示:遇到“连接数一多就不稳定”的问题,第一步先看进程的 fd 使用量,不要急着调内核网络参数。

3.2 消息“丢”了?不,是重启顺序把我坑了

有一次我发布新版本,重启buzz服务,重启完成后测试消息发送,客户端就是收不到。查日志发现事件确实进了服务端,WebSocket 也提示客户端在线,但消息没有被下发。

我盯着代码看了很久也没发现问题,直到我把发布过程重新梳理了一遍,才意识到是我自己的操作顺序问题:我先启动了 buzz 服务,然后才去启动依赖的存储进程。

服务启动时,它会对数据库做一次连接初始化,连接失败就直接跳过缓存、进入降级模式。表面上服务进程活着,实际上内部状态已经不完整了。之后客户端连上来,发现自己没有拿到订阅关系列表,自然拒绝推送。

这个问题的排查方法其实不复杂,就是把日志级别调到 DEBUG,看到服务启动时的错误:

2025-01-01 10:00:01 ERROR storage init failed, enter degraded mode 2025-01-01 10:00:05 WARN subscription table unavailable, skip rule loading

从那以后我做了两件事:一是把启动脚本改成强制按依赖顺序启动;二是给服务加了一个健康检查接口,启动时如果关键依赖没就绪,直接返回 503 而不是装作一切正常。

3.3 回调风暴:一个不平衡消费导致的雪崩

上线一个月后,某个客户系统的 webhook 配置错误,一次性回调了 5 万条历史事件进来。buzz的入口接收到这批事件后,路由引擎匹配规则,发现其中 3 万条匹配到了某个在线终端的标签,于是疯狂向那个连接推送。

这个客户端也好巧不巧,处理能力跟不上,消息在客户端本地越积越多,最终浏览器标签页直接卡死。更糟的是,客户端的自动重连机制不断重连,每次重连后服务端又尝试把积压的离线消息补推给它,形成恶性循环。

这次教训让我意识到一个很根本的问题:推送系统不能只看服务端能不能发出去,还要看客户端能不能接得住。

修复方案做了三层:

  1. 服务端增加针对单连接的推送速率限制,超过阈值先丢弃非关键事件,只保高级别告警。
  2. 离线补推不是一次性全部推送,而是分页拉取,客户端每次主动拉 100 条,处理完再拉下一条。
  3. 事件入口层限制单来源的并发速率,超过阈值直接返回 429,让上游系统自己排队。

这个“回调风暴”和经典的消息队列消费者堆积其实是同一个问题,只不过在推送场景里,消费者是浏览器里的一个 JS 会话,更脆弱。

3.4 去重与幂等的“最后一公里”教训

有一次上游系统重复推送了同一条事件,我眼睁睁看着客户端弹了三条一样的通知。表面上这是因为上游 Webhook 机制自带重试,实际上也是buzz自身缺少幂等保护。

一开始我觉得,事件 ID 是上游生成的,我只要存起来判断是否处理过不就行了?但试了才发现没那么简单:上游可能不传 ID,可能传一样的 ID 给不同事件,也可能连续两次请求之间只有几百毫秒间隔,我还没把第一条的 ID 写入存储,第二条已经到了。

最后我用的是“短窗口内存去重 + 长窗口存储去重”组合:

  • 内存去重:用 LRU 缓存保存最近 5 分钟内收到的事件 ID,相同的直接丢弃或返回去重标记。
  • 存储去重:在事件表里对source + type + 原始事件ID建唯一索引,数据库层的唯一约束兜底。

这背后的原理其实和消息队列消费者里的幂等设计一模一样:任何可能重发的上游,下游都必须具备幂等处理能力,而且不能只靠一层防。内存去重挡高频重复,存储唯一索引挡低频但必达的历史重复。

4. 性能调优与可靠性:我用半年时间换来的数据与权衡

4.1 压测数据:不是越高越好,关键是摸到天花板

给buzz做了几次压测之后,我记录了一组比较真实的数据,不是实验室里的极限,而是“同时在线连接 + 事件推送混合负载”下的数据:

场景并发连接数事件速率单条端到端延迟(P99)
日常运行20020 条/秒45ms
促销模拟500120 条/秒80ms
压测上限尝试1000300 条/秒180ms
超过上限1500500 条/秒大量断连、重连风暴

最有参考价值的是最后一行:不是系统“一定不能处理 1500 连接”,而是在我的默认配置下,超过某个阈值后,单个进程的 CPU 飙升、GC 频繁、心跳超时导致大量连接被误判为死连接,然后客户端一起重连,反而把系统拖垮。

所以我对“压测通过”的理解变了:不是跑出多高数字,而是明确知道自己的天花板在哪、在接近天花板时系统是优雅降级还是直接雪崩。现在我设置了连接数告警,超过设计容量会提前通知我扩容,而不是等雪崩发生。

4.2 内存与连接数的关系,以及 GC 问题的处理

我最初对内存的估计是以“每条事件大约 1KB”来算的,结果忽略了一个大头:WebSocket 连接在服务端要维护状态对象、发送缓冲区、心跳定时器,平均每个连接占用的内存比一条事件大得多。200 个连接大约吃掉 30MB 内存,但 1000 个连接时,内存曲线会涨到接近 200MB,主要就是连接对象的开销。

GC 问题起初也不明显,后来事件吞吐上来,每秒钟创建大量临时对象(消息解析、路由匹配、推送动作的中间结构),Go 的 GC 频繁触发,CPU 消耗持续高位。我在 profile 里看到大量时间花在垃圾回收上,于是做了两个优化:

  • 事件解析复用对象池,减少每次请求都新建解析器的开销。
  • 推送时的消息体尽量共享缓冲区,不重复拷贝完整 JSON。

优化后 GC 频率大概下降了 40%,那个阶段最明显的感受就是 CPU 曲线从锯齿状变成平缓状。

4.3 可靠性兜底:离线消息到底该存多久

消息存多久是一个产品决策,不只是技术决策。我现在设置的默认是 7 天,但这个值不是拍脑袋定的,它来自使用场景分析:

  • 监控告警类事件,超过 7 天还看的历史价值很低,而且真正需要追溯的会进入告警平台自己的历史。
  • 业务通知类事件(比如订单状态变更、用户操作日志),最好保留更久,所以这类事件我单独打retention:30标签。
  • 全量事件不能无限存,不然检索会变慢、备份会变贵。

存储介质上我用了嵌入式存储加定期归档,没有为离线消息做缓存队列。原因很直白:事件本身已经落库,客户端上线时只需要用“最后收到的消息 ID”做一次数据库查询,效率和效果都比维护一套“离线缓存再转发”的机制好。很多人习惯用 Redis 做离线消息队列,但如果是这种“事件有存储、按 ID 补差集”的模式,存储层就是最可靠的离线队列。

4.4 可观测性:没有链路追踪的推送系统等于盲飞

我觉得做推送系统最容易忽略的就是可观测性。初期我只能回答“服务还活着”,但回答不了三个核心问题:

  • 一条事件从进来到被推送给客户端,每一跳到底花了多久?
  • 哪一条客户端连接是活跃的、哪一条早就死了但还占着资源?
  • 历史上某个时段推送成功率到底是多少?

所以我给每个事件随手加了链路跟踪 ID,在这个 ID 下记录四个阶段的耗时:进入接口、路由匹配、查找在线连接、推送完成。日志里一行就能看到整条链路:

evt_20250101_0001 ingest=2ms match=1ms lookup=0.5ms push=40ms

连接层面,定期输出活跃连接数、心跳超时数、重连次数。有了这些之后,我排障的速度明显加快了,至少不会再出现“消息到底发没发出去”这种纯靠猜的情况。

提示:可观测性不是上线后才补的,最好在第一版就有最小化的链路日志。哪怕只是一个 trace ID + 几个耗时字段,也能帮你省下大量排查时间。

5. 哪些场景真正适合用 buzz:选型边界与实用建议

5.1 适合的场景

跑了半年,我总结出几类真正适合buzz的用法:

  • 个人或小团队的告警聚合:把服务器监控、CI/CD 结果、线上错误率汇集到一个收件箱,重要级别过滤后推给值班的人。这类场景事件量不大,但对实时性和可靠性要求高。
  • Web 应用内的实时通知中心:自己的产品需要一个“通知铃铛”,用户在线时用 WebSocket 实时收,离线时下次打开补推。buzz的推拉结合模型刚好匹配。
  • 自动化脚本的统一回调口:以前每个脚本自己处理通知渠道,现在只要curl一下buzz的接口,路由规则由它管理,不用每个脚本里重复实现。

它特别适合那些“不想为通知功能引入一套重量级 IM 基础设施、又不想用聊天机器人凑合”的场景。

5.2 不适合的场景,别硬套

我也要认真说一下它不适合做什么:

  • 大规模多租户 SaaS 消息平台:多租户隔离、配额管理、审计合规这些需求,buzz没做,硬套会遇到一堆边界问题。
  • 高吞吐事件流处理:如果你需要每秒处理几万条事件并做复杂流式计算,这是消息队列和流处理框架该做的事,而不是推送系统。
  • IM 聊天场景:聊天要求消息时序、多端同步、已读回执等等,这已经是一个完整即时通讯产品该管的范围,不是“事件通知”能覆盖的。

选型的判断标准其实就一句:你需要的是“把事件语义的消息送到人面前”,还是“构建一套完整的消息通信基础设施”。前者用 buzz 这种轻量系统,后者就应该上更重的平台。

5.3 部署与维护的几个实操建议

最后分享几个实际维护中的小经验:

  • 配置统一走文件,不要散在环境变量和启动参数里。我吃过一次亏:一个服务跑在测试环境好好的,部署到生产后行为不一致,最后发现是环境变量没传,把该加载的规则配置漏了。
  • 客户端一定要有重连退避。不要每过一秒就重连一次,应该用指数退避加随机抖动,不然 30 个客户端同时断网再恢复时,服务端会同时收到 30 个连接风暴。
  • 接入新推送源先写适配测试。我给自己定了一条规矩:任何新来源接入,必须先写一个模拟该来源事件的测试用例,跑通整个链路再上线配置。这样能避免“接入很开心,配置很酸爽,出问题很崩溃”的情况。
  • 不要把所有事件都默认推给所有人。路由规则应该显式声明,宁可默认丢弃也不默认全发。我在生产上出现过一次误把 DEBUG 事件推到全局的情况,从此加强了默认规则。

我自己实际使用的体会是:这种轻量级推送系统的复杂度不在于某个单独组件有多难写,而在于你如何把“接入、路由、推送、补推、历史查询”这一整条链路设计得顺。buzz最让我满意的不是某一个技术点,而是它把实时性、可靠性和简单性在这套场景里平衡住了。如果你也有类似的场景,完全可以参考这套模型——不需要全部照搬,把“推拉结合”“幂等设计”“可观测性链路”这几个核心思想拿过去,就能解决大部分通知类系统的问题。

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

马德拉岛自驾徒步全攻略:火山、月桂林与levada水渠

1. 差点被名字骗了:马德拉不是普通海岛,是一座长满月桂树的火山第一次听说马德拉,是在里斯本一家青旅的公共厨房。一个英国老头一边切奶酪一边跟我说,他每年冬天都去马德拉待两个月,因为那里“永远是春天”。我当时以为…

作者头像 李华
网站建设 2026/10/1 14:13:54

马德拉蛋糕复刻指南:从马德拉酒到经典裂纹磅蛋糕的完整烘焙手记

朋友从里斯本回来,行李里塞了一瓶马德拉酒。深琥珀色的液体挂在瓶颈上,标签只有一行字:Madeira。我盯着这个词愣了几秒——它既像地名,又像酒名,还是我在烘焙书里见过无数次的蛋糕名字。后来我决定把这个问题彻底搞清楚…

作者头像 李华
网站建设 2026/10/1 14:13:32

Three.js创建三维人物:程序化建模、GLB导入与点云方案全解析

有人问我:Three.js能“做”一个人出来吗?我的回答是能,但这个“做”字背后的门道比想象中多。我最近正好用Three.js给自己搭了一个网页端的虚拟形象,从纯代码用几何体拼人,到Blender里建模再导出GLB加载,再…

作者头像 李华
网站建设 2026/10/1 14:12:40

AI工程从零搭建:模型上线全链路实践指南

我最近大半年都在折腾一件事:把手头几个AI项目从“能跑通代码”变成“能稳定上线服务”。这整个过程的本质,就是标题里写的 ai-engineering-from-scratch ——AI工程从零开始。老实讲,模型调参本身并没有那么折磨人,真正让人半夜…

作者头像 李华
网站建设 2026/10/1 14:12:24

Model-Optimizer:模型压缩与部署优化的完整实践指南

做深度学习模型部署的人,早晚会碰上一个问题:模型在训练机上跑得飞快,一上生产环境就慢得让人抓狂,显存占用高、延迟不稳、服务成本直线上升。这时候大家就会开始聊模型优化,聊 Model-Optimizer 这类工具。Model-Optim…

作者头像 李华
网站建设 2026/10/1 14:11:32

马德拉:一座火山海岛与一瓶氧化加强酒的同源传奇

如果你在搜索引擎里敲下“Madeira”,大概率会看到两条完全不同的信息流:一端是漂浮在大西洋上的葡萄牙海岛,另一端是酒杯里那抹琥珀色的加强酒。过去我也以为这只是个浪漫的重名巧合,直到认真研究过之后才发现,这座岛和…

作者头像 李华