Spring Boot 邮箱验证码全链路实战:存储、防刷与并发校验
每次看到有人问邮箱验证码怎么做我都会先反问一句你是要做演示 Demo还是要上一个真正能跑业务的版本很多人觉得这不是同一个功能吗——生成一个随机码、发一封邮件、校验一下三件事串起来就完了。等真正把代码放到生产环境你才会发现验证码存在哪儿、过期了怎么处理、用户疯狂点发送按钮怎么办、邮件服务商对发信频率有什么限制这些才是让人抓狂的地方。这就是我想写这篇文章的原因。我会把一条基于 Spring Boot 的邮箱注册验证码链路从头到尾过一遍从最基础的依赖和表设计开始到邮件发送服务、验证码生成与存储、注册接口校验再到上线前必须考虑的防刷和边界场景。所有的代码我都会给出可以直接参考的实现更重要的是会说明每一步为什么这么设计。这篇文章面向两类人一类是刚接触 Spring Boot、想做完整注册功能的入门开发者另一类是已经写过类似功能但总觉得自己的实现不够稳、想补齐细节的中级开发者。1. 邮箱验证码链路里的三个关键设计决策1.1 验证码该存哪里Redis、数据库还是本地内存先把话放这儿不同存储方案决定了这个功能到底能撑多大并发、能不能平滑重启。最常见的实现是直接把验证码放到本地内存里比如用 ConcurrentHashMap 存一个 email 到 code 的映射。这么做在单机演示环境下运行完全没有问题但一旦服务重启所有未过期的验证码全部丢光如果后面上了多实例部署用户第一次请求落到 A 实例第二次请求被负载均衡转发到 B 实例B 实例里根本找不到这张验证码问题就来了。所以在真实项目中本地内存方案只建议用来写测试用例不建议作为业务实现。数据库存储也是一个可选方案建一张验证码表字段包含邮箱、验证码、过期时间、使用状态。这个方案的好处是可靠但代价也很明显每次验证码校验都要读写一次数据库且验证码过期后还需要专门的清理任务去删除脏数据否则表会越积越大。数据库在高并发下本来就应该留给更重要的业务数据拿它来存这种短生命周期的临时凭证有点浪费。真正适合这个场景的是 Redis。Redis 自带的EXPIRE机制天然支持验证码N分钟后自动失效SET NX和DEL的原子性又能支撑同一用户同时只能有一个有效验证码这类约束。我通常用这样的 Key 结构mail:captcha:{邮箱}值为验证码本身过期时间 5 分钟mail:captcha:send:count:{邮箱}记录一小时内发送次数用于防刷mail:captcha:verified:{邮箱}记录已验证状态注册时用来做最终确认三个 Key 的分工很明确第一个是主存储第二个是限流计数第三个是防止用户在注册时绕过验证码校验直接调注册接口。使用 Redis 这种带过期时间的内存存储过期数据的清理完全不需要操心而且 Redis 天然支持跨实例共享多实例部署时不会出现验证码在 A 机器、校验到了 B 机器的问题。1.2 六位数字加五分钟有效期理由在哪验证码格式我推荐直接用六位纯数字有效期固定五分钟这两个参数不是拍脑袋定的。先看位数。四位纯数字只有一万种组合在没有频率限制的接口上理论上最多一万次请求就能穷举出来很明显不安全。六位纯数字有一百万种组合配合后面要讲的发送频率控制和校验错误次数限制暴力破解的成本已经足够高。七位八位当然也可以但数字越长用户输入的出错率越高体验反而变差。纯数字的好处是用户不用切换输入法手机上输入也方便。至于验证码本身绝大多数人都能在几十秒内完成输入五分钟的有效期已经留了足够的余量同时把过期后验证码被重放利用的时间窗口压缩到很低。如果有效期设成 30 分钟甚至一小时验证码泄露或被恶意利用的风险会明显上升。再说验证码生成。使用 JDK 自带的ThreadLocalRandom生成 100000 到 999999 之间的随机数就可以满足绝大多数场景。但要注意如果项目对安全性要求特别高比如金融类应用建议换成SecureRandom这个我们放到后面第三部分细说。发送间隔也是个需要定的参数。一般会限制用户必须等待 60 秒后才能再次点击发送这既能防止用户手滑连点也照顾了邮件服务商对发信频率的限制。很多服务商对同一收件地址一分钟内的发信次数是有隐性限制的超过会直接拒信。1.3 发信频率控制为什么必须在最前端做很多人做验证码功能时把全部精力放在生成、发送、校验这三步忽略了频率控制。结果是什么呢上线第二天某个用户手滑连续点了十几次发送按钮或者某个恶意脚本用随机邮箱去调发送接口你的发信通道就被打满了所有真实用户的邮件都会延迟甚至失败整个注册流程直接瘫痪。频率控制要分两层做。第一层是同一邮箱地址的发送频率比如限制 60 秒内只能发一次、一天之内最多发十次。这个限制用 Redis 的INCR加EXPIRE就能实现。第二层是同一 IP 的全局发送频率比如同一 IP 一小时内最多调用 20 次发送接口。这一层主要是防脚本批量注册因为恶意脚本为了效率一定会用同一个出口 IP 去请求。实现上有个细节值得注意计数逻辑必须在接口入口处就执行而不是在邮件发送完成之后。因为邮件发送如果走异步线程等发送完成再去计数高频请求早就穿透进去了。另外计数接口的 Key 一定要设置过期时间不然 Redis 里会残留大量永远用不上的计数 Key。2. 环境搭建与基础表结构先把地基打好2.1 依赖清单最小的五件套这里我用 Spring Boot 的 Web 项目作为示例需要的依赖其实就五个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-mail/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最后再加一个 Lombok 用于简化实体类代码。spring-boot-starter-mail是邮件发送的核心spring-boot-starter-data-redis负责验证码存储和频率计数spring-boot-starter-validation用来做参数校验避免自己在接口里写一堆 if 判断。有一点要注意如果你用的是 Spring Boot 2.x底层邮件库是javax.mail如果升级到 Spring Boot 3.x底层已经切换成jakarta.mail。对业务代码来说影响不大但排查问题搜资料时看到javax.mail.AuthenticationFailedException和jakarta.mail.AuthenticationFailedException这两种异常名不要觉得奇怪。我这里把代码示例基于 Spring Boot 2.7 的风格写Spring Boot 3.x 只有导入包名不同核心逻辑完全一样。如果读者新建项目用的是 3.x记得把javax.*换成对应的jakarta.*。2.2 application.yml 里的授权码陷阱邮件发送的配置写在 application.yml 里但这里有一个几乎人人都会踩的坑配置里填的 password 不是邮箱登录密码而是 SMTP 授权码。很多主流邮箱服务商出于安全考虑不允许第三方程序直接用登录密码发信需要在邮箱设置里单独生成一个授权码这个授权码一般是一串比较长的字符串。如果你用真实登录密码去配置JavaMail 连接 SMTP 服务器时大概率会报AuthenticationFailedException。配置示例如下注意把示例值换成你自己的spring: mail: host: smtp.example.com port: 465 username: yournameexample.com password: your-smtp-auth-code protocol: smtp default-encoding: UTF-8 properties: mail: smtp: auth: true ssl: enable: true socketFactory: class: javax.net.ssl.SSLSocketFactory端口和加密方式的组合是另一个坑。有的服务商用 465 端口配 SSL有的用 587 端口配 STARTTLS如果端口和加密方式不匹配连接会一直卡住或直接抛连接超时。我建议按 465 SSL 这个组合来配绝大多数服务商都支持。什么时候需要 587 加 STARTTLS 呢碰到某些企业邮箱的 SMTP 服务明确要求时再单独调整就行。不管选哪个组合spring.mail.host里的配置最好在配置文件里额外加上mail.smtp.connectiontimeout和mail.smtp.timeout把连接超时和读取超时都设成 3 秒或 5 秒。默认值偏长一旦邮件服务商侧有异常用户会感觉像卡死了一样。这个参数很容易被忽略但实际体验影响非常大。2.3 用户表与验证码日志表怎么设计用户表属于注册模块的基础。假设我们做一个最简单的注册场景只要求邮箱和密码用户表至少要有这些字段CREATE TABLE sys_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, email VARCHAR(128) NOT NULL COMMENT 邮箱, password VARCHAR(255) NOT NULL COMMENT 密码BCrypt加密, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0正常1禁用, register_ip VARCHAR(64) DEFAULT NULL COMMENT 注册IP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (id), UNIQUE KEY uk_email (email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;邮箱字段必须加唯一索引。这不是锦上添花而是并发注册下最后一道防线——当两个请求同时带着同一个新邮箱提交注册时唯一索引会直接让第二个请求插入失败配合捕获唯一约束异常就可以向用户返回邮箱已被注册的准确提示。如果没有唯一索引就得在代码里先查一遍再插入而这个先查再插在并发下一定会出现竞态窗口最终导致同一邮箱注册出两条记录。另外我强烈建议加一张邮件发送日志表不要嫌麻烦。很多问题比如大量用户反映收不到邮件没有日志表根本无从查起。日志表的结构很简单CREATE TABLE mail_send_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, email VARCHAR(128) NOT NULL COMMENT 收件邮箱, captcha VARCHAR(10) DEFAULT NULL COMMENT 验证码建议部分脱敏存储, template VARCHAR(32) DEFAULT register_captcha COMMENT 邮件模板标识, status TINYINT NOT NULL DEFAULT 0 COMMENT 0成功1失败, fail_reason VARCHAR(255) DEFAULT NULL COMMENT 失败原因, send_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_email_time (email, send_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT邮件发送日志表;验证码本身要不要存进日志表我的建议是存但要做脱敏——比如只存前两位和后两位中间用星号代替。这样运维排查时能确认发出去的码和系统生成的码一致又不会让日志表变成验证码泄露源。3. 写一个不发虚的邮件服务从验证码生成到模板渲染3.1 用 SecureRandom 而不是 Math.random 生成验证码验证码生成看似简单但很多人第一版实现都用错了随机源。Math.random()生成的是 double 类型如果要转成六位数字还得经过一系列运算更关键的是Math.random()基于线性同余算法随机性不足以应付安全敏感场景。安全评审里这是一个明确的扣分点。正确做法是使用SecureRandom。它在绝大多数操作系统上会借助系统级的熵源比如/dev/urandom生成的随机数无法被预测成本也完全可以接受。生成六位验证码的代码最稳妥的是这样写public static String generateCaptcha() { SecureRandom random new SecureRandom(); int code 100000 random.nextInt(900000); return String.valueOf(code); }nextInt(900000)的区间是 0 到 899999再加上 100000整体落在 100000 到 999999 之间保证一定是六位不会出现首位为 0 的情况。有些人喜欢用String.format(%06d, random.nextInt(1000000))这种方式也是可以的但要注意它可能生成首位为 0 的验证码比如001234用户输入时反而容易困惑。我更推荐前一种写法。验证码生成后不要再次查询直接在方法里同时完成生成、保存和发送减少不必要的中间状态。保存到 Redis 时我会把验证码加上一个简单的用途前缀比如注册验证码、重置密码验证码分别用不同的 Key 前缀这样同一套 Redis 能服务多个业务场景互不干扰。3.2 JavaMailSender 的初始化与邮件发送细节依赖引入之后Spring Boot 会自动配置一个JavaMailSender实例业务代码里直接注入就能用。这里要强调一个很多人忽略的细节Spring Boot 只有在 classpath 里存在spring-boot-starter-mail且配置了spring.mail.host时才会自动装配JavaMailSender。如果你只引了依赖没配 host启动时会报找不到JavaMailSender类型的 Bean——不是配置问题是这个 Bean 根本没被创建。邮件发送的代码要区分简单文本和富文本内容。发送验证码邮件一般会用 HTML 格式让邮件看起来更正规。完整代码大致是这样Service public class MailService { private final JavaMailSender mailSender; public MailService(JavaMailSender mailSender) { this.mailSender mailSender; } public void sendCaptchaMail(String toEmail, String captcha) throws MessagingException { MimeMessage message mailSender.createMimeMessage(); MimeMessageHelper helper new MimeMessageHelper(message, true, UTF-8); helper.setFrom(yournameexample.com); helper.setTo(toEmail); helper.setSubject(注册验证码); helper.setText(buildContent(captcha), true); mailSender.send(message); } private String buildContent(String captcha) { return div style\font-family: sans-serif; padding: 20px;\ h2你的注册验证码/h2 p style\font-size: 28px; color: #333; letter-spacing: 4px;\b captcha /b/p p验证码五分钟内有效请勿泄露给他人。/p /div; } }几个关键细节MimeMessageHelper的第二个参数true表示支持附件和 HTML如果不传这个参数setText里的 HTML 会被当成纯文本原样显示。setText(content, true)的第二个参数true表示按 HTML 格式发送。这两个true少一个邮件都会变成一堆标签源码。setFrom填写的地址要和配置文件里的spring.mail.username一致。部分服务商强制校验发件人地址不一致会直接报错。关于 HTML 模板我个人建议不要在 Java 代码里用字符串拼接来写邮件内容尤其是当邮件有抬头、有品牌色、有各种动态字段时拼接代码会迅速变得难以维护。项目中我会把模板放到resources/templates/mail/captcha.html用 Thymeleaf 渲染直接使用一个Context填充变量这样代码更干净页面调整也不用改 Java 逻辑。入门阶段用字符串拼接没问题但要意识到它的局限性。邮件发送本身建议放到异步线程去执行。发送验证码接口的响应时间通常要求在一两秒之内而 SMTP 发信在极端情况下可能耗时数秒甚至超时。如果直接同步调用用户点击发送验证码后会有一个明显的等待体验很差。用Async或者直接丢到一个简单的线程池里执行接口先返回邮件发送中的状态邮件到了用户自然能收到。不过异步调用也有代价——无法把发送失败结果即时返回给前端所以一定要配合日志表和失败重试机制。在项目初期并发量不大时也可以先用同步发送等有性能诉求了再改异步两者权衡要清楚。3.3 不同邮箱服务商的配置差异这里直接给一个通用的对照参考。注意不同服务商的具体配置实际以你在对应平台设置页看到的为准配置项常见方案一常见方案二协议smtpsmtp端口465587加密方式SSLSTARTTLS授权码需单独生成需单独生成发件域名必须在服务商处完成身份验证必须在服务商处完成身份验证选择标准其实就一条按你正在使用的邮箱服务商官方文档来不要盲目抄网上的配置。有的服务商只开放了 465 端口有的只开放 587把端口和加密方式配错JavaMail 会给出各种莫名其妙的异常Connection refused、Could not connect to SMTP host都有可能出现。另外建议在测试阶段就把发信地址配成一个你随时能收到邮件的真实邮箱不要用那种随便编造的地址。不然你会发现代码逻辑全对但邮件就是收不到排查半天才发现是发到了一个不存在的地址或进了垃圾箱。测试邮箱最好加白名单否则某些服务商的反垃圾系统可能拦截所有测试邮件。4. 注册接口的完整校验链路与并发陷阱4.1 验证码校验的逻辑顺序注册接口是整条链路里最需要小心的地方。很多人在 Controller 里这么写先查验证码对不对对了就创建用户错了就返回错误。看起来没错但有一个致命隐患——如果创建用户成功而验证码没有被清除同一个验证码可以反复多次完成注册。虽然正常用户不会这么操作但测试人员和恶意脚本一定会发现这个漏洞。完整校验逻辑应该按这样的顺序来对请求参数做基础校验邮箱格式、密码强度用 Validation 注解或手写校验都可以从 Redis 取出该邮箱对应的验证码如果 Key 不存在直接返回验证码已过期请重新获取比对验证码是否一致使用常量时间比较避免时序攻击如果验证码正确且未使用立刻删除这个 Key标记为已消费再创建用户创建过程中捕获唯一索引冲突的异常整个链路全部完成后返回成功。注意第四步和第五步的顺序先消费验证码再创建用户。这样即使创建用户失败验证码也被消费掉了用户需要重新获取从安全角度讲更合理。有些人会问那创建失败用户岂不是要重新收一封邮件确实如此但比起验证码可重复使用带来的风险这点代价完全值得。4.2 原子删除验证码并发下的关键动作上面第六步在并发场景下要单独思考。设想两个请求同时提交同一个邮箱和同一个正确验证码如果代码是先查询验证码是否匹配再删除两个请求都能通过查询然后各自执行删除最终可能创建出两条记录——当然用户表的唯一索引会拦住第二个但更稳妥的做法是让校验消费成为一个原子操作。用 Redis 的 Lua 脚本可以轻松做到这一点if redis.call(get, KEYS[1]) ARGV[1] then redis.call(del, KEYS[1]) return 1 else return 0 end这段脚本的意思是只有当 Key 的当前值和传入的验证码完全相等时才删除这个 Key。因为 Redis 单线程执行脚本天然具备原子性。这样即使两个并发请求同时到达也只有一个能成功消费验证码另一个会拿到验证码不正确或已使用的失败结果。在 Spring Boot 中调用 Lua 脚本也很简单用DefaultRedisScript封装后注入即可。不过有一个细节脚本的结果类型要明确指定因为 Lua 返回的 1/0 会被默认当成 Long 类型如果泛型写错转换阶段就可能抛异常。如果项目里暂时没有引入 Redis用数据库表存储验证码的话可以用一条条件删除的 SQL 达到类似效果DELETE FROM user_captcha WHERE email ? AND code ? AND expire_time NOW()通过影响行数判断是否消费成功。这也是一种原子操作思路。4.3 用户重复注册与唯一约束用户重复注册有两个层面的防护。第一层是在发送验证码之前做一次预检查如果邮箱已经存在于用户表直接提示该邮箱已注册请直接登录这样用户连验证码都不用申请。但预检查只是体验优化不能作为防重的主要手段因为存在时间窗口。第二层才是关键——数据库唯一索引。无论代码里做了多少检查唯一索引都保证同一邮箱不可能出现两条记录。所以注册时调用插入方法后要捕获DuplicateKeyException并转换成友好的错误信息返回。这个异常如果不捕获默认会变成 500 错误用户看到的是系统异常既不友好也不准确。这里还要考虑一个场景攻击者可能在用户注册之前就先去发送验证码接口刷邮件。所以发送验证码接口同样要做邮箱格式校验和频率控制不能因为反正不是注册接口就放松警惕。控制器的完整实现可以看下面这个示例RestController RequestMapping(/api/auth) public class AuthController { private final CaptchaService captchaService; private final UserService userService; public AuthController(CaptchaService captchaService, UserService userService) { this.captchaService captchaService; this.userService userService; } PostMapping(/captcha) public ResultVoid sendCaptcha(RequestBody Valid SendCaptchaRequest request) { captchaService.sendCaptcha(request.getEmail()); return Result.success(); } PostMapping(/register) public ResultVoid register(RequestBody Valid RegisterRequest request) { boolean consumed captchaService.consumeCaptcha(request.getEmail(), request.getCaptcha()); if (!consumed) { return Result.fail(验证码不正确或已过期); } userService.register(request); return Result.success(); } }为了简化这个示例把消费验证码和创建用户拆成了两个 Service 方法。实战中建议在UserService.register内部完成消费动作或者用事务把两步包在一起。如果拆成两个独立方法且在 Controller 层串联一旦userService.register抛出异常验证码已经消费掉了用户需要重新获取这在逻辑上可以接受但要注意给前端返回明确错误否则用户会困惑我验证码明明是对的为什么提示失败5. 上线前必须补上的防刷、失效与日志细节5.1 接口防刷用户维度与 IP 维度的双重限制前面讲了频率控制这里给一套可以直接落地的方案。发送验证码接口的防刷我推荐在 Service 层用一个独立方法统一处理这样 Controller 层不需要关心限流逻辑。用户维度的限制用 Redis 计数器实现public void checkSendLimit(String email, String ip) { // 用户维度60秒内只能发一次 String perUserKey mail:captcha:send:interval: email; Boolean firstSet stringRedisTemplate.opsForValue().setIfAbsent(perUserKey, 1, 60, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(firstSet)) { throw new BizException(发送过于频繁请稍后再试); } // 用户维度1小时内最多发5次 String userCountKey mail:captcha:send:hour: email; Long userCount stringRedisTemplate.opsForValue().increment(userCountKey); if (userCount 1) { stringRedisTemplate.expire(userCountKey, 1, TimeUnit.HOURS); } if (userCount 5) { throw new BizException(今日发送次数已达上限); } // IP维度1小时最多发20次逻辑同上 String ipCountKey mail:captcha:send:ip: ip; // ... }这个实现有一个容易出错的细节increment之后再设置过期时间这两个操作不是原子的。如果进程刚执行完increment还没执行expire就宕机了Redis 里会残留一个没有过期时间的 Key计数永远不清零。解决方式有几种要么用 Lua 脚本把两个操作包成原子组合要么把计数逻辑换成先setIfAbsent初始化计数再increment但同样有原子性问题。最省心的方式是使用一段简单的 Lua 脚本把判断是否存在—不存在则初始化—存在则自增—判断是否超限一次性完成。关于 IP 维度的防刷还有一个补充很多用户是在公司内网或校园网出口整个办公室可能共享一个公网 IP。如果 IP 维度限制得太狠比如一小时只允许 5 次正常用户会被误伤。20 次一小时通常够用但仍然建议结合邮箱格式校验和验证码校验错误的次数限制一起做避免单一维度误伤。5.2 验证码的失效与清理策略验证码有过期时间Redis 会自动清理但这不代表就没有失效场景要处理。实际运行中至少要面对三类情况第一类是验证码被正确输入但用户最终放弃了注册。这种情况下验证码已经被消费Redis 里没有残留不用额外操心。第二类是验证码超时未使用Redis 过期后自动消失用户再提交时会得到验证码已过期。第三类是验证码发送了多次Redis 里只有一个最新 Key。每次发送新验证码时采用覆盖式写入旧验证码立即失效。这个策略非常重要如果不清除旧验证码用户邮箱里躺着好几个验证码哪个对哪个错根本分不清。用set直接覆盖 Key 就行这也是我推荐用 Redis 而非数据库表的原因之一——数据库表要做使旧验证码失效的 UPDATE 操作而 Redis 天然只有最新值。校验失败的错误信息也要区分开。验证码不存在、过期、不正确这三种场景对用户来说感受不同对排查来说原因也不同。推荐在错误提示里做区分Key 不存在提示验证码已过期请重新获取值不匹配提示验证码错误校验次数过多提示验证码错误次数过多请重新获取这样用户不会对着同一个错误反复猜。有些实现为了统一话术把三种情况全部返回同一句话导致用户根本不知道是重发还是重新输入体验非常差。5.3 日志、异常与邮件积压的应对邮件发送失败是必然会发生的事关键是要能及时发现。我建议为每次发送动作记录日志至少要包含收件邮箱、模板标识、发送结果、失败原因、耗时。如果发信量更大可以把日志同步到日志平台再配置针对失败率的告警比如过去五分钟发信失败率超过 5%就报警。邮件积压的问题在注册场景下也不能完全忽略。如果一个邮箱服务商因为某种原因拒绝收信或者 SMTP 服务响应很慢同步发送时请求会排队阻塞最终把线程池打满。缓解手段有几个发送逻辑走异步线程池并给线程池设置合理大小和拒绝策略发送失败时记录日志并允许人工补发必要时在邮件服务内部做个简单的重试但重试次数不要太多否则反而放大问题。还有一个和邮件相关的隐藏坑某些邮箱服务商对发信内容有敏感词过滤验证码邮件中如果包含特定格式的链接或过强的营销用语邮件会被直接丢弃或进垃圾箱。建议验证码邮件保持简洁不加多余的营销内容主题清晰指明这是验证码邮件。5.4 本地测试与常见报错排查最后给一份调试阶段的常见问题对照表都是实际开发中遇到过的报错现象常见原因排查方向AuthenticationFailedException授权码错误或使用了登录密码检查 password 配置重新生成授权码Could not connect to SMTP host端口或加密方式不匹配核对 SMTP 端口是 465 还是 587Connection timed out服务器防火墙拦截或超时时间过短检查出网策略调整 socket 超时参数邮件收到但全是 HTML 源码忘了把 setText 第二个参数设为 true检查 MimeMessageHelper 的初始化参数邮件长时间收不到邮箱服务商反垃圾策略检查垃圾箱测试时先加白名单接口响应特别慢同步发送邮件未走异步把发送改成异步执行验证码永远已过期Redis Key 过期时间设置错误或装错环境检查 Redis 里的 Key 和过期时间排查验证码问题时可以把 Redis 客户端打开直接看mail:captcha:*相关的 Key 是否存在、TTL 是多少、Value 是否正确。这一步能快速判断问题出在根本没有写入 Redis还是发送和校验用了不同的 Redis 环境。我见过团队在本地连了测试环境的 Redis导致验证码校验各种灵异事件最后发现是配置里的 Redis 地址不对——这类问题靠看代码很难发现看 Key 一眼就明白了。本地测试时还可以临时加一个万能验证码开关只在测试环境开启比如 123456 直接视为正确验证码。这能省下大量反复收邮件的等待时间。不过这个开关一定要通过配置项控制并确保生产环境关闭否则等于给线上开了一个后门。更稳妥的做法是测试环境用专门的测试邮箱服务避免在生产邮件通道里发测试邮件。就我个人实际踩坑的经验来说邮箱验证码最难的部分从来不是那几行邮件发送代码而是那些看不见的边界验证码的消费时机、并发下的重复提交、被脚本刷爆的发信通道、以及配置里一个端口一个授权码带来的各种幺蛾子。按这条链路一步一步做下来再配合日志和 Redis 的 Key 检查这个功能基本就能稳了。如果你手头项目已经有验证码逻辑建议对照第五部分逐一检查自己的实现大概率能发现一两个还没处理的漏洞。