
1. 推送这件事为什么值得认真做一次消息推送是个挺容易被低估的功能。很多项目做到一半业务方提个需求“我要给用户发通知”开发同学第一反应是接入个推送SDK或者直接写个轮询接口拉数据。结果上线之后不是消息丢了就是延迟严重要么就是用户反馈手机莫名其妙被通知轰炸最后只能草草下线。我这次做的“消息推送”项目算是我自己的一个实战总结。核心目标其实不复杂在Web环境下实现一个可靠、可控制、能追踪的消息推送链路覆盖浏览器通知、站内消息中心、以及向微信侧发送模板消息这几条常见通道。整个项目包含前端、后端、调度和落库四个部分属于那种“看着简单做起来全是细节”的典型场景。先别急着划走如果你觉得消息推送无非就是“后端调用一下接口前端弹个窗”那这篇文章值得你看完。实际落地的时候你会发现真正难的点在于通知类型怎么设计、去重怎么做、推送失败要不要重试、用户拒收之后怎么办、以及日志怎么追踪一条消息从产生到触达的全过程。这些问题不提前想清楚后面排查故障的时候会非常痛苦。文章会按我实际的开发顺序来讲先聊方案选型再聊浏览器通知的完整实现接着是后端推送链路的细节然后是消息中心与去重策略最后是问题排查和实测体会。内容偏实战代码和配置会给到能用的程度同时也会解释每个关键选择背后的理由。2. 选定推送方案之前先想清楚这几个问题2.1 需求根本不是为了“发通知”这么简单项目一开始需求文档上写的只有一句话“用户能收到系统消息重要操作要有推送提醒。”但“收到消息”和“推送”其实是两件完全不同的事。前者属于拉取模式用户打开页面后能看见消息列表就行后者属于主动触达模式系统要在用户没有打开页面时也能把信息送到用户眼前。当我把这两个场景拆开之后才发现这个项目真实需求有三种站内信用户在消息中心能看到完整的历史记录要求永久保存可以标记已读。浏览器实时通知用户正在看网页时重要事件比如审核通过、新评论、订单状态变更需要立刻出现在屏幕右下角。微信通知用户不在电脑前时关键消息需要异步转发到微信保证触达率。一旦把需求拆到这个粒度技术选型就不是“随便拉个长连接”能解决的了。站内信需要稳定的存储和查询接口浏览器通知需要Web Notification API支撑微信通知则需要走模板消息接口。三套能力底层逻辑完全不同但如果一开始不统一设计消息格式和数据模型后面会非常混乱。2.2 轮询、WebSocket、SSE我选了哪套组合先坦白我最开始图省事想全部用WebSocket。前端连上长连接后端有消息就直接往连接里塞。但仔细推敲后放弃了这个方案。原因是这个项目的消息频率不算高一天可能几十条到几百条WebSocket的实时性优势发挥不出来反而引入连接状态维护、心跳重连、断线补发这一堆复杂度。而且浏览器原生的Notification弹窗并不依赖长连接——哪怕你用HTTP短轮询拿到一条消息一样可以弹通知。为了“实时”而实时属于典型的技术选型过度。最终我采用了“短轮询 浏览器通知 模板消息”的组合前端每隔30秒轮询一次未读消息接口拿到新消息就弹浏览器通知并写入消息中心列表。后端提供REST接口负责消息的写入、查询和已读标记。微信通知作为异步旁路在关键消息类型上单独触发。你可能觉得30秒轮询太“低端”但说句实在话这种非高频场景下轮询是最容易维护、最容易排查问题的方案。没有常驻连接就没有断线重连的隐患服务器重启不会影响客户端日志追踪也简单——请求来了响应回了整个过程一目了然。2.3 时间轮询间隔的秘密不是越短越好轮询间隔我实测过好几组最终定的30秒。这个值不是拍脑袋背后有两个考量。第一用户体验。30秒对于“新评论通知”“订单状态更新”这类场景来说感知上是及时的吗实测下来绝大多数用户不会觉得1分钟内收到通知有什么问题。如果你做的是股票行情、在线协同一类强实时场景那确实需要换成WebSocket或SSE但大多数Web应用的推送需求其实没那么苛刻。第二服务器压力。假设你有5000个在线用户轮询间隔20秒那么平均每秒就有250个请求打到服务器上。如果每个请求都查一遍未读消息表数据库压力不小。把间隔放宽到30秒每秒请求数降为167左右配合索引优化单机完全扛得住。再配合“如果请求头带If-Modified-Since之类的能力做弱化处理还能进一步减少不必要的响应体传输”。我给你的建议是不要迷信“实时推送”这个概念先按业务容忍度倒推轮询间隔再考虑要不要上长连接。多数项目30秒轮询已经够用后续并发高了可以平滑升级到SSE。3. 浏览器右下角通知核心代码与踩坑记录3.1 浏览器通知到底怎么触发出来浏览器右下角的通知弹窗技术名称叫Web Notification API。它不依赖任何第三方SDKChrome、Edge、Firefox、Safari都支持。使用之前必须先向用户申请权限这个权限是浏览器级联的用户一旦点了“阻止”后续任何通知都弹不出来而且JavaScript无法重新唤起授权请求。我的实现逻辑放在前端独立模块里初始化时统一处理权限申请// notification.js export function initNotification() { if (!(Notification in window)) { console.warn(当前浏览器不支持通知功能); return false; } if (Notification.permission default) { Notification.requestPermission().then(permission { console.log(通知权限结果:, permission); }); } } export function sendBrowserNotification(title, options {}) { if (!(Notification in window)) return; if (Notification.permission ! granted) { console.warn(没有通知权限无法弹出浏览器通知); return; } const notification new Notification(title, { body: options.body || , icon: options.icon || /logo.png, tag: options.tag || default, data: options.data || {} }); notification.onclick function () { // 点击通知时聚焦到对应页面 window.focus(); this.close(); if (options.onClick) options.onClick(this.data); }; }这段代码中最关键的是tag字段。同一个tag的新通知会替换同标签下的旧通知避免同一时刻弹出多个重复提醒。比如用户连续收到三条评论回复如果每条都用不同的tag屏幕上会挤一排通知用同一个tag的话新通知会顶掉旧通知体验会干净很多。3.2 用户拒收之后别硬弹权限申请这块有个很容易被忽视的细节请求权限的时机。如果页面一打开就立刻弹授权框用户大概率会拒绝——因为他还没理解“这个网站为什么要弹通知”。正确的做法是把授权触发点放在用户执行了某个明确动作之后比如点击“开启提醒”按钮或者在用户提交某个表单时顺带请求。我见过不少项目把授权申请放在路由守卫里用户刚登录就弹窗结果授权率惨不忍睹。后来我调整成用户浏览到消息中心页面时顶部显示一个“开启桌面通知”的引导条用户主动点击后请求权限授权率提升非常明显。如果用户选择拒绝或者忽略前端要给出后端可识别的状态标记。我当时是这么设计的初始化时无论权限是什么状态都会把Notification.permission的值上报一次后端记录每个用户的通知能力状态。这个状态的意义在于后续推送时如果某类消息同时支持浏览器通知和微信通知而后端判断该用户浏览器通知不可用就自动降级到微信发送而不是白白丢一条消息。3.3 通知点击跳转别只会打开首页很多实现里通知的onclick事件只写了window.focus()点击之后回到站内首页这其实很浪费。用户从通知点进来应该精准落到那条消息对应的业务页面。我的做法是在创建通知时把消息详情URL塞进data字段里// 创建通知 new Notification(订单已发货, { body: 您的订单已出库运单号 SF1234567890, tag: order-shipped, data: { url: /order/detail/1008612 } });点击事件解析data.url用window.open(url, _blank)打开新的标签页。如果是站内路由可以直接用前端路由跳转如果通知是来自后台管理端的操作提醒可能还要带上authToken之类的参数才能打开目标页面。这个细节看起来简单但直接影响用户对“推送质量”的感知——点进去能直接看到相关内容和点进去落在首页让用户自己找完全是两种体验。4. 后端推送链路从消息投递到状态回执4.1 消息模型设计决定后续好不好扩展后端消息模型是这个项目里最值得斟酌的部分。我一开始只建了一张messages表字段是id、user_id、content、is_read后来发现根本不够用。因为消息不只是“文本”它还需要类型、关联业务ID、跳转链接、优先级、过期时间等属性。我最终的数据模型分成了两张表一张存“消息模板”一张存“用户消息”。模板表负责定义消息的标题、内容主体、跳转路径规则用户消息表则记录每个用户实际收到的消息实例。这样设计的好处是以后新增通知类型时只需要加模板不用改表结构。用户消息表的核心字段如下字段说明id自增主键user_id接收用户IDmsg_type消息类型如 ORDER_STATUS、COMMENT_REPLY、SYSTEMtitle消息标题content消息正文jump_url点击消息后的跳转地址biz_id关联的业务ID如订单号priority优先级 1-33为最高is_read是否已读push_status推送状态pending/success/failed/skippedchannel推送渠道browser/wechat/station_onlycreated_at创建时间push_status和channel这两个字段是我后补的但极其重要。没有它们你只能看到“消息确实存在了”但完全不知道它有没有被推到用户面前、从哪条渠道推的。有了状态位后续做重试、统计和排查都会方便得多。4.2 推送写接口一条消息是怎么发出去的我后端的推送接口设计很简单单条发送和批量发送共用一套逻辑// 发送消息服务端核心逻辑 async function sendMessage(sendReq) { const { userIds, msgType, title, content, bizId, channel } sendReq; // 1. 查询目标用户的推送能力是否开启通知、是否拒收 const users await userRepo.findByIds(userIds); // 2. 批量插入用户消息记录 const messages users.map(u ({ user_id: u.id, msg_type: msgType, title, content, jump_url: buildJumpUrl(msgType, bizId), biz_id: bizId, push_status: pending, channel: resolveChannel(u, channel) })); await messageRepo.batchInsert(messages); // 3. 触发异步推送任务 pushQueue.add({ messageIds: messages.map(m m.id) }); return { success: true, count: messages.length }; }这里的批次设计重点在于第2步先落库再入队推送。好处是消息在数据库里留下完整记录即使用户当前不在线、微信接口失败后续仍然可以补推或者让用户在消息中心看到。如果反过来先推送再落库推送成功但消息没存下来用户就失去了历史记录这个损失没法接受。resolveChannel函数的作用是判断这条消息走哪个渠道如果用户是活跃在线状态且浏览器通知权限为 granted就走 browser如果用户不活跃或者没有浏览器权限而消息类型允许走微信就走 wechat如果既不在线又不支持微信就只落库等用户下次打开站点时在消息中心看到。4.3 推送任务的重试与幂等防止重复通知推送任务进入队列后执行端要处理的第一个问题就是“不能重复发”。例如推送接口超时了任务重试如果重试时又完整跑一遍发送逻辑用户就可能收到两条相同的通知。我的解决方法是利用数据库消息记录的push_status字段做幂等控制。执行端在推送前先执行一条“原子比较更新”UPDATE user_messages SET push_status sending WHERE id ? AND push_status pending如果影响行数为0说明这条消息已经被其他任务处理过直接跳过。如果影响行数为1说明抢占成功继续执行推送。这一步能同时防住两层问题一是重复触达二是并发任务间的竞争。推送执行完无论成功还是失败都要回写最终状态。失败的消息进入一个最多重试3次的退避循环重试间隔分别设为1分钟、5分钟、30分钟。为什么这么设计因为消息推送失败大多是瞬时的网络抖动、服务重启、第三方接口限流短时间重试大概率能成功间隔递增则是避免连续失败时对第三方接口造成压力。4.4 向微信侧发送模板消息的能力接入项目里要求支持微信通知但这块比较特殊不能直接给用户发服务号模板消息因为普通个人开发者没有服务号和模板消息权限。我采用的替代方案是“业务通知接入企业微信群机器人 关键消息作为模板消息走公众号或服务号”。如果你也是个人项目建议采用这个思路申请一个企业微信群添加机器人后拿到Webhook地址后端用HTTP POST往Webhook推送消息即可。这个方案门槛极低适合第一时间做通“服务器到微信”的通路用来给自己或者团队成员发“服务器异常”“订单超时”这类运维级通知。核心实现非常简单import requests import json def send_wechat(group_webhook_url, content): data { msgtype: text, text: { content: content, mentioned_mobile_list: [] } } resp requests.post(group_webhook_url, jsondata, timeout5) return resp.status_code 200但要注意两点。第一Webhook地址是敏感配置绝不能写在前端代码里必须放在服务端的密钥管理或环境变量中。第二企业微信机器人对消息频率有限制实测下来每秒一条比较稳突发大量消息时要自行排队和限速。如果项目里需要向真实用户个体发送微信模板消息那需要走微信公众平台的服务号并且要求用户关注公众号、获取openid、择优选择模板流程长且需审核这部分我建议单独立项目来做不要和Web推送混在一起。5. 消息中心与去重策略站内信设计中的干货5.1 未读数量统计你以为是COUNT其实没那么简单消息中心最基础的功能是展示未读数量。很多人直接写一句SELECT COUNT(*) FROM user_messages WHERE user_id ? AND is_read 0数据量小的时候没问题但表数据量一上来这个查询会越来越慢因为is_read字段的区分度太低了只有0和1两个值索引效益有限。我采用的做法是单独建一张unread_count表每个用户一行只存未读数。每次写消息时对对应行的未读数加1每次标记已读时减1。这样前端查询未读数量时只做一次主键查询速度极快。未读数量表的更新和消息表的写入放在同一个数据库事务里保证一致性。万一两者因为bug导致不一致再写一个周期任务重算校正。这个方案的代价是读取性能极好但写入逻辑增加了一个步骤。对于这个项目每天几百条消息的体量来说完全值得。如果你做的是千万级用户量的系统可能还要引入消息队列和Redis计数但那是后话不要一开始就套用复杂架构。5.2 消息去重防止重复通知的前端与后端双保险去重是消息推送里非常容易被忽略的一环。场景很典型后端调度任务在短时间内重复触发比如订单状态回调接口收到了两次回调或者前端重试逻辑在超时后请求了两次未读接口结果都发现了新消息弹了两遍通知。我的去重设计分为两层。后端这一层用消息的业务唯一键来做-- 同类业务消息只保留第一条 ALTER TABLE user_messages ADD UNIQUE KEY uk_user_biz_type (user_id, msg_type, biz_id);这个唯一键的作用是同一个用户、同一种消息类型、同一个业务ID比如同一个订单只允许存在一条消息记录。后端插入时捕获唯一键冲突如果是重复插入直接忽略如果要更新内容则走ON DUPLICATE KEY UPDATE覆盖内容。前端这一层也要做一次短期内的重复通知拦截。做法是在内存里维护一个已通知的消息ID列表每次轮询拿到新消息后先检查ID是否在列表里如果已经通知过就跳过。列表只保留最近100条防止内存膨胀。不要完全指望后端去重因为特殊情况永远存在——比如后端明确写了多条重复消息但业务上就是想每次操作都提醒一次前端的短期去重更贴近用户体验。5.3 已读与全部已读多数项目的交互死角消息中心里“全部标为已读”这个按钮看着不起眼但实现起来有个性能坑。不少人的第一版实现是UPDATE user_messages SET is_read 1 WHERE user_id ?这条SQL在数据量小的时候没问题但一旦该用户有几千条消息全表扫描确认行数、逐行加锁更新可能要几百毫秒甚至更久。好在我的场景数据量可控但为了规范我改成了先取一批未读消息ID再逐批更新每批200条。好处是锁范围精确到行不会长时间锁住整个用户的数据页。另外一个交互细节是消息列表不要一次性加载全部。我用的是游标分页通过消息ID做游标每页20条。避免深度分页OFFSET过大带来的性能损耗。前端列表里新增一个“最近加载中”的状态用户体验会好很多。6. 轮询接口优化与实时性平衡6.1 从轮询到“伪实时”缓存中间层怎么设计上一节说到轮询间隔30秒但有用户会抱怨“消息到得太慢”。这个需求本身有点矛盾但可以通过一个简单的优化来缓解轮询接口先查缓存再查数据库。未读消息的ID列表放在Redis里每次有新消息写入时顺便把消息ID推到Redis的列表头部。轮询接口先读缓存把最近的ID拿回来再按ID去数据库查详情。这样做的直接好处是热点数据不会反复打到数据库上。即使轮询频率从30秒提高到10秒数据库压力也不会显著上涨因为大部分请求命中Redis只有缓存没命中时才走数据库。对于我这种单机部署的场景这个优化足够撑到用户量再翻几倍。但这个方案也有前提缓存和数据库之间会存在短暂的不一致窗口。接受这个窗口才能享受缓存的性能收益。如果业务要求绝对一致那只能每次都查数据库性能换一致性没有两头都占的好事。6.2 条件轮询用HTTP缓存省掉无效响应轮询接口返回的未读消息数据大部分时间其实是“没有新消息”。如果每次都返回完整的消息列表带宽和JSON解析开销都不小。我加了一个优化前端轮询时带上最新一条消息ID作为since_id参数后端只返回比这个ID更大的消息。如果结果为空接口返回204 No Content响应体为空省掉大量传输。更进一步后端响应里设置Cache-Control: private, max-age10让浏览器在10秒内对相同请求直接走缓存不再发送到服务器。这样轮询频率即使设为10秒实际打到服务器的请求也会更少。这里要小心这个缓存只针对同一用户的私有响应绝对不能设置成public否则用户A的响应可能被缓存提供给用户B属于严重的数据泄露事故。6.3 多标签页打开时的同步问题用户不会只开一个标签页。如果两个标签页同时跑着轮询逻辑就会出现第一个标签页弹了通知第二个标签页也拿到同一条新消息再次弹通知。这就是上一节提到的前端去重要面对的场景但只靠内存List还不够——第二个标签页是独立内存看不到第一个标签页的已通知列表。解决方案有两个。一是使用BroadcastChannel让同一浏览器下的不同标签页直接通信。新消息被某个标签页消费后通过广播通知其他标签页“这条消息我已经弹过了”其他标签页直接跳过。二是利用localStorage存储最近通知ID列表其他标签页读取时先过滤。我最终用了BroadcastChannel因为它实时性更好代码也更干净const bc new BroadcastChannel(msg-channels); // 弹通知前广播 bc.postMessage({ type: NOTIFIED, messageId: newMsg.id }); // 监听其他标签页的广播记录下来避免重复弹出 bc.onmessage (event) { if (event.data.type NOTIFIED) { alreadyNotifiedIds.add(event.data.messageId); } };这里要考虑到用户关闭所有标签页后BroadcastChannel会断开不留下任何持久状态所以它只适合做短期去重。长期去重仍然依赖后端唯一键。7. 排查实录推送链路里最常见的五个坑7.1 消息发出去了但用户没收到这个问题的排查顺序我建议从后往前先查微信日志或者浏览器控制台再查推送状态字段最后查消息表。我遇到最多的情况是浏览器通知权限是denied代码里虽然判断了权限但没走降级逻辑消息从后端落库后直接标记push_status failed用户自然收不到。解决办法就是文章前面讲的resolveChannel把“用户不接受浏览器通知”当成正常业务分支处理自动走微信渠道或者仅存站内信。推送状态具体是哪种情况必须在后台页面上展示出来方便运营排查而不是只写在日志里。7.2 推送服务重启队列里的任务全丢了我第一版推送用的内存队列服务一重启队列里未处理的任务全部丢失。后来改成基于数据库表的简单任务队列推送任务先插入push_task表执行器扫描表中status pending的任务。服务重启后任务还在数据库表里执行器启动时自动扫描继续执行。这套方案没有引入额外的消息队列组件但已经足够保证重启不丢任务。如果业务量进一步增长可以直接把表队列替换成Redis List或者RabbitMQ但核心逻辑不变任务必须持久化消费进度可追踪。7.3 浏览器通知弹不出来控制台也无报错有次调试时发现前端代码走了new Notification()但没有弹窗控制台也没有任何错误。排查后发现问题出在“通知只能在用户激活的标签页里弹出”。如果用户把浏览器最小化或者切到了其他应用通知请求会被浏览器策略直接忽略这是浏览器原生的行为不是代码bug。解决方式有两个层面。第一在真正常用的场景下比如用户正在操作页面这个限制不影响体验。第二如果一定要在用户离开页面时也能触达只能走服务端推送系统通知类的能力比如配合Electron的托盘通知或者原生App推送。纯Web页面的通知能力是受浏览器限制的。7.4 微信机器人消息被限流企业微信群机器人调用频率过高会返回错误码45009。我刚开始批量推送测试时一口气发了50条消息结果触发限流后面几十条全部失败。这是我的调度侧没有做速率限制。解决办法很简单加一个简单的令牌桶限流。后端维护每个Webhook的发送时间戳两次发送间隔不低于1秒超过就直接排队等待。实际开发中不要依赖第三方限流自己先限住才能保证推送的稳定性。7.5 消息乱序先发的后到后发的先到有一次测试发现两条消息内容反了先创建的消息显示在后面后创建的反而排上面。排查后定位到是前端列表的排序依赖自增ID的创建顺序但插入消息时用到了批量插入自增ID的分配顺序和传入顺序在个别数据库配置下不保证一致。解决方式很简单消息表增加created_at字段列表排序用created_at DESC而不是id DESC。这条经验启动项目是就应该注意别等出了问题再补。8. 我在这个项目里最终沉淀下来的几点经验消息推送这个项目做完后我最大的感受是它看起来是“发个通知”但真正做到稳定可用涉及权限管理、状态机、幂等控制、缓存同步、前端交互等多层问题。每个细节都不复杂但组合在一起任何一个环节断裂整条链路就不可用。如果让我重新做一次我仍然会坚持几个核心决策先落库再推送用数据库状态字段做幂等不迷信长连接优先把浏览器通知和消息中心做好再考虑微信旁路。这几个决策保证了这个项目的可维护性——毕竟一个推送系统稳定比花哨重要得多。最后分享一个调试技巧推送类的问题最怕“看不到中间状态”。我在前端和控制台把所有关键节点都打了日志权限状态、轮询响应、通知创建、点击事件后端也把所有状态流转输出到结构化日志。定位问题的时候顺着日志一路看下去基本几分钟就能锁定故障点。如果你也在做类似项目不要跳过这个步骤它能帮你省下的时间远比写日志的十分钟多。