必须用Lua脚本校验验证码,因为GET+DEL两步操作非原子,高并发下会导致两个请求同时读到并删除同一验证码,造成重复使用;而Lua脚本在Redis中单线程原子执行,可确保“存在校验-值比对-删除”三步不可分割,彻底避免竞态。

为什么必须用Lua脚本校验验证码
直接用Redis的GET + DEL两步操作校验验证码,在高并发下会出问题:两个请求几乎同时读到同一个验证码,都判断“存在”,接着都执行DEL,结果一个请求成功、另一个也误判成功——验证码被重复使用。这不是理论风险,真实压测中QPS超200时就稳定复现。
根本原因是“读-判-删”不是原子操作。而Redis执行Lua脚本是单线程且原子的,整个逻辑封装进一段脚本里,就能彻底避免竞态。别图省事写两行Java代码去调get和del,那是给自己埋雷。
校验脚本必须包含三个动作
一个安全的验证码校验Lua脚本,不能只做“比对+删除”,至少要覆盖这三个环节:
- 检查key是否存在(即验证码是否过期或已被消费)
- 比对value是否完全一致(注意:不能用模糊匹配,也不能忽略大小写)
- 只有比对成功才
DEL,并返回1;否则返回0
示例脚本:
if redis.call('exists', KEYS[1]) == 0 then
return 0
else
local code = redis.call('get', KEYS[1])
if code == ARGV[1] then
redis.call('del', KEYS[1])
return 1
else
return 0
end
end
调用时传入KEYS[1]为sms:code:13800138000,ARGV[1]为用户提交的验证码字符串。
防刷限流必须和校验脚本解耦
很多人把发送限流逻辑硬塞进校验脚本里,结果导致校验变慢、缓存穿透风险上升。正确做法是:发送短信前走一套独立的限流脚本,校验时只专注“这个码对不对、还能不能用”。
限流脚本推荐用滑动窗口(有序集合),比如限制手机号5分钟最多10次发送:
- key用
sms:limit:13800138000 - 每次发送前用
ZCOUNT查当前窗口内请求数 - 不超过阈值才允许发,并用
ZADD+EXPIRE记录时间戳
注意:不要在限流脚本里SET验证码,那是发送阶段的事;也不要在校验脚本里INCR计数,那属于发送风控范畴。
register_script比eval更稳,但别跨连接复用
用redis-py时,务必用register_script预加载脚本,而不是每次调eval。前者把脚本SHA1存在客户端,后续只传哈希值,快且安全;后者每次传完整字符串,网络开销大,还容易因转义出错。
但有个关键坑:注册脚本的对象绑定在某个Redis连接上,如果用连接池取了新连接去执行,会报NOSCRIPT No matching script。解决办法只有两个:
- 用同一个连接对象完成注册和后续所有
execute调用 - 或者改用
Script类手动管理SHA1,在连接池取出连接后先script_load(不推荐,复杂度高)
生产环境建议把脚本注册逻辑放在应用启动时,用单例Redis client完成,之后所有地方复用该client的脚本对象。











