1. 账号微服务里为什么非要单独做一条短信验证码接口
账号微服务是整个系统里最容易被低估、也最容易背锅的模块。注册、登录、找回密码、绑定手机号,几乎每一条链路都要跟短信验证码打交道。我见过不少团队一开始图省事,把验证码逻辑直接塞在注册接口里,结果后面要加登录验证码、改密码验证码、换绑手机验证码的时候,代码像滚雪球一样越滚越大,最后没人敢动。所以这次我打算把“注册短信验证码”单独抽成一条接口来做,标题里写的“11.6”其实就是我们内部迭代排期里的一个编号,意思是账号微服务第11个迭代的第6个任务点,专门啃这块硬骨头。
先说清楚这条接口到底解决什么问题。用户在注册页面填完手机号,点一下“获取验证码”,前端调的就是这条接口。它要做的事情看起来简单:校验手机号格式、判断是不是频繁请求、生成验证码、存起来、调短信通道发出去、返回结果。但真正落地的时候,每一个环节都有坑。比如怎么防止有人拿脚本疯狂刷短信、验证码存哪里、有效期设多久、发送失败怎么重试、同一手机号多端请求怎么处理,这些才是这条接口的核心价值所在。
这条接口适合谁来参考?如果你是刚接触微服务拆分的新手,可以把它当成一个标准的“薄接口”案例来学,理解一个接口从参数校验到落库到外部调用的完整链路。如果你是有一定经验的开发者,这里面的限流策略、缓存设计、幂等处理、异常兜底,应该能给你一些可以直接抄作业的思路。我不打算讲太多虚的架构大图,就围绕这条接口本身,把设计取舍、代码实现、踩坑经验掰开揉碎说清楚。
2. 接口整体设计与方案选型拆解
2.1 为什么把验证码接口从注册主流程里拆出来
最开始我们也是把发验证码和注册提交写在一个Controller里,两个方法挨着放,看着挺方便。但很快问题就来了:注册提交那个接口需要做事务、要写用户表、要初始化账户,逻辑重;发验证码这个接口要调外部短信通道,响应时间不可控,还经常因为通道抖动超时。两者混在一起,日志难看、监控难做、压测的时候互相干扰。更关键的是,登录、改密、换绑这些场景后面都要发验证码,如果每个业务接口都复制一份发送逻辑,维护成本直接爆炸。
拆成独立接口之后,职责非常清晰:/account/sms/send只干一件事——把验证码发出去。注册接口只负责校验验证码是否正确,然后落库。这样做的好处是,短信通道的限流、重试、降级全部收敛在一个地方,后面换短信服务商只需要改这一处。坏处也有,就是多了一次网络调用,但对内网微服务来说这点开销完全可以接受。我的判断标准很简单:凡是涉及外部依赖、且被多个业务复用的能力,就应该独立成接口,哪怕它看起来很小。
2.2 验证码存储选型:Redis还是数据库
验证码这种数据有几个鲜明特点:生命周期短(通常5分钟)、读写频率高、不需要持久化、过期自动清理。拿数据库存验证码,光是清理过期数据就得写定时任务,高并发下还会产生大量无意义的写操作。所以我们毫不犹豫选了Redis。具体结构上,我见过两种存法:一种是key用手机号,value直接存验证码字符串;另一种是key用手机号,value存一个JSON,里面带验证码、发送时间、发送次数。
我选的是第二种,虽然稍微多占一点内存,但换来的是后续扩展空间。比如要做“同一验证码在有效期内可复用”或者“记录发送渠道用于对账”,JSON结构直接加字段就行,不用改key设计。key的命名我习惯用业务前缀加冒号分隔,比如sms:register:code:{手机号},这样在Redis里一眼能看出是哪个业务场景的验证码,排查问题的时候特别省事。过期时间设300秒,跟短信里写的“5分钟内有效”严格对齐,避免用户看到文案和实际行为不一致。
2.3 限流策略:为什么选滑动窗口而不是固定计数器
防刷是这条接口的生命线。最早我们用固定时间窗口计数器,比如同一手机号60秒内只能发一次。实现简单,但有个明显漏洞:如果用户在59秒时发了一次,下一秒窗口重置又能发一次,相当于2秒内收到两条短信。虽然不算严重,但体验不好,也容易被薅羊毛。后来换成滑动窗口,用Redis的ZSET记录每次请求的时间戳,每次请求前先清理窗口外的记录,再判断窗口内数量是否超限。
具体参数我们定的是:同一手机号60秒内最多1次,同一IP 10分钟内最多10次,同一设备指纹1小时内最多5次。三层限流叠加,基本能把脚本刷接口的成本拉到很高。这里有个细节要注意,IP限流不能太严,因为很多用户走的是同一个出口IP(比如公司网络、校园网),太严会误伤。我们一开始IP限制设成10分钟5次,结果有用户反馈“公司里几个人同时注册就发不出去了”,后来放宽到10次才消停。所以限流参数一定要结合真实用户分布来调,不能拍脑袋。
2.4 短信通道调用的超时与重试设计
调外部短信通道是这条接口里最不可控的一环。我们的原则是:超时时间必须设短,重试必须有上限,失败必须能降级。超时设3秒,因为用户在前端等着,超过3秒还没返回,用户大概率会以为没发出去然后狂点按钮。重试最多1次,而且只对“网络超时”这类可重试异常重试,对“手机号非法”“通道余额不足”这类业务异常直接返回失败,重试没有意义。
还有一个容易被忽略的点:重试的时候不能重新生成验证码。我们一开始就踩过这个坑,第一次发送超时后重试,代码里又走了一遍生成逻辑,结果用户收到两条短信,验证码还不一样,直接懵了。正确做法是把生成验证码和发送动作分开,先生成并落Redis,再调通道,重试时复用同一个验证码。这个顺序很重要,先落库再发送,哪怕发送失败,用户重新请求时也能看到冷却时间,不会无限刷。
3. 核心细节解析与实操要点
3.1 手机号校验不能只靠正则
手机号格式校验是最基础的一步,但很多人只写一个正则就完事了。实际业务里,除了格式,还要考虑号段是否合法、是否是虚拟号段、是否在黑名单里。我们用的是“正则粗筛加号段白名单”两层校验。正则负责拦掉明显不是手机号的输入,号段白名单负责拦掉一些已知的营销号段或者测试号段。号段库不用自己维护,定期从公开渠道更新一份配置就行,放在配置中心里,改的时候不用发版。
另外,前端传过来的手机号一定要做去空格和去特殊字符处理。我遇到过用户从通讯录复制号码,带上了不可见字符,正则匹配失败但用户坚称自己号码没问题。后来在入口统一做了一次trim和过滤非数字字符,这类问题就再没出现过。还有个小技巧:校验失败时返回的错误信息不要写“手机号格式错误”这种笼统的话,要区分“请输入11位手机号”和“该号段暂不支持”,用户知道具体哪里错了,客诉能少一半。
3.2 验证码生成:位数、字符集与安全性权衡
验证码生成看起来就是随机数,但里面也有讲究。位数我们定的是6位纯数字,原因是用户输入方便,短信里也清晰。字符集只用数字0到9,不用字母,避免用户分不清0和O、1和I。安全性上,6位数字有100万种组合,配合5分钟有效期和60秒发送间隔,暴力猜解的窗口非常小。如果业务对安全性要求更高,可以上8位或者数字加字母混合,但用户体验会打折扣,注册场景没必要过度设计。
生成方式我用的是SecureRandom,而不是Random。Random的种子如果被预测,验证码序列可能被推算出来,虽然实际攻击成本不低,但既然有更安全的选项,没理由不用。代码上就是new SecureRandom().nextInt(1000000),然后格式化成6位,不足补零。这里提醒一句,不要用Math.random(),那个在并发下性能和安全性都不行。生成之后立刻写Redis,写成功再调短信通道,顺序不能反。
3.3 Redis写入的原子性与并发防护
同一手机号在极短时间内并发请求两次,如果处理不当,可能生成两个验证码,后写的覆盖先写的,但两条短信都发出去了,用户收到两条不一样的码,只有一条能用。解决这个问题有两个思路:一是用Redis的SET NX命令,只有key不存在时才写入,写入失败说明已有验证码在有效期内,直接返回“请勿重复发送”;二是用分布式锁,锁住手机号维度,串行处理。
我选的是SET NX加过期时间一条命令搞定,SET key value NX EX 300,原子操作,不需要额外加锁。但这里有个细节:如果用户第一次发送成功,60秒冷却期过了想重发,此时key还在(因为有效期5分钟),SET NX会失败。所以冷却判断和key存在判断要分开:冷却用单独的限流key(60秒过期),验证码key用5分钟过期,两者互不干扰。重发时先删旧key再写新key,或者直接用SET覆盖,覆盖前确认冷却期已过。这个逻辑我画过好几次流程图才理顺,核心就是“限流窗口”和“验证码有效期”是两个独立的时间维度,不能混用同一个key。
3.4 短信内容模板与变量替换
短信内容不是随便拼字符串。我们用的是模板加变量替换的方式,模板存在配置里,比如“您的注册验证码是{code},5分钟内有效,请勿泄露。”变量替换时要注意转义和长度控制,验证码本身是纯数字不存在转义问题,但模板里如果有其他变量(比如业务名称),要确保不会因为变量内容导致短信超长被通道截断。国内短信一般70个字一条,超了会拆多条计费,所以模板尽量精简。
还有一个实操心得:短信里不要带链接。带链接的短信被拦截的概率高很多,而且用户也容易怀疑是诈骗。纯文本验证码的到达率和信任度都更好。另外,模板内容要提前报备,不同通道商对模板审核的严格程度不一样,注册类验证码模板一般比较容易过,但文案里不要出现“贷款”“中奖”这类敏感词,否则可能被通道拦截。
4. 实操过程与核心环节实现
4.1 接口定义与参数设计
接口路径定为POST /account/sms/send,请求体用JSON,字段就三个:mobile(手机号)、scene(场景标识,注册传register)、channel(可选,标识来源端,比如web、app)。为什么加scene?因为这条接口后面要复用于登录、改密等场景,用场景字段区分不同业务的验证码key和限流策略,避免注册的验证码被拿去登录用。channel主要用于统计和风控,不同端的风险特征不一样。
响应体统一格式:code、message、data。成功时code为0,data里返回一个cooldown字段告诉前端多少秒后可以重发,前端拿这个值做倒计时,比前端自己写死60秒更可靠。失败时code用业务错误码,比如1001手机号格式错误、1002发送过于频繁、1003短信通道异常。错误码要提前规划好段位,别到时候跟其他接口冲突。
{ "mobile": "13800000000", "scene": "register", "channel": "web" }4.2 核心代码骨架与关键注释
下面这段是接口主流程的骨架,我用的是Java加Spring Boot,其他语言思路一样。重点看注释里标的几个关键点。
@PostMapping("/account/sms/send") public Result sendSmsCode(@RequestBody @Valid SmsSendRequest request) { String mobile = request.getMobile().trim(); String scene = request.getScene(); // 1. 手机号格式与号段校验 if (!mobileValidator.isValid(mobile)) { return Result.fail(1001, "手机号格式不正确"); } // 2. 滑动窗口限流:手机号维度 String limitKey = "sms:limit:mobile:" + mobile; if (!slidingWindowLimiter.tryAcquire(limitKey, 60, 1)) { return Result.fail(1002, "发送过于频繁,请稍后再试"); } // 3. IP维度限流(从请求上下文取真实IP) String ip = RequestContext.getClientIp(); if (!slidingWindowLimiter.tryAcquire("sms:limit:ip:" + ip, 600, 10)) { return Result.fail(1002, "当前网络请求过多,请稍后再试"); } // 4. 生成验证码并原子写入Redis String code = smsCodeGenerator.generate6Digit(); String codeKey = "sms:code:" + scene + ":" + mobile; Boolean written = redisTemplate.opsForValue() .setIfAbsent(codeKey, buildCodePayload(code), Duration.ofSeconds(300)); if (Boolean.FALSE.equals(written)) { // key已存在,说明有效期内已有验证码,直接返回冷却提示 Long ttl = redisTemplate.getExpire(codeKey); return Result.fail(1002, "验证码已发送,请" + ttl + "秒后重试"); } // 5. 调用短信通道,失败不删key,保留冷却语义 try { smsChannel.send(mobile, scene, code); } catch (SmsChannelException e) { log.error("短信发送失败 mobile={} scene={}", mobile, scene, e); return Result.fail(1003, "短信发送失败,请稍后重试"); } return Result.ok(new SmsSendResponse(60)); }第4步的setIfAbsent就是前面说的SET NX,原子性由Redis保证。第5步失败时不删key,是有意为之:用户看到“发送失败”会重试,但key还在,重试会被冷却拦住,避免通道故障时用户狂点导致雪崩。等5分钟key自然过期后,用户可以重新发起。这个设计我们内部讨论过,有人觉得失败应该让用户立刻能重试,但实测下来,通道故障往往持续几十秒到几分钟,立刻放开重试只会加重故障,不如让用户等一会儿。
4.3 滑动窗口限流的实现细节
滑动窗口用Redis的ZSET实现,核心逻辑是:每次请求前,用ZREMRANGEBYSCORE删掉窗口开始时间之前的记录,再用ZCARD统计当前窗口内数量,小于阈值则ZADD当前时间戳并放行。这几个命令要放在一个Lua脚本里执行,保证原子性,否则并发下统计会不准。
-- KEYS[1]: 限流key -- ARGV[1]: 窗口大小(秒) -- ARGV[2]: 阈值 -- ARGV[3]: 当前时间戳(毫秒) local windowStart = tonumber(ARGV[3]) - tonumber(ARGV[1]) * 1000 redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, windowStart) local count = redis.call('ZCARD', KEYS[1]) if count < tonumber(ARGV[2]) then redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3]) redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[1]) * 1000) return 1 end return 0这里有个性能注意点:ZSET里的成员用时间戳,如果同一毫秒有多个请求,成员会重复,ZADD会覆盖而不是新增,导致统计偏少。解决办法是成员用“时间戳加随机后缀”,或者直接用Redis的INCR加过期时间做简化版固定窗口。我们场景下并发没那么极端,用时间戳加请求ID后缀就够了。如果你的系统QPS很高,建议评估一下ZSET的内存开销,必要时换成令牌桶方案。
4.4 短信通道适配层与降级处理
短信通道我们抽象了一个SmsChannel接口,里面就一个send方法,具体实现按服务商不同分别写。这样做的好处是切换通道或者做多通道容灾时,上层代码不用动。多通道容灾的逻辑是:主通道调用失败且异常类型可重试时,自动切备用通道再试一次。备用通道的到达率可能低一些,但总比完全发不出去强。
降级方面,我们做了一个开关,配置在配置中心。当短信通道整体不可用时,运维可以手动打开降级开关,此时接口直接返回“短信服务维护中,请稍后”,不再调通道,避免大量超时拖垮线程池。这个开关我们演练过几次,打开后接口响应时间从平均800毫秒降到20毫秒,效果很明显。另外,发送成功率要打点监控,低于阈值自动告警,别等用户投诉了才发现通道挂了。
5. 常见问题与排查技巧实录
5.1 用户收不到短信但接口返回成功
这是最常见的客诉。接口返回成功只代表我们调通道成功了,不代表用户一定能收到。排查顺序我总结成一张表,按这个顺序走基本能定位。
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 手机号是否欠费/停机 | 通道商后台查发送状态回执 | 用户手机号状态异常 |
| 是否被手机安全软件拦截 | 让用户查看拦截记录 | 短信被标记为营销短信 |
| 通道回执状态 | 查通道商提供的回执接口 | 通道侧发送失败但同步接口返回成功 |
| 号段是否在通道黑名单 | 对比通道商黑名单配置 | 部分虚拟号段通道不支持 |
| 短信内容是否触发拦截 | 检查模板文案 | 含敏感词被运营商拦截 |
实操心得:一定要接通道商的回执接口,把最终发送状态回写到我们的日志里。同步接口返回成功只是“提交成功”,异步回执才是“送达成功”。我们一开始没接回执,用户说没收到我们只能干瞪眼,接了之后排查效率高很多。回执状态也要打点,送达率突然下降就是通道出问题的信号。
5.2 验证码正确却提示错误
用户输入验证码提示错误,先别怀疑用户。排查几个点:第一,Redis里的key是否还在,可能刚好过期;第二,场景是否匹配,注册的码拿去登录场景校验肯定失败;第三,用户是否重复点击发送,收到多条短信,输入了旧的那条。第三种情况特别常见,用户看到第一条短信就输入,但后面又点了一次发送,Redis里已经是新码了。
解决办法有两个层面:产品层面,发送成功后前端按钮进入倒计时,减少重复点击;技术层面,校验时如果失败,可以返回“验证码错误或已过期”,不要区分是错误还是过期,避免给攻击者信息。另外,校验成功后要立刻删除key,防止同一个验证码被重复使用。删除用DEL命令,删之前可以再比对一次值,用Lua脚本保证“比对加删除”的原子性。
5.3 限流误伤正常用户
前面提过IP限流误伤公司网络的问题,这里展开说。除了放宽阈值,还可以做“白名单加动态调整”。白名单针对已知的办公网络出口IP,配置在配置中心,这些IP不参与IP维度限流,只受手机号维度限制。动态调整是指根据历史数据统计各IP段的请求特征,对明显是正常用户的IP段适当放宽。这个需要数据支撑,前期可以先人工观察,积累一段时间再自动化。
还有一个容易忽略的点:限流key的过期时间。滑动窗口的ZSET如果忘记设过期,会一直占内存。我们在Lua脚本里每次都PEXPIRE,确保key不会永久残留。另外,限流失败时返回的提示要友好,不要直接说“触发限流”,用户听不懂,说“操作太频繁,请稍后再试”就行。
5.4 短信通道超时导致接口线程堆积
通道超时如果处理不好,请求线程会一直等着,高并发下线程池很快被打满,整个服务不可用。我们的做法是:通道调用必须设连接超时和读取超时,连接超时1秒,读取超时2秒,加起来不超过3秒。同时,通道调用放在独立的线程池里,跟业务线程隔离,即使通道全挂,也只影响短信接口,不影响账号服务的其他接口。
线程池参数上,核心线程数按峰值QPS估算,队列用有界队列,满了直接拒绝并返回“系统繁忙”。拒绝策略不要用CallerRunsPolicy,那样会把压力传导回业务线程,适得其反。我们用的是自定义拒绝策略,记录日志并快速失败。这套隔离做完之后,有一次通道商故障持续了十几分钟,我们的账号服务其他接口完全没受影响,短信接口快速失败,用户重试几次不行就过一会儿再来,整体可控。
6. 我在实际落地中的几点体会
这条接口从设计到上线大概花了两天,但后续调优和补漏花了差不多两周。最大的体会是:验证码接口的复杂度不在“发出去”,而在“防刷、防重、防雪崩”。限流参数、冷却时间、key的过期策略,这些看起来是细节,实际上每一个都直接影响用户体验和系统稳定性。我建议刚开始做的时候,先把手机号维度的限流和冷却做扎实,IP和设备维度的限流可以后续根据实际攻击情况再加,不要一上来就堆太多规则,规则多了互相干扰,排查起来很痛苦。
另外,日志一定要打全。每次发送记录手机号(脱敏)、场景、限流结果、通道返回、耗时,这些字段在排查问题时缺一不可。我们有一次用户投诉收不到码,查日志发现是号段校验把用户的新号段拦了,如果没打号段校验的日志,根本定位不到。脱敏方面,手机号中间四位打星号,验证码绝对不落日志,这是底线。
最后分享一个小技巧:验证码的Redis key可以加一个版本前缀,比如v1,将来如果验证码结构要改,新旧版本可以共存一段时间,平滑迁移,不用一刀切。这个习惯是从一次痛苦的升级里学来的,当时改key结构导致升级瞬间所有在途验证码失效,用户集体投诉,加了版本前缀之后再没出过这种事。