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 即时通讯实战,电商场景的订阅玩法能参考 小程序商城实战。

延伸阅读