
验证码这个小功能用户侧只看到“获取验证码”和“输入验证码”两个动作后端却要包揽生成、存储、发送、校验、销毁一整套流程。我最早在项目里做这个功能时第一个念头是接一个短信服务商结果一看资质审核、签名备案、模板审批的流程当场被劝退。后来换了个思路先用 QQ 邮箱模拟发信验证码用 Redis 来存整条链路跑通之后再考虑替换成真实短信通道。这个方案一直用到现在我觉得非常适合做课设、练手项目或者公司内部系统的临时验证码功能。很多人问为什么偏偏是 QQ 邮箱 Redis这篇文章我会完整拆解这个方案背后的选型逻辑从 Redis 安装、QQ 邮箱授权码获取到验证码生成、邮件发送、存储过期、校验防刷一路写到实际踩坑记录和生产化改造方向。文章里的代码可以直接复制跑通你改一下邮箱参数就能用。1. 为什么是QQ邮箱Redis场景与选型逻辑1.1 验证码链路要解决什么问题验证码这条链路拆开看本质就三件事发出去、存起来、拿去校验。发出去需要一个能触达用户的通道短信也好、邮件也好存起来需要一个短暂存储的临时凭证区能自动过期最好拿去校验就是用户把收到的码回传系统比对后决定放行还是拒绝。这三件事听着简单但都有一个共同特点数据是短命的。验证码一般 5 分钟就过期过期之后没必要保留。这个特点决定了用什么存储很关键。如果你把它写进 MySQL 表里你要建一张captcha表字段设计 email、code、expire_time、used_flag然后还得写一个定时任务去清理过期记录查库、标记、清理每一步都是多余的复杂度。如果用 Redis这些问题几乎不存在。存一个 key设置过期时间时间一到 Redis 自动帮你删掉完美匹配验证码“用一次、活几秒”的特性。1.2 Redis 凭什么比数据库更适合存储验证码我认为有两个核心原因存储模型天然匹配和操作足够原子。从存储模型看验证码就是一串短字符串Redis 的 String 类型存起来最直白。存储的时候直接SETEX verify:userexample.com 300 123456意思是“这个 key 300 秒后自动消失”。不存在空记录、脏数据、索引维护的问题。从操作原子性看Redis 的SETEX、INCR、DEL都是原子操作。比如校验成功之后要删除验证码Redis 里一条DEL就完事如果用 MySQL你得UPDATE used_flag 1 WHERE email ?再做查询判断还要考虑并发请求会不会同时读到同一个验证码两个请求都校验通过。Redis 天然能扛住这种高并发小数据的读写数据库反而需要加锁、优化、设计唯一索引才能做到。再加上一个实际体验Redis 的命令非常少学一遍就能用。官方客户端 redis-py 的 API 我用一个下午就上手了写业务的时候脑子里不需要想 SQL 怎么拼。1.3 QQ 邮箱 SMTP 在模拟阶段的主场优势有人会问短信通道不好申请那用网易邮箱、Gmail 不行吗也行但 QQ 邮箱在国内开发环境里有一个不可替代的优势个人账号就能开启 SMTP 服务流程全程网页点选5 分钟出授权码。网易也开放 SMTP但 QQ 邮箱的授权流程更短而且腾讯系内部系统、企业微信、微信登录等场景天然和 QQ 邮箱绑定很多国内项目的测试环境就默认用它。更关键的是QQ 邮箱 SMTP 支持 SSL 加密连接Python 的 smtplib 标准库改两个参数就能调用不像某些邮件服务商还要你配 STARTTLS、端口绕过或者让你在服务器上装 CA 证书。对初学者来说越少前置依赖越容易跑通。从“模拟”到“真实”的迁移成本也很低你只需要把“发邮件”这个动作替换成“发短信”SDK 的调用验证码生成、Redis 存储、校验逻辑一行都不用改。这种替换成本几乎为零的模拟方案对我来说就是最优解。2. 环境准备把 Redis 跑起来并拿到 QQ 邮箱授权码环境准备阶段最容易被卡住但每件事步骤都固定照着做很快。2.1 安装并验证 RedisWindows 那边的用户最省事的方式是直接用 Docker 跑一个容器docker run -d --name redis -p 6379:6379 redis:7-alpine如果电脑上没有 Docker也可以找第三方维护的 Windows 版本 Redis或者用 WSL 里装 Ubuntu 再apt install redis-server。Mac 上一行命令brew install redis brew services start redisLinux 服务器上apt install redis-server systemctl enable --now redis-server装好之后打开终端输入redis-cli ping看到PONG就说明服务已经起来了。另外建议顺手装一个 Redis 可视化客户端比如 Another Redis Desktop Manager。后面验证码有没有写进去、剩余 TTL 还有多少秒图形界面里一眼就能看到不用每次敲命令行。这不是必须的但排查问题非常省时间我在后面第 4 节会经常提到用它检查 key。2.2 开启 QQ 邮箱 POP3/SMTP 并生成授权码这一步很多人卡住。登录 mail.qq.com进入设置 → 账户往下拉找到“POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV 服务”这一块把“POP3/SMTP 服务”开启。开启时需要手机扫码验证发一条指定短信到腾讯指定号码发送成功后页面会弹出一串16 位字母授权码。这个授权码只显示一次务必立刻复制保存后面 SMTP 登录用的就是它。这里必须强调一下授权码不是你的 QQ 密码是一个专门给第三方客户端登录 SMTP 的独立凭证。很多新手在这里就把 QQ 密码填进去了然后 SMTP 认证一直失败浪费了大量时间。这个坑我在第 5 节会专门复盘。2.3 创建 Flask 项目和依赖我选用 Python Flask 来做示例因为代码量最少、最容易读。项目只需三个文件captcha-demo/ ├── app.py ├── requirements.txt └── .envrequirements.txt内容flask3.0.3 redis5.0.8至于发信用的 smtplib、email还有生成验证码用的 random都是 Python 标准库不需要额外安装。.env文件里放邮箱配置实际编码时不建议把邮箱账号和授权码硬编码在代码里万一项目分享出去账号就直接暴露了。我建议用环境变量管理用 Python 的 os.getenv 读取。3. 核心实现验证码生成、发信、存储一次跑通3.1 验证码生成避开混淆字符保证随机性验证码生成看似简单背后有讲究。我见过有人直接用random.randint(100000, 999999)生成 6 位纯数字这个方案最大的问题是字符集包含了容易混淆的数字比如 0 和 O、1 和 I用户凌晨盯着屏幕输入时很容易看错。我的做法是用排除混淆字符后的字符集生成验证码import random secure_rand random.SystemRandom() def gen_code(length6): chars 23456789ABCDEFGHJKLMNPQRSTUVWXYZ return .join(secure_rand.sample(chars, length))这段代码干了三件事用random.SystemRandom()替代默认的random它是基于操作系统熵源的加密安全随机数生成器避免使用可预测的随机种子。字符集去掉了 0、O、1、I 这四个字符减少用户在键盘和屏幕上混淆的概率。用sample采样保证同一个验证码里的字符不会重复降低被暴力枚举的概率。生成的验证码类似7K3M2P既不能一眼猜中又方便用户读出来。这里生成的验证码类型是字母数字混合串虽然和短信常见纯数字验证码不同但本质上是同一套业务逻辑。3.2 邮件发送smtplib 连接 QQ 邮箱发信这块用标准库就能完成不值得引入第三方发信库。核心代码import os import smtplib from email.mime.text import MIMEText from email.header import Header def send_code_email(to_addr, code): smtp_host smtp.qq.com smtp_port 465 sender os.getenv(QQ_MAIL) auth_code os.getenv(QQ_AUTH_CODE) body f您的验证码是{code}5 分钟内有效。 msg MIMEText(body, plain, utf-8) msg[From] f验证码服务 {sender} msg[To] to_addr msg[Subject] Header(验证码通知, utf-8) with smtplib.SMTP_SSL(smtp_host, smtp_port) as server: server.login(sender, auth_code) server.sendmail(sender, [to_addr], msg.as_string())几个细节说明。SMTP 端口我用的 465对应 SSL 加密连接这是 QQ 邮箱最稳的方式不用额外处理 TLS 握手。如果要用 587 端口就必须按 STARTTLS 的流程来代码里就得改成 SMTP starttls很容易搞混。MIMEText构造函数的第三个参数utf-8解决中文乱码问题不写的话标题和正文会变成乱码。sendmail的收件人参数是列表结构这是 smtplib 的设计它支持一次群发。我们这里只发给一个人但也传列表这是规范用法。3.3 Redis 写入setex 五分钟后自动消失邮件发出去以后验证码要存进 Redisimport redis r redis.Redis( host127.0.0.1, port6379, db0, decode_responsesTrue ) # 存储验证码5 分钟过期 r.setex(fverify:{email}, 300, code)这里有几个关键点。第一个是setex这个命令。它把“赋值”和“设过期时间”合成了原子操作不会出现写了一半没有过期时间的中间状态。如果分两步先写盘再设过期一旦异常容易留下永不过期的垃圾数据。第二个是decode_responsesTrue。这个参数能让 redis-py 自动把返回值从 bytes 解码成字符串如果不设置后续读出来的都是b7K3M2P这样的字节串和用户输入的字符串比较时永远不相等这个坑我第 5 节会专门讲。第三个是 key 的名称我用了verify:用户邮箱这种格式。冒号分隔是 Redis 社区惯例方便按业务分组在可视化工具里也能按前缀一眼认出属于哪个业务。3.4 发送接口与校验接口整体整合成一个 Flask 应用提供两个接口/api/send_code负责发送验证码/api/verify负责校验。from flask import Flask, request, jsonify app Flask(__name__) app.route(/api/send_code, methods[POST]) def send_code(): data request.json or {} email data.get(email, ).strip() if not email: return jsonify({code: 1, msg: 邮箱不能为空}) # 防刷同一邮箱 60 秒内只能发送一次 resend_key fresend:{email} if r.exists(resend_key): return jsonify({code: 1, msg: 发送太频繁请稍后再试}) code gen_code(6) try: send_code_email(email, code) except Exception as e: app.logger.error(fsend email failed: {e}) return jsonify({code: 1, msg: 邮件发送失败请稍后再试}) # 邮件发送成功后才写入验证码 r.setex(fverify:{email}, 300, code) r.setex(resend_key, 60, 1) # 清理旧的失败计数避免重新获取后仍被上次错误次数锁死 r.delete(ffail:{email}) # debug 模式下把验证码直接返回方便本地联调 return jsonify({ code: 0, msg: 验证码已发送, debug_code: code if app.debug else None }) app.route(/api/verify, methods[POST]) def verify(): data request.json or {} email data.get(email, ).strip() user_code data.get(code, ).strip().upper() if not email or not user_code: return jsonify({code: 1, msg: 参数不完整}) # 防暴力枚举同一邮箱错误超过 5 次就限制 fail_key ffail:{email} fail_count int(r.get(fail_key) or 0) if fail_count 5: return jsonify({code: 1, msg: 错误次数过多请重新获取验证码}) stored_code r.get(fverify:{email}) if not stored_code: return jsonify({code: 1, msg: 验证码不存在或已过期}) if stored_code ! user_code: # 记录失败次数10 分钟内有效 pipe r.pipeline() pipe.incr(fail_key) pipe.expire(fail_key, 600) pipe.execute() return jsonify({code: 1, msg: 验证码错误}) # 校验成功删除验证码一次性使用 r.delete(fverify:{email}) r.delete(fail_key) return jsonify({code: 0, msg: 验证成功})这个 Flask 应用跑起来以后你可以用 curl 或者 Postman 测试curl -X POST http://127.0.0.1:5000/api/send_code \ -H Content-Type: application/json \ -d {email: 你的测试邮箱qq.com}验证码发出后打开你的邮箱收件然后调用校验接口curl -X POST http://127.0.0.1:5000/api/verify \ -H Content-Type: application/json \ -d {email: 你的测试邮箱qq.com, code: 收到的验证码}这里我特别说明一下邮件发送和 Redis 写入的顺序。我先调用send_code_email发信失败就返回不会写入 Redis只有发信成功后才执行setex。这样做是为了避免“Redis 里的验证码已经存在但邮件没发出去”导致用户输入验证码时提示不存在的尴尬情况。4. 流程跑通后的冷思考Redis 在真实业务里的边界代码跑通只算第一步真正有意思的地方在于思考这套方案在业务里怎么设计得更健壮。我从 key 设计、防刷、并发安全三个维度展开。4.1 key 设计和过期策略应该怎么定Redis 里每个 key 要存活多久不是一个拍脑袋的数字。验证码场景的常规标准是 5 分钟也就是 300 秒。这个值其实是安全和体验的平衡太短用户还没看到邮件就过期了太长又容易被人拿去暴力枚举。过期策略上我建议显式设计不要只依赖 Redis 的 TTL 兜底。比如发送验证码时设定 300 秒过期但校验接口我会同时判断验证码是否存在如果已过期就明确告诉用户“请重新获取”而不是抛一个验证码不存在的模糊错误。key 命名用一个固定的前缀比如verify:而不是直接用邮箱作为 key。这样一是有利于业务隔离多个服务共享同一个 Redis 实例时分得清谁是谁二是后续如果需要清理某个业务的所有 key直接扫前缀就能拿到。错误次数 key 一般设置 10 分钟过期独立于验证码过期时间。这样即使验证码已经失效失败计数也不会无限期占用 Redis 内存。4.2 防刷频率控制的几种实现差异我的发送接口里用了一个简单的防刷策略同一个邮箱 60 秒内不能重复发送。这个用resend的 key 配合 exists 就能实现简单直观。但生产环境中只按邮箱维度防刷是不够的还应该考虑 IP 维度、设备维度甚至用户行为特征。更严格的频率控制可以用 Redis 的INCR配合EXPIRE。比如限制“同一个 IP 一分钟最多发 5 次”伪代码如下key frate:ip:{ip} count r.incr(key) if count 1: r.expire(key, 60) if count 5: return jsonify({code: 1, msg: 请求太频繁})这样写的好处是计数和过期完全交给 Redis 维护不占应用内存。缺点是INCR和EXPIRE不是同一个原子操作理论上比较高并发时可能出现 count 第一次后没设置过期导致 key 永久存活。实际业务中这个窗口极小可以接受如果追求绝对严谨就用 Lua 脚本把INCR EXPIRE包成一个原子操作。4.3 验证码的一次性与并发安全分析校验成功之后删除验证码是必须的否则同一个验证码能反复使用相当于给把这个凭证共享给所有人。但这里有一个并发问题GET验证码和DEL是两个独立操作。假设用户同时发来了两个校验请求两个请求都读到了同一个 code然后都走删除流程结果就是两个请求都校验通过验证码被消费了两次。单靠 Python 代码解决这个并发问题需要加锁而且锁还有跨进程的问题。Redis 自己的 Lua 脚本是原子执行的把“比对 删除”整体塞进一个脚本就能完美解决-- check_and_consume.lua local key KEYS[1] local input_code ARGV[1] local code redis.call(GET, key) if not code then return 0 -- 不存在或已过期 end if code ~ input_code then return 1 -- 验证码错误 end redis.call(DEL, key) return 2 -- 校验成功并已消费在 Flask 应用里注册这个脚本consume_script r.register_script( local code redis.call(GET, KEYS[1]) if not code then return 0 end if code ~ ARGV[1] then return 1 end redis.call(DEL, KEYS[1]) return 2 ) result consume_script(keys[fverify:{email}], args[user_code])这样在校验的同时完成删除整个操作原子执行不会出现并发重复消费。这是我后面做生产改造时强烈建议做的一个优化代码改动量不大但能把最隐蔽的 bug 防住。5. 实测避坑记录这五个问题占了 80% 的排错时间这一节是全文最有价值的部分。我跑第一版的时候前前后后踩了不下十个坑下面五个问题占了所有排查时间的 80%而且每个问题都很典型。5.1 授权码填成 QQ 密码535 认证失败第一次调用server.login(sender, auth_code)时我填的是 QQ 密码结果报错smtplib.SMTPAuthenticationError: (535, bLogin Fail. Please enter your authorization code to login.)这个报错信息已经把原因说得非常清楚了登录失败请使用授权码登录。但当时的我没细看又去改了代码里的发件人地址、SSL 端口折腾很久才意识到是凭据用错了。经验就是QQ 邮箱 SMTP 登录的密码位置填的就是第 2.2 节生成的 16 位授权码不是邮箱密码。这个授权码只在开启 SMTP 时生成一次丢了的话就得重新生成。如果你也踩了这个坑先检查授权码是不是正确粘贴进了环境变量。5.2 SMTP 端口与 SSL 方式不匹配QQ 邮箱有两种 SMTP 连接方式465 端口对应 SSL587 端口对应 STARTTLS。我用smtplib.SMTP_SSL连接 587 端口结果直接断连smtplib.SMTPServerDisconnected: Connection unexpectedly closed反过来如果用smtplib.SMTP连接 465 端口邮箱服务器也不会配合完成加密握手。这两个是固定搭配不要混用。我的建议是如果你刚上手直接用 465 SMTP_SSL这一套最稳。587 的 STARTTLS 需要额外的握手步骤多一个环节就多一个变量。5.3 Redis 返回值是 bytes 导致的比对失败这个坑非常隐蔽。早期我创建 Redis 连接时没有写decode_responsesTrue结果存进去的是7K3M2P读出来是b7K3M2P。Python 3 里这两个类型虽然长得差不多但比较时永远是 False b7K3M2P 7K3M2P False这个 bug 的表现是用户输入了完全正确的验证码后端却一直报“验证码错误”。排查时看 Redis 客户端里面数据明明是对的最后才发现是类型问题。解决方式很简单创建连接池时统一加上decode_responsesTrue让 redis-py 自动解码成字符串。如果你维护的老项目已经用了 bytes那校验时一定要手动 decode 一次再比较。5.4 QQ 邮箱发信频率限制与垃圾箱问题QQ 邮箱 SMTP 对外发信是有限额的同一个账号短时间内发太多封邮件会触发频率控制机制。我在联调阶段疯狂点验证码按钮十几封邮件发出去结果后面的邮件全部到不了收件箱全跑进了垃圾箱。这个问题的崩溃点在于你永远不知道用户会不会去垃圾箱找验证码。为了稳定我后来强制在代码里加了一层 60 秒最小间隔避免自己调试的时候把邮箱发爆。另一个容易被忽略的点是收件地址必须是真实有效的邮箱不要拿“user163.com”这种猜的账号去测。QQ 邮箱对不存在的收件地址会退信发信太多还会拉低你发信账号的信用。测试时最好用另一个自己的真实 QQ 邮箱来收信。5.5 短时间重复发送时旧验证码如何处理用户第一次获取验证码之后5 分钟之内又点了一次“获取验证码”。此时要把旧的覆盖掉吗我自己项目里的做法是60 秒内的重复请求直接拒绝超过 60 秒就生成新验证码覆盖旧的。覆盖时要注意清理旧的失败计数。如果不清理用户第一次输错几次重新获取验证码后校验接口还是会先检查旧的失败计数可能直接提示“尝试次数过多”让用户陷入死循环。所以我在 send_code 接口里加了一行r.delete(ffail:{email})这是上线后被用户反馈教育出来的经验。6. 模拟方案如何平滑过渡到生产级验证码服务这套模拟方案跑通之后如果项目要真正上线有几个改进方向非常关键。6.1 替换发送通道从 SMTP 到短信服务最大的替换点就是发送通道。前面封装的send_code_email函数直接替换成短信 SDK 的调用函数就行函数签名保持不变。验证码生成、Redis 存储、过期策略、校验逻辑、失败计数这些代码一行都不用动。我在项目里做替换时把发送模块抽成了一个独立的小文件def send_captcha(phone, code): # 调用短信服务商 SDK ...然后原来的邮箱验证码接口只是多保留了一个 debug 路由正式环境不对外暴露。这种设计让“模拟”到“真实”的切换只需要改一行 import。6.2 叠加图形验证码与行为验证码因为发送通道有成本线上服务必须防自动化脚本刷接口。只在 Redis 里做 60 秒限制还不够脚本换个 IP 就能绕过。标准的做法是在发送验证码之前再加一道图形验证码比如滑动拼图、点选文字只有图形验证码通过了才会触发短信或邮件的发送。这道图形验证码的通行凭证也可以用 Redis 来存。用户在图形验证码阶段通过后后端生成一个短时效的 ticket 写进 Redis紧接着请求发短信时带上这个 ticket后端校验 ticket 有效才真正调用短信服务商接口。这样就把“人机识别”和“发送通道”两个环节解耦了。6.3 验证码服务的可观测性与审计验证码服务是典型的面向外部用户的入口最容易出问题的是“用户说没收到你说发了”这种无头案。为了排查这类问题日志和审计特别重要。我的做法是每次短信或邮件下发用一个全局递增的 request_id 串联整个链路日志里记录请求方的脱敏信息比如邮箱只保留前两位和后两位、验证码的短签名比如只要校验码的后两位避免敏感信息全量落盘、发送耗时、Redis 对应 key 的 TTL 状态。这些日志排错时非常有用。最后再提一点QQ 邮箱模拟验证码只能作为开发和测试方案真要用于生产环境面向真实用户还是得走短信通道或者专业邮件服务。但从学习角度讲这套模拟方案把验证码的底层逻辑完整复刻了一遍等你需要切换真实通道时就会发现前面所有的设计都没有白费。