
在企业中,很多场景都需要设计发短信。比如用户注册/登录流程中,短信验证码是必不可少的安全验证环节。
所有,设计到企业成本的时候,一定要多思考可能存在的异常情况,不然会给企业造成极大的损失!
但是发短信的成本其实挺贵的,所以必须要做限制,防止被恶意刷:
1
恶意攻击者通过脚本频繁调用短信接口,导致短信费用激增
2
业务安全风险:验证码被暴力破解
3
服务资源耗尽:大量无效请求占用系统资源
某电商平台在上线初期就曾遭遇此类攻击,单日短信费用超过正常水平的10倍。

@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;
}
整个Service可以直接copy很通用
@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;
}
}
}
@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脚本,我们可能需要这样实现:
/**
* 非原子操作 - 存在并发问题
*
* @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;
}
但是并发情况下会出现的问题,比如可能存在下面这种问题:
1、线程A和线程B同时执行步骤1,都获取到当前值=0
2、两个线程都通过检查,都执行increment操作
3、最终计数变成2,但实际应该只允许1个请求通过

Redis保证Lua脚本在执行期间是原子的,其他命令必须等待脚本执行完成。这样就确保了:
•
检查 → 增加 → 设置过期时间 这三个操作作为一个不可分割的单元执行
•
不会出现多个客户端同时修改同一个key导致的计数不准确
•
避免了在高并发场景下限流失效的风险

这正是Redis限流方案可靠性的关键所在!