必须实现毫秒级短信防刷:使用Redis Lua脚本原子计数(键名sms:throttle:{手机号}:{业务类型}),校验手机号后限频5次/60秒,超频返回retry_after;短信发送须DB事务内先写日志再设Redis标记,避免状态不一致。

用户在注册、登录或找回密码时频繁点击“发送短信验证码”按钮,导致同一手机号在短时间内收到大量重复短信,既增加短信成本又引发用户投诉。必须在Laravel应用中实现毫秒级响应的单点防刷控制,并确保计数器更新与短信发送逻辑不出现竞态条件。
配置Redis连接与短信限流键名规则
在 .env 中确认 REDIS_CLIENT=phpredis,否则 incr 和 expire 原子组合会失效。
定义键名为 sms:throttle:{手机号}:{业务类型},例如 sms:throttle:13800138000:register,避免不同业务共用同一计数器。
业务类型必须硬编码传入,不可由前端提交——【前端传来的 type 字段一律丢弃,只接受服务端预设值】。
实现带过期时间的原子计数器
方法一:使用 Redis Pipeline 批量执行
在短信发送前调用 Redis::pipeline()->incr('key')->expire('key', 60)->exec(),返回数组第一个元素即当前计数值。
方法二:使用 Lua 脚本保障真正原子性(推荐)
创建 app/Support/RedisScripts.php,写入脚本:if redis.call("INCR", KEYS[1]) == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end return redis.call("GET", KEYS[1])。
调用时传入键名和过期秒数:Redis::eval($script, 1, $key, 60),这一步必须用 eval,evalsha 在部署灰度时可能因脚本未加载而失败。
拦截超频请求并返回结构化错误
第一步:从请求中提取手机号与业务类型,校验手机号格式(正则 /^1[3-9]\d{9}$/),不匹配直接返回 400。
第二步:生成 Redis 键名,执行 Lua 计数脚本,获取当前次数。
第三步:若返回值 > 5,则抛出 ThrottleException 并携带 retry_after 字段(当前时间 + 60 秒),前端据此禁用按钮倒计时。
第四步:若通过,则继续走短信发送流程;注意——【计数器已自增,此处不能再调用 incr,否则下次请求直接触发限流】。
短信发送与状态标记联动
使用 DB::transaction() 包裹数据库写入与 Redis 状态更新。
先插入 sms_logs 表记录(含手机号、验证码、业务类型、IP、created_at),再执行 Redis::setex("sms:used:{$phone}:{$type}", 300, $code) 标记该号码该业务类型下验证码已发出。
这一步不能颠倒顺序:如果先设 Redis 再写 DB,DB 插入失败会导致 Redis 状态残留,后续相同请求被误判为“已发”,但日志缺失,无法审计。


















