1. 手机验证码登录到底解决了什么问题
前几年做后台系统的账号体系,清一色是"用户名 + 密码 + 图形验证码"三件套。这套方案在桌面浏览器上跑了二十年,没什么大毛病。可一旦搬到移动端,问题就全冒出来了。用户在手机上打字本来就慢,还得切换到密码管理器复制一串大小写数字符号混合的字符串,中间再被图形验证码坑一次,整套流程的流失率高得吓人。我们内部统计过一次,注册到登录的转化率只有六成出头,剩下的四成基本卡在"忘记密码"和"验证码看不清"这两个环节。
手机验证码登录就是冲着这个场景来的。它的核心逻辑特别简单:用户输入手机号,后端生成一串随机数字发到手机上,用户填回来,后端比对一致就认为"这个手机号目前在用户本人手上",直接放行。整个过程只有手机号输入框、验证码输入框、发送按钮和登录按钮这四样东西,前端负责倒计时、表单校验和令牌存储,后端负责验证码的生成、缓存、校验以及最终身份令牌的签发。这套东西搭起来不难,但要做扎实,坑一点都不少——限流怎么做、验证码存哪里、令牌过期怎么续、并发重复提交怎么办,每一个都能写一篇。
这篇内容适合三类人:一是刚学完 Spring Boot 和 Vue、想找个完整前后端分离项目练手的朋友;二是正在做 SPA 项目、需要接入短信登录的开发者;三是想搞清楚"一次登录背后到底发生了多少次交互"的前端同学。代码层面我用 Java(Spring Boot)做后端、Vue 3 做前端来演示,但思路是通用的,换成 Node、Python、Go 一样成立。核心关键词就四个:验证码、登录、代码实现、前端与后端。
2. 整体架构设计与接口契约
在动手写第一行代码之前,我习惯先把"一次完整的登录到底经过哪些环节"画清楚。很多人上来就怼接口,写到一半发现验证码存的位置不对、令牌刷新逻辑没地方放,回头返工的成本比前期多想二十分钟高得多。这一节就把架构层面的几个关键决策定下来,后面写代码基本就是填空题。
2.1 一次完整登录的链路拆解
把整条链路拆开,其实只有两个核心接口:一个是"发送验证码",一个是"校验验证码并登录"。前者做四件事——校验手机号格式、做频率限制、生成六位随机码、调用短信网关下发并把验证码写进缓存。后者做五件事——校验手机号与验证码的格式、从缓存取出验证码比对、比对失败累加错误次数、比对成功则删除验证码并签发令牌、最后返回给前端。
前端这边则是三步:用户点击"发送验证码"按钮,前端先做一次本地手机号正则校验,通过后调用发送接口,接口返回成功就启动 60 秒倒计时并把按钮置灰;用户填完六位数字点登录,前端调用登录接口,拿到令牌后写入本地存储;后续所有需要鉴权的请求,都在请求头里带上这个令牌。
这里有个容易被忽略的点:发送验证码接口和校验登录接口必须是完全独立的两个接口,不能合并成一个"提交"动作。因为用户在输入手机号之后、输验证码之前,中间可能退出页面、可能把 App 切到后台待五分钟,这是两个时间点完全不同的动作,合并接口会导致链路彻底没法做倒计时和重发。
2.2 验证码该存在哪里:Redis 与本地方案的取舍
这是第一个真正的技术决策点。验证码有两个天然属性:第一,它有时效性,通常 5 分钟就作废;第二,它是临时的,登录成功后就没用了。这两个属性直接指向"带过期时间的键值存储",也就是 Redis 这类缓存。
那能不能用 Java 的ConcurrentHashMap存在 JVM 内存里?单机部署的小项目确实可以,代码还简单,但一旦你上多实例部署,A 机器发出的验证码请求打到了 B 机器的校验接口上,用户就会遇到"明明收到了验证码但一直提示错误"的诡异现象。而这种问题在开发环境(单机)永远复现不了,只在生产环境(多机)出现,排查起来极其痛苦。我见过有团队在这上面耗了整整两天。
那存 MySQL 呢?也不是不行,但每次发送验证码要写库、每次校验要读库,加上过期数据的清理任务,成本高、收益低。如果项目规模确实很小、连 Redis 都不想引入,可以退一步用 MySQL,但一定要给验证码表加一个expire_at字段,并且用定时任务定期清理。
我的建议是:只要用了前后端分离架构,就直接上 Redis。它的SETEX(或SET key value EX seconds)天生就是为这种场景设计的,写入即带过期时间,读取不需要额外判断时间戳,过期自动消失。常用的键设计大致是这样:
| 键名模式 | 值含义 | 过期时间 | 用途 |
|---|---|---|---|
sms:code:{手机号} | 六位验证码 | 300 秒 | 存验证码本体 |
sms:interval:{手机号} | 固定值 1 | 60 秒 | 控制重发间隔 |
sms:daily:{手机号} | 当天发送次数 | 到当天 24 点 | 控制单日总量 |
sms:ip:{IP} | 该 IP 发送次数 | 3600 秒 | 控制单 IP 频率 |
sms:fail:{手机号} | 校验失败次数 | 900 秒 | 控制暴力猜码 |
2.3 接口契约怎么定才不容易返工
前后端分离项目最容易扯皮的环节就是接口字段。我的习惯是在写代码之前先把接口文档定死,字段名、类型、错误码全部约定清楚,前端拿着文档可以并行开发,用 Mock 数据先跑通页面。
登录模块的返回体我倾向于统一成下面这个结构,code用业务码而不是 HTTP 状态码,这样前端只需要写一次拦截器就能处理所有异常:
{ "code": 0, "message": "ok", "data": { "token": "eyJhbGciOiJIUzI1NiJ9...", "expiresIn": 7200, "userId": 10086, "phone": "138****8000", "isNewUser": false } }两个接口的定义分别是:POST /api/auth/sms/send,请求体{ "phone": "13800008000" };POST /api/auth/sms/login,请求体{ "phone": "13800008000", "code": "482913" }。业务码里 0 是成功,1001 是手机号格式错误,1002 是发送过于频繁,1003 是验证码错误或已过期,1004 是当日发送超限,1005 是账号被临时锁定。把错误码拆细一点,前端就能给出准确的提示文案,而不是一律弹"操作失败"。
3. 后端代码实现:从生成验证码到签发令牌
后端这块我用 Spring Boot 来演示。整体分成三层:Controller 只做参数接收和返回包装,Service 承载业务逻辑,一个SmsClient接口负责对接实际的短信网关。这样分层的好处是,将来换短信服务商只需要改一个实现类,业务代码完全不用动。
3.1 依赖引入与基础配置
pom.xml里需要的关键依赖是这几个:spring-boot-starter-web提供 Web 能力,spring-boot-starter-data-redis做验证码缓存,spring-boot-starter-validation做参数校验,jjwt用来签发令牌。短信网关的 SDK 一般服务商都会提供,但我不建议直接在业务代码里 import 它的类,而是包一层自己的接口。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency>配置文件中把 Redis 连接和令牌密钥写进去。密钥这一项特别提醒一句:千万不要硬编码在代码里并提交到代码仓库,用环境变量或者配置中心注入。我见过一个开源项目把密钥直接写在application.yml里推到公开仓库,等于把所有人的令牌签发权交出去了。
spring: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 auth: jwt: secret: ${JWT_SECRET} expire-seconds: 7200 sms: code-length: 6 code-ttl-seconds: 300 interval-seconds: 60 daily-limit: 10 ip-hourly-limit: 20 fail-limit: 5 lock-seconds: 900把这些参数抽成配置而不是写死在代码里,好处是运营侧要调整策略时不用重新发版。比如大促期间想把单日上限从 10 条提到 20 条,改个配置重启就行。
3.2 验证码生成:为什么不能用 Random
生成六位数字验证码,很多人顺手就写new Random().nextInt(900000) + 100000。这段代码有两个问题:一是Random的种子是可预测的,攻击者理论上可以通过观察若干次输出来推测后续序列;二是nextInt的取值范围处理不当会出现不足六位的情况,比如需要补零。
正确做法是用SecureRandom,它是密码学安全的随机源:
private static final SecureRandom RANDOM = new SecureRandom(); public String generateCode(int length) { StringBuilder sb = new StringBuilder(length); for (int i = 0; i < length; i++) { sb.append(RANDOM.nextInt(10)); } return sb.toString(); }注意这里逐位生成而不是整体生成一个整数,这样天然保证了每一位都有值,不用处理补零,也不会出现首位为 0 时数字位数不够的问题。另外,不要把验证码打到日志里。开发阶段为了调试方便打印一下可以理解,但上线前一定要去掉,日志文件往往是内部人员最容易接触到的敏感信息来源。
3.3 发送验证码接口的完整实现
限流我放在最前面做,因为它是成本最低的一道防线——一旦短信被刷,每条都是真金白银。判断顺序是:先查设备锁定状态,再查 60 秒间隔,再查单 IP 小时限量,最后查单手机号日限量,任何一层不通过直接返回对应的业务码,根本不进入生成和下发流程。
@Service @RequiredArgsConstructor public class SmsAuthService { private final StringRedisTemplate redis; private final SmsClient smsClient; private final AuthProperties props; public void sendCode(String phone, String ip) { // 1. 设备是否处于锁定状态(连续猜错导致) if (Boolean.TRUE.equals(redis.hasKey("sms:lock:" + phone))) { throw new BizException(1005, "操作过于频繁,请稍后再试"); } // 2. 重发间隔 Boolean first = redis.opsForValue() .setIfAbsent("sms:interval:" + phone, "1", props.getIntervalSeconds(), TimeUnit.SECONDS); if (!Boolean.TRUE.equals(first)) { throw new BizException(1002, "发送过于频繁,请稍后再试"); } // 3. 单 IP 小时限量 Long ipCount = redis.opsForValue().increment("sms:ip:" + ip); if (ipCount != null && ipCount == 1L) { redis.expire("sms:ip:" + ip, 1, TimeUnit.HOURS); } if (ipCount != null && ipCount > props.getIpHourlyLimit()) { throw new BizException(1004, "当前网络环境请求过多"); } // 4. 单手机号日限量 Long daily = redis.opsForValue().increment("sms:daily:" + phone); if (daily != null && daily == 1L) { long seconds = secondsUntilMidnight(); redis.expire("sms:daily:" + phone, seconds, TimeUnit.SECONDS); } if (daily != null && daily > props.getDailyLimit()) { throw new BizException(1004, "今日发送次数已达上限"); } // 5. 生成并缓存 String code = generateCode(props.getCodeLength()); redis.opsForValue().set("sms:code:" + phone, code, props.getCodeTtlSeconds(), TimeUnit.SECONDS); // 6. 异步下发 smsClient.sendAsync(phone, code); } }这里setIfAbsent是关键,它对应 Redis 的SET key value NX EX seconds,是一条原子命令。如果用先get再set的写法,两个并发请求可能同时通过检查,间隔限制就形同虚设了。这类"检查再执行"的场景,一定要用原子操作或者 Lua 脚本兜住。
还有一个细节:验证码必须在调用短信网关之前就写进缓存。有些同学怕发不出去白占内存,就先发短信、成功了再写缓存。结果用户手速快,短信刚到就提交了,此时缓存还没写进去,直接提示验证码错误。顺序反了就出这种问题。
3.4 校验验证码并签发令牌
校验环节的逻辑顺序同样重要:先查锁定状态,再取缓存中的验证码,比对失败累加计数,成功则清理所有相关键并签发令牌。
public LoginResult login(String phone, String code) { if (Boolean.TRUE.equals(redis.hasKey("sms:lock:" + phone))) { throw new BizException(1005, "操作过于频繁,请稍后再试"); } String cached = redis.opsForValue().get("sms:code:" + phone); if (cached == null) { throw new BizException(1003, "验证码已过期,请重新获取"); } if (!cached.equals(code)) { Long fails = redis.opsForValue().increment("sms:fail:" + phone); if (fails != null && fails == 1L) { redis.expire("sms:fail:" + phone, props.getLockSeconds(), TimeUnit.SECONDS); } if (fails != null && fails >= props.getFailLimit()) { redis.opsForValue().set("sms:lock:" + phone, "1", props.getLockSeconds(), TimeUnit.SECONDS); redis.delete("sms:code:" + phone); throw new BizException(1005, "错误次数过多,请稍后再试"); } throw new BizException(1003, "验证码不正确"); } // 比对成功,清理痕迹 redis.delete(List.of("sms:code:" + phone, "sms:fail:" + phone)); User user = userService.findOrCreateByPhone(phone); String token = jwtService.sign(user.getId(), phone); return new LoginResult(token, props.getJwt().getExpireSeconds(), user.getId(), mask(phone), user.isNewlyCreated()); }比对的时候有一点必须注意:用equals而不是==,验证码是字符串,==比的是引用地址,绝大多数情况下都会判定为不相等。这个坑看起来低级,但在实际项目里真的有人踩过,尤其是在 IDE 不报错的静默状态下。
关于令牌的内容,我只放用户 ID、一个唯一标识(jti)和过期时间,不要在令牌里塞手机号、昵称、角色列表这些信息。令牌是 Base64 编码而非加密,任何人都能解开看内容。手机号属于个人信息,放进去就等于在每次请求里明文传递。要展示手机号,让前端拿用户 ID 去查一次用户信息接口,或者干脆在登录返回体里给一份脱敏后的字符串。
3.5 短信下发的异步化处理
调用短信网关是一个网络请求,快则几百毫秒,慢则几秒。如果同步执行,用户点击按钮后要一直转圈等着,体验很差。我的做法是把它丢到线程池里异步执行,接口立刻返回"发送成功"。
@Async("smsExecutor") public void sendAsync(String phone, String code) { try { SmsResponse resp = gateway.send(phone, templateId, Map.of("code", code)); if (!resp.isSuccess()) { log.warn("短信下发失败 phone={} respCode={}", mask(phone), resp.getCode()); } } catch (Exception e) { log.error("短信下发异常 phone={}", mask(phone), e); } }线程池要显式配置,不要用默认的SimpleAsyncTaskExecutor,那个是不复用线程的,高并发下会不断创建新线程。核心线程数建议按"每秒短信峰值 × 平均响应耗时"来估算,比如峰值 20 条每秒、单次调用耗时 0.3 秒,理论上 6 个线程就够了,留一倍余量配 12 个,队列给 500,拒绝策略用CallerRunsPolicy保证不丢任务。
注意:异步线程里不能依赖
RequestContextHolder或者事务上下文,那些都是绑定在请求线程上的,异步线程取不到。真需要传用户信息,通过方法参数显式传进去。
4. 前端实现:倒计时、表单校验与令牌管理
前端我用 Vue 3 的组合式 API 来写,配合 Element Plus 做表单。这套组合在当下的项目里出镜率非常高,组件库直接用能省掉大量样式工作。
4.1 登录页结构与手机号校验
整个页面其实就是一个表单,两个输入框加两个按钮。手机号的校验用正则,国内号码用^1[3-9]\d{9}$就够用,不要写那种把运营商号段写死到具体数字的正则,新号段一出来就失效了。
<template> <el-form ref="formRef" :model="form" :rules="rules" @submit.prevent> <el-form-item prop="phone"> <el-input v-model="form.phone" maxlength="11" placeholder="请输入手机号" /> </el-form-item> <el-form-item prop="code"> <el-input v-model="form.code" maxlength="6" placeholder="请输入验证码"> <template #append> <el-button :disabled="counting || !phoneValid" @click="onSend"> {{ counting ? `${left}s 后重发` : '获取验证码' }} </el-button> </template> </el-input> </el-form-item> <el-button type="primary" :loading="submitting" @click="onSubmit"> 登录 </el-button> </el-form> </template>有个交互细节值得说一下:发送按钮在手机号不合法时应该是禁用状态,而不是让用户点了之后再弹错误提示。前者是预防,后者是补救,体验差一个档次。可以在手机号输入框上加@input监听,实时计算phoneValid。
4.2 倒计时到底应该怎么写
倒计时看起来简单,坑却不少。最常见的错误是只用setInterval递减一个数字,这会遇到三个问题:一是组件卸载后定时器还在跑,造成内存泄漏;二是页面切到后台再切回来,浏览器会节流定时器,导致显示的秒数和实际经过的时间对不上;三是用户刷新页面,倒计时直接归零,可以立刻再发一次。
正确的做法是用目标时间戳来算剩余秒数,而不是累减:
const counting = ref(false); const left = ref(0); let timer = null; function startCountdown(seconds) { const deadline = Date.now() + seconds * 1000; localStorage.setItem('sms_deadline', String(deadline)); counting.value = true; tick(); timer = setInterval(tick, 1000); function tick() { const rest = Math.ceil((deadline - Date.now()) / 1000); if (rest <= 0) { counting.value = false; left.value = 0; clearInterval(timer); timer = null; localStorage.removeItem('sms_deadline'); return; } left.value = rest; } } onMounted(() => { const saved = Number(localStorage.getItem('sms_deadline') || 0); const rest = Math.ceil((saved - Date.now()) / 1000); if (rest > 0) startCountdown(rest); }); onUnmounted(() => { if (timer) clearInterval(timer); });用时间戳的好处是,无论浏览器怎么节流、页面切走多久,回来算出来的剩余时间都是准的。把截止时间存到localStorage里,刷新页面后倒计时也能续上,避免用户靠刷新绕过限制。
提示:前端倒计时只是体验优化,真正的限流必须在后端。前端存的那点时间戳,用户改一下就是零,所以后端那层
SETNX才是不可绕过的。
4.3 请求封装与令牌的存储位置
axios的拦截器要干两件事:请求前把令牌塞进请求头,响应后统一处理业务错误码和 401。令牌存哪里?localStorage和cookie各有取舍。localStorage用起来简单,但容易受跨站脚本攻击影响;cookie设成HttpOnly后前端脚本读不到,安全性更好,但需要后端配合处理跨域凭证。
如果是内部系统或者学习项目,用localStorage完全够用,但记得存的是令牌本身,不要顺手把手机号、身份证这类信息一起塞进去。如果是面向公众的产品,建议走HttpOnly+Secure+SameSite的 Cookie 方案,令牌只在传输层流动。
const http = axios.create({ baseURL: '/api', timeout: 10000 }); http.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); http.interceptors.response.use( (res) => { const { code, message, data } = res.data; if (code === 0) return data; if (code === 1003 || code === 1005) ElMessage.warning(message); else ElMessage.error(message || '请求失败'); return Promise.reject(new Error(message)); }, (err) => { if (err.response?.status === 401) { localStorage.removeItem('token'); router.replace('/login'); } return Promise.reject(err); } );这样封装之后,业务代码里只需要写const data = await http.post('/auth/sms/login', form),异常全部在拦截器里消化掉了,页面代码干净很多。
4.4 路由守卫与令牌过期处理
令牌有效期到了怎么办?两种策略:一种是过期就让用户重新登录,简单直接;另一种是配一个刷新令牌,访问令牌过期时自动用刷新令牌换新的,用户无感知。后者的实现成本高一些,但体验好。
我的建议是分场景选择:后台管理系统用前者,反正用户每天都要登录,重新登录不痛不痒;面向 C 端的应用用后者,用户正在下单突然被踢到登录页,这个体验是非常糟糕的。
路由守卫的写法很简单:
router.beforeEach((to) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { return { path: '/login', query: { redirect: to.fullPath } }; } return true; });注意redirect参数要把用户原本想访问的地址带上,登录成功后跳回去,这个小细节能明显提升体验。另外守卫里不要做令牌有效性的远程校验,那会让每次路由跳转都多一次网络请求,正确做法是本地解码检查exp字段,或者干脆不检查、等接口返回 401 再处理。
5. 频率限制与防刷策略的实际配置
短信接口是所有对外接口里最需要防刷的,因为它直接对应成本。我见过一次事故,某活动页面没做任何限流,一夜之间被刷了几十万条短信,账单出来的时候整个团队都傻了。这一节把限流的层次和参数梳理一下。
5.1 三层限流的具体参数与理由
限流不能只做一层,因为单一维度的限制都有绕过方式。只限手机号,攻击者可以换号;只限 IP,可以用大量不同来源的请求。三层叠加才有效果:
| 限流维度 | 触发条件 | 限制时长 | 设计理由 |
|---|---|---|---|
| 手机号重发间隔 | 60 秒内第二次发送 | 拒绝本次 | 防手抖连点,正常用户不需要这么快 |
| 手机号日限量 | 24 小时内超过 10 条 | 拒绝本次 | 正常用户一天登录三五次顶天了 |
| IP 小时限量 | 1 小时内超过 20 条 | 拒绝本次 | 防单机批量刷,正常办公网络不会超过 |
| IP 日限量 | 24 小时内超过 100 条 | 拒绝本次 | 兜底,防止慢速刷 |
| 校验失败次数 | 15 分钟内错 5 次 | 锁定 15 分钟 | 防六位码暴力枚举 |
关于最后一条,六位数字一共一百万个组合,如果不限制尝试次数,用脚本跑一遍也就几分钟的事。加上 5 次失败锁 15 分钟的规则后,理论上需要 15 分钟才能试 5 次,跑完全部组合需要几万年,这个威胁就基本消除了。
参数的取值没有标准答案,要根据业务实际调整。比如外卖、打车这类高频场景,用户可能一天登录很多次,日限量就要放宽;而一些低频的工具类应用,5 条就够。定完之后一定要在日志里记录触发情况,跑一周看看有多少正常用户被误伤,再调。
5.2 图形验证码该在什么时候介入
纯粹的短信限流有个矛盾:限得太松防不住,限得太严会误伤正常用户。解法是引入"自适应"的人机校验——前几次发送不弹图形验证码,一旦某个手机号或 IP 在短时间内发送超过阈值,就在发送前弹出一个滑块或字符验证。
这个判断逻辑放在后端做:发送接口先检查该手机号的当日发送次数,如果大于 3 次,就在返回体里带一个needCaptcha: true的标记,前端拿到后弹出验证组件,用户通过后拿到一个一次性凭证,再带着这个凭证重新请求发送接口。后端校验凭证的有效性,通过才真正下发短信。
这样正常用户(每天发一两次)完全无感,而批量刷的行为会被迅速拦住——因为每次都要过一遍人机验证,成本高到不值得。
注意:图形验证码的凭证要设计成一次性且短时效的,比如 5 分钟有效、使用后立即删除。如果凭证可以重复使用,攻击者过一次验证就能无限发短信,整个机制就白做了。
6. 常见问题排查实录
这一节是我在实际项目中真真切切踩过的坑,整理成速查表的形式,遇到问题可以对着排查。
6.1 验证码总是提示错误的三条排查路径
第一种情况:用户收到的和缓存里的不一致。排查方法是把收到的验证码和 Redis 里sms:code:{手机号}的值都打印出来比对。曾经遇到过一次,短信模板里的变量名写错了,模板用的是${1}而代码传的是code,导致短信里显示的是变量名本身或者上一个变量。还有一次是模板里多加了一个空格,用户复制的时候把空格也带上去了,提交时报错。提交前一定要对用户输入做trim(),这个动作能挡掉一大批"莫名其妙"的错误。
第二种情况:Redis 里的值已经过期了。检查TTL命令的输出,看看剩余时间是不是正常。如果剩余时间明显小于配置的 300 秒,说明有其他地方在重复写这个键。我遇到过一种情况是,发送接口被前端调了两次(一次是用户点击,一次是某个组件的副作用),第二次覆盖了第一次的值,用户手机上看到的是第一条短信,而缓存里存的是第二条。
第三种情况:多实例部署但没共用缓存。这个前面提过,现象是"有时能登上,有时登不上",概率大概是实例数的倒数。解决办法就是老老实实用统一的 Redis。
6.2 短信收不到的几种常见原因
用户反馈"没收到短信",实际上有相当一部分是用户自己手机的问题,比如短信被手机管家拦截、收件箱满了、信号不好。这类情况我们没办法,但可以在提示文案里引导用户检查拦截记录。
技术侧的原因主要有几个:一是网关返回了限流错误,服务商那边通常也有自己的频率限制,比如同一个号码同一内容 1 分钟内只能发一条,这和我们的限制是叠加的,要确认两边不冲突;二是模板未审核通过,新申请的模板需要走审核流程,审核期间下发的短信会被丢弃;三是余额不足,这个最容易被忽略,服务商的账户余额用完后接口会返回失败,但很多人的代码里没有处理这个失败分支,日志里只有一行warn,没人注意到。
我的做法是加一个监控:短信接口的失败率超过 5% 就触发告警,不管是网关问题还是余额问题,都能第一时间发现。
6.3 重复提交导致重复建号
用户在提交按钮上连点两下,两个请求几乎同时到达后端,两个线程都查到"该手机号不存在",于是都执行了插入,数据库里就出现了两条相同手机号的记录。这个问题在测试环境很难复现,因为点击间隔通常没那么短。
解决方案有三个层次,我建议都做:
数据库层,给手机号字段加唯一索引。这是最后一道防线,即使代码逻辑有问题,数据库也不会让脏数据落库。
代码层,注册逻辑用分布式锁包起来,锁的键就是手机号,这样同一个号码的并发请求会被串行化处理。
前端层,提交按钮加loading状态,请求返回前直接禁用。这一层最容易做,但也最容易被绕过,所以不能只靠它。
String lockKey = "lock:register:" + phone; Boolean locked = redis.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(locked)) { throw new BizException(1006, "请求处理中,请稍候"); } try { // 查库 + 插入逻辑 } finally { // 注意:这里不能无脑删锁,要判断是不是自己加的锁 }关于删锁,严格来说应该用 Lua 脚本校验值再删,或者直接用 Redisson 这类成熟框架提供的锁。自己手写删锁逻辑很容易出错,比如请求 A 因为处理慢导致锁自动过期,请求 B 拿到锁,此时 A 处理完又把锁删了,B 的锁就被误删了。
7. 上线前的自查清单
把上面所有内容串起来,上线前对着过一遍,能挡掉大部分问题。
- 令牌密钥是否通过环境变量注入,有没有出现在代码仓库里
- 验证码是否在调用短信网关之前写入缓存
- 日志里是否还在打印完整的验证码和手机号,脱敏做了没有
- Redis 的过期时间是否和短信里承诺的有效期一致
- 三层限流是否都配置了,参数是否在真实数据上调过
- 前端倒计时用的是时间戳还是累减,刷新页面会不会重置
- 校验失败次数和锁定时长的关系是否合理,会不会误伤正常用户
- 短信接口失败率是否有监控告警
- 手机号字段是否有唯一索引
- 令牌里是否夹带了手机号等个人信息
- 接口返回的错误码是否足够细,前端能否给出准确提示
- 是否在真机上测试过完整流程,不只是浏览器模拟器
另外还有两个容易被忽略的点。一是时间同步,如果服务器时间有偏差,令牌的签发时间和校验时间对不上,会出现刚签发的令牌就提示过期的现象,部署时确保各节点都开启了时间同步服务。二是跨域配置,前端开发时用代理解决,上线后如果前后端不同域,要正确配置允许的来源、方法和请求头,尤其是Authorization头必须显式允许,否则浏览器会把请求头丢掉,后端拿不到令牌,一律返回 401。
这套方案我自己在几个项目里都用过,跑起来之后基本没再出过登录相关的事故。真正花时间的从来不是写那两个接口,而是限流参数怎么定、异常分支怎么处理、用户体验的细节怎么打磨。把这几块想透了,代码反而是最快落地的部分。