
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存在判断要分开冷却用单独的限流key60秒过期验证码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为0data里返回一个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结构导致升级瞬间所有在途验证码失效用户集体投诉加了版本前缀之后再没出过这种事。