3.4 触达策略:多通道兜底
场景:没有一条通道是永远通的
上线第二个月的一个雨天,订阅消息接口抖了十五分钟。那十五分钟里正常的提醒流程全部卡住,因为我们的发送逻辑写的是单通道直连:有授权走订阅消息,没有就不管。结果是那天下午三个客人错过时段,门店外排了一溜等着空位的人。
林姐的评价很直接:你们这个东西,一条腿断了就不会走了。从那天起,触达策略从单通道改成了编排:每条消息都有一条预想的完整路径,任何一个环节失败,下一个通道立刻顶上。本节讲的就是这张兜底网怎么织。
换一个角度想,通道编排和门店排班是一回事:美甲师请假有替补,通道故障也要有替补。理解了这一点,调度器的设计就不是技术玄学而是店务常识——给每条消息排好班,谁负责谁替补,清清楚楚。
方案:一张触达决策表
所有提醒的发送不再由业务代码直接调用通道,而是投递到一个统一的 notify 表,由一个调度器决定怎么发。调度器按客人画像和通道状态打分选路。notify 表是整个策略的中枢,业务侧只管往里塞消息:
CREATE TABLE t_notify (
notify_id BIGINT PRIMARY KEY AUTO_INCREMENT,
book_id BIGINT NOT NULL,
scene VARCHAR(32) NOT NULL COMMENT '触发场景',
state TINYINT NOT NULL COMMENT '0 待发 1 已送达 2 全失败',
KEY idx_state_send (state)
);
把消息从业务流程里剥离出来,最大的好处是业务代码不再关心通道细节——下单接口不需要知道走短信还是订阅消息,它只需要说这个预约成功了,你们看着通知。
| 优先级 | 通道 | 条件 | 成本 |
|---|---|---|---|
| 1 | 订阅消息 | 有剩余授权次数 | 0 |
| 2 | 短信 | 次数耗尽或接口异常 | 每条约 4 分 |
| 3 | 公众号模板 | 已关注公众号 | 0 |
| 4 | 店员电话 | 高价值预约且全失败 | 人力 |
决策不是静态的。同一个客人在不同业务下选择可能不同:预约成功这种喜庆消息走订阅消息最合适,爽约告知这种敏感消息反而只走短信,因为短信的庄严感能压住情绪。
通道状态也在决策输入里。调度器会读取最近五分钟各通道的失败率:订阅消息失败率超过 20% 时整体降级到短信优先,避免对抖动通道反复冲击。这就是熔断在业务层面的样子——不是断电,而是换路线。
三个月跑下来的通道覆盖数据:
| 指标 | 数值 | 说明 |
|---|---|---|
| 订阅消息覆盖 | 58% | 有授权的客人 |
| 短信补位 | 37% | 含降级和兜底 |
| 公众号补位 | 3% | 已关注未授权 |
| 人工兜底 | 2% | 店员电话 |
| 综合到达率 | 96% | 短信回执加回执确认 |
这组数据里有意思的是人工兜底那一行 2%。看起来不起眼,但它兜住的全是高价值预约,产生的营收占当月预约额的 9%。林姐说这就是门店生意:两分钱的电话费,接住的是几百块的单子。
代码:调度器与降级链
调度器的核心是一个按优先级排列的通道列表和统一的发送抽象。每个通道实现同一个接口,失败返回错误,调度器捕捉后尝试下一个:
type Channel interface {
Try(n *Notify) error // 发送或失败
}
func dispatch(n *Notify) {
for _, ch := range buildChannels(n) {
if err := ch.Try(n); err == nil {
markSent(n, ch.Name())
break
}
recordFail(n, ch.Name(), err)
}
}
每个通道的 Try 内部自己处理重试,调度器只负责切换。落地两个多月,新增公众号通道时只加了一个实现文件,调度器一行代码没改——接口抽象的价值在这种时候最直观。
buildChannels 根据客人授权数据、关注状态和预约金额决定通道顺序。高价值预约(超过 300 元)在全通道失败时会进入人工兜底队列,店员端我们没做独立 App,而是在企业微信里加了一个自建应用,收到兜底提醒像收到同事消息一样,这比单独维护一个 App 划算太多。
降级链上最容易实现错的是等不等。订阅消息接口偶尔慢两秒,调度器如果同步等待,整条队列会被拖垮。我们给每个通道加了超时熔断:两秒没回包直接判失败切下一个,慢的通道留在后台重试,成功与否不影响主链路。熔断上线后,通知任务的平均耗时从 4.2 秒压到 0.8 秒。
调度器还有一个细节值得记录:同一客人在两秒内被投递多条消息时,调度器会做合并。比如预约成功的下一秒客人又改期,两条消息合并成一条最终态提醒,客人只看到一条改期通知。合并的判断依据是客人 维度的小窗口聚合,窗口设为三秒,短到客人无感知、长到足以收起连续操作。合并的代价是消息内容必须足够通用,所以我们只在同场景同订单间合并,跨场景的消息各发各的。
配置:触发矩阵
什么时候发什么消息,我们整理成一张触发矩阵,运营和开发共用这一张表,避免代码里散落各种 if:
| 触发点 | 消息 | 主通道 | 兜底 |
|---|---|---|---|
| 预约成功 | 预约确认 | 订阅消息 | 短信 |
| 服务前 2 小时 | 到店提醒 | 订阅消息 | 短信 |
| 取消成功 | 取消确认 | 短信 | 订阅消息 |
| 爽约后 | 未到访告知 | 短信 | 无 |
| 排班变更 | 改期通知 | 订阅消息 | 短信加电话 |
注意取消确认的主通道是短信而不是订阅消息。原因是取消动作往往发生在客人焦虑情绪下,短信的到达确定性最高,而且客人取消时通常不在小程序里,订阅消息的打开动作路径更长。
触发矩阵最初没有改期通知这一行,是林姐提醒的:有一次门店美甲师集体培训,某天所有预约被批量改期,客人没收到任何通知,二十多个人按原时间冲到店里扑了空。批量改期时矩阵里的改期通知按每人一条发送,整个事件处理花了三个小时,从那以后矩阵里永远有改期通知这一行。
踩坑:编排现场的四个坑
第一个坑是静默失败不知道。订阅消息发送失败时异步回调是主要告警来源,最初没有统一回收口,失败散落在各通道日志里,一周才汇总一次。后来所有通道的失败都落到一张失败流水表,每十分钟聚合统计,失败率超过 5% 就告警值班人。
告警第一次真正发威是某个周日下午:订阅消息接口抽风,五分钟失败率飙到 60%,值班同事两分钟内手动切到短信优先配置,只有那批消息延迟了十分钟,没有一条丢失。若按以前的方式,这事要到下周一看报表才会被发现。
第二个坑是重复触达。客人改期三次,提醒发了三次,每次都是服务前两小时。第三次客人打电话来抱怨说知道啦别再发了。后来加了频控:同一预约的提醒消息二十四小时内只发一次,改期只更新内容不重发。
第三个坑是取消消息比取消动作慢。客人点完取消立刻收到的是旧版提醒(因为到点任务已在队列里)。我们在任务执行前加了一道状态校验:订单已取消则丢弃提醒,几行代码消灭了一整类客诉。
同样的问题还有一个变种:客人取消后又改期回来,提醒被丢弃了。状态校验于是改成读取订单最新状态并判断提醒是否仍然适用,而不是一刀切丢弃。编排逻辑越多,状态校验的颗粒度就得越细,这是复杂度守恒。
第四个坑是降级链不分轻重。最初所有消息走同一条链,高峰期短信配额被低价值提醒占满,真正重要的爽约告知发不出去。后来消息分级:普通消息不进人工队列、高价值消息带优先标记,调度器按级分队列处理。
分级之后还有一个副作用提醒我们注意:低优先级消息可能被永远排在后面。我们给普通消息加了老化机制,超过两小时未发送则提级,宁可走贵通道也不能让提醒变成遗产。
小结
触达策略的本质是承认所有通道都不可靠,然后用编排把不可靠叠成可靠。58% 免费加 37% 廉价加 2% 昂贵的人力,这组比例是我们一个季度试出来的最优解。林姐的评价从一条腿断了就不会走变成了这个月没有一位客人错过时段。
整个策略里最有价值的不是任何一段代码,而是那张触发矩阵——它把什么时候用什么通道讲清楚,运营改规则改配置不改代码。
这张网跑到第三个月的某一天通过了一次真正的考验:短信平台区域性故障两小时,调度器自动把全部流量切到订阅消息和公众号通道,短信配额恢复后又切回来。全过程没有一条提醒丢失,值班同学是第二天看周报才知道出过事。冗余设计的价值就在这种时刻——平时像多余的零件,出事时是唯一转动的齿轮。下一章换个方向,从客人端走到店务端:多门店、员工和看板。
延伸阅读
- 通道接入细节,翻 短信接入:腾讯云 SMS 与 订阅消息:小程序内通知
- 模板审核的花样,见 模板与签名:审核实操
- 通知之上是店务运营,见 多门店:账号与数据权限
- 通用的多通道消息工程,翻 Go IM 实战
- 节假日流量对触达链路的考验,见 高峰应对:节假日预约洪峰
