消息推送实战:微信模板消息、Web Push与渠道降级全解析
消息推送这个项目我做过不止一次。每次接手前都觉得“不就是发个通知吗”但真正落地时才发现水挺深。微信消息推送要过模板审核、要处理 access_token浏览器右下角推送看似简单其实牵扯到 Service Worker 和用户授权策略。这篇文章把我在“项目实战6-消息推送”里踩过的坑和能直接用的方案梳理一遍给准备做推送系统或正在为推送落地头疼的人参考。我会尽量少讲虚的多讲能直接抄走的代码和排查思路。文中会聊到几个真实场景用户在网页上留下订单系统通过微信服务号给他发一条模板消息用户在电脑前挂着工具类页面网站通过浏览器通知在屏幕右下角弹出提示还有一些业务没有特殊渠道就用站内信兜底。这些场景凑在一起就是一个典型的消息推送项目。文章不会只讲某一个平台而是从整体方案讲到具体渠道既有设计也有代码还有我实际踩过的坑。1. 项目整体设计与需求拆解1.1 消息推送到底在解决什么问题消息推送本质上是把信息主动送到用户面前而不是等用户回来查询。对于业务系统来说推送是转化率最直观的手段之一也是挽回沉默用户的方式。但“推送”是个大词展开之后包含触达渠道选择、消息内容组织、用户授权管理、发送频率控制、到达率监控。如果这些要素没有在项目一开始就想清楚后面很容易变成一团乱麻。我经手的项目里最初需求经常就一句话订单状态变化时给用户发微信消息推送。到了后面运营同事会补一句如果微信没发出去能不能用网页右下角推送补一下再往后又希望用户能在站内信里看到历史消息。这些需求叠加起来就不再是调一个接口那么简单了。你需要能管理多个渠道还要能判断什么时候该走哪个渠道。先看几个常见消息场景。交易类通知比如支付成功、退款到账、发货提醒用户对这类信息期待度高晚几分钟都能接受但必须稳定营销类通知比如优惠券到账、活动开始这类消息讲究时机发得太频繁容易被屏蔽系统类通知比如账号登录、安全提醒这类必须走可靠渠道短信或微信都行。所以做消息推送的第一步不是写代码而是把消息分好类。不同类别的消息对到达率、延迟、重复发送次数的容忍度完全不同后续所有重试、限流、退订策略都会围绕这个分类展开。1.2 需求边界与渠道选型推送渠道看似很多但真正实际用得上的通常就那么几个。这里先要把渠道特点搞清楚再定方案否则项目做着做着就会陷入“每个渠道都要接一遍”的泥潭。渠道触达强度成本适用场景主要限制微信消息推送高中交易通知、会员通知需关注或授权模板审核浏览器右下角推送Web Push中低页面通知、工具类提醒需HTTPS用户授权浏览器设置站内信低低历史记录、站内公告用户需主动访问短信高高紧急通知、验证码成本高需要特别授权和文案合规上表里的“成本”不只是钱也是用户关系成本。短信虽然触达强但如果发多了用户直接投诉浏览器右下角推送也有“屏蔽”风险第一次弹出授权框时机不对后面基本没机会再授权。所以我通常建议把微信和浏览器通知作为主动触达主力站内信作为低频兜底。如果产品是面向PC用户浏览器右下角推送会很香用户正在用电脑时右下角弹出提醒不打断工作又比邮件即时。我见过很多工具类产品包括一些后台管理系统都用这个方式提醒任务完成。但要注意浏览器推送依赖用户在同一浏览器上的授权换台电脑、换浏览器就收不到所以它适合补充而不是替代。1.3 整体架构与核心指标消息推送中心可以理解成一个独立服务业务系统只需要调用一个接口说明“给谁发什么内容走哪个渠道”剩下的事情由推送中心处理。这里说的“剩下的事情”包括用户订阅判断、渠道适配、发送重试、记录回执以及后续的统计。架构上我习惯分成四层。接入层负责接收业务系统请求做参数校验和消息入库调度层处理发送任务包括优先级调度、延迟发送、定时重试适配层针对每一个渠道做单独实现例如微信模板消息适配器、Web Push适配器存储层负责保存消息记录、订阅关系、发送回执。每层之间用消息队列解耦避免业务高峰期同步阻塞。线上最关心的指标不是“发送条数”而是到达率和点击率。微信消息推送到达率高因为用户关注了服务号就能收到浏览器右下角推送的到达率受系统通知设置影响需要监控订阅失效比例。还有一个容易被忽略的指标退订率。用户主动关掉推送开关或者取消关注比到达率更能反映消息健康度。这层设计想清楚后再进入技术实现。2. 核心细节解析消息模型与推送引擎设计2.1 通用消息数据结构消息推送最忌讳每接一个渠道就写一套发送逻辑。我的习惯是先定义统一的 Message 结构所有业务方都按这个结构传参。这样做的好处是业务系统不需要关心渠道细节推送中心也可以通过一份结构统一处理所有消息。核心字段包括message_id 全局唯一用于幂等和追踪scene 表示业务场景例如 order_status_change、marketing_campaignuser_id 或 channel_target 表示接收方title、body、data 是展示内容和跳转参数priority 表示优先级紧急消息可以跳过夜间策略ttl 表示消息有效时间过了时间就不必再发template_code 表示具体渠道的模板编号。下面是一个 JSON 示例不同渠道会从这个结构里各取所需。{ message_id: MSG20250812103000123, scene: order_status_change, user_id: U10086, channels: [wechat_mp, browser_push, inbox], title: 订单发货通知, body: 您的订单已发货物流单号 SF1234567890, data: { order_id: ORD20250812001, url: https://example.com/order/ORD20250812001 }, priority: high, ttl: 7200, template_code: order_ship_notice }为什么一定要有全局 message_id因为消息推送一旦接入异步队列和重试很容易出现重复消费。把 message_id 作为唯一键发送前检查 Redis发送后记录状态就能把重复问题从根上掐住。我在实际项目里经常看到有人把消息主键当业务订单号用结果同一订单触发了两次通知用户立刻投诉。数据里还会放一个 channels 数组表示这条消息希望走哪些渠道。推送中心会根据用户订阅情况和渠道可用性决定实际发送路径。比如用户没有授权浏览器通知那就自动跳过只用微信和站内信。这里的“自动跳过”看似简单但如果不在消息结构上做预留后续每加一个渠道都要改上游接口。2.2 订阅关系与用户偏好用户是否愿意接收某一类消息并不是一句简单的“他同意推送”就能概括的。他可能愿意接收交易提醒但不希望收到营销推送可能在微信上授权过但在浏览器里没开过通知。所以我在项目里会把订阅关系拆成用户维度、渠道维度、场景维度。可以用一张表表示user_channel_preferenceuser_id、channel、scene、status、updated_at。status 有三种active、muted、none。none 表示用户连渠道都未授权muted 表示用户授权过但主动关掉了某个场景。这样设计的好处是后续加营销活动时可以直接过滤掉 muted 用户。很多项目在初期没有做这个梳理结果一到运营要发活动就变成全量轰炸投诉直线上升。微信场景下有一个细节用户取消关注公众号后你调发送接口会返回错误但因为用户已经不是粉丝你也没法再通过模板消息触达他。这时候应该自动把该用户的微信渠道置为不可用。同理浏览器推送的 subscription 失效后也要及时清理否则每次发送都报错。2.3 渠道适配层微信模板消息与浏览器通知的实现原理先讲微信消息推送的原理。目前最常见的是通过公众号或服务号向用户发送模板消息或订阅消息。服务号模板消息可以在用户触发某个动作后由服务器主动推送前提是用户关注了服务号并且模板内容和场景匹配一次性订阅消息则是用户每次授权后允许你发送一条特定模板的订阅消息。开发时需要注意公众号后台配置模板 ID 后还要等审核草稿箱里要有对应类目的模板。接入流程大致是后端配置 AppID 和 AppSecret通过接口获取全局 access_token然后在发送接口里带上用户的 openid、模板 ID、消息内容和跳转 URL。access_token 有效期 7200 秒必须缓存起来统一刷新不能每次现拿。如果后端同时部署多个实例还要注意 token 刷新时的竞态问题否则一个实例刷新了另一个实例还在用旧 token就会随机出现鉴权失败。再看浏览器右下角推送也就是 Web Push。它依赖浏览器原生的 Notification API 和 Push API。用户第一次访问网站时前端调用 Notification.requestPermission() 申请通知权限用户点击允许后浏览器会生成一个 subscription 对象里面包含 endpoint 和密钥。前端把这个 subscription 发给后端保存后端之后就能在任意时间向这个终端推送加密消息。关键是 Service Worker。浏览器推送的展示逻辑要在 Service Worker 里实现即使网页已经关闭浏览器收到 push 事件后Service Worker 也会被唤醒然后调用 showNotification 在屏幕右下角弹出通知。这就是为什么你在 QQ 电脑浏览器里会看到右下角弹窗提醒实际上就是一套 Web Push 机制。实现推送时后端会用 VAPID 密钥签名请求不同浏览器对推送服务有各自的频率和配额限制所以发送前要先规划好批次。2.4 渠道降级的思路多渠道组合后还要考虑降级。最典型的场景用户微信没关注公众号但订阅了浏览器通知或者用户微信消息发送失败但浏览器通知权限还在。业务方关心的是“用户能不能收到”而不是“渠道是不是完美闭环”所以推送中心可以在发送策略里配置降级顺序。举个例子一条订单状态变更消息首选微信模板消息如果微信返回“用户未关注”或“模板不存在”之类的错误且用户有有效的浏览器订阅就自动走浏览器推送如果浏览器通知也不在最后落站内信。这么处理之后业务方就不用在每次调用时自己写一堆渠道判断。不过降级要有一个底线不能改变消息的含义。比如营销活动通知降级到短信可能引起反感降级到站内信就够了而安全风控提醒则不应该因为微信渠道失败就放弃其他所有渠道。具体策略要按场景设计不能全项目一刀切。这个思想会在下面的实操代码里体现出来。3. 实操过程从零搭一套可用的推送服务3.1 技术选型与环境准备下面这段是实操复盘。我这次以 Node.js 为例因为推送逻辑本身不重Node 处理这种 I/O 密集任务合适如果你团队更熟 Java 或 Go换成对应语言也一样。主要依赖express 作为 HTTP 服务redis 做去重和缓存axios 作为 HTTP clientweb-push 做浏览器推送。环境准备方面微信消息推送需要你有一个已认证的服务号并且开通模板消息权限浏览器推送要求网站是 HTTPS。本地开发可以用 localhost但线上没有 HTTPS 证书Notification API 在 Chrome 和 Edge 里基本是拿不到权限的。另外用 web-push 前要先生成 VAPID 密钥对公钥发给前端私钥留在后端。我一般会准备一个最小可运行的服务结构app.js 做为服务入口routes/push.js 提供推送 APIservices/wechat.js 封装微信公众号接口services/webpush.js 封装浏览器推送逻辑store/message.js 管理消息记录。代码不多但结构清晰比堆功能重要因为后面接新渠道时你只需要新增一个 adapter。3.2 用户订阅接口与前端授权先做用户侧。前端页面需要一个按钮让用户开启浏览器通知。不要一进页面就弹授权框那样用户很可能直接拒绝。建议在用户完成关键操作后再问比如下单成功、任务创建成功后。这个细节直接影响订阅率我见过很多项目因为弹窗时机不对授权率只有 5%后来改到关键操作之后能到 30% 以上。前端核心逻辑是注册 Service Worker 和请求权限。// 前端请求浏览器通知权限并保存订阅 if (serviceWorker in navigator PushManager in window) { const sw await navigator.serviceWorker.register(/sw.js); const permission await Notification.requestPermission(); if (permission granted) { const subscription await sw.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: 你的VAPID公钥 }); // 将 subscription 发给后端保存 await fetch(/api/push/subscribe, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ userId: U10086, subscription: subscription }) }); } }这里有几个细节必须说清楚。userVisibleOnly: true 是必须的它告诉浏览器这次订阅的消息都会展示给用户否则订阅会被拒绝。applicationServerKey 要把 VAPID 公钥转为 Uint8Array如果不做转换浏览器会报错。很多教程里只写字符串实际跑起来会卡在教学里没有的那一步。Service Worker 的 sw.js 里要处理 push 事件和 notificationclick 事件。push 事件调用 self.registration.showNotification 展示通知。点击通知时最好能跳转到业务相应页面。示例见下。// sw.js self.addEventListener(push, function (event) { const payload event.data ? event.data.json() : {}; event.waitUntil( self.registration.showNotification(payload.title || 新消息, { body: payload.body || , icon: /icon.png, data: payload.data || {} }) ); }); self.addEventListener(notificationclick, function (event) { event.notification.close(); event.waitUntil(clients.openWindow(event.notification.data.url || /)); });如果只做展示而不处理点击跳转用户会觉得这个通知是在弹广告。加上跳转后右下角推送才真正变成业务入口。注意 event.notification.data 是从后端传过来的自定义数据在发送 payload 时要把跳转 URL 放进去。3.3 后端消息发送核心实现后端收到业务请求后主要做几件事校验参数、保存消息、查询用户订阅、按渠道分发、记录结果。下面给出一个简化但完整的发送服务示例。// 发送消息入口 async function sendMessage(msg) { // 1. 参数校验 if (!msg.message_id || !msg.user_id) { throw new Error(missing required fields); } // 2. 幂等检查同一个 message_id 只处理一次 const dedupKey push:dedup:${msg.message_id}; const dedup await redis.set(dedupKey, 1, EX, 86400, NX); if (!dedup) { console.log(duplicate message, skip, msg.message_id); return; } // 3. 保存消息记录 await messageStore.save(msg); // 4. 查询用户各渠道订阅 const prefs await getUserChannelPrefs(msg.user_id); // 5. 依次尝试渠道直到成功或全部不可用 if (prefs.wechat_active msg.channels.includes(wechat_mp)) { try { await sendWechatTemplate(msg, prefs.openid); await messageStore.markResult(msg.message_id, wechat_mp, success); return; } catch (err) { await messageStore.markResult(msg.message_id, wechat_mp, error: err.code); } } if (prefs.browser_active msg.channels.includes(browser_push)) { try { await sendWebPush(msg, prefs.browser_subscription); await messageStore.markResult(msg.message_id, browser_push, success); return; } catch (err) { await messageStore.markResult(msg.message_id, browser_push, error: err.message); } } // 6. 兜底站内信 await sendInbox(msg); }这段代码把核心流程压缩得很短实际项目里还要处理事务和异常。但最重要的思路是不要把业务系统直接和微信、Web Push 耦合而是通过订阅关系判断让推送中心做分发。这样后面每增加一个渠道只需要新增一个适配器不影响上游。在真正大规模发送前我强烈建议先做小流量用户测试确认推送通知能正常弹出再逐步放宽。3.4 微信消息推送接入要点微信这块有很多细节。首先是 access_token这个 token 是全局唯一的服务号每天获取次数有限不能每次发送都调接口去拿。正确做法是第一次获取后放到 Redis设 7000 秒过期提前刷新。后台还会验证 IP 白名单如果服务器 IP 没配好调用会直接失败。其次模板消息的内容需要和后台模板完全匹配。比如模板可能是“您有新的订单消息{{orderId.DATA}}”你传值时就必须用 orderId 字段。不匹配会报错。发送接口返回的 JSON 中 errcode 为 0 才表示成功其他错误码要单独处理。常见错误码里48001 表示 api 功能未授权40003 表示 openid 无效45026 表示发送太频繁。拿到错误码先查表而不是盲目重试。还有一个容易被坑的点openid 不是用户的全局 ID而是用户在该公众号下的唯一标识。如果用户在 A 公众号和 B 公众号都用微信登录得到的是两个不同的 openid。所以推送系统和用户体系绑定必须保存用户与当前公众号的 openid 映射关系。如果用户换了环境或重新授权openid 不会变但取消关注后再关注之前存下的 openid 通常仍然有效只是不能发模板消息要等新的用户事件触发。一次性订阅消息的授权是一次性的用户点一次授权你只能给他发一条。如果业务上需要周期性提醒例如每日日报这个渠道就不合适要申请长期订阅或者改用服务号模板消息。模板消息虽然也需要用户成为粉丝但在合法业务场景下可以做到多次触发。合规上不要忽视消息文案要真实、可退出。把退订入口做在通知里或设置页里不仅是对用户负责也能降低被平台处罚的风险。3.5 浏览器通知发送细节浏览器推送的发送推荐直接使用 web-push 库。后端只要拿到之前保存的 subscription 对象调用 sendNotification 就能发送。subscription 对象里包含 endpoint、keys 里的 p256dh 和 auth这些字段不能随便打码因为就是靠它们定位到这个浏览器终端的。示例代码const webpush require(web-push); webpush.setVapidDetails( mailto:yourexample.com, VAPID公钥, VAPID私钥 ); async function sendWebPush(msg, subscription) { const payload JSON.stringify({ title: msg.title, body: msg.body, data: msg.data }); try { await webpush.sendNotification(subscription, payload); } catch (err) { // 410/404 表示订阅失效需要清理 if (err.statusCode 410 || err.statusCode 404) { await removeBrowserSubscription(msg.user_id); } throw err; } }web-push 库会帮你完成 VAPID 签名和 payload 加密不用自己加密。所以后端代码里看起来只是一个 sendNotification 调用但实际上网上传输的是经过签名的授权请求和加密后的消息内容。这里要注意同一个浏览器不同设备的 subscription 都不同比如用户在 Windows Chrome 和 Mac Chrome 里各授权一次你会得到两条完全不同的记录。正确做法是给一个 user_id 保存多条浏览器订阅记录发送时逐个订阅发送任何一个订阅失效都不能影响其他设备。清理失效订阅时要按订阅的 endpoint 判断而不是用户维度一刀切。如果要做右下角弹窗的展示效果通知的标题和正文不能太长Windows 上的浏览器通知宽度有限太长会截断。图标也建议用 128x128 以上的 PNG清晰度会直接影响用户对产品的信任。公司没有现成图标时宁可用一个简单色块加文字也不要随便找一张模糊的小图。3.6 完整流程串联与灰度策略把上面的模块拼起来一个完整消息推送流程是业务系统调用 POST /api/push/send传统一消息结构接入层校验并入库调度层根据用户订阅判断渠道顺序如果用户渠道不可用就做降级发送成功后写回执并更新统计数据。实际项目里我不建议把所有消息实时同步发出去。更稳的方案是先写入待发送表由定时任务或消息队列消费控制发送速率。比如浏览器通知每批 500 条批间停 500 毫秒防止触达平台频率限制微信消息推送也尽量控制在每秒几笔到几十笔之间具体取决于你的公众号后台配额。如果某次活动要发几十万条提前把队列堆积量监控起来比临时扩容更重要。灰度策略上我会把新推送支持的消息场景先限制在 5% 到 10% 的用户内跑观察点击率和投诉率。确认没有文案问题、没有重复推送后再全量放开。消息推送一旦造成负面体验用户流失比没有消息提醒更严重所以宁可上线慢一点。4. 常见问题与排查技巧实录4.1 微信推送失败率偏高微信推送最常出现的问题是 errcode 非 0。比如 48001 表示 api 功能未授权40003 表示 openid 无效45026 表示消息发送太频繁。遇到这些信息不要盲目重试先对照官方错误码表排查。一个比较实用的排查顺序是先看 access_token 是否新鲜因为 Redis 缓存后可能存在并发刷新导致旧 token 被覆盖再看发送参数里的模板 ID、openid 是否和当前公众号匹配最后检查 IP 白名单。如果上面都对再看返回错误码到公众号后台打开调试工具模拟一次发送。这里有一个隐藏问题模板内容字段顺序。你在代码里传的对象键值顺序本身不影响但模板里每个字段都必须出现。有些接口对类型严格比如数字不能传字符串。用测试号先验证一次比上线后看日志高效得多。还有一类失败来自用户侧。用户取消关注后你继续调接口就会报错。与其每次发完失败再清理不如在公众号后台的取消关注事件回调里直接更新用户微信渠道状态。这样不仅减少了无效请求还能避免把用户标记为“可触达”但实际始终失败。4.2 浏览器通知收不到或右下角不弹浏览器通知收不到原因通常不是后端代码而是前端订阅和系统设置。先到浏览器里打开 F12在 Console 执行 Notification.permission如果结果是 denied说明用户之前拒绝过权限代码再调也不会弹窗只能提示用户去站点设置里重新允许。接着看 Service Worker 是否注册成功Application 面板里能看到 sw.js 的激活状态。再看 Push 事件是否触发可以在 Service Worker 内部加日志观察是否走到展示那一步。很多 Windows 用户右下角不弹是因为操作系统把通知关闭了或者浏览器的通知权限被系统静默。Chrome 有自己的“安静通知”机制如果用户最近拒绝过其他网站的通知浏览器会默认把新站点通知也设为安静模式。这种情况下网站侧没法强弹只能在页面内引导用户手动打开。也可以在授权弹窗前先显示一个自定义遮罩解释“我们会通知你订单进度”这样授权通过率会明显提高。订阅失效也是个高频坑。用户清空浏览器缓存、重装浏览器、或使用了隐身模式后之前保存的 subscription 会失效。后端在发送时遇到 410/404 一定要清理无效订阅否则后续统计会被脏数据污染。我记得有个项目上线第一周浏览器推送成功率只有 60%后来一查大量无效订阅还在库里占着清理之后成功率立刻回到 85% 以上。4.3 重复推送与用户投诉重复推送大概分两类上游业务重试导致同一条消息发两次以及定时任务或用户操作触发了多次相同通知。解决思路都差不多使用 message_id 做幂等。如果消息来自同一个业务事件message_id 要保持唯一且相同如果只是相似内容比如同一订单发了两封通知需要通过去重窗口来控制比如 5 分钟内同一用户同一场景只发一条。在应用层发送前先查 Redis用 SET NX 实现幂等。但要注意即使发送失败后重试也不能重新生成 message_id否则幂等就失效了。我见过不少同事在这个细节上栽过跟头业务框架自带重试每次重试都重新生成消息 ID最后用户收到好几条一模一样的通知。排查方式也很简单看日志里 message_id 是否一致即可。用户投诉是另一个需要高度警觉的信号。当投诉率上升微信平台会限制公众号的发送能力。所以要在后台记录每个消息场景的发送量、到达量、投诉量一旦某个场景投诉率明显偏高立刻停止该场景的发送并人工审核文案。这里有个经验值可以参考营销类推送的投诉率最好控制在万分之几交易类通知的正常投诉率会更低。如果某天投诉突然翻倍优先看是不是文案语气太营销化或者发送频次太高。4.4 高并发与性能瓶颈大规模推送时最容易出现的瓶颈是同步等待。如果业务系统直接从推送中心同步调用微信接口或 Web Push一旦对方响应慢整个请求就会被拖住。更合理的做法是接入层入库即返回 200后台消费者异步发送。这样对业务系统的响应时间几乎不增加推送失败也不阻塞业务主流程。异步之后还有一个挑战削峰。比如大促零点的优惠券提醒瞬间可能有几十万条消息进来。这时要用队列缓冲消费者根据渠道配额动态调整拉取速度。Redis 的 List 可以当作简易队列但生产环境更建议用专门的 MQ因为要处理消息确认、重试和延迟队列。如果业务上要求几分钟内全部发完那就得横向扩容消费者。但扩容前要看微信或浏览器推送服务是否有配额限制。还有一个容易被忽略的问题日志量。高并发推送时如果每条消息都打全量日志日志系统可能会先被打爆。建议把日志分成发送轨迹和业务日志两层发送轨迹只记录 message_id、渠道、耗时、结果业务日志才记录完整内容。排查问题时用 message_id 串起整个链路既精准又不产生海量噪音。5. 实战后的几点体会做完这个项目我最大的感受是消息推送系统叫“推送”但真正的功夫在“治理”。一开始大家都关注能不能发出去后面才会意识到发得太多、太乱、太吵才是真正让产品受伤的原因。所以我建议所有做消息推送的朋友在项目初期就把三件事做进去用户可退订场景可分级发送可灰度。这三件事看着简单等用户量上来后再补成本会翻好几倍。就拿退订来说如果一开始只存了“允许接收”后面用户想细分到营销类不接收、交易类接收就需要重新折腾订阅表。最后分享一个小技巧给每个消息场景设计一个场景码比如 order_shipping、refund_success。日志全部按场景码打点。这样排查的时候你可以快速统计每个场景的发送量、到达量、点击量而不是在一堆原始日志里翻来翻去。推送系统的可观测性真的能决定这个项目能走多远。