上个月帮朋友排查一个商城项目的注册接口,日志里同一个手机号两分钟刷了四百多条发送记录,短信费用直接跑掉小几百。他们后端用的是 Java 那套常见组合,SpringBoot 做服务、SpringCloud 拆微服务、Vue 写前端,网络商城该有的注册登录、下单支付都做了,唯独短信服务这块只写了个“调接口发出去”就上线了,Redis 只拿来存了个验证码。这次我就顺着这个场景,把网络商城项目里短信服务接入和 Redis 限流这两件事讲透:验证码接口为什么天然高危、短信服务平台的三件套怎么准备、Redis 限流几种方案各自的翻车点在哪、限流键怎么设计、验证码在 Redis 里的完整生命周期怎么管、联调时哪些问题会被误判成限流。无论你是刚写完注册模块想补防护,还是已经在线上被刷过一轮,这些内容都能直接抄去改。
1. 短信验证码接口为什么天生就是“高危入口”
1.1 一次被刷四百条的注册接口
先把那个案例说完。朋友的商城项目里,发送验证码的接口长这样:前端 Vue 页面输入手机号,点“获取验证码”,后端校验手机号格式合法,直接调用短信服务商的 SDK 把六位数字发出去,同时把验证码写进 Redis,设五分钟过期。逻辑上没毛病,功能也能跑通,问题在于这个接口对调用方完全敞开——不校验来源、不限制频率、不区分场景,只要能构造 HTTP 请求,就能触发一次真实的短信下发。
那天的情况是有脚本在跑,手机号是随机生成的号码段,接口返回一直是 200,脚本就一直发。事后复盘,损失的钱是小事,更麻烦的是短信服务商的账号被风控限制了,导致正常用户的验证码也发不出去,注册入口直接瘫痪了两个多小时。这就是短信接口的特殊性:它不像查询接口那样被打只消耗带宽和 CPU,它是每调一次就真金白银扣一次钱、并且会消耗你账号信誉额度的接口。带成本的接口,天生就是黑产眼里的“提款机”。
再往深一层想,这个接口还有两个隐性风险。第一,它会被当成短信轰炸工具,攻击者用脚本拿别人的手机号反复请求,受害者的手机会被验证码短信塞满,这在投诉层面是很严重的事。第二,验证码本身是身份凭证,如果发送接口不做隔离,攻击者可以先用自己手机号拿到验证码、再尝试用这个验证码去撞别人的账号,形成撞库链路的一环。所以我一般会跟人说:注册登录模块里,最该被当成“对外网关”来设计的不是登录接口,而是这个看起来人畜无害的获取验证码接口。
1.2 限流的本质是成本控制,不是技术炫技
很多人一听到限流就想到 Sentinel、想到网关的 RequestRateLimiter、想到各种花哨的算法,实际上在短信这个场景里,限流要解决的核心问题非常朴素:把每一次可能产生费用的调用,控制在一个可预期、可解释、可追溯的范围内。它不是一个纯技术指标,而是一笔经济账。
算一笔简单的账。假设你的商城注册转化率是 8%,日新增注册 2000 人,那么正常情况下每天验证码发送量大概 3 万条左右(含重发、登录、改密等场景)。给这个量留 50% 的缓冲,日发送量红线定在 4.5 万条。一旦当日发送量逼近这个数字,你需要的不是“优雅降级”,而是立刻告警并收紧策略。这就是为什么我一直主张限流要分层:最外层是总量闸门,中间是维度闸门(手机号、IP、设备),最内层才是单次调用的间隔控制。三层配合,才能在成本、体验和安全性之间找到平衡点。
还有一点经常被忽略:限流的拒绝方式也是设计的一部分。直接抛个 500 或者一串英文异常,用户会以为系统坏了,反复点击重试,反而放大流量。正确的做法是返回明确的业务提示,比如“发送过于频繁,请 60 秒后再试”,同时把这次的错误码统一,让前端能识别出来做倒计时。这一点在 Vue 那端实现起来很简单,但后端如果返回结构不统一,前端就只能靠猜。
2. 接入短信服务前,先把签名、模板和参数想清楚
2.1 签名和模板为什么不能“随便填”
短信服务商给你的不是“发一条短信”这么简单的能力,而是“用某个签名、按某个已报备的模板、填充若干变量”来下发。签名是短信开头那个方括号里的身份标识,模板是正文的固定骨架,两者都必须提前报备审核。我见过有人图省事,模板内容写成“您的验证码是 ${code},5分钟内有效”,结果审核被驳回,理由是缺少场景说明;后来改成“您正在进行账号注册,验证码 ${code},5分钟内有效,请勿泄露”,一次就过了。
这个细节值得多说两句。模板内容审核是有倾向性的,越是明确写出“用途场景”和“风险提示”,越容易通过。而且不同场景必须用不同模板——注册一个、登录一个、找回密码一个、支付确认一个。有人想用一个模板打天下,把场景做成变量传进去,审核大概率不通过,就算通过了,用户在收到短信时也分不清这条验证码是干什么用的,体验很差。
签名这块的坑在于,签名内容和你的产品名称、备案主体要保持一致。我遇到过项目改名,前端页面、后台配置全改了,唯独短信签名没同步,结果新用户收到的短信开头还是个旧名字,客服被问了三天。所以签名的值一定要抽成配置项,而不是硬编码在代码里,改的时候改配置就行。
2.2 模板参数拼接里最容易埋的坑
模板参数的拼接看起来是纯粹字符串填充,实际上有三个细节会咬人。第一个是参数必须是字符串类型,如果直接传数字,某些 SDK 会在内部序列化时把123456变成别的形态,最典型的是前导零丢失或者被当成科学计数法。验证码这种东西,稳妥做法是一律用字符串生成、字符串传递,生成时就固定六位、不够补零。
第二个是模板里变量的命名和顺序。有的平台按变量名匹配,有的按传入数组的下标顺序匹配。如果你的模板是${code}和${minutes}两个变量,而代码里按minutes, code的顺序传数组,短信内容就会变成“您的验证码是 5,5分钟内有效”。这种 bug 单测很难覆盖到,只有真机收到短信才会发现。我的做法是每次新增模板,先在测试环境用自己手机号发一条,肉眼确认内容,再提交。
第三个是特殊字符。手机号里不会有特殊字符,但如果模板里带了其他变量(比如商家名称),名称里含有英文括号、&、<这类符号时,可能触发转义问题或者直接下发失败。处理方式是在填充前做一次白名单过滤,只保留中英文、数字和少数标点,超出范围的一律替换掉。
2.3 发送回执要落哪些字段
发送接口返回之后,很多人只判一个成功与否就完事了。实际生产环境里,你需要把回执的结构完整落到一张表里,字段至少包括:业务流水号(自生成的,方便链路追踪)、手机号(脱敏后存)、场景标识、模板编号、请求时间、平台返回码、平台返回信息、平台消息 ID、耗时。这张表在排查问题时价值极高。
举两个实际用到的场景。有一次业务方说“用户收不到验证码”,我按手机号查这张表,发现请求确实发出去了,平台返回的是“号码状态异常”,那就是运营商侧的问题,跟我们的代码无关,直接把结论甩给业务方,省了两小时的代码排查。另一次是账单对不上,发现有部分请求在本地限流就拦掉了,根本没到平台,但业务方以为是发送失败了。有了这张流水表,两边一对就清楚了。
提示:手机号在流水表里务必脱敏存储,比如只保留前三后四。这张表会被很多人查询,明文手机号一旦泄露,合规风险远大于它带来的排查便利。
另外建议给这张表加一个“场景 + 日期”的联合索引,按天统计各场景发送量非常方便,配合定时任务就能做每日发送量的趋势监控,量突然涨上去能第一时间发现。
3. Redis 限流三种实现,以及它们各自会在哪翻车
3.1 固定窗口计数器:能跑但有边界漏洞
固定窗口是最容易想到的方案:给每个手机号维护一个计数器,一分钟内超过 N 次就拒绝,key 的过期时间设成一分钟。Java 里用 StringRedisTemplate 两行就能写出来。
String key = "shop:sms:limit:" + phone; Long count = redis.opsForValue().increment(key); if (count != null && count == 1L) { redis.expire(key, Duration.ofSeconds(60)); } if (count != null && count > 5) { return "发送过于频繁"; }这段代码能跑,但有两个坑必须知道。第一个坑是increment和expire不是原子操作。如果第一次increment返回 1 之后、expire执行之前,应用进程挂了或者 Redis 抖了一下,这个 key 就永远不会过期,那个手机号从此被永久限流。这种情况概率极低但确实会发生,解决办法是把这两步塞进一个 Lua 脚本里,或者干脆改用SET key 1 EX 60 NX的思路——先把锁占住再累加。
第二个坑是窗口边界。一分钟窗口内,用户在第 58 秒连发 5 次,全都通过;第 61 秒进入新窗口,又能连发 5 次。两秒钟之内实际放行了 10 次。对于短信这种按条计费的场景,边界效应带来的损失是实打实的。如果你的限流阈值放得比较宽,这个漏洞会被放大得更明显。
3.2 滑动窗口:ZSet 方案与内存代价
滑动窗口的思路是记录每一次请求的时间戳,每次判断时先清掉窗口外的记录,再看窗口内还剩多少条。Redis 的有序集合天然适合这个结构:成员用请求的唯一 ID,分值用时间戳。
-- KEYS[1] 窗口 key -- ARGV[1] 当前时间戳(毫秒) ARGV[2] 窗口长度(毫秒) ARGV[3] 阈值 ARGV[4] 请求唯一ID redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2])) local count = redis.call('ZCARD', KEYS[1]) if count >= tonumber(ARGV[3]) then return 0 end redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4]) redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1这个方案精度高,没有边界漏洞,但代价是内存。每次请求都要往 ZSet 里塞一个成员,如果一个热门手机号被高频请求,或者你把窗口维度放大到“每个 IP 每小时”,那这个 ZSet 的成员数会相当可观。而且ZREMRANGEBYSCORE虽然时间复杂度是 O(log N + M),但每次调用都在做删除,频繁执行对 Redis 的 CPU 有压力。
我的实践经验是:滑动窗口适合阈值小、维度窄的场景,比如“单手机号 60 秒内最多 3 次”这种,成员数最多也就 3 个,内存和 CPU 都不是问题。但如果你要做“单 IP 每小时最多 200 次”,用 ZSet 就不太合适了,200 个成员乘以海量 IP 数,Redis 内存会很快吃紧。这种量级的限流,用计数器配合多级窗口更划算。
3.3 令牌桶:用 Lua 把“取令牌”做成原子操作
令牌桶是这几种里最贴近真实业务直觉的:桶有一定容量,按固定速率往里面补充令牌,请求来了就取一个,取不到就拒绝。它允许短时突发(只要桶里攒了令牌),又能把长期平均速率压住。缺点是实现比计数器复杂,而且必须保证“读令牌数—补令牌—扣令牌—写回”这一整套动作是原子的,否则并发下会超发。
-- KEYS[1] 桶 key(Hash 结构,字段 tokens、ts) -- ARGV[1] 容量 ARGV[2] 每秒补充速率 ARGV[3] 当前时间(秒,浮点) ARGV[4] 本次请求令牌数 local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local need = tonumber(ARGV[4]) local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts') local tokens = tonumber(data[1]) local ts = tonumber(data[2]) if tokens == nil then tokens = capacity ts = now end local delta = now - ts if delta < 0 then delta = 0 end tokens = math.min(capacity, tokens + delta * rate) local allowed = 0 if tokens >= need then tokens = tokens - need allowed = 1 end redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now) redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 60) return allowedJava 侧用DefaultRedisScript加载这段脚本,注意一定要用StringRedisTemplate而不是RedisTemplate,这一点我在下一节会展开说。
三种方案的取舍我一般这么定:
| 方案 | 精度 | 内存开销 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 固定窗口计数器 | 有边界漏洞 | 极低 | 低 | 日配额、小时总量这类粗粒度限制 |
| 滑动窗口 ZSet | 高 | 中到高 | 中 | 单手机号短周期高频拦截 |
| 令牌桶 | 高,允许突发 | 低 | 中高 | 需要平滑放行、容忍短时突发的接口 |
实际项目里我通常混用:单手机号 60 秒一次用固定窗口的SET NX EX就够了,日配额用计数器,IP 维度用令牌桶。没必要所有地方都上最复杂的方案。
4. 把限流挂到接口上:键设计、AOP 与网关分工
4.1 限流键的四个维度怎么组合
限流做得好不好,八成取决于键设计。短信接口我一般会同时考虑四个维度,按优先级从高到低是:手机号、IP、设备标识、全局总量。每一个维度解决的是不同的问题,不能互相替代。
手机号维度是防止单号码被反复轰炸,也是最贴近用户体验的一层,阈值一般设成 60 秒 1 次、1 小时 5 次、1 天 10 次。IP 维度防的是单台机器用不同号码批量请求,机房 IP 和代理池的特征很明显,阈值可以设成 1 小时 20 次。设备标识维度需要前端配合传一个本地生成的 UUID,它比 IP 更稳定,但容易被伪造,只能作为辅助参考。全局总量是最后一道闸门,防止整体被拖垮,按天设置,超过就告警。
键的命名要有清晰的层级,方便排查时用SCAN匹配。我习惯用这种格式:
shop:sms:gap:{phone} 60 秒间隔锁 shop:sms:hour:{phone} 小时计数 shop:sms:day:{phone}:{yyyyMMdd} 日计数 shop:sms:ip:{ip} 小时 IP 计数 shop:sms:global:{yyyyMMdd} 全局日总量冒号分隔的好处是可读性好,运维用SCAN shop:sms:*能一把捞出来分析。如果 Redis 是集群模式,还要注意同一个业务相关的 key 尽量落在同一个 slot 上,必要时用 hash tag,比如把手机号的中间几位作为 tag,避免跨 slot 操作时报错。
4.2 注解 + AOP 的实现骨架
限流逻辑写在业务方法里,代码会变得又臭又长,一个sendCode方法里塞满了各种if判断,后续加维度就得改方法。我倾向于用注解加切面把它剥出去,业务代码只关心“发短信”这一件事。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface SmsRateLimit { String scene(); int gapSeconds() default 60; int hourLimit() default 5; int dayLimit() default 10; }切面里通过 SpEL 或者直接从方法参数里取手机号和 IP,按上面设计的键依次做判断。判断顺序很重要:先判间隔锁,再判小时,再判日,最后判全局。间隔锁是最省的,一次SET NX就搞定了,能拦掉绝大部分重复点击;日计数要读 Redis 并自增,开销稍大;全局计数每次都要读写,放最后。
@Around("@annotation(limit)") public Object around(ProceedingJoinPoint pjp, SmsRateLimit limit) throws Throwable { String phone = resolvePhone(pjp); String ip = RequestHolder.currentIp(); String gapKey = "shop:sms:gap:" + phone; Boolean ok = redis.opsForValue().setIfAbsent(gapKey, "1", Duration.ofSeconds(limit.gapSeconds())); if (Boolean.FALSE.equals(ok)) { throw new BizException(42901, "发送过于频繁,请稍后再试"); } String dayKey = "shop:sms:day:" + phone + ":" + today(); Long dayCount = redis.opsForValue().increment(dayKey); if (dayCount != null && dayCount == 1L) { redis.expire(dayKey, Duration.ofHours(26)); } if (dayCount != null && dayCount > limit.dayLimit()) { redis.delete(gapKey); // 当天超限,把间隔锁释放掉,避免用户等到 60 秒后才发现也不行 throw new BizException(42902, "今日获取验证码次数已达上限"); } try { return pjp.proceed(); } catch (Throwable t) { redis.delete(gapKey); // 业务失败要回滚间隔锁,否则用户点一次失败就得等 60 秒 throw t; } }这里有个细节值得单独拎出来:失败要回滚间隔锁。如果短信平台调用失败或者数据库异常,用户实际上是没收到短信的,这时候还让他等 60 秒就是纯粹的体验伤害。上面代码里那个catch块看起来不起眼,但它是实际运营中被投诉最多的点之一。
4.3 网关限流和业务限流别做重复功
SpringCloud 项目里,网关层通常还有一层限流,比如基于 Redis 的 RequestRateLimiter。这层限流和业务层的短信限流职责不一样,不要搞混:网关层是按路由或按 IP 做粗粒度保护,目的是防止整个服务被大流量打挂;业务层是按手机号、场景做细粒度控制,目的是控制成本和防轰炸。
我见过有团队在网关上给/api/sms/**配了个每分钟 10 次的 IP 限流,结果办公网出口 IP 是共享的,一个小办公室里的十几个同事同时注册,只有前十次能过,后面的全部被网关拦了,报错信息还是网关默认的英文提示,业务方一头雾水。所以网关限流的阈值要留足余量,并且要能识别真实 IP——如果你的服务前面有反向代理,得从X-Forwarded-For里取第一个非内网地址,而不是直接取getRemoteAddr(),否则拿到的全是代理机的 IP,等于给所有用户共用一个限流桶。
注意:取真实 IP 时不要无脑相信请求头,
X-Forwarded-For是可以伪造的。稳妥做法是只信任自己那一层代理追加的段,比如固定取倒数第一个由自家代理写入的地址,具体取第几个取决于你的代理层数,这一点必须在压测环境验证过再上线。
5. 验证码的存、验、失效:Redis 里的完整生命周期
5.1 Key 设计与 TTL 的取舍
验证码的 Key 我一般写成shop:sms:code:{scene}:{phone},把场景也拼进去。为什么?因为注册和改密码用的是两套模板,验证码也不应该通用。如果只用手机号做 key,用户在改密码页面拿到的验证码,就能拿去注册页面用,虽然看起来影响不大,但攻击面被放大了。
TTL 设多长是个权衡。设太短,用户收到短信、切到 App、输入完成这一套流程走下来可能就超时了;设太长,验证码被截获后可用于重放的时间窗口就长。行业里常见的做法是 5 分钟,我个人也觉得 5 分钟比较合适——正常用户 30 秒内就能填完,5 分钟给了足够的容错,同时对攻击者来说窗口足够短。
还有一个容易忽略的点:验证码要带业务指纹。我见过有项目把验证码直接存成code -> phone的映射,校验时只查 code 对不对。这种设计下,如果两个用户同时请求,验证码恰好撞了(虽然概率低),就会串号。更稳妥的是校验时同时比对 scene 和 phone,Key 里已经带了这两个信息,天然就做到了。
5.2 校验失败计数与“错误次数”独立 key
验证码有个六位数字的空间,也就是一百万种可能。如果不限制校验次数,攻击者拿到手机号和 5 分钟窗口,理论上是可以暴力枚举的——虽然百万分之一的命中率听起来很低,但在高并发下这是真实存在的风险。所以校验接口必须单独做失败计数。
String failKey = "shop:sms:fail:" + scene + ":" + phone; String codeKey = "shop:sms:code:" + scene + ":" + phone; String saved = redis.opsForValue().get(codeKey); if (saved == null) { throw new BizException(42903, "验证码已过期,请重新获取"); } if (!saved.equals(inputCode)) { Long fails = redis.opsForValue().increment(failKey); if (fails != null && fails == 1L) { redis.expire(failKey, Duration.ofMinutes(30)); } if (fails != null && fails >= 5) { redis.delete(codeKey); // 连续错 5 次,直接作废当前验证码 throw new BizException(42904, "错误次数过多,请重新获取验证码"); } throw new BizException(42905, "验证码不正确"); } redis.delete(codeKey); // 校验通过立刻删除,防重放 redis.delete(failKey);这里有两个设计决策值得解释。一是“错 5 次作废验证码”,而不是“错 5 次锁定账号”。锁定账号的做法会带来骚扰风险——攻击者可以故意输错别人的验证码,把别人锁死。作废验证码则只影响这一次,用户重新获取就好。二是校验通过后立刻删除 code,这是防重放的刚需,否则同一个验证码在 5 分钟内可以被反复使用。
失败计数的 key 单独设 30 分钟过期,比验证码的 5 分钟长。这看起来不符合直觉——验证码都过期了,还留失败计数干什么?实际上这是为了防止攻击者用“错几次、等验证码过期、重新获取、再错几次”的方式绕过计数。计数窗口长一点,暴力尝试的总成本就上去了。
5.3 并发与幂等:同一手机号同时点两次
前端按钮如果没有做防抖,或者用户手快连点两下,很可能发出两个几乎同时的请求。间隔锁用的是SET NX,理论上只有一个能成功,第二个会直接被拒绝。但这里有个极端情况:第一个请求成功占到锁,然后短信平台调用超时,代码进入 catch 块把锁删了,此时第二个请求刚好进来,又把锁占上,用户最终收到两条短信。
要彻底解决这个问题,得用一把更“重”的锁,同时配合幂等设计。我的做法是在占间隔锁之后,再写一个短 TTL 的“发送中”标记,业务成功后把它换成正式的“已发送”标记,失败时两个一起删。这样能保证在同一个 60 秒窗口内,最多只有一次真正走到短信平台调用这一步。
还有一种更彻底的思路:把发送动作异步化。接口收到请求后只做参数校验和限流判断,然后把任务丢进队列,立刻返回“发送中”,由消费者去调用短信平台。这样接口的响应时间从几百毫秒降到几毫秒,并发能力大幅提升,而且天然具备了削峰能力。代价是复杂度上去了,需要引入消息队列、需要处理消费失败重试、需要让前端轮询或等待推送。中小规模的商城项目,我不建议一开始就上这套,等真正遇到性能瓶颈再演进也不迟。
6. 联调和上线后,那些被误判成限流的问题
6.1 返回码混淆:平台报错被当成限流
这个坑我踩过两次。第一次是短信平台返回的“成功”标识是字符串,代码里用result.getCode() == 0去判断,结果永远不相等,所有发送都被判定为失败,业务代码一路走进 catch,把间隔锁删掉、把验证码删掉,用户端表现就是“点了没反应”。第二次是平台的限流错误码和参数错误码相邻,代码里用区间判断,把参数错误也归到了“频率超限”,导致业务方以为是被自己限流拦了,排查了半天。
处理这类问题的原则是:平台返回码只做枚举映射,不要做区间判断,也不要用等号比较字符串。写一个映射表,把平台的成功码、限流码、号码异常码、余额不足码分别映射到自己的错误码体系里,日志里原样打印平台的返回码和返回信息。这样出问题时,看日志就能一眼定位是平台的问题还是自己的问题。
另外,很多平台的 SDK 在失败时不是抛异常,而是返回一个包含错误信息的对象。如果你的代码只写了try-catch,没有检查返回值,就会出现“调用成功了但实际没发出去”的静默失败。这种 bug 最阴险,因为它不会报错,只会在账单和日志里慢慢暴露。
6.2 时钟、序列化与连接池引发的假故障
有几个看起来像限流、实际上跟限流没关系的问题,我列一下方便你对号入座。
序列化器不一致。限流脚本用RedisTemplate执行时,key 会被加上 JDK 序列化的前缀,Lua 脚本里KEYS[1]拿到的是一串带乱码的字节,导致每次判断都是新 key,限流完全失效。解决办法很简单,限流相关的所有操作统一用StringRedisTemplate,或者给RedisTemplate显式配置StringRedisSerializer。
容器时间漂移。滑动窗口和令牌桶都依赖时间戳。如果应用容器的时钟和 Redis 服务器的时钟差了几十秒,计算出来的窗口就会错位。我的做法是令牌桶脚本里优先使用 Redis 侧的TIME命令取时间,或者干脆在应用启动时和上游做一次对时,把偏差记录下来在计算时补偿。
连接池耗尽。短信接口本身很快,但如果限流判断里做了大量 Redis 操作,而这些操作又在高并发下排队,Lettuce或Jedis的连接池可能被打满,表现出来就是接口超时。这时候日志里会出现大量连接超时的异常,很多人第一反应是“Redis 挂了”,其实是自己的用法太重。解决办法是把所有限流判断合并成一次 Lua 调用,减少网络往返。
6.3 观测指标与参数调整的经验值
限流参数不能拍脑袋定,得有数据支撑。我在项目里会埋这几个指标:每分钟发送请求数、每分钟实际下发数、被间隔锁拦截数、被日配额拦截数、平台返回失败数、验证码校验成功率。这几个数放在一张看板上,异常一眼就能看出来。比如“请求数暴涨但下发数不变”,说明限流在起作用;“下发数正常但校验成功率暴跌”,说明是短信通道的问题,跟限流无关。
参数的经验值,我目前用的这套在几个中等规模的商城里都比较稳:
| 维度 | 阈值 | 说明 |
|---|---|---|
| 单手机号间隔 | 60 秒 | 低于这个值用户体验没提升,只会增加成本 |
| 单手机号小时上限 | 5 次 | 覆盖正常重发 + 手滑误操作 |
| 单手机号日上限 | 10 次 | 防止被当成长期轰炸目标 |
| 单 IP 小时上限 | 20 次 | 兼顾办公网共享 IP 的情况 |
| 验证码有效期 | 5 分钟 | 常规取值 |
| 校验失败上限 | 5 次 | 超过则作废当前验证码 |
| 失败计数窗口 | 30 分钟 | 比验证码有效期长,防止绕过 |
这套值不是绝对的,得根据自己业务的实际情况调。调整的原则是:先看被拦截的比例。如果间隔锁拦截率长期在 30% 以上,说明 60 秒对你们用户来说太长了,可以考虑降到 45 秒;如果日配额拦截率经常冒头,先确认是不是真的有人在刷,还是你们业务本身就有高频场景(比如批量导入会员),别一刀切把自己的正常业务也砍了。
最后分享一个小技巧。上线初期,我给限流加了一个“观察模式”:所有判断照常执行,但不真的拦截,只在日志里打一条“本应拦截”的记录。跑三天,看看会拦掉多少、拦的是谁,心里就有底了,再切换到真正的拦截模式。这个过渡期能帮你避免“上线就把正常用户拦了”的尴尬,也能让你对阈值的合理性有个直观感受。