资讯动态

SpringBoot整合Redis实现短信验证码登录注册全流程

发布时间:2026/10/21 16:05:23 来源:尧图企业网站定制
1. 为什么短信验证码登录成了现代应用的标配做过用户系统的朋友应该都有体会传统的账号密码登录正在被短信验证码登录快速取代。你打开任何一个新上线的App或者小程序注册流程基本都是“输入手机号→获取验证码→填写验证码→完成注册登录”一气呵成用户不用记密码开发者也不用操心密码加密存储、找回密码那一堆麻烦事。这套方案的核心价值在于三点降低用户门槛、提升安全性、简化后端逻辑。手机号本身就是唯一标识验证码验证了用户对手机号的持有权天然完成了实名认证的第一步。对于SpringBoot开发者来说实现这套流程并不复杂但要把细节做扎实——比如验证码有效期控制、发送频率限制、防刷机制、Redis缓存设计——这些才是区分“能跑”和“能用”的关键。这篇文章我会从零开始把SpringBoot实现短信验证码登录注册的完整方案拆开讲透。包括整体架构怎么设计、Redis怎么用、验证码怎么生成和校验、第三方短信服务怎么对接、安全防护怎么做以及我在实际项目中踩过的坑和总结的经验。不管你是刚接触SpringBoot的新手还是想优化现有登录系统的老手都能从中找到可以直接复用的代码和思路。2. 整体方案设计与技术选型思路2.1 核心流程拆解与状态流转短信验证码登录注册的本质是一个基于临时凭证的身份验证流程。用户提交手机号服务端生成一个随机验证码通过短信通道发送到用户手机用户回填验证码服务端比对通过后完成登录或注册。整个流程涉及三个关键状态验证码生成、验证码存储、验证码校验。我习惯把这个流程拆成两条独立的业务线来设计。第一条是发送验证码接收手机号→校验手机号格式→检查发送频率→生成验证码→存入Redis→调用短信服务→返回发送结果。第二条是校验验证码并登录接收手机号和验证码→从Redis取出验证码→比对→判断用户是否存在→存在则登录不存在则自动注册→返回Token。为什么要拆成两条线因为发送和校验的触发时机、频率限制、安全策略完全不同。发送接口是防刷的重点校验接口是防爆破的重点分开设计能让每一条线的逻辑更清晰也方便后续独立扩展。状态流转上验证码在Redis中的生命周期是这样的生成时写入设置过期时间通常5分钟校验成功后立即删除校验失败则保留直到过期。这个“用后即焚”的机制很关键能有效防止同一个验证码被重复使用。2.2 为什么选Redis而不是Session或数据库验证码的存储方案有好几种选择我逐一分析一下为什么Redis是最优解。用Session存储验证码的问题在于Session依赖服务端的会话管理在分布式部署或者前后端分离的场景下Session共享本身就是个麻烦事。而且Session的过期时间控制不够灵活验证码需要独立的过期策略跟Session生命周期绑在一起不合适。用数据库存储验证码的问题更明显验证码是高频读写、短生命周期、允许丢失的数据用数据库存这种数据属于杀鸡用牛刀。每次发送和校验都要读写数据库QPS一上来数据库压力很大而且过期数据的清理也需要额外的定时任务。Redis的优势就很突出了天然支持过期时间写入时设置TTL到期自动删除不需要额外的清理逻辑读写性能极高单机轻松扛住几万QPS支持原子操作可以用INCR命令做发送频率计数用DEL命令做验证码的一次性消费分布式友好多个服务实例共享同一个Redis不存在Session共享问题。注意Redis一定要设置密码并限制访问来源验证码虽然价值有限但被恶意读取后配合撞库攻击仍然有风险。2.3 技术栈选型与依赖清单这套方案的核心依赖不多我列一下实际项目中用的版本组合组件选型版本建议用途说明框架SpringBoot2.7.x / 3.x基础框架缓存Redis6.x验证码存储与频率控制Redis客户端Spring Data Redis随Boot版本操作Redis连接池Lettuce默认Redis连接管理短信服务任意云短信API-短信发送通道参数校验Hibernate Validator随Boot版本手机号格式校验工具库Hutool5.8.x随机数生成等SpringBoot 3.x和2.7.x在Redis配置上差异不大主要注意Jakarta EE的包名变化。短信服务这里我不绑定具体厂商因为各家API大同小异核心都是“调用SDK→传入手机号和模板参数→返回发送结果”我会给出一个通用的抽象层设计你换成任何一家都能用。3. 核心细节解析与实操要点3.1 验证码生成策略与安全考量验证码的生成看似简单但细节决定安全性。我见过不少项目直接用Math.random()生成4位数字这种做法有两个问题一是随机性不够强二是位数太少容易被暴力破解。我的做法是使用SecureRandom生成6位数字验证码。为什么是6位4位数字只有1万种组合配合5分钟有效期和有限的尝试次数理论上仍然有被爆破的可能6位数字有100万种组合安全性提升两个数量级而用户输入成本只增加了一点点。为什么用SecureRandom而不是Math.random()因为Math.random()是伪随机种子可预测而SecureRandom是密码学安全的随机数生成器不可预测。import java.security.SecureRandom; public class SmsCodeGenerator { private static final SecureRandom RANDOM new SecureRandom(); private static final int CODE_LENGTH 6; public static String generate() { StringBuilder sb new StringBuilder(CODE_LENGTH); for (int i 0; i CODE_LENGTH; i) { sb.append(RANDOM.nextInt(10)); } return sb.toString(); } }除了纯数字还有一种做法是数字加字母混合安全性更高但用户体验会打折扣——用户需要在短信和输入框之间来回切换纯数字更容易记忆和输入。对于绝大多数应用场景6位纯数字已经足够。还有一个细节是验证码的存储Key设计。我用的格式是sms:code:{场景}:{手机号}比如sms:code:login:13800138000。为什么要加场景前缀因为同一个手机号可能同时用于登录、注册、修改密码、绑定手机等多个场景如果不区分场景用户在登录时收到的验证码可能被用于修改密码存在安全风险。场景隔离是必须的。3.2 Redis缓存结构设计与过期策略Redis中需要存储两类数据验证码本身和发送频率计数。我分别说一下设计思路。验证码的存储用String类型就够了Key是sms:code:{场景}:{手机号}Value是验证码字符串过期时间设置为5分钟。这里有个经验值过期时间不宜太短太短用户来不及输入也不宜太长太长增加被爆破的窗口期。5分钟是我测试下来比较平衡的值用户从收到短信到输入完成平均耗时在30秒到2分钟之间5分钟给了充足的容错空间。// 存储验证码 redisTemplate.opsForValue().set(codeKey, code, 5, TimeUnit.MINUTES);发送频率计数用另一个Key格式是sms:limit:{手机号}Value是发送次数过期时间设置为1分钟或10分钟取决于你的限流策略。我通常做两层限流1分钟内只能发送1次防止用户连点1小时内最多发送5次防止恶意刷短信。第一层用setIfAbsent实现第二层用increment配合过期时间实现。// 第一层1分钟内只能发1次 Boolean acquired redisTemplate.opsForValue() .setIfAbsent(sms:limit:minute: phone, 1, 60, TimeUnit.SECONDS); if (Boolean.FALSE.equals(acquired)) { throw new BusinessException(发送过于频繁请稍后再试); } // 第二层1小时内最多5次 String hourKey sms:limit:hour: phone; Long count redisTemplate.opsForValue().increment(hourKey); if (count ! null count 1) { redisTemplate.expire(hourKey, 1, TimeUnit.HOURS); } if (count ! null count 5) { throw new BusinessException(今日发送次数已达上限); }提示increment之后要判断返回值是否为1如果是1说明是第一次设置需要补上过期时间。这个细节很多人会漏掉导致Key永不过期计数一直累加。3.3 手机号校验与参数防护手机号校验是第一道防线必须在进入业务逻辑之前就拦截掉非法请求。我用Hibernate Validator的Pattern注解做基础格式校验正则表达式用^1[3-9]\\d{9}$覆盖国内主流号段。public class SmsSendRequest { NotBlank(message 手机号不能为空) Pattern(regexp ^1[3-9]\\d{9}$, message 手机号格式不正确) private String phone; NotBlank(message 场景不能为空) private String scene; }但光有格式校验不够还需要考虑几个安全点。第一接口层面要做IP限流防止有人用脚本批量调用发送接口即使每个手机号有限制攻击者可以换手机号刷。第二验证码校验失败次数要限制同一个手机号连续输错3次就锁定一段时间防止暴力破解。第三返回信息要模糊化不要告诉调用方“这个手机号已注册”或“这个手机号未注册”避免被用来探测用户信息。// 校验失败计数 String failKey sms:fail: phone; Long failCount redisTemplate.opsForValue().increment(failKey); if (failCount ! null failCount 1) { redisTemplate.expire(failKey, 10, TimeUnit.MINUTES); } if (failCount ! null failCount 3) { throw new BusinessException(验证码错误次数过多请10分钟后再试); }3.4 短信服务抽象层设计短信服务商的选择可能随着业务发展而变化所以我不建议在业务代码里直接调用某家厂商的SDK而是抽象出一个接口层。public interface SmsSender { SendResult send(String phone, String templateId, MapString, String params); }然后针对不同的服务商写不同的实现类通过配置决定使用哪一个。这样做的好处是切换服务商时只需要新增一个实现类业务代码零改动测试时可以注入一个Mock实现不实际发短信还可以做多通道容灾主通道失败自动切备用通道。Service public class AliyunSmsSender implements SmsSender { // 具体实现 } Service public class TencentSmsSender implements SmsSender { // 具体实现 } Component public class SmsSenderFactory { Value(${sms.provider}) private String provider; private final MapString, SmsSender senderMap; public SmsSender getSender() { return senderMap.get(provider); } }发送短信本身是网络IO操作可能超时或失败所以要做好异常处理和重试。我的做法是发送失败时记录日志返回给用户“发送失败请稍后重试”但不暴露具体失败原因。同时用异步方式发送避免阻塞主流程——用户点击发送后立即返回“发送中”实际发送在后台线程完成。4. 完整实操流程与核心代码实现4.1 项目结构与依赖配置先看一下整体项目结构我按职责分层src/main/java/com/example/smslogin/ ├── config/ │ ├── RedisConfig.java │ └── WebConfig.java ├── controller/ │ └── AuthController.java ├── service/ │ ├── SmsService.java │ ├── UserService.java │ └── impl/ │ ├── SmsServiceImpl.java │ └── UserServiceImpl.java ├── sender/ │ ├── SmsSender.java │ └── impl/ │ └── MockSmsSender.java ├── entity/ │ └── User.java ├── dto/ │ ├── SmsSendRequest.java │ └── SmsLoginRequest.java ├── common/ │ ├── BusinessException.java │ └── Result.java └── SmsLoginApplication.javapom.xml中需要引入的核心依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.22/version /dependencyapplication.yml的Redis配置spring: redis: host: 127.0.0.1 port: 6379 password: your_password database: 0 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 04.2 Redis配置与序列化处理SpringBoot默认使用JDK序列化存到Redis里是二进制乱码不方便排查问题。我习惯改成String序列化Key和Value都用StringRedisSerializer。Configuration public class RedisConfig { Bean public RedisTemplateString, String redisTemplate( RedisConnectionFactory factory) { RedisTemplateString, String template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer serializer new StringRedisSerializer(); template.setKeySerializer(serializer); template.setValueSerializer(serializer); template.setHashKeySerializer(serializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } }注意如果你用StringRedisTemplate它默认就是String序列化不需要额外配置。但如果你需要存对象就要考虑用Jackson序列化。验证码场景下全是字符串用StringRedisTemplate最省事。4.3 发送验证码接口实现Controller层只做参数接收和结果返回业务逻辑放在Service里。RestController RequestMapping(/api/auth) public class AuthController { Autowired private SmsService smsService; PostMapping(/sms/send) public ResultVoid sendSmsCode(Valid RequestBody SmsSendRequest request) { smsService.sendCode(request.getPhone(), request.getScene()); return Result.success(); } PostMapping(/sms/login) public ResultLoginVO smsLogin(Valid RequestBody SmsLoginRequest request) { LoginVO vo smsService.loginOrRegister( request.getPhone(), request.getCode()); return Result.success(vo); } }Service层的发送逻辑我把关键步骤都加了注释Service public class SmsServiceImpl implements SmsService { Autowired private StringRedisTemplate redisTemplate; Autowired private SmsSender smsSender; private static final String CODE_KEY_PREFIX sms:code:; private static final String LIMIT_MINUTE_PREFIX sms:limit:minute:; private static final String LIMIT_HOUR_PREFIX sms:limit:hour:; private static final int CODE_EXPIRE_MINUTES 5; Override public void sendCode(String phone, String scene) { // 1. 一分钟内只能发一次 String minuteKey LIMIT_MINUTE_PREFIX phone; Boolean acquired redisTemplate.opsForValue() .setIfAbsent(minuteKey, 1, 60, TimeUnit.SECONDS); if (Boolean.FALSE.equals(acquired)) { throw new BusinessException(发送过于频繁请稍后再试); } // 2. 一小时内最多五次 String hourKey LIMIT_HOUR_PREFIX phone; Long count redisTemplate.opsForValue().increment(hourKey); if (count ! null count 1) { redisTemplate.expire(hourKey, 1, TimeUnit.HOURS); } if (count ! null count 5) { throw new BusinessException(发送次数已达上限请一小时后再试); } // 3. 生成验证码 String code SmsCodeGenerator.generate(); // 4. 存入Redis5分钟过期 String codeKey CODE_KEY_PREFIX scene : phone; redisTemplate.opsForValue().set(codeKey, code, CODE_EXPIRE_MINUTES, TimeUnit.MINUTES); // 5. 发送短信 MapString, String params new HashMap(); params.put(code, code); SendResult result smsSender.send(phone, SMS_LOGIN_TEMPLATE, params); if (!result.isSuccess()) { // 发送失败删除验证码和限流Key允许用户重试 redisTemplate.delete(codeKey); redisTemplate.delete(minuteKey); throw new BusinessException(短信发送失败请稍后重试); } } }这里有个细节值得展开说发送失败时要回滚限流计数。如果不回滚用户因为网络问题发送失败一分钟内不能再试体验很差。但回滚也要谨慎只回滚分钟级的限流小时级的计数不回滚防止有人利用失败重试来绕过小时限制。4.4 校验验证码与自动注册登录校验逻辑是整个流程的核心我把它拆成校验和登录注册两步。Override public LoginVO loginOrRegister(String phone, String code) { // 1. 校验失败次数限制 String failKey sms:fail: phone; String failCountStr redisTemplate.opsForValue().get(failKey); if (failCountStr ! null Integer.parseInt(failCountStr) 3) { throw new BusinessException(错误次数过多请10分钟后再试); } // 2. 取出验证码 String codeKey CODE_KEY_PREFIX login: phone; String cachedCode redisTemplate.opsForValue().get(codeKey); if (cachedCode null) { throw new BusinessException(验证码已过期请重新获取); } // 3. 比对验证码 if (!cachedCode.equals(code)) { Long failCount redisTemplate.opsForValue().increment(failKey); if (failCount ! null failCount 1) { redisTemplate.expire(failKey, 10, TimeUnit.MINUTES); } throw new BusinessException(验证码错误); } // 4. 验证成功删除验证码和失败计数 redisTemplate.delete(codeKey); redisTemplate.delete(failKey); // 5. 查询用户不存在则自动注册 User user userService.findByPhone(phone); if (user null) { user userService.registerByPhone(phone); } // 6. 生成Token返回 String token jwtService.generateToken(user.getId()); return new LoginVO(token, user.getNickname(), user.getAvatar()); }自动注册这里有个设计决策新用户注册时给什么默认值。我的做法是昵称默认用“用户手机号后四位”头像用系统默认图这样用户登录后不会看到一个空荡荡的个人中心。当然你也可以在注册后引导用户完善资料但默认值必须有否则前端展示会出问题。4.5 短信发送通道的Mock实现开发阶段不可能真的发短信所以需要一个Mock实现把验证码打印到日志里。Service ConditionalOnProperty(name sms.provider, havingValue mock) public class MockSmsSender implements SmsSender { private static final Logger log LoggerFactory.getLogger(MockSmsSender.class); Override public SendResult send(String phone, String templateId, MapString, String params) { log.info(【模拟短信】手机号: {}, 模板: {}, 验证码: {}, phone, templateId, params.get(code)); return SendResult.success(); } }生产环境换成真实实现通过配置sms.provideraliyun或sms.providertencent来切换。这种设计在多人协作时特别有用前端联调阶段用Mock测试环境用测试通道生产环境用正式通道互不干扰。5. 常见问题与排查技巧实录5.1 验证码收不到的五种排查路径用户反馈“收不到验证码”是最常见的问题我总结了一套排查路径按顺序检查能快速定位。排查顺序检查项可能原因解决方法1日志中是否有发送记录接口未调用成功检查前端请求和参数2Redis中是否有验证码存储失败检查Redis连接和序列化3短信服务返回结果通道故障或余额不足查看服务商控制台4手机号是否被限流触发频率限制查看限流Key的TTL5用户手机是否拦截短信被归入垃圾箱建议用户检查拦截记录我遇到最多的情况是第2种Redis连接正常但验证码没存进去。排查后发现是序列化配置问题——用了默认的JDK序列化存进去的是二进制用Redis客户端查看时显示乱码误以为没存成功。改成String序列化后就正常了。还有一种情况是Redis的Key过期时间设置错误。比如把过期时间设成了5秒而不是5分钟用户还没输入验证码就过期了。这种问题在测试环境不容易发现因为测试时输入很快到了生产环境用户操作慢就暴露了。建议在代码里把过期时间定义为常量并加上注释说明单位。5.2 验证码校验失败的典型场景校验失败的原因也很多我列几个高频场景。场景一验证码已过期。用户收到短信后过了很久才输入Redis中的Key已经自动删除。这时候要给出明确的提示“验证码已过期请重新获取”而不是笼统的“验证码错误”否则用户会反复尝试同一个验证码。场景二场景不匹配。用户在登录页面获取的验证码却用在了注册页面。因为Key中带了场景前缀登录场景的验证码在注册场景下取不到会提示过期。这个设计是故意的但要在前端做好引导确保用户在当前场景下获取验证码。场景三多设备同时操作。用户在两台设备上分别获取了验证码后获取的会覆盖先获取的。因为Key相同后写入的验证码覆盖了前一个。这是预期行为但用户可能会困惑“为什么第一个验证码不对”。解决方案是提示用户“请使用最新收到的验证码”。场景四Redis主从延迟。在高并发场景下写入主节点后立即读取从节点可能读到旧数据。验证码场景下这个概率很低但如果你的Redis做了读写分离要注意这个问题。解决方案是验证码的读写都走主节点。5.3 防刷机制的实战调优防刷机制太松会被恶意刷短信太严会影响正常用户。我经过多次调优总结出一套比较平衡的参数。分钟级限流60秒内只能发送1次。这个参数基本没有争议正常用户不会在1分钟内反复点击。小时级限流1小时内最多5次。这个参数需要根据业务调整。如果是登录场景5次足够了如果是营销活动场景用户可能反复获取可以放宽到10次。天级限流1天内最多10次。这个作为兜底防止有人用脚本慢速刷。实现方式和小时级类似只是过期时间改成24小时。IP限流同一个IP1小时内最多20次。这个在网关层做可以用Nginx的limit_req模块也可以用Redis做。IP限流能有效防止单个IP批量刷不同手机号。提示IP限流的阈值不要设得太低因为很多用户在公司或学校共用出口IP设太低会误伤。20次/小时是我测试下来比较合理的值。还有一个进阶技巧对可疑手机号做标记。如果某个手机号在短时间内触发了多次限流可以把它加入黑名单一段时间内直接拒绝发送。这个逻辑可以用Redis的Set实现但要注意黑名单的过期时间避免永久封禁正常用户。5.4 分布式环境下的注意事项单机环境下跑通的代码到了分布式环境可能会出问题。我列几个需要注意的点。第一限流计数的原子性。用increment做计数是原子的但“判断是否超过阈值”和“设置过期时间”这两步不是原子的。在高并发下可能出现两个线程同时判断都没超过阈值然后都执行了发送。解决方案是用Lua脚本把这几步打包成原子操作。String luaScript local count redis.call(incr, KEYS[1]) if count 1 then redis.call(expire, KEYS[1], ARGV[1]) end return count;第二验证码的一次性消费。校验成功后要删除验证码但“读取-比对-删除”这三步不是原子的。极端情况下两个请求同时读到同一个验证码都比对成功都执行了登录。解决方案是用getAndDeleteRedis 6.2支持或者Lua脚本。第三时钟同步问题。分布式环境下各节点时钟可能有偏差影响过期时间的计算。建议所有节点都配置NTP时间同步或者直接用Redis的TTL命令来获取剩余时间而不是在应用层计算。第四Redis高可用。验证码服务依赖Redis如果Redis挂了整个登录流程就瘫痪了。生产环境建议用Redis Sentinel或Cluster并做好降级预案——比如Redis不可用时临时切换到本地缓存虽然多实例间不共享但至少能保证服务可用。5.5 性能优化与扩展思路这套方案在中小规模下完全够用但如果你的应用有百万级用户可以考虑以下优化。验证码生成优化SecureRandom的性能比Math.random()差但在验证码这种低频场景下可以忽略。如果QPS很高可以预生成一批验证码放在队列里用的时候直接取。Redis管道化发送验证码时需要执行多个Redis命令限流检查、验证码存储可以用Pipeline批量执行减少网络往返。异步发送短信短信发送是网络IO耗时可能几百毫秒。用Async注解把发送逻辑放到独立线程池接口立即返回用户体验更好。Async(smsExecutor) public void sendSmsAsync(String phone, String code) { // 发送逻辑 }多级缓存如果Redis压力大可以在应用层加一层Caffeine本地缓存缓存验证码的元数据比如是否已发送减少Redis读取。但验证码本身不建议本地缓存因为多实例间不一致。监控告警对发送量、成功率、验证失败率做监控异常时及时告警。比如发送成功率突然下降可能是短信通道故障验证失败率突然上升可能是被攻击了。6. 我在实际项目中的几点体会这套短信验证码登录方案我在多个项目中用过从日活几千到几十万的应用都跑过整体稳定性很好。有几个体会比较深分享出来供参考。关于验证码位数我一开始用4位后来改成6位。4位在测试环境没问题但上线后发现有恶意用户用脚本爆破虽然有限流但仍然有漏网之鱼。改成6位后爆破成本大幅提升基本杜绝了这类攻击。关于Redis的持久化验证码数据不需要持久化建议Redis关闭AOF或者设置合理的RDB策略。验证码丢了就丢了用户重新获取即可没必要为了这点数据牺牲性能。关于日志发送验证码的日志一定要打但要脱敏。手机号中间四位用星号代替验证码不要打到日志里Mock环境除外。我见过有项目把验证码打到日志里结果日志被泄露攻击者直接拿到了验证码。关于用户体验发送验证码后前端要做倒计时60秒内禁用按钮。这个虽然是小细节但能有效减少用户重复点击降低服务端压力。倒计时结束后按钮恢复用户可以重新获取。关于测试单元测试要覆盖正常流程、验证码过期、验证码错误、限流触发、自动注册等场景。集成测试可以用Testcontainers启动一个真实的Redis比Mock更可靠。最后再分享一个小技巧如果你的应用同时支持密码登录和验证码登录建议在用户表中加一个phone_verified字段记录手机号是否已验证。验证码登录成功后把这个字段置为true后续做手机号相关的敏感操作时可以要求用户先完成验证。这个字段在风控和账号安全场景下很有用。这套方案的核心代码不多但细节很多。把限流、防刷、过期、一次性消费这些点都做扎实才能算是一个生产可用的短信验证码登录系统。希望这些经验能帮你少踩几个坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑