Redis限流实战:精准保护短信验证码接口,杜绝恶意刷量
一、短信限流背景
在企业业务中,许多场景都依赖短信发送。例如,用户注册或登录流程中,短信验证码是确保安全验证的核心环节。因此,涉及企业成本的关键环节必须全面考虑异常情况,否则可能造成严重经济损失。
短信服务成本较高,必须实施限流措施,防止恶意刷量:
- 恶意攻击者利用脚本高频调用短信接口,导致短信费用急剧上涨。
- 业务安全风险:验证码可能被暴力破解,威胁用户账号安全。
- 服务资源过度消耗:大量无效请求会快速耗尽系统资源。
曾有一家电商平台在上线初期就遭遇此类攻击,当日短信费用飙升至正常水平的10倍以上。

二、核心代码实现
1. 限流配置类
@Component
@ConfigurationProperties(prefix = "sms.rate-limit")
@Data
public class RateLimitConfig {
// 手机号频率限制:60秒内仅1次
private long phoneInterval = 60;
private int phoneMaxAttempts = 1;
// IP总量限制:24小时内不超过100次
private long ipInterval = 24 * 60 * 60;
private int ipMaxAttempts = 100;
}
2. Redis限流服务
整个Service可直接复用,通用性强。
@Service
@Slf4j
public class SmsRateLimitService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RateLimitConfig rateLimitConfig;
private static final String PHONE_PREFIX = "sms:phone:";
private static final String IP_PREFIX = "sms:ip:";
private static final String GLOBAL_KEY = "sms:global";
/**
* 检查是否允许发送短信
*/
public RateLimitResult checkRateLimit(String phoneNumber, String clientIp) {
RateLimitResult result = new RateLimitResult();
// 检查手机号频率限制
if (!checkPhoneLimit(phoneNumber)) {
result.setAllowed(false);
result.setMessage("操作过于频繁,请60秒后再试");
return result;
}
// 检查IP总量限制
if (!checkIpLimit(clientIp)) {
result.setAllowed(false);
result.setMessage("今日发送次数已达上限");
return result;
}
result.setAllowed(true);
return result;
}
/**
* 手机号频率限流检查
*/
private boolean checkPhoneLimit(String phoneNumber) {
String key = PHONE_PREFIX + phoneNumber;
return checkAndIncrement(key, rateLimitConfig.getPhoneInterval(),
rateLimitConfig.getPhoneMaxAttempts());
}
/**
* IP总量限流检查
*/
private boolean checkIpLimit(String clientIp) {
String key = IP_PREFIX + clientIp;
return checkAndIncrement(key, rateLimitConfig.getIpInterval(),
rateLimitConfig.getIpMaxAttempts());
}
/**
* 通用的Redis限流检查方法
* 使用Lua脚本保证原子性操作,避免并发问题
*
* @param key Redis键
* @param interval 时间间隔(秒),在此时间窗口内进行限流计数
* @param maxAttempts 最大允许尝试次数,超过次数则触发限流
*/
private boolean checkAndIncrement(String key, long interval, int maxAttempts) {
try {
// 采用Lua脚本确保原子性
String luaScript =
"local current = redis.call('get', KEYS[1]) " +
"if current and tonumber(current) >= tonumber(ARGV[1]) then " +
" return 0 " +
"else " +
" redis.call('incr', KEYS[1]) " +
" if tonumber(current) == 0 then " +
" redis.call('expire', KEYS[1], ARGV[2]) " +
" end " +
" return 1 " +
"end";
RedisScript<Long> script = RedisScript.of(luaScript, Long.class);
Long result = redisTemplate.execute(script,
Collections.singletonList(key),
maxAttempts, interval);
return result != null && result == 1;
} catch (Exception e) {
log.error("Redis限流检查异常, key: {}", key, e);
// Redis异常时,为保障主流程可用,默认放行
return true;
}
}
}
3. 业务服务层
@Service
@Slf4j
public class SmsService {
@Autowired
private SmsRateLimitService rateLimitService;
@Autowired
private SmsProvider smsProvider;
/**
* 发送短信验证码
*/
public SendSmsResult sendVerificationCode(String phoneNumber, String clientIp) {
// 1. 限流检查
RateLimitResult limitResult = rateLimitService.checkRateLimit(phoneNumber, clientIp);
if (!limitResult.isAllowed()) {
log.warn("短信发送被限流, phone: {}, ip: {}, reason: {}",
phoneNumber, clientIp, limitResult.getMessage());
return SendSmsResult.fail(limitResult.getMessage());
}
// 2. 生成验证码
String verificationCode = generateVerificationCode();
try {
// 3. 调用短信服务商API
boolean sendResult = smsProvider.sendSms(phoneNumber,
"您的验证码是:" + verificationCode + ",5分钟内有效");
// 其他业务操作
} catch (Exception e) {
log.error("短信发送异常, phone: {}", phoneNumber, e);
return SendSmsResult.fail("系统异常,请稍后重试");
}
}
private String generateVerificationCode() {
// 生成6位随机数字验证码
Random random = new Random();
return String.format("%06d", random.nextInt(1000000));
}
private void saveVerificationCode(String phoneNumber, String code) {
String key = "sms:code:" + phoneNumber;
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES);
}
}
该方案已在多个生产环境稳定运行,日均处理百万级短信发送请求,有力保障了系统的稳定与安全。
Redis的高性能与原子操作特性,使得这套限流方案既简洁又高效,是Java后端开发中必备的防护利器。
三、为什么需要 Lua 脚本?🤔
不少人会提出疑问:为什么在操作计数时必须使用Lua脚本?
若是不用Lua脚本,我们可能会这样实现:
/**
* 非原子操作——存在并发问题
*
* @param key Redis键
* @param interval 时间间隔(秒),在该时间窗口内进行限流计数
* @param maxAttempts 最大允许尝试次数,超过该次数即触发限流
*/
private boolean checkAndIncrementUnsafe(String key, long interval, int maxAttempts) {
// 步骤1:获取当前计数值
Integer current = (Integer) redisTemplate.opsForValue().get(key);
// 步骤2:检查是否已达上限
if (current != null && current >= maxAttempts) {
return false;
}
// 步骤3:执行递增
redisTemplate.opsForValue().increment(key);
// 步骤4:如果是第一次设置该键,则添加过期时间
if (current == null) {
redisTemplate.expire(key, interval, TimeUnit.SECONDS);
}
return true;
}
然而,在高并发场景下会出现典型问题,比如:
- 线程A和线程B同时执行步骤1,均获取到当前值=0。
- 两个线程都通过检查,并都执行了increment操作。
- 最终的计数变成了2,而实际期望只允许1个请求通过。

Lua 限流原理
Redis确保Lua脚本执行期间为原子操作,所有其他命令必须等待脚本完成后再执行。这样便实现了:
- 检查 → 递增 → 设置过期时间这三个操作作为一个不可分割的单元运行。
- 避免了多个客户端同时修改同一个key所导致的计数误差。
- 彻底杜绝高并发场景下限流机制失效的风险。

这正是Redis限流方案可靠性的核心所在!