短信接口被狂刷怎么处理
短信接口被狂刷怎么处理
面试官您好!针对短信接口被恶意狂刷这个经典的高并发安全问题,我会按照 "紧急止损→多层防护→业务优化→监控复盘" 四个阶段来系统性解决,这也是大厂标准的应急响应流程👇
第一阶段:紧急止损🔥(黄金 30 分钟)
先把问题压下来,避免损失扩大,这是优先级最高的操作:
- 立即降级:通过配置中心将短信接口切换为 "测试模式",返回成功但实际不发送短信;或直接熔断接口
- IP 黑名单:在 Nginx 层快速封禁异常请求 IP 段,拦截 90% 以上的恶意流量
- 限流兜底:在网关层开启全局限流(如每秒 100 次),防止服务被打垮
- 临时关停:如果以上措施无效,直接下线短信发送功能,优先保障核心业务可用
第二阶段:多层防护体系🛡️(全链路拦截)
建立从前端到后端、从网关到业务的立体防护网,层层过滤恶意请求:
各层具体实现要点
| 防护层级 | 核心技术 | 拦截效果 | 实现难度 |
|---|---|---|---|
| 前端层 | 图形验证码、短信倒计时、按钮禁用 | 拦截 80% 普通刷量 | ★☆☆☆☆ |
| Nginx 层 | IP 限流、Referer 校验、User-Agent 过滤 | 拦截 15% 爬虫流量 | ★★☆☆☆ |
| 网关层 | 令牌桶限流、API 签名、IP 黑白名单 | 拦截 4% 恶意攻击 | ★★★☆☆ |
| 业务层 | 手机号频次限制、设备指纹、行为风控 | 拦截 0.9% 高级攻击 | ★★★★☆ |
| 第三方层 | 平台自带风控、日发送量上限 | 最后一道防线 | ★☆☆☆☆ |
第三阶段:业务侧优化🎯(从根源解决)
技术防护只能治标,业务逻辑优化才能治本:
- 频次限制:同一手机号 60 秒内只能发 1 次,24 小时内最多 5 次,1 个月内最多 20 次
- 场景隔离:注册、登录、找回密码等不同场景分别限流,避免一个场景被刷影响全局
- 验证升级:对异常 IP / 手机号强制要求滑块验证码或语音验证码
- 成本分摊:对高风险操作(如批量发送)引入付费机制,提高攻击者成本
- 号码校验:对接运营商号码库,过滤虚拟号、物联网卡和风险号码
第四阶段:监控与事后复盘📊
- 实时监控:搭建短信发送量、成功率、失败率、异常 IP 请求数等指标的大盘,设置阈值告警
- 日志审计:记录所有短信发送请求的 IP、手机号、设备信息、时间戳,便于事后溯源
- 攻击分析:定期分析攻击特征,更新风控规则,形成闭环
- 压力测试:模拟恶意刷量场景,验证防护体系的有效性
核心技术细节(加分项✨)
- 限流算法:推荐使用令牌桶算法(支持突发流量),而非漏桶算法;分布式环境下用 Redis+Lua 脚本实现原子性限流
- 缓存设计:手机号频次限制用 Redis 的
INCR+EXPIRE命令,过期时间自动重置 - 防刷技巧:使用 Redis 的HyperLogLog统计每日不同手机号数量,节省内存;用布隆过滤器快速过滤无效手机号
- 灰度发布:新的风控规则先在小流量范围验证,再全量上线
面试官可能追问的问题及回答思路
Q:分布式环境下如何保证限流的准确性?
A:使用 Redis+Lua 脚本实现原子性操作,避免并发问题;同时采用 "最终一致性" 思想,允许极小误差
Q:攻击者使用代理 IP 池怎么办?
A:结合设备指纹、手机号、行为特征进行多维度风控,而不仅仅依赖 IP
Q:Redis 挂了怎么办?
A:采用 Redis 集群保证高可用;同时在本地内存中保留一份降级限流规则,作为兜底
核心代码实现(带技术亮点标注✨)
1. Redis+Lua 分布式令牌桶限流(最核心!面试必问)
技术亮点:Lua 脚本保证原子性,避免并发计数偏差;支持突发流量,性能是分布式锁的 10 倍以上
-- 令牌桶限流Lua脚本(KEYS[1]=限流key, ARGV[1]=桶容量, ARGV[2]=令牌生成速率/秒, ARGV[3]=当前时间戳)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 获取桶内剩余令牌和上次刷新时间
local bucket = redis.call('HMGET', key, 'tokens', 'last_refresh')
local tokens = tonumber(bucket[1]) or capacity
local last_refresh = tonumber(bucket[2]) or now
-- 计算生成的新令牌数
local delta = math.max(0, now - last_refresh) * rate
tokens = math.min(capacity, tokens + delta)
-- 尝试获取令牌
if tokens >= 1 then
redis.call('HMSET', key, 'tokens', tokens - 1, 'last_refresh', now)
redis.call('EXPIRE', key, 3600) -- 1小时过期
return 1 -- 获取成功
else
return 0 -- 获取失败
end// Java调用端
@Service
public class RateLimitService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LUA_SCRIPT = "上面的Lua脚本内容";
/**
* 令牌桶限流
* @param key 限流维度(ip:xxx / phone:xxx / device:xxx)
* @param capacity 桶容量
* @param rate 每秒生成令牌数
* @return 是否通过限流
*/
public boolean tryAcquire(String key, int capacity, int rate) {
long now = System.currentTimeMillis() / 1000;
Long result = redisTemplate.execute(
new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
Collections.singletonList(key),
String.valueOf(capacity),
String.valueOf(rate),
String.valueOf(now)
);
return result != null && result == 1;
}
}2. 手机号频次限制(Redis 原子操作)
技术亮点:INCR+EXPIRE 组合实现滑动窗口,避免多线程并发问题;不同场景隔离限流
@Service
public class SmsFrequencyService {
@Autowired
private StringRedisTemplate redisTemplate;
// 不同场景限流配置(可配置化)
private static final Map<String, int[]> SCENE_LIMIT = Map.of(
"register", new int[]{1, 60, 5, 86400}, // 注册:60s1次,24h5次
"login", new int[]{1, 60, 10, 86400}, // 登录:60s1次,24h10次
"reset", new int[]{1, 60, 3, 86400} // 找回密码:60s1次,24h3次
);
public boolean checkFrequency(String phone, String scene) {
int[] limit = SCENE_LIMIT.get(scene);
if (limit == null) return false;
String minuteKey = "sms:freq:" + scene + ":minute:" + phone;
String dayKey = "sms:freq:" + scene + ":day:" + phone;
// 1分钟内次数检查
Long minuteCount = redisTemplate.opsForValue().increment(minuteKey);
if (minuteCount == 1) redisTemplate.expire(minuteKey, limit[1], TimeUnit.SECONDS);
if (minuteCount > limit[0]) return false;
// 24小时内次数检查
Long dayCount = redisTemplate.opsForValue().increment(dayKey);
if (dayCount == 1) redisTemplate.expire(dayKey, limit[3], TimeUnit.SECONDS);
return dayCount <= limit[2];
}
}3. 布隆过滤器快速过滤无效手机号
技术亮点:空间效率极高,1000 万条数据仅需 12MB 内存;O (1) 时间复杂度查询
// 基于Guava布隆过滤器
@Component
public class PhoneBlacklistFilter {
private BloomFilter<String> bloomFilter;
@PostConstruct
public void init() {
// 预期插入1000万条数据,误判率0.01%
bloomFilter = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8), 10_000_000, 0.0001);
// 启动时加载历史黑名单手机号
List<String> blacklist = phoneBlacklistMapper.selectAll();
blacklist.forEach(bloomFilter::put);
}
public boolean mightContain(String phone) {
return bloomFilter.mightContain(phone);
}
// 新增黑名单时同步更新布隆过滤器
public void addBlacklist(String phone) {
bloomFilter.put(phone);
phoneBlacklistMapper.insert(phone);
}
}4. API 签名验证(防止接口被非法调用)
技术亮点:时间戳 + 随机数防重放,签名算法防篡改,客户端密钥动态更新
@Service
public class ApiSignService {
// 客户端密钥(存储在配置中心,支持动态更新)
@Value("${sms.api.secret}")
private String apiSecret;
public boolean verifySign(Map<String, String> params) {
// 1. 检查时间戳是否在5分钟内
long timestamp = Long.parseLong(params.get("timestamp"));
if (System.currentTimeMillis() - timestamp > 5 * 60 * 1000) return false;
// 2. 检查随机数是否已使用(防重放)
String nonce = params.get("nonce");
if (redisTemplate.hasKey("sms:nonce:" + nonce)) return false;
redisTemplate.opsForValue().set("sms:nonce:" + nonce, "1", 5, TimeUnit.MINUTES);
// 3. 生成签名并比对
String clientSign = params.remove("sign");
String serverSign = generateSign(params, apiSecret);
return clientSign.equals(serverSign);
}
private String generateSign(Map<String, String> params, String secret) {
// 参数按字典序排序
List<String> keys = new ArrayList<>(params.keySet());
Collections.sort(keys);
// 拼接参数和密钥
StringBuilder sb = new StringBuilder();
for (String key : keys) {
sb.append(key).append("=").append(params.get(key)).append("&");
}
sb.append("secret=").append(secret);
// MD5加密并转大写
return DigestUtils.md5DigestAsHex(sb.toString().getBytes()).toUpperCase();
}
}技术难点与解决方案(面试加分项🔥)
| 技术难点 | 核心问题 | 解决方案 | 实现要点 |
|---|---|---|---|
| 分布式限流计数不准确 | 多节点同时请求导致计数超发 | Redis+Lua 原子操作 | 所有判断和更新在一个 Lua 脚本中执行,避免竞态条件 |
| 代理 IP 池绕过 IP 限流 | 攻击者使用百万级代理 IP 轮换 | 多维度联合风控 | IP + 手机号 + 设备指纹 + 行为特征(如点击间隔、滑动轨迹) |
| Redis 宕机导致防刷失效 | 单点故障引发整个防护体系崩溃 | 集群 + 本地限流兜底 | 1. Redis 主从 + 哨兵集群 2. 本地内存保留降级限流规则(如单机每秒 10 次) |
| 验证码被打码平台破解 | 图形验证码识别成本降至 0.01 元 / 条 | 动态验证码升级 | 1. 异常用户强制滑块验证码 2. 高风险场景使用语音验证码 3. 验证码图片添加干扰线和噪点 |
| 高并发下 Redis 性能瓶颈 | 每秒 10 万 + 请求导致 Redis CPU 打满 | 多级缓存 + 分片 | 1. 本地缓存热点数据(如 5 分钟内的限流结果) 2. Redis 按业务分片部署 3. 使用 Redis Pipeline 批量操作 |
| 误杀正常用户 | 风控规则过严影响用户体验 | 灰度发布 + 白名单 | 1. 新规则先 10% 流量灰度验证 2. 白名单机制放行内部测试和老用户 3. 误判申诉通道快速恢复 |
| 虚拟号 / 物联网卡批量注册 | 攻击者使用低成本虚拟号码 | 运营商号码校验 | 1. 对接三大运营商号码库 2. 过滤 170/171/162 等虚拟号段 3. 高风险号码要求实名认证 |
| 短信接口被 CC 攻击 | 每秒数十万请求导致服务雪崩 | 全链路限流 + 熔断 | 1. Nginx 层 IP 限流(每秒 10 次) 2. 网关层全局限流(每秒 1000 次) 3. Sentinel 熔断降级 |
| 短信发送链路超时 | 第三方平台响应慢导致线程阻塞 | 异步发送 + 超时控制 | 1. 使用线程池异步发送短信 2. 设置第三方接口超时时间(5 秒) 3. 失败重试机制(最多 2 次) |
| 攻击溯源困难 | 无法定位攻击者真实身份 | 全链路日志审计 | 1. 记录所有请求的 IP、UA、设备指纹、时间戳 2. 日志存储在 ELK 集群,支持快速检索 3. 异常请求自动生成攻击报告 |
面试终极加分回答💯
面试官如果问:你觉得这个方案还有什么可以优化的地方?
我觉得可以从三个方向进一步优化:
- 引入 AI 风控:通过机器学习模型分析用户行为特征,自动识别异常请求,比规则风控更精准
- 短信通道降级:当主通道被攻击时,自动切换到备用通道,保证业务连续性
- 成本优化:对不同风险等级的用户采用不同的验证方式,高风险用户用短信,低风险用户用验证码,降低短信成本
真实面试模拟
真实面试模拟
面试官 😊:
来,咱们聊个很接地气的线上问题——短信接口被狂刷,你会怎么处理? 你可以先整体说说思路。
候选人 🧑💻:
好的面试官。这个问题本质是 “用最小成本把正常用户和脚本/黑产区分开,保护短信资费和接口可用性”。
我不会只靠一个点去防,而是按请求的流转路径,从外到内搭一套立体防御。我用张图先给您看下架构:
总共五层:客户端人机识别 → 网关 IP 限流 → 业务多维风控 → 通道熔断兜底 → 事后日志反哺黑名单。
面试官 👍:
图很清晰,那咱们一层层拆。先说第一层,前端怎么做?
候选人 🧑💻:
第一层必须上行为式验证码,比如滑块、点选,不能用传统的字符验证码,那个早就被打穿了。
前端拿一个 ticket,请求发短信时带上,后端再调第三方服务(极验、网易易盾等)做二次校验。
这一层 ROI 最高,能把绝大部分脚本直接挡掉,后端压力小很多。
面试官 🤔:
嗯,那假如黑产绕过了验证码,比如用打码平台,接下来怎么防?
候选人 🧑💻:
那就到了网关层。这里我针对 IP 维度做高频限流。
用 Redis + Lua 实现原子化的滑动窗口或令牌桶,比如单个 IP 每分钟最多请求 5 次短信接口。伪代码逻辑是这样的:
local key = "sms_limit:ip:" .. ip
local current = redis.call("INCR", key)
if current == 1 then
redis.call("EXPIRE", key, 60) -- 60秒窗口
end
if current > 5 then
return 0 -- 拒绝
end
return 1如果有 API 网关(Kong/APISIX),直接配插件就行,更低成本。
面试官 💡:
这个思路没问题,但如果对方用大量代理 IP 池来刷,IP 限制不就失效了?
候选人 🧑💻:
没错,这时候 IP 限制效果会打折,所以必须上升到业务层多维风控,不只盯 IP。
我一般会拉上这几个维度一起打组合拳:
| 维度 | 规则举例 | 目的 |
|---|---|---|
| 手机号 | 同手机号 60s 内只能发 1 次,24h 最多 5 次 | 防单号爆破 |
| 用户ID | 单用户每日获取上限 10 次 | 防登录态被盗用刷量 |
| 设备指纹 | 同设备短时间内换不同手机号请求 → 触发风控 | 识别脚本换号 |
| IP+手机号关联 | 1 个 IP 在窗口期内请求超过 3 个不同手机号 → 拉黑 IP | 猫池设备特征 |
设备指纹很难批量伪造,加上手机号频控,攻击成本就上去了。风控可以用规则引擎动态调整,不用每次改代码。
面试官 👏:
不错,这就能对付 IP 池了。那假如前端、网关、业务层全被打穿(虽然概率极低),你最底线的保护是什么?
候选人 🧑💻:
最后一道防线是短信通道自身的熔断和费用兜底。
- 我会对单分钟发送量设阈值,超了就熔断,直接降级返回“发送失败,请稍后重试”,不再调供应商接口。
- 同时做日费用监控,比如预算 500 元,用掉 80% 钉钉预警,100% 强制关通道。
- 另外通道隔离很重要,登录、支付等核心业务和营销短信用不同通道,营销被打爆也不影响核心功能。💰
面试官 🧐:
除了实时防御,事后还有什么手段?
候选人 🧑💻:
我会把所有短信请求的完整上下文——IP、设备指纹、用户ID、手机号、验证码结果等,全量落日志到 ELK 或 ClickHouse。
离线分析出攻击特征后,反向输出黑名单(IP 段、设备指纹),回灌到线上限流和风控系统里,让整个防御体系“越打越聪明”。🔄
面试官 😄:
可以,整个链路都考虑到了,而且层与层之间有互补。那你觉得在整个方案里,性价比最高的一层是哪里?
候选人 🧑💻:
绝对是行为式验证码。成本低,接入快,能直接杀掉 80% 以上的自动化脚本,给后面几层降压。
但我不会只靠它,因为一旦被黑产针对性突破,后面必须有纵深防御兜底。
面试官 👍✨:
很好,这个问题就聊到这儿,思路很扎实,场景感也不错。通过!
候选人内心 OS:😌🛡️ 果然,立体防御 yyds。
面试官 🤓:
思路很清晰,那咱们深入一点。你刚才提到业务层的多维风控和通道熔断,能不能挑一两个技术亮点,用代码表达一下你的设计? 不用全写,关键部分就行。
候选人 🧑💻:
没问题。我写两个最核心的:一是基于注解的分布式多维限流,二是滑动窗口熔断器。这两块代码是直接能扛流量的。
💎 亮点一:声明式多维限流(Redis + Lua + 自定义注解)
我一般会封装一个 @RateLimit 注解,可以灵活指定限流的维度(手机号、IP、用户ID)和阈值,底层用 Redis + Lua 保证原子性。
1. 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key(); // 支持 SpEL,如 "#mobile" "#ip"
int limit(); // 窗口内最大请求数
int window(); // 窗口大小,秒
String message() default "请求过于频繁,请稍后再试";
}2. AOP 切面 + Lua 原子限流
@Aspect
@Component
public class RateLimitAspect {
@Autowired
private StringRedisTemplate redisTemplate;
// Lua 脚本:原子性检查并递增
private static final String LUA_SCRIPT =
"local key = KEYS[1] " +
"local limit = tonumber(ARGV[1]) " +
"local window = tonumber(ARGV[2]) " +
"local current = redis.call('INCR', key) " +
"if current == 1 then " +
" redis.call('EXPIRE', key, window) " +
"end " +
"if current > limit then " +
" return 0 " + // 超频
"else " +
" return 1 " + // 放行
"end";
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
// 解析 SpEL,拿到动态 key
String dynamicKey = parseSpel(rateLimit.key(), pjp);
String redisKey = "rate_limit:" + rateLimit.key() + ":" + dynamicKey;
Long result = redisTemplate.execute(
new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
Collections.singletonList(redisKey),
String.valueOf(rateLimit.limit()),
String.valueOf(rateLimit.window())
);
if (result == 0) {
throw new BusinessException(rateLimit.message()); // 统一异常处理
}
return pjp.proceed();
}
// SpEL 解析略,Spring 自带工具可做
}3. 在短信接口上使用
@RestController
public class SmsController {
// 手机号维度:60秒内1次
@RateLimit(key = "#mobile", limit = 1, window = 60, message = "验证码已发送,请稍后再试")
// IP 维度:60秒内5次(可以和上面组合,或另外配置网关限流)
@RateLimit(key = "#request.remoteAddr", limit = 5, window = 60, message = "请求频繁")
@PostMapping("/send-sms")
public Result sendSms(@RequestParam String mobile, HttpServletRequest request) {
// ... 发送逻辑
}
}亮点:原子性由 Lua 保证,不会出现并发超发;声明式注解让业务代码零侵入,不同维度可以随意叠加;Redis 瓶颈极低,单机轻松扛万级 QPS。💪
💎 亮点二:短信通道滑动窗口熔断器
当调用短信供应商之前,需要实时判断当前通道是否过载。我用一个线程安全的滑动窗口计数器实现轻量熔断,不依赖外部组件。
public class SlidingWindowCircuitBreaker {
// 窗口大小(秒)
private final int windowSize;
// 窗口内最大允许请求数
private final int maxRequests;
// 每个时间槽的计数器(数组索引按秒映射)
private final AtomicInteger[] buckets;
// 窗口起始时间戳(秒)
private volatile long startTime;
// 总计数器,用于快速判断
private final AtomicInteger totalCount = new AtomicInteger(0);
public SlidingWindowCircuitBreaker(int windowSize, int maxRequests) {
this.windowSize = windowSize;
this.maxRequests = maxRequests;
this.buckets = new AtomicInteger[windowSize];
for (int i = 0; i < windowSize; i++) {
buckets[i] = new AtomicInteger(0);
}
this.startTime = System.currentTimeMillis() / 1000;
}
/**
* 尝试获取一个许可,返回 true 代表未熔断,可发送;false 代表需降级
*/
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis() / 1000;
// 如果窗口过期,重置
if (now - startTime >= windowSize) {
resetWindow(now);
}
// 清理过期槽位(滑动)
slideWindow(now);
if (totalCount.get() >= maxRequests) {
return false; // 熔断
}
// 对应槽位计数+1
int index = (int) (now % windowSize);
buckets[index].incrementAndGet();
totalCount.incrementAndGet();
return true;
}
private void slideWindow(long now) {
long expiredSlots = now - startTime;
for (long i = 0; i < expiredSlots && i < windowSize; i++) {
int idx = (int) ((startTime + i) % windowSize);
int oldVal = buckets[idx].getAndSet(0);
totalCount.addAndGet(-oldVal);
}
startTime = now;
}
private void resetWindow(long now) {
for (AtomicInteger bucket : buckets) {
bucket.set(0);
}
totalCount.set(0);
startTime = now;
}
}在短信发送服务中使用:
@Service
public class SmsService {
// 1分钟窗口内最多发送100条,超过就熔断降级
private SlidingWindowCircuitBreaker breaker =
new SlidingWindowCircuitBreaker(60, 100);
public void sendSms(String mobile, String content) {
if (!breaker.tryAcquire()) {
// 熔断降级:直接返回失败提示或扔队列
throw new SmsCircuitBreakException("短信通道繁忙,请稍后重试");
}
// 调用供应商发送...
}
}亮点:纯内存实现,零外部依赖,极低延迟;synchronized 在这种低频控制场景完全够用,且滑动窗口精确到秒级,避免临界突发流量把通道打爆。😎
面试官 🧐:
代码写得挺扎实。那你结合刚才的方案和代码,总结一下这个场景的技术难点和对应的解决方案。
候选人 🧑💻:
好的,核心难点和拆解方案我用一张表归纳:
| 技术难点 | 具体表现 | 解决方案 | 对应代码/模块 |
|---|---|---|---|
| 自动化脚本突破 | 黑产用无头浏览器+打码平台直接调用接口 | 行为式验证码 + 二次校验,强制人机识别 | 客户端验证码 ticket 校验 |
| 高并发下计数不准确 | 多实例/多线程同时递增计数导致超发 | Redis + Lua 原子操作,单线程执行计数 | @RateLimit + Lua 脚本 |
| IP 池代理绕过 | 大量代理 IP 使得 IP 限流失效 | 多维度组合拳:设备指纹 + 手机号 + 用户ID 联合风控 | 多维注解叠加,风控规则引擎 |
| 短信通道成本雪崩 | 即便内部限流被突破,短信资费仍可能爆炸 | 通道级熔断降级 + 日费用监控 + 核心/营销通道隔离 | 滑动窗口熔断器,费用告警 |
| 风控规则僵化 | 攻防对抗不断变化,写死代码无法快速响应 | 规则引擎动态加载,日志离线分析反哺黑名单 | Drools/自研规则引擎 + ELK 分析管道 |
| 分布式一致性 | 分布式环境下限流状态要统一 | Redis 集中式计数器,通过 Lua 保证读写原子性 | 所有限流 key 落 Redis |
这些方案环环相扣,既保证线上实时防护的刚性,又保留事后学习和动态调整的柔性,落地起来坑相对少。
面试官 😄👍:
不错,从理论到代码,再到难点复盘,这一整套很完整。这个问题你拿下了!
面试结束后的彩蛋 🥚:
候选人(内心):😌 幸亏平时真这样挡过线上攻击,Lua 脚本的原子性、设备指纹的权重、熔断的滑动窗口,这些都是填过的坑啊~
