做iOS内购的团队几乎都遇到过这样的场景:产品策划了一场发放订阅优惠码的活动,活动上线十分钟,兑换接口的QPS从平时的两位数直接飙到五位数,其中绝大部分请求来自脚本和模拟器。兑换服务被打挂之后,连正常的收据验证、订阅状态查询也被拖垮,整个IAP链路瘫痪。问题的根源不在于优惠码逻辑本身,而在于兑换接口天然是一个高价值、低门槛、易自动化的攻击目标。本文围绕这套接口,讲清楚限流和熔断应该怎么分层设计、代码怎么落地、参数怎么调。

为什么兑换接口需要分层限流而不是单一QPS限流
很多团队的第一反应是在网关配一个全局QPS阈值,比如每秒500次请求,超了直接返回429。这种做法在正常大促场景下勉强够用,但面对恶意流量时几乎形同虚设。原因是攻击者会控制请求频率卡在阈值之下,或者在阈值被正常用户占满时反而把真实用户挡在门外。单一维度的限流缺乏对请求来源的区分能力,等于把攻击者和普通用户放进同一个桶里排队,最终受伤的反而是付费意愿最强的那批用户。
正确的思路是把限流拆成多层,每一层关注不同的维度。第一层是用户维度,单个Apple ID或单个设备ID每分钟最多发起几次兑换请求,正常用户不可能一分钟兑换十次,这个阈值可以设得很低;第二层是优惠码维度,同一个优惠码的并发核销请求数不能超过剩余库存,超出的请求直接拒绝排队;第三层才是全局维度的兜底阈值,保护下游服务整体不被打穿。三层各自的职责不同,任何一层单独失效时,其他层仍然能兜住底。
从数据结构上看,用户维度适合用令牌桶算法,因为它允许短时间的突发请求通过,符合人类操作的自然节奏;优惠码维度更适合用Redis的DECR原子扣减配合分布式锁,保证库存不会超卖;全局兜底则可以用简单的固定窗口计数器,实现成本低且精度要求不高。下面是一个基于Redis + Lua的用户维度令牌桶实现,关键是把判断和扣减放在同一个Lua脚本里执行,保证原子性:
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_time')
local tokens = tonumber(bucket[1]) or capacity
local last_time = tonumber(bucket[2]) or now
-- 按时间流逝补充令牌
tokens = math.min(capacity, tokens + (now - last_time) * rate / 1000)
if tokens < 1 then
return 0
end
redis.call('HMSET', key, 'tokens', tokens - 1, 'last_time', now)
redis.call('EXPIRE', key, 60)
return 1调用侧把用户ID作为key的一部分,容量设为3,速率设为每秒0.05个令牌,也就是单个用户每分钟最多补充3次兑换机会。对于绑定在Apple ID上的兑换行为,还可以额外叠加设备ID维度做双保险,因为攻击者往往用一批新注册账号但复用少量设备的特征来刷量,设备维度的限流对这种模式的拦截效果远好于账号维度。
熔断机制的设计:识别故障并阻止扩散
限流解决的是入口流量问题,熔断解决的是故障传播问题。兑换接口通常依赖两个下游:一是Apple的收据验证服务,二是自己的数据库或订单服务。Apple验证服务本身就有限流和偶发超时的特性,如果兑换接口在高并发下对它发起同步调用,一旦Apple侧响应变慢,兑换服务的线程池会被打满,进而拖垮部署在同一个实例上的其他IAP接口。这就是典型的雪崩链路,熔断要在链路中间切一刀。
熔断器的状态机分为关闭、打开、半开三个状态。关闭状态下正常放行请求并统计失败率;当滑动窗口内的失败率超过阈值(比如50%且请求数不少于20次),熔断器翻转为打开状态,后续请求直接走降级逻辑,不再触碰下游;经过一段静默期(比如10秒)后进入半开状态,放行少量探测请求,如果探测全部成功则恢复关闭,否则重新打开。核心在于失败率统计必须设置最小请求数门槛,否则服务刚启动时的几次偶发失败就会误触发熔断。
Java生态里可以直接用Alibaba Sentinel,注解方式接入成本很低。下面的示例展示了如何对收据验证这一步做熔断保护,并给出降级逻辑——把请求标记为待异步复核后先返回受理成功,避免用户端直接报错:
@SentinelResource(
value = "appleVerify",
fallback = "verifyFallback",
blockHandler = "verifyBlocked",
exceptionsToIgnore = {BusinessException.class}
)
public VerifyResult verifyReceipt(String receipt, String userId) {
// 调用Apple的verifyReceipt接口
return appleClient.verify(receipt);
}
// 业务异常时的降级:进入异步复核队列
public VerifyResult verifyFallback(String receipt, String userId, Throwable t) {
retryQueue.push(new VerifyTask(receipt, userId));
return VerifyResult.accepted(userId);
}
// 触发限流或熔断时的处理:直接快速失败并告知用户稍后重试
public VerifyResult verifyBlocked(String receipt, String userId, BlockException ex) {
throw new BizException("当前兑换人数过多,请稍后再试");
}降级策略的选择要结合业务语义。兑换场景下最忌讳的是直接返回失败,因为用户可能已经付出了获取优惠码的成本(比如邀请好友、完成任务),粗暴失败会引发客诉。更稳妥的方式是先落库标记为待验证状态,把Apple验证异步化,验证通过后再激活订阅。这样即使Apple服务长时间不可用,用户侧的体验仍然是完整的,代价只是订阅生效时间稍有延迟。另外要注意异步复核队列本身也要限流和持久化,否则熔断期间积压的任务会在恢复瞬间形成第二波冲击,这就是所谓的流量回放问题。
异常行为识别与参数调优实践
限流和熔断是通用手段,针对恶意请求还可以加一层行为识别。羊毛党的请求模式有几个明显特征:UA高度集中在少数几种、请求时间间隔服从固定分布、收据格式合法但账号注册时间极短、同一IP段短时间内出现大量不同用户ID。可以在网关侧埋点采集这些特征,用滑动窗口统计每个IP段的独立用户数,一旦超过阈值就把该IP段加入临时黑名单几分钟。这层逻辑用Redis的Set结构就能实现,不需要引入复杂的风控系统:
public boolean isSuspicious(String ip, String userId) {
String windowKey = "ip_users:" + ip + ":" + (System.currentTimeMillis() / 60000);
// 记录该IP在当前分钟窗口内出现过的用户数
redis.opsForSet().add(windowKey, userId);
redis.expire(windowKey, 120);
Long distinctUsers = redis.opsForSet().size(windowKey);
// 单IP一分钟内出现超过10个不同用户,判定为可疑
return distinctUsers != null && distinctUsers > 10;
}参数调优方面,有几个经验值可以参考。用户维度限流的容量设为正常用户单次活动最多兑换次数加1,速率按活动时长均摊;熔断的失败率阈值建议在40%到60%之间,太灵敏会误伤,太迟钝则失去保护意义;静默期取下游平均故障恢复时间的1.5倍左右,Apple服务一般取10到30秒。上线初期所有阈值都应该配置成可动态调整的,通过配置中心下发,观察一到两天的真实数据后再固化。另外建议在监控面板上把限流触发次数和熔断状态变迁做成告警项,限流频繁触发说明阈值偏紧或存在攻击,熔断反复翻转则说明下游本身不稳定,这两种情况的处置方向完全不同。
最后强调一点,限流和熔断的测试不能只靠线上观察。上线前应该用压测工具模拟三种流量形态:纯正常用户、正常加低频恶意、正常加高频恶意,验证各层限流是否按预期工作、熔断翻转和恢复是否符合状态机设定、降级路径的数据是否最终一致。只有把这三类场景都跑通,兑换接口才能在真实的大促活动中扛住冲击。