☰
基于Redis的邮箱验证码存储与校验方案:Spring Boot与QQ邮箱SMTP完整实现
2026/10/1 10:27:59 网站建设 项目流程

做项目做到注册、登录、找回密码这一步,十有八九都会碰上"邮箱验证码"这个功能。我最早做的时候,图省事直接把验证码扔数据库里,没多久就被线上问题教育了:用户收不到邮件重复点击、验证码超时没人清理、同一封邮件被反复校验……后来换成Redis存验证码,整个链路清爽了很多。这里把我用QQ邮箱做模拟验证码验证的完整方案拆开讲一遍,从环境配置到核心代码再到排坑思路,照着做就能跑通。

1. 方案设计:为什么验证码必须放Redis

1.1 验证码场景的真实痛点

先想清楚验证码这个东西的存储诉求。一个验证码从生成到失效,生命周期很短,通常是3到5分钟。如果我们把它写进数据库表,每次校验都要走一次SQL查询,高并发场景下数据库压力不小。更要命的是那些过期数据,如果不做定时清理,表会越积越臃肿,总觉得为这点小功能养个定时任务不太值。

如果不用数据库,简单写个内存Map存验证码行不行?本地测试可以,但一上生产就露馅。应用一重启,所有登录中的验证码全部消失;多实例部署时,请求打到不同机器上,A机器存的验证码B机器根本读不到,用户明明输入对了还是报错。这就是典型的"无状态服务被有状态数据坑了"的案例。

Redis天然就是为这种短生命周期、高频读写、跨实例共享的数据设计的。验证码存Redis,本质上就是把"临时状态"从业务服务器里剥离出来,放到统一的缓存层去管理。这不只是换个存储位置的问题,而是让整个服务的扩展性变好了——你随便横向扩容,验证码公共访问,谁也丢不了谁。

1.2 技术选型:Redis为什么是最优解

从数据类型上看,验证码就是一个典型的String类型。一条记录存下来,设置过期时间,读写都只有一次,不需要复杂的数据结构。有人可能会想,用Redis的Hash或者List是不是也行?能存是能存,但没必要。String配合SETEX命令,一个操作就把存值和过期时间一起搞定了,原子性、效率都拉满。

Redis还有一个很大的优势是自带过期机制。验证码5分钟失效,你只需要在写入的时候设置expire,时间一到Redis自动帮你删除,完全不需要写定时任务去扫表清理。这个特性看起来不起眼,但省掉的心力和维护成本,实际操作过才懂。

安全层面也得提一句。验证码这种敏感临时凭证,过期时间越短,被重放攻击的风险越小。Redis的高性能读写让"短过期时间"在技术上完全没有压力,反正是毫秒级操作,就算用户反复点发送,系统也撑得住。

1.3 整体流程拆解:发送链路和校验链路

这个功能的完整流程其实有两条链路,一条是发验证码,一条是验验证码。发验证码的链路是这样:用户在前端点"获取验证码",后端收到请求后生成一个6位随机数字,把它作为Value存进Redis,Key就用captcha:email:用户邮箱这种格式,同时设置5分钟过期。存好之后,再调用邮件服务把这串数字发到用户邮箱。

校验链路就反过来:用户把收到的验证码填进表单,后端拿用户提交的邮箱作为Key去Redis里取之前存的验证码,然后和用户提交的验证码比对。如果一致,校验通过,同时顺手把这个Key删掉,保证一个验证码只能用一次;如果不一致或者Key已经过期不存在,就返回校验失败。

这两条链路核心都不复杂,难点全在细节:验证码怎么生成才安全、Redis的Key怎么设计才能区分不同业务场景、发送失败时Redis里的脏数据怎么处理、怎么防止用户高频请求轰炸后端。这些我在后面会逐个展开讲。

2. 环境准备:QQ邮箱SMTP和Redis的配置

2.1 Spring Boot项目引入必要依赖

我用的是Spring Boot为基础搭建,先把最关键的两个依赖加进pom.xml。一个是操作Redis的spring-boot-starter-data-redis,一个是发邮件用的spring-boot-starter-mail,这俩都是Spring Boot官方封装好的starter,引入之后几乎不需要额外写工具类。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-mail</artifactId> </dependency>

如果你用的是Maven,加上之后重新加载依赖就可以了。这两个starter会自动装配RedisTemplate和JavaMailSender,少写很多样板代码。

2.2 QQ邮箱SMTP开通与授权码获取(新手必看)

这一步是整个流程里最容易卡住的地方。QQ邮箱不像某些邮箱服务,直接用邮箱密码就能发信,它强制要求使用授权码。所谓授权码,就是你在QQ邮箱里为第三方客户端单独生成的一个密码,作用范围只限SMTP服务,避免你的QQ密码因为第三方应用泄露。

操作路径是:登录QQ邮箱网页版,进入"设置",找到"账户"标签,往下翻能看到"POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务"这一栏,找到"SMTP服务"这一项,点击开启。开启的过程中会让你发一条短信验证身份,确认之后会给你一串16位左右的字母和数字组合,这就是授权码。注意这串码只在生成的时候完整显示一次,之后再看就只能重新生成。

拿到授权码之后,把它当成spring.mail.password的值填进配置,千万不要填你的QQ邮箱登录密码,否则连接SMTP服务器时一定会报535 Authentication Failed。

2.3 application.yml核心配置项

整个邮件和Redis的配置我都汇总到application.yml里,这样维护起来直观。先看邮件部分:

spring: mail: host: smtp.qq.com port: 465 username: 你的QQ邮箱@qq.com password: 你的邮箱授权码 default-encoding: UTF-8 properties: mail: smtp: ssl: enable: true socketFactory: class: javax.net.ssl.SSLSocketFactory

QQ邮箱的SMTP服务器支持465端口SSL加密和587端口STARTTLS加密。我用的是465,因为这个端口配合SSL的兼容性最好,几乎不会因为加密协议问题连不上。如果你本地网络环境对465限制比较多,可以换成587,对应配置改成smtp.starttls.enable: true。

Redis的配置比较简单,本地开发一般就是用默认的localhost:6379。如果像我一样装了Redis Desktop Manager做可视化查看,连的也是这个地址。

spring: data: redis: host: localhost port: 6379 database: 0 timeout: 3000ms

这里有一点要提的是timeout,默认值是0表示无限等待,我习惯设成3秒。如果Redis服务挂了,后端请求不会无限阻塞,而是快速失败并返回错误信息,这对用户体验很重要。

3. 核心代码实现:验证码生成、存储、发送、校验

3.1 验证码生成:随机数怎么生成更可靠

生成6位数字验证码,很多人第一反应是new Random().nextInt(999999),但这样会有一个小坑:生成的数字可能不足6位。虽然前面补0也不是不行,但用户收到的验证码变成"000123"这种,总归体验有点怪。

我的做法是用ThreadLocalRandom生成一个从100000到999999的整数,一次性保证6位。之所以用ThreadLocalRandom而不是Random,是因为前者在高并发场景下性能更好,线程竞争更少,也避免了多线程共享Random实例带来的安全问题。

private String generateCode() { return String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); }

有的业务会要求验证码不能出现重复数字这种花活,我个人的看法是,6位数字空间足够大,一百万分之一的碰撞概率基本可以忽略,没必要过度设计。你要是真不放心,可以生成之后去Redis里做一次唯一性检查,但我从实际经验看完全没必要。

3.2 Redis存储:Key设计和过期时间怎么定

Key设计是这块的核心。我用的格式是captcha:email:用户邮箱,比如captcha:email:test@qq.com。冒号分隔的习惯是Redis生态里的通用规范,相当于命名空间,用Redis Desktop Manager看着也整齐,后面想按前缀批量删除也方便。

如果业务里不止一个场景需要验证码,比如注册、登录、找回密码各来一发,建议在Key里再加一个业务维度,比如captcha:register:email:test@qq.com。这样不同业务之间的验证码相互独立,不会出现注册时发的验证码被拿去登录用的问题。

写入Redis的时候,我会用stringRedisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES)。注意这里带着过期时间一起set,不是set完了再单独调expire。多一条命令就多一次网络往返,中间还可能有间隙,虽然实际出问题的概率极低,但养成好习惯总是没错的。

过期时间设多长呢?5分钟是我比较推荐的值。设太短,用户还没看完邮件验证码就没了,容易烦躁;设太长,验证码长时间有效,被暴力破解和重放攻击的风险会变大。5分钟在"用户体验"和"安全"之间是个比较平衡的点。

3.3 邮件发送:JavaMailSender实战

发邮件这一步,Spring Boot的JavaMailSender已经封装得很好。创建一个SimpleMailMessage,设置发件人、收件人、主题、正文,然后交给mailSender.send()就完事了。核心逻辑看这段:

@Service public class MailService { @Resource private JavaMailSender mailSender; @Value("${spring.mail.username}") private String from; public void sendCaptcha(String to, String code) { SimpleMailMessage message = new SimpleMailMessage(); message.setFrom(from); message.setTo(to); message.setSubject("【XX系统】验证码通知"); message.setText("您正在使用邮箱验证服务,验证码为:" + code + ",5分钟内有效,请勿泄露给他人。"); mailSender.send(message); } }

有一点需要注意,setText里存的纯文本,默认按UTF-8发送,中文完全没有问题。我之前犯过一个错误:在正文里直接拼HTML标签,但用的还是默认的纯文本发送方式,结果用户收到一封显示着<b>验证码</b>这种源码的邮件,观感很差。后来发现发送HTML内容要用MimeMessageHelper,设置setText(html, true),才算彻底解决。

3.4 发送逻辑封装:串联Redis和邮件服务

光有生成、存储、发送这几个零散方法还不够,核心的发送验证码逻辑,要把它们穿起来,同时还要考虑到"发送失败时的数据清理"。

发邮件的动作放在最后的考虑是:如果Redis写入失败,没必要发邮件;如果Redis写入成功了但邮件服务调不通,那Redis里的验证码就变成了无效数据,必须删掉,不然用户下次点击发送时会以为验证码已经发出来了。

我封装了一个CaptchaService,完整逻辑看下面的代码:

@Service public class CaptchaService { private static final String CAPTCHA_PREFIX = "captcha:email:"; private static final long CAPTCHA_EXPIRE_MINUTES = 5; @Resource private StringRedisTemplate stringRedisTemplate; @Resource private MailService mailService; public void sendCaptcha(String email) { String code = generateCode(); String key = CAPTCHA_PREFIX + email; try { stringRedisTemplate.opsForValue().set(key, code, CAPTCHA_EXPIRE_MINUTES, TimeUnit.MINUTES); mailService.sendCaptcha(email, code); } catch (Exception e) { stringRedisTemplate.delete(key); throw new RuntimeException("验证码发送失败,请稍后重试", e); } } private String generateCode() { return String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); } }

这段代码有两点值得注意。第一,使用了StringRedisTemplate而不是RedisTemplate。因为验证码存的是纯字符串,StringRedisTemplate默认的序列化器就是String方式,存取之间不会出现乱码或者带莫名前缀的问题。第二,try-catch里先写Redis再发邮件,一旦发邮件失败就删掉刚写进去的数据,保证内存里的状态和用户实际收到的状态保持一致。

3.5 校验逻辑:从Redis取值并一次删除

校验验证码看起来就是"先查再比",实际上有个细节很关键:校验成功之后,一定要把Redis里的Key删掉,让验证码变成一次性凭证。不然用户反复提交同一个验证码,后端每次都放行,验证码就失去意义了。

public boolean verifyCaptcha(String email, String code) { String key = CAPTCHA_PREFIX + email; String storedCode = stringRedisTemplate.opsForValue().get(key); if (storedCode != null && storedCode.equals(code)) { stringRedisTemplate.delete(key); return true; } return false; }

这个逻辑是"取出来后比对,比对成功就删"。如果取出来的值是null,说明验证码已经过期或者一开始就不存在,直接返回false。比对时我用的是storedCode.equals(code)而不是反过来,是因为storedCode已经在非空判断之后,不会出现空指针问题。

从严格意义上讲,这段代码存在一个极小的并发漏洞:两个请求同时带着同一个正确验证码来校验,可能都通过了。要彻底解决得用Redis的Lua脚本做原子操作,但对于大部分项目来说,验证码功能本身就不是高敏感资产,这点风险可以接受。如果你想做得更严谨,可以搜一下"Redis Lua脚本优化验证码校验"这个话题。

4. 接口层串联:Controller配合前端联调

4.1 发送验证码接口

有了Service层,Controller层就比较薄了。我一般会设计两个接口,一个负责发送,一个负责校验。发送接口接收前端传过来的邮箱地址,调用captchaService.sendCaptcha(),返回成功信息。这里我在参数上做了一点约束,交给@Validated和@Email注解做基础的格式校验,避免把乱七八糟的字符串传到Service层去。

@RestController @RequestMapping("/api/auth") public class AuthController { @Resource private CaptchaService captchaService; @PostMapping("/sendCode") public String sendCode(@RequestParam @Email String email) { captchaService.sendCaptcha(email); return "验证码发送成功"; } @PostMapping("/verifyCode") public String verifyCode(@RequestParam @Email String email, @RequestParam String code) { boolean passed = captchaService.verifyCaptcha(email, code); return passed ? "验证通过" : "验证码错误或已过期"; } }

校验接口接收邮箱和用户输入的验证码两个参数,把结果封装成简单的字符串返回。实际项目中肯定是要返回统一JSON结构的,这里为了突出核心逻辑,先用最简单的返回方式,你根据自己项目的统一响应体去替换就行。

4.2 用Postman做完整联调

接口写完,我用Postman做了一轮完整的联调测试。先发一个POST请求到/api/auth/sendCode,参数里加上email=你的测试邮箱@qq.com。正常情况下接口返回"验证码发送成功",同时你的QQ邮箱会在几秒内收到一封新邮件。

这时候打开Redis Desktop Manager连上本地Redis,刷新一下Key列表,能看到一个captcha:email:你的测试邮箱@qq.com的Key,Value就是你收到的那个6位验证码。这一步非常直观,也能帮你确认Redis写入是否成功。

然后发起第二个POST请求到/api/auth/verifyCode,参数带上同样的邮箱和邮件里的验证码。返回"验证通过"再去Redis里看,那个Key已经被删掉了。如果你再重复请求一次校验接口,就会返回"验证码错误或已过期"。

4.3 前端页面的交互流程参考

前端这边,常见的交互流程是:用户填完邮箱点"获取验证码",按钮进入60秒倒计时,同时把这个请求发到/sendCode接口;用户收到邮件后填入验证码,提交表单时带着邮箱、验证码一起调/verifyCode。如果校验通过就正常走注册或登录流程,不通过就提示用户重新输入。

倒计时的逻辑可以放在前端做,也可以后端在发送验证码的响应里返回一个expireIn字段表示秒数。我更推荐后者,因为前端时钟不准的问题挺常见的,后端统一告诉前端"多久之后才能重新发送",体验更一致。

5. 常见问题与排查技巧实录

5.1 邮件一直发不出去,报535 Authentication Failed

这个问题90%的情况是授权码填错了,或者把QQ邮箱的登录密码当成了SMTP密码。排查思路很直接:先去QQ邮箱设置里确认SMTP服务是开启状态,并拿到正确的授权码。确认之后看配置里spring.mail.password这一项,把授权码重新填一次,重启应用再试。

如果授权码确认没问题还报错,再看看端口和加密协议。QQ邮箱要求必须用加密通道,465端口配SSL、587端口配STARTTLS,如果加密方式不对,服务端会直接拒绝连接。

5.2 邮件发送成功但Redis里Key已经存在,导致用户重新发送时拿不到新验证码

这个坑我踩过。顺理成章的发送逻辑是先写Redis再发邮件,如果邮件代码一调用就报错,我们会进入catch块删除Key。但有一种情况很隐蔽:邮件服务其实发出去了,只不过因为网络抖动或者延时,JavaMailSender这边抛了个超时异常,导致Redis里的Key被误删。用户那边收到邮件了,后端Redis里反而没有验证码,等用户来校验的时候必然失败。

我现在的处理方式是,把"邮件是否真正发送成功"的判断做重试而不是直接删除。更稳妥的做法是先发邮件,成功之后再写Redis,虽然理论上有一小段时间用户已经到了验证码但后端还没写入,但实际影响微乎其微,而一致性大幅提升。我最终还是选择了后写Redis的顺序。

5.3 怎么防止用户疯狂点击发送验证码

用户的耐心是有限的,但不太需要频繁点。要防"验证码轰炸",最简单实用的方案是在发送前检查Redis里有没有同一个邮箱的"发送冷却Key"。比如用户每次发送成功之后,额外存一个captcha:limit:email,过期时间60秒。下一次点击进来先查这个Key,如果存在就直接返回"请勿频繁发送",不存在才继续走发送逻辑。

private static final String LIMIT_PREFIX = "captcha:limit:"; public void sendCaptchaWithLimit(String email) { String limitKey = LIMIT_PREFIX + email; Boolean canSend = stringRedisTemplate.opsForValue().setIfAbsent(limitKey, "1", 60, TimeUnit.SECONDS); if (Boolean.FALSE.equals(canSend)) { throw new RuntimeException("发送过于频繁,请60秒后重试"); } // 继续发送逻辑 }

这里用的是setIfAbsent命令,也就是Redis里的SETNX,只有Key不存在的时候才能设置成功,天然适合做这种轻量级的限流。

5.4 Redis出现序列化乱码

如果你用的是RedisTemplate而不是StringRedisTemplate,可能会发现Redis里存进去的值带了\xAC\xED\x00\x05t...这种前缀。这其实是默认的JDK序列化器在搞鬼。验证码这种纯字符串数据,直接用StringRedisTemplate就能解决,或者在配置RedisTemplate时把Key和Value的序列化器都改成StringRedisSerializer。

5.5 本地连接不上Redis服务

排查顺序建议先确认Redis进程是否在运行。Windows下我一般是用redis-server.exe redis.windows.conf启动,看到"Ready to accept connections"字样才算启动成功。然后用redis-cli ping,返回PONG说明连接正常。如果还是不通过,检查一下项目配置里的spring.data.redis.port是不是被改过。

最后再分享一点经验

这个功能做完之后,我最大的感受是——光把功能跑通不难,真正拉开差距的是那些"异常路径"处理。验证码这个功能虽小,但把发送失败清理、一次性校验、频率限制这些细节都考虑到,代码质量会上一个台阶。

如果你后续想扩展,可以考虑把验证码从纯数字升级为图形验证码或滑动验证码,存储和校验逻辑核心不变,只是在生成阶段多一层校验。也可以把发送通道从QQ邮箱换成阿里云邮件推送,配置方式类似,但发送成功率更稳定。总之,先把这个链路跑通,后面换通道、加验证方式,都是顺势而为的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询