4.3 消息通知:订阅消息推送
场景:评论提醒从短信到免费推送
我们最早的通知方案是短信,评论被回复时给绑定手机号的用户发一条。日活五千的时候每月短信费八百元,效果还差:短信打开率不到百分之二十,多数用户根本不知道自己被回复了,用户在社区里互动的热情也因此一直上不来。调研后转向小程序订阅消息,不花钱、触达精准,代价是需要用户主动订阅,每次只订阅一次推送机会。
切换当月成本立竿见影,短信费从八百元降到了零,但麻烦也跟着来了:订阅率只有百分之三十四,远低于短信时代假设的触达率。我们又花了两个月迭代订阅引导的位置和文案,把订阅率拉到百分之六十一,这个过程比想象中更产品化——订阅率几乎只取决于引导出现的时机。具体来说,把订阅框放在发帖成功回调里比放在首页弹窗的转化率高两倍以上,因为用户刚完成动作、情绪正向、对后续反馈有期待,是订阅意愿最强的瞬间。
订阅消息的机制对产品形态有要求:用户在点击某个按钮时可以勾选订阅,之后系统才能下发一条模板消息。我们的策略是在关键动作后引导订阅:发帖时订阅评论提醒,评论时订阅楼内回复提醒。模板固定四条:新评论、新粉丝、帖子审核结果、活动公告。订阅入口费了不少心思,试过首页弹窗、结果页提示、自然交互三种位置,最后发现紧贴用户刚完成的动作、就在同一页面里弹出订阅框的转化率最高,强塞在首页的弹窗大概率被秒关。
方案:订阅次数池与静默重试
订阅消息与模板消息最大的差别是次数概念:用户勾选一次等于授权一次发送,发完就清零。后端维护一张订阅次数表,按模板与用户累计可用次数,每次发送前检查余量,设有余量再发送,避免发送失败被微信限流。余量数据每天对账一次,库里余量和 Redis 之和若有偏差立刻告警,次数就是产品的钱,错不起。发送失败统一进重试队列,重试队列按指数退避最多三次,消费任务每分钟扫一次,把失败的发送重新投递。这个队列的实现就是 Redis 的 List 加一个定时任务,没有引入任何新组件,简单到没有出故障的可能。失败原因里最多见的是用户卸载或长期未打开小程序,这类发送重试三次后放弃,并清零该用户余量,避免持续无效发送浪费配额:
// 重试队列消费任务
async function drainRetry() {
const raw = await redis.rpop('notify:retry');
if (!raw) return;
const job = JSON.parse(raw);
if (job.attempt > 3) {
await cls.log('notify_drop', job); // 超过次数记日志兜底
return;
}
const ok = await sendOnce(job);
if (!ok) {
job.attempt += 1;
await redis.lpush('notify:retry', JSON.stringify(job));
}
}
| 模板 | 触发场景 | 订阅来源 | 频控 |
|---|---|---|---|
| 新评论 | 帖子被评论 | 发帖页 | 每小时聚合 |
| 楼内回复 | 被回复 | 评论页 | 每小时聚合 |
| 审核结果 | 审核完成 | 发帖页 | 即时 |
| 活动公告 | 运营群发 | 首页弹窗 | 每日一条 |
把三种通知手段的对比也放这里,当初我们在这张表上做的决策,回头看是正确的取舍:
| 手段 | 成本 | 触达率 | 时效 | 缺点 |
|---|---|---|---|---|
| 短信 | 每条三分五 | 百分之二十 | 即时 | 贵且容易被忽略 |
| 订阅消息 | 免费 | 百分之六十 | 即时 | 需要订阅次数 |
| 公众号模板 | 免费 | 百分之五十 | 准实时 | 用户需关注公众号 |
通知手段的选择不是非此即彼,我们最终的组合是 日常用订阅消息、运营大事件用公众号模板、紧急客服回调用短信,三层搭配各司其职,月成本控制在五十元以内。
关键设计是频控聚合:同一用户一小时内多条评论提醒合并成一条发送,既省次数又防骚扰。聚合规则还有一个隐藏好处:模板内容只显示最新一条的摘要,反而让用户产生点开看全部的冲动,我们的点开率比逐条发送时期还高了百分之十五。
配置与代码
发送成功后扣减,失败进入重试队列。余量存在 t_subscribe 表里,Redis 只是加速读取的副本,任何一边的写入都会同步另一边,余量清零这样的关键操作必须先写库再删缓存,保证一致性不丢次数:
const TPL = { comment: 'tpl_id_xxx', reply: 'tpl_id_yyy' };
async function pushNotify(uid, type, data) {
const remain = await redis.get('sub:cnt:' + uid + ':' + type);
if (+remain <= 0) return;
const openids = await getUserOpenid(uid);
const res = await wxSendSubscribe({
touser: openids,
template_id: TPL[type],
page: 'pages/detail?id=' + data.post_id,
data: packTemplate(data)
});
if (res.errcode === 0) {
await redis.decr('sub:cnt:' + uid + ':' + type);
} else {
await retryQueue.push({ uid, type, data, attempt: 1 });
}
}
小程序端订阅动作只在交互时触发,不能用诱导手段,否则会被判违规。平台对订阅引导的底线是不得与利益挂钩,比如不能用领积分等方式交换订阅,踩线被抓轻则模板下线、重则能力封禁,不值得。
wx.requestSubscribeMessage({
tmplIds: ['tpl_id_xxx'],
success(res) {
if (res['tpl_id_xxx'] === 'accept') {
wx.request('/api/sub/incr', { data: { type: 'comment' } });
}
},
fail() { /* 用户拒绝不弹错,静默处理 */ }
});
踩坑
第一个坑是订阅次数没持久化。早期次数只存 Redis,重启或 key 过期后余量清零,有用户反馈从没收到通知,才发现订阅次数丢了,改成 Redis 加数据库双写。第二个坑是发送频控缺失,一个热帖作者一秒收到十七条提醒,部分用户直接关掉订阅,加了按小时聚合发送后投诉消失。聚合的实现很轻,Redis 里给每个用户存一个 Sorted Set,发送任务每小时扫一遍把窗口内的内容合并成一条模板,比逐条发送的代码还少。第三个坑是模板内容审核,模板参数里不能放营销话术,我们一条活动公告被驳回,原因是文案里有诱导分享表述,模板文案走了三轮修改才过审。审模板这件事没有捷径,建议直接参考同类已过审的模板措辞,官方文档里也能找到大量示例。第四个坑是 access_token 并发刷新,所有推送共用一个公众号令牌,高并发时多个进程同时刷新互相作废,加分布式锁只允许一个进程刷新才稳定。第五个坑是退订无感,用户在小程序设置里关了订阅但我们后端不知道,发送全失败浪费配额,定期同步用户订阅状态做兜底。第六个坑是聚合窗口的告警误判,某用户一小时内收到八条评论,聚合发送只发了一条,但监控把发送量和评论量对比后误报为漏发,后来监控口径改为按聚合窗口统计,告警才准。第七个坑是 access_token 刷新的时差,分布式锁只保证一个进程刷新,但其他进程拿到旧令牌仍会发送失败,加了令牌版本号读取机制,刷新后所有进程立刻可见。第八个坑是推送落地页路径失效,小程序改版后旧路径跳转到不存在的页面,推送发出等于浪费一次次数,加了路径白名单校验,路径不在白名单的发送前直接拦截。第九个坑是次数到期的提醒缺失,用户订阅余量为零后,下次发帖时订阅框已经不再出现,导致老用户永远收不到通知,我们在发帖成功页加了一次二次引导,余量不足时再次请求订阅,续订率回升百分之二十。
小结
订阅消息是社区类小程序最经济的触达手段,但机制决定了它稀缺。用好它的关键是把次数池管理好、频控做好、退订同步好。聚合发送是一条尤其值得遵守的规则,用户体感会好很多。最后提一句策略层面的感悟:通知是产品的延伸而非负担,每发一条都要问自己这条对用户是不是真的有用,带着这个标准去配模板和频控,订阅率会自己往上涨。我们把每一条模板文案都在内部群读过出声才提交,这个仪式感帮我们挡掉了好几条自嗨式文案。离线推送与未读数的更多设计,可以看 IM 即时通讯实战,电商场景的订阅玩法能参考 小程序商城实战。
延伸阅读
- IM 即时通讯实战(离线推送与未读数设计)
- 小程序商城实战:从选型到上线(订阅消息的电商用法)
- AI 工具实战(触达链路的自动化)
