背景
小程序订阅消息(一次性订阅/长期订阅)有一个绕不开的机制:一次授权对应一次发送额度。用户在页面上勾选一次订阅,服务端才拿到一个 send 配额,发完即失效。业务侧的通知场景却很多:订单状态、配送进度、活动提醒、到期提醒......稍不留神就会出现三类线上问题:
- 配额明明还有,用户却收不到------被频控或内容校验拦截;
- 大促批量推送时接口大面积报错,没有补偿,消息永久丢失;
- 运营无约束地发营销消息,用户投诉导致模板被封,整个消息能力瘫痪。
本文记录我们在一个日活几十万的小程序里重做消息投递服务的完整方案,核心是三件事:授权配额令牌池化、发送全链路限流、失败可重试的补偿机制。代码以 Java + Spring Boot + Redis 为主,思路与语言无关。
一、先把配额模型搞对
1.1 一次一授权,配额必须记账
订阅消息接口返回的 errcode=43101(用户未接受/已拒绝接收)、47003(模板参数不符)等错误,本质都要求服务端对"谁、在什么时间、授权了哪个模板、额度还剩多少"有精确账本。
最朴素也最可靠的设计是一张授权流水表:
sql
CREATE TABLE `msg_subscribe_quota` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`template_id` varchar(64) NOT NULL,
`scene` varchar(32) NOT NULL COMMENT '授权场景:下单页/支付成功页',
`status` tinyint NOT NULL COMMENT '0待使用 1已使用 2已过期',
`expire_time` datetime NOT NULL,
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_tpl_status` (`user_id`,`template_id`,`status`)
) ENGINE=InnoDB;
用户每勾选一次授权,回调成功后插一条 status=0 的记录;真正发消息时,先抢占一条配额,再调发送接口。
1.2 踩坑点1:先发送后记账导致超发
最初的实现是"先调微信接口,成功后再写已使用状态"。并发场景下(比如订单支付成功同时触发了订单通知和积分通知两个异步任务),两个线程都查到 status=0 的同一条配额,各发一次,其中一次必然 43101,更糟的是营销模板和服务模板混用配额,账单彻底对不上。
修复方式是用条件更新做原子抢占,而不是查出来再改:
java
@Update("UPDATE msg_subscribe_quota SET status=1, use_time=NOW() " +
"WHERE id=#{id} AND status=0")
int occupyQuota(@Param("id") Long id);
抢占返回 0 说明额度已被别的任务用掉,直接走"无配额"分支(降级为站内信/下次交互再引导订阅),不调发送接口。
1.3 长期订阅模板要单独建账
长期订阅模板(部分类目可用)一次授权可多次下发,但有频次上限。这类不适合做"一次性令牌",要维护滚动窗口计数器(下文限流部分统一讲)。两类模板在代码里走不同的 QuotaStrategy,不要图省事共用一个方法。
二、发送前频控:三层防线
消息服务被投诉封模板,几乎都是营销消息失控造成的。我们在发送前加了三层频控,任何一层不通过都不投递。
2.1 第一层:用户级令牌桶(防打扰)
同一用户对同一类模板设最小间隔(如营销类 72 小时 1 条,服务类不限制但同类通知 1 小时内去重)。用 Redis + Lua 保证原子性:
lua
-- key: rc:{userId}:{tplGroup} arg1=窗口秒数 arg2=当前时间戳
local last = redis.call('GET', KEYS[1])
if last and (tonumber(ARGV[2]) - tonumber(last)) < tonumber(ARGV[1]) then
return 0
end
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[1])
return 1
2.2 踩坑点2:先查GET再SET的频控形同虚设
早期用 redisTemplate.opsForValue().get() 判断、再 set(),压测下两个线程同时 GET 到空值,双双放行。频控判断必须是单条 Lua/单条命令原子完成,或者直接用 SET NX EX 这类原子原语,不能拆成两步。
2.3 第二层:模板级滚动窗口(防封模板)
微信对单个模板有下发量级和投诉率的隐式风控。我们对每个模板做分钟级、小时级发送量上限,用滑动窗口(Redis ZSet 实现):发送时 ZADD 时间戳,先 ZREMRANGEBYSCORE 清理窗口外成员,再 ZCARD 判断是否超限。长期订阅模板的用户级次数上限也用同一个工具类:
java
public boolean tryAcquire(String windowKey, int limit, long windowSec) {
long now = System.currentTimeMillis();
redis.opsForZSet().removeRangeByScore(windowKey, 0, now - windowSec * 1000);
Long count = redis.opsForZSet().zCard(windowKey);
if (count != null && count >= limit) return false;
redis.opsForZSet().add(windowKey, now + "-" + ThreadLocalRandom.current().nextLong(), now);
redis.expire(windowKey, windowSec + 1);
return true;
}
ZSet 的 member 用"时间戳+随机数"而不是单纯时间戳,避免同毫秒写入互相覆盖------这是踩过的第三个坑:大促整点几万条任务同一毫秒执行,member 冲突导致窗口计数偏小,限流失效。
2.4 第三层:服务实例级令牌桶(保护自己)
即便微信侧不限,业务系统自己也要控制调用 QPS,避免把连接池打满、避免触发微信接口的频率错误码(45009)。实例内用 Guava RateLimiter 设单节点 QPS(如 200),集群总量上限通过配置中心统一下发。全链路消息发送走独立线程池,与下单主链路隔离:
java
ThreadPoolExecutor msgPool = new ThreadPoolExecutor(
16, 32, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(50000),
new ThreadFactoryBuilder().setNameFormat("msg-send-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy());
队列长度和拒绝策略要显式设置。我们用 CallerRunsPolicy 而不是 AbortPolicy:消息是宁可背压降速也不能静默丢弃的任务。
三、发送与失败补偿
3.1 发送任务状态机
每条待发消息落库,状态流转:
待发送 → 发送中 → 成功 / 待重试 / 终态失败
sql
CREATE TABLE `msg_send_task` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`template_id` varchar(64) NOT NULL,
`quota_id` bigint NOT NULL,
`content_json` varchar(1024) NOT NULL,
`status` tinyint NOT NULL COMMENT '0待发 1发送中 2成功 3待重试 4终态失败',
`retry_count` int NOT NULL DEFAULT 0,
`next_retry_time` datetime NOT NULL,
`last_errcode` int DEFAULT NULL,
`version` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_retry` (`status`,`next_retry_time`)
) ENGINE=InnoDB;
发送前用乐观锁把任务从"待发/待重试"翻到"发送中",避免定时补偿任务和实时发送任务重复投递。
3.2 错误码分级,重试策略不能一刀切
这是整个补偿设计里最关键的一张表,不同错误码语义完全不同:
| 错误码 | 含义 | 处理策略 |
|---|---|---|
| 0 | 成功 | 置成功,quota 置已使用 |
| 43101 | 用户未授权/配额已用完 | 终态失败,不重试,回滚配额占用 |
| 47003 | 模板参数不符 | 终态失败,告警给开发,重试无意义 |
| 40003 | openid 无效 | 终态失败 |
| 45009 | 接口调用频率超限 | 可重试,指数退避 |
| -1 / 系统繁忙 | 微信侧抖动 | 可重试,指数退避 |
| 40001/42001 | access_token 问题 | 刷新 token 后立即重试 |
可重试错误走指数退避(1 分钟、5 分钟、30 分钟、2 小时、6 小时),超过 5 次转终态失败并告警。把 47003 这类参数错误拿去无脑重试,是我们第四个踩坑:重试任务堆积几十万条把补偿线程池占满,真正该重试的系统抖动消息反而排不上。
3.3 access_token 的中心化管理
access_token 有效期 7200 秒、全集群共享且每天获取次数有限,必须集中存取:所有实例从 Redis 读 token,由定时任务在到期前 10 分钟统一刷新并加分布式锁,杜绝多实例各自刷新导致的互相失效(40001 玄学问题大多来源于此)。
3.4 幂等:补偿重试不能发重
重试天然有"超时但其实成功"的可能:接口调用超时,任务留在待重试,实际上微信已经投递。订阅消息没有幂等号参数,只能靠业务侧去重:以 (quota_id) 为唯一键------一条配额最多只能被成功消费一次。发送成功回写时条件更新 WHERE status=1 AND version=#{v},并在调接口前确保该 quota 没有成功记录。极端情况下的重复,由"一个配额一次发送"这个业务约束兜住。
四、可观测性:消息系统必须回答的三个问题
上线后我们补了三块监控,缺一块都会在故障时抓瞎:
- 漏斗指标:授权数 → 抢占成功数 → 接口调用数 → 成功数,按模板、按场景分维度。哪一环掉量一眼可见;
- 错误码大盘:43101 占比持续升高,说明前端订阅引导设计有问题(用户没勾就关弹窗);45009 突增说明令牌桶 QPS 设高了;
- 补偿队列积压告警:待重试任务数超过阈值直接告警到值班群。曾在一次微信侧 20 分钟抖动中积压了 8 万条,退避队列平稳消化,没有一条丢失------这个看板给了我们敢让营销活动放量的底气。
五、小结
订阅消息投递服务看起来是个"调接口"的简单活,真正上线后考验的全是边界:
- 配额要原子抢占,不能先查后改;
- 频控要原子判断,Lua 或原子原语一步完成;
- 限流窗口要防同毫秒 member 冲突;
- 失败要按错误码分级,参数错误不重试、系统抖动才退避;
- token 集中管理、重试以业务唯一键幂等、主链路与消息链路线程池隔离。
把这六件事做扎实,消息服务才能在大促量级下做到"不超发、不漏发、不封模板"。