先说个我自己的经历。两年前我接手一个消息推送服务,每天要向微信用户批量下发模板消息。功能上线第三天,后台就开始刷 45009,日志里一串 api call freq out of limit。查了一圈,网上答案翻来覆去就是“用令牌桶限流”。可问题是——我用了令牌桶,照样被限。后来我把接口日志和微信返回码逐条对齐,发现罪魁祸首根本不是“速度不够”,而是经典令牌桶的固定速率模型,跟微信“分钟限额 + 日限额叠加”的规则不匹配。这篇文章就讲讲我怎么改进令牌桶算法,并且用 Spring Boot + Redis 把它接进了生产项目,踩过的坑也都写在里面。
如果你也维护过微信 API 接入层,大概率遇过 45009、40001、以及群里同事半夜发的“消息发不出去了”。限流方案这东西,平时感觉不到存在,一旦被触发,就是事故现场。把问题想透、把桶参数算准,比单纯引入一个框架重要得多。
1. 微信API限流,你到底被谁限了?
做限流之前,得先搞清楚微信到底在限什么。很多文章一上来就讲算法,却没说清楚业务场景,结果参数拍脑袋填,上线照样翻车。
1.1 微信API常见的限制维度
微信开放平台的频控不是一个单一的“每秒调用量”,而是一套多维度的配额体系。我整理了一个简化表格,方便你对照自己的业务:
| 限制维度 | 常见对象 | 典型表现 |
|---|---|---|
| 令牌获取次数 | access_token | 每日刷新有上限,正常使用一天刷新十几次就够,频繁刷新反而触发风控 |
| 分钟级频控 | 消息发送、素材上传 | 短时间高并发触发 45009 |
| 日级配额 | 模板消息、群发 | 日累计达到上限后返回 45009 |
| 用户维度 | 单 openid 收信频率 | 同一用户短时间内收到过多消息被拦截 |
| IP 维度 | 部分支付、风控相关接口 | 频率异常时被临时限制 |
这里要说明,具体数值经常变化,不同类目、不同资质账号的配额也不一样。我建议别把网上搜到的数字当金科玉律,以微信官方文档和实际返回码为准。
1.2 为什么偏偏是令牌桶
常见的限流算法有固定窗口、滑动窗口、漏桶、令牌桶。我最终选令牌桶,是因为微信的频控方式本质上带“配额池”的特征:允许你在短时间内用掉一定量的配额,但总量有限。
固定窗口在跨边界时会出问题。比如限制每分钟 300 次,前 59 秒用了 299 次,第 60 秒再用 1 次,下一秒又到新窗口,瞬间又可以发 300 次,实际上两秒内发出了近 600 次,窗口切换那一刻的流量尖刺会直接撞上微信的统计。
漏桶则相反,它把出口速率钉死在固定值上,完全不接受突发。微信接口并不是完全拒绝突发,否则大批量通知场景就没办法做了。
令牌桶允许平均速率受限、但可以积攒一定量的突发容量,这个特性比较贴合实际使用习惯。问题在于,经典实现里那个“桶”太粗糙了,直接搬到微信场景会踩出不少坑。
2. 标准令牌桶在微信场景里的三个坑
我并不是说令牌桶算法本身有问题,而是说经典实现有几个关键假设,在微信 API 场景下不成立。
2.1 单一时间尺度挡不住“分钟 + 日级”双重限制
如果给某个发送接口配置一个桶,容量 300、补充速率 5 个/秒,这能保证一分钟内最多发 300 个左右。但日限额可能是 45000。5 个/秒平摊到一天是 432000 个,远超日限额。也就是说,这个桶根本防不住日配额。
反过来,如果按日配额配置一个桶,容量 45000、补充速率 0.52 个/秒,那 60 秒内只能积攒不到 31 个令牌,你连一次正常的批量下发都发不出去。
结论很清晰:微信场景你得同时表达分钟级和日级两个尺度,单一令牌桶做不到。
2.2 积攒容量反而触发惩罚
令牌桶有一个特性:休眠一段时间后,桶会被填满,然后允许一次大突发。假如桶容量 300,半夜没人用,早晨上班瞬间发 200 条模板消息,算法层面是允许的,因为桶里有 300 个令牌。
但微信的窗口计数器不会这么想。如果它的算法是“最近 60 秒内不允许超过 300 次”,而你这 200 次集中在 10 秒内发出,那大概率直接触发 45009。
我管这个叫“积攒容量惩罚”。令牌桶允许你攒,微信不鼓励你攒着一次性打光。解决办法后面会说到,核心思路是控制可积累的突发上限,而不是让桶无限蓄水。
2.3 单机桶在分布式环境里形同虚设
生产环境一般至少两个实例。如果每个实例都在本地维护一个 ConcurrentHashMap 桶,那每个实例的限额都是 300,整体并发就是 600,直接翻倍。
最常见也最坑的“分布式限流”是:用 Redis 的 GET 去读当前计数,判断没超限后再 SET。两个实例同时读到 299,都认为可以放行,然后各自 +1,最终变 301。这种非原子操作在流量高峰时必然超卖。
要解决原子性,要么用 Redisson 现成的 RateLimiter,要么用 Redis Lua 脚本。我更推荐 Lua,原因在后面实现部分详细说。
3. 我做的四点改进
针对上面三个坑,我在项目里做了四项改进,每一项都对应一个具体的线上问题。
3.1 双桶校验:分钟级与日级组合判定
我的做法是同一类 API 调用,同时维护两个桶。
比如某个消息发送接口,分钟配额 300、日配额 45000:
| 桶名 | 容量 | 补充速率 | 含义 |
|---|---|---|---|
| wx:{push}:min | 300 | 5 个/秒 | 分钟级频控 |
| wx:{push}:day | 45000 | 0.52 个/秒 | 日级配额 |
请求进来时,两个桶都要扣减成功才放行。任何一个桶不服,就拒绝本次请求并返回等待时间。这样既能保证分钟级峰值,又不会漏掉日级总量。
双桶的代价是 Redis 多一次操作。如果拆成两次独立调用,可能出现分钟桶扣了、日桶没扣成功的半成功状态。所以我最终选了多 Key 的 Lua 脚本,把两个桶的扣减放进同一个原子操作里。
3.2 懒补充与负债模式
经典令牌桶通常有一个定时任务,每秒往桶里补令牌。这在单机进程里还行,在分布式环境里就很蠢,因为每个实例都可能起一个定时器,何况还要考虑时钟不一致。
我改成“懒补充”:不预设定时任务,而是在每次请求进来时,用当前时间减去上次补充时间,算出这段时间应补多少令牌。写入 Redis 时只需要存两个字段:剩余令牌数、上次补充时间戳。代码结构大概是这样:
local elapsedMs = now - lastRefill if elapsedMs > 0 and refillPerSec > 0 then local refill = math.floor((elapsedMs / 1000.0) * refillPerSec + 0.5) tokens = math.min(capacity, tokens + refill) end这里还有一个容易被忽略的细节:扣完令牌后,如果令牌数变成负数,不要急着把它拉回 0。把这个负数存下来,当作“欠债”。后面的请求会先还债,再重新攒令牌。这个负债模式天然地实现了“等待效果”,不需要任何锁,多个并发请求同时来的时候,大家都会得到一个合理的等待时间。
3.3 冷启动步进
我发现在服务刚启动,或者 access_token 刚刷新后的前几秒,如果立刻按正常速率放量,特别容易踩到微信的实时风控。原因可能是微信服务端对短时间内的“陌生流量”更敏感。
解决办法是给新服务的桶一个预热区。在预热时间内,真实补充速率打五折,然后逐步恢复到目标值。实现上不需要改 Lua 脚本,只需要在 Java 侧计算有效速率,再传给脚本。
举例:目标速率是 5 个/秒,预热期 30 秒。前 10 秒有效速率 2.5,中间 10 秒有效速率 3.75,最后 10 秒升到 5。这样可以平滑度过冷启动,给微信服务端一个“逐步适应”的过程。
3.4 惩罚熔断
双桶和负债解决的是“预防”,但总会有漏网之鱼。一旦真的收到 45009,说明微信已经在惩罚你了,这时候继续发只会加重处罚。
我加了一个惩罚开关:捕获到 45009 后,给对应的桶设置一个 penaltyUntil 时间戳,比如 30 秒。在这个时间段内,所有请求直接快速失败,不再尝试扣减令牌。
这相当于给限流器加了一个“熔断”档位。业务侧看到失败后,可以把消息丢进延迟队列,等惩罚期过了再重试。这个设计让系统在异常情况下的行为变得可预测,而不是一堆线程挤在 Redis 前面疯狂重试。
4. Spring Boot落地:注解+AOP+Redis Lua
算法想清楚之后,落地就变得顺理成章。我选择 Spring Boot + Redis Lua 的组合,核心代码不复杂,但每一行都有讲究。
4.1 为什么用Lua脚本而不是Java代码
很多人会问,为什么不用 Redisson 的 RateLimiter?我试过,Redisson 的 API 简单,但它不支持我把“双桶校验、惩罚期、负债模式”这些自定义逻辑塞进同一次原子操作里。
Lua 脚本的优势有三个:
- 原子性:Redis 执行 Lua 脚本时整个脚本不被其他命令打断,天然避免超卖
- 网络开销小:一次 RTT 完成读取、计算、写入
- 时间源统一:脚本内部可以用 redis.call('TIME') 直接取 Redis 服务器时间,避免各实例时钟漂移
4.2 核心脚本与参数解释
最终我用的核心脚本如下,我把它命名为 acquire.lua:
local times = redis.call('TIME') local now = tonumber(times[1]) * 1000 + math.floor(times[2] / 1000) local key = KEYS[1] local capacity = tonumber(ARGV[1]) local refillPerSec = tonumber(ARGV[2]) local permits = tonumber(ARGV[3]) local penaltyUntil = tonumber(ARGV[4]) local data = redis.call('HMGET', key, 'tokens', 'lastRefill', 'penaltyUntil') local tokens = tonumber(data[1]) local lastRefill = tonumber(data[2]) local prePenalty = tonumber(data[3]) if tokens == nil then tokens = capacity lastRefill = now elseif tokens > capacity then tokens = capacity end if prePenalty ~= nil and prePenalty > now then return -(prePenalty - now) end local expireSec = math.max(capacity * 2, 60) local elapsedMs = now - lastRefill if elapsedMs > 0 and refillPerSec > 0 then local refill = math.floor((elapsedMs / 1000.0) * refillPerSec + 0.5) tokens = math.min(capacity, tokens + refill) end tokens = tokens - permits if tokens >= 0 then redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now, 'penaltyUntil', 0) redis.call('EXPIRE', key, expireSec) return 1 end local waitMs = -1 if refillPerSec > 0 then waitMs = math.ceil((0 - tokens) / refillPerSec * 1000) end redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now, 'penaltyUntil', 0) redis.call('EXPIRE', key, expireSec) return -waitMs几个参数的作用我在表里拆开:
| 参数 | 含义 |
|---|---|
| KEY | 桶的唯一标识,建议带业务前缀 |
| ARGV[1] | 桶容量 |
| ARGV[2] | 每秒补充的令牌数 |
| ARGV[3] | 本次请求消耗的令牌数,通常为 1 |
| ARGV[4] | 惩罚截止时间戳,0 表示无惩罚 |
返回值约定:正数表示放行;负数表示拒绝,绝对值是等待毫秒数。如果是 -1,表示当前配置下永远无法放行,通常用于 refillPerSec 为 0 的纯熔断桶。
注意我在脚本里把负数令牌持久化了。这个细节很重要,它保证了“透支”会被后续的补充速率逐渐还清,而不是扣到一个负数后,下一轮直接重置回容量,那样就完全失去了限流意义。
4.3 注解与切面代码
Spring Boot 侧,我用的方案是自定义注解加 AOP 切面。调用方只需要在方法上写一行注解,限流逻辑统一收口。
自定义注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface WechatRateLimit { String key(); // 支持SpEL: #openid 之类 long capacity(); double refillRate(); // 每秒补充令牌数 int permits() default 1; long penaltySeconds() default 0; }核心服务:
@Component public class RedisTokenBucketService { private static final String ACQUIRE_LUA = "...上述脚本..."; private final StringRedisTemplate redisTemplate; private final DefaultRedisScript<Long> acquireScript; public RedisTokenBucketService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; this.acquireScript = new DefaultRedisScript<>(ACQUIRE_LUA); this.acquireScript.setResultType(Long.class); } public TryResult tryAcquire(String key, long capacity, double refillRate, int permits, long penaltyUntilMs) { Long result = redisTemplate.execute(acquireScript, Collections.singletonList(key), String.valueOf(capacity), String.valueOf(refillRate), String.valueOf(permits), String.valueOf(penaltyUntilMs)); if (result == null || result > 0) { return TryResult.granted(); } return TryResult.rejected(-result.longValue()); } }切面:
@Aspect @Component public class WechatRateLimitAspect { private final RedisTokenBucketService bucketService; @Around("@annotation(limit)") public Object around(ProceedingJoinPoint pjp, WechatRateLimit limit) throws Throwable { String key = resolveKey(limit.key(), pjp); TryResult result = bucketService.tryAcquire( key, limit.capacity(), limit.refillRate(), limit.permits(), 0L); if (!result.isGranted()) { throw new TooManyRequestsException(result.getWaitMs()); } try { return pjp.proceed(); } catch (WechatApiLimitException e) { bucketService.penalty(key, limit.penaltySeconds() * 1000L); throw e; } } }SpEL 解析那一行,我默认编译时带上了 -parameters 参数,让 Spring 能拿到真实的方法参数名。如果你没开这个编译参数,可以从 ConcurrentHashMap 缓存方法名对应的参数名,实测稳妥。
4.4 配置动态调整
注解里的值是编译期常量,线上要改配额怎么办?我建议把规则挪到配置中心,比如 Nacos。
定义一个 RateLimitRuleProperties 配置类,用 @ConfigurationProperties 绑定 YAML,再在切面里按 key 查规则:
wx: rate-limit: fallback-mode: fail-fast rules: - key: msg:send capacity: 300 refill-rate: 5 permits: 1 penalty-seconds: 30 - key: msg:send capacity: 45000 refill-rate: 0.52 permits: 1 penalty-seconds: 0如果同一个 key 有多条规则,那就是给双桶用的。切面层拿到规则列表后,用一个支持多 Key 的 Lua 变体一次扣减。Nacos 刷新配置后,新的配额会立即生效,不需要重启。
这里有一个小坑:降低 capacity 时,Redis 里可能已经存了比较大的 tokens 值。脚本里第一段做了一个 tokens > capacity 就截断的处理,所以新配置生效后,旧桶里的超额存量会被立即压掉,不会给你“多出来一截额度”。
5. 线上排查与避坑实录
算法和代码都只是开始,真正麻烦的是运行期间冒出来的各种问题。我挑三个最典型的讲。
5.1 实例间时钟不一致导致限流失效
早期版本我的时间和令牌数都由 Java 应用侧生成,用 System.currentTimeMillis()。某个线上实例的时钟快了 5 秒,它补充令牌时就多算 5 秒的量,限流器就像漏了一个洞。
后来我把时间源统一到 Redis 的 TIME 命令,在 Lua 脚本里取服务器时间,应用侧再也不传时间戳。这样所有实例用的是同一台 Redis 的时间,彻底消除了时钟漂移。
如果你不想用 TIME 命令,也可以统一走 NTP 并加监控,但我还是推荐在脚本里取,简单可靠。
5.2 Redis偶发超时把正常请求误伤
限流器依赖 Redis,Redis 一抖动,整个调用链路跟着遭殃。我遇到过 Redis 连接池被打满,导致所有限流检查超时,业务方看到的错误是“限流未知异常”,不是业务异常。
我的处理方式是给 Redis 操作设置合理的超时时间,比如连接、读、写都在 200ms 以内。超时后按照 fallback-mode 处理:
- fail-fast:直接快速失败,适合不能降级的支付类接口
- local-bucket:在本地用一个简单的内存桶兜底,适合可以接受轻微偏差的场景
- pass-all:直接放行,适合对丢消息零容忍、但愿意冒被微信限流风险的场景
我最终在消息推送服务里用的 pass-all,因为消息可以进重试队列,微信返回 45009 也晚于 Redis 超时,不会造成丢数据。
5.3 多Key脚本在集群环境下的槽位问题
双桶校验意味着一次 Lua 脚本要操作两个 Key。如果用的是 Redis Cluster,两个 Key 不在同一个哈希槽,执行就会报 CROSSSLOT 错误。
解决办法是给 Key 加上哈希标签。Redis Cluster 只对花括号内的内容做哈希,所以这样命名两个桶:
wx:{push}:min wx:{push}:day因为花括号里都是 push,所以两个 Key 必然落在同一个槽位,Multi Key 脚本才能正常执行。这一点在单节点 Redis 上不暴露,一上集群就翻车,建议提前规避。
还有一个冷门但很容易踩的坑:StringRedisTemplate 默认用 StringRedisSerializer,如果你不小心换成了 GenericJackson2JsonRedisSerializer,存进去的“300”会变成带引号的 JSON 字符串 ""300"",Lua 里 tonumber 解析出来是 nil,所有判断瞬间失灵。
6. 与Sentinel、Nacos方案怎么取舍
有人会说,Spring Cloud Alibaba 里现成的 Sentinel 不是挺好吗,配合 Nacos 还能动态配置,为什么还要自研?
我的看法是:看你的需求边界。
如果你的系统里有二十个以上接口需要网关级流控,还想要实时监控面板、熔断降级、热点参数限流,那 Sentinel 确实省事。它配合 Nacos 可以做到规则持久化和动态刷新,这是自研方案要花大力气才能追上的一部分。
但如果你只是给微信 API 那三五个核心接口做精细频控,Sentinel 反而有点重。Sentinel 默认提供的流控规则粒度是“接口 + 热点参数”,而微信场景需要分钟桶、日桶、惩罚期、负债模式这些特殊规则,你最终还是得自己实现,或者通过 Sentinel 的 Slot 扩展点做定制,成本并不低。
| 维度 | 自研Redis令牌桶 | Sentinel + Nacos |
|---|---|---|
| 定制能力 | 高,双桶、惩罚、负债随意扩展 | 中,需要写扩展代码 |
| 运维成本 | 低,只要Redis | 中,需要部署控制台和持久化配置 |
| 动态调整 | 配合Nacos刷新规则 | 原生支持 |
| 接入成本 | AOP注解,一个切面搞定 | 引入依赖,配置规则 |
| 适用场景 | 少量核心接口精细限流 | 大量接口统一治理 |
我目前的做法是两层并存:入口流量用 Sentinel 做网关保护,避免突发并发打垮服务;微信业务调用的细粒度频控用自研令牌桶,专门应对 45009 这类返回码。两者不冲突,各管一段。
最后分享一个我自己的习惯:限流方案确定后,第一件事不是写代码,而是拿线上一天的调用日志,把分钟峰值、日总量、失败返回码都统计出来,再去填两个桶的参数。参数填错了,算法再先进也白搭。
另外,凡是涉及微信返回码的限流,我建议都在调用方埋一个计数器,把 45009 出现次数作为核心监控。一旦偏离正常基线,第一时间查算法和参数,而不是一头扎进代码里追逻辑。限流器不是上线之后就一劳永逸的,它需要根据业务增长、微信侧规则变化持续调参。把可观测性做上去,配合惩罚机制,这套方案才能真正在一个长期运行的生产环境里站住脚。