跳到正文
Kale的数字花园
目录 文章
/9 分钟/9 阅读/0 评论

Redis 限流与远程调用熔断框架

ERP 系统需要对接大量第三方电商平台 API(Amazon、eBay、TikTok Shop、Shopify 等),每个平台都有严格的**接口调用频率限制(Rate Limit)**: 在引入限流框架之前,业务代码中的调用控制是"盲写"的

1. 背景与痛点

业务背景

ERP 系统需要对接大量第三方电商平台 API(Amazon、eBay、TikTok Shop、Shopify 等),每个平台都有严格的接口调用频率限制(Rate Limit):

  • Amazon MWS API:不同接口每秒 1~10 次不等
  • eBay Trading API:每小时 5000 次上限
  • TikTok Shop:每秒 30 次,每日 10000 次
  • 平台侧响应头中会返回 X-RateLimit-Remaining 和 X-RateLimit-Reset,告知剩余配额和重置时间

技术痛点

在引入限流框架之前,业务代码中的调用控制是"盲写"的:

// 硬编码 sleep,粗暴低效
public void callAmazonApi() {
    try {
        Thread.sleep(1000); // 假设每秒最多1次,盲等1秒
    } catch (InterruptedException ignored) {}
    amazonClient.getOrders();
}

核心问题:

  • 不知道平台实时的剩余配额,盲目等待导致效率极低(配额充足时也在空等)
  • 平台返回的 429 Too Many Requests 错误需要每个调用方单独处理
  • 不同平台限流策略差异巨大(滑动窗口、固定窗口、令牌桶、并发限制),无法统一封装
  • 多实例部署时,本地限流计数器不共享,无法协调全局流量

2. 技术方案

整体架构

@RateLimit 注解(标注在 Feign 方法上)
        ↓
Feign RequestInterceptor(切面拦截每个请求)
        ↓
RedisCallThrottling.acquire() → 检查本地 Redis Hash 配额
        ↓
配额充足 → 直接放行
配额不足 → 按平台 restoreRate 计算等待时间 → Thread.sleep → 重试
        ↓
请求返回后 → Feign ResponseInterceptor 解析响应头
        ↓
updateHourlyQuota() → 用平台返回的真实值修正本地配额

双重限流模型

框架同时维护两层配额:

层级 来源 说明
常规配额 @RateLimit 注解声明(maximumRequestQuota / restoreCount / restorePeriod) 本地主动控速,参考平台文档
小时级配额 平台响应头(quotaRemaining / quotaResetsAtTime) 被动修正,用平台真实值覆盖本地值

这种"主动控速 + 被动校准"的双向机制,确保既不会超过平台限制,也不会在配额充足时空等。

Redis 数据结构

每个接口在 Redis 中存储为一个 Hash(key = throttling:<identifier>):

remainingQuota       - 当前剩余可请求数
maxQuota             - 最大请求数(容量)
restoreCount         - 每次恢复的请求数
restorePeriod        - 恢复周期(毫秒)
nextRestoreTime      - 下次恢复时间戳(UTC,精确到毫秒)
hourlyEnable         - 0 或 1,是否启用小时级限制
hourlyRemainingQuota - 每小时剩余请求数
hourlyMaxQuota       - 每小时最大请求数
hourlyResetsOn       - 下次重置时间戳(UTC)

3. 实现细节

注解定义(@RateLimit)

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
  /** 限流 key 前缀 */
  String key() default "rate:limit";

  /** 最大容量 */
  int frequency() default 1;

  /** 周期时间(毫秒) */
  int duration() default 1000;

  /** 超频错误提示 */
  String message() default "requests are too frequent";
}

实际业务中,Amazon/eBay 等平台使用 creeks-common-services 中的 RateLimit 注解(带更多参数),与平台 SDK 的简化版注解并存。

核心获取逻辑(RedisCallThrottling.doTryAcquire)

// 1. 读取 Redis Hash 全量数据(一次 get,避免多次网络往返)
RedisRecord record = loadRecord(ops, key);

// 2. 执行 restore 逻辑(按 restoreCount 和 restorePeriod 自动恢复配额)
restoreQuota(ops, key, record, resetRestoreTimeOnFull);

// 3. 检查双重配额是否满足
if (remainingQuota > 0 && (!enableHourlyQuota || hourlyRemainingQuota > 0)) {
    // 4. 扣减剩余请求数(Redis INCR 原子操作)
    long newRemaining = decreaseRemainingQuota(ops, key, 1);
    if (newRemaining >= 0) {
        // 扣减后仍 >= 0,成功放行
        return true;
    } else {
        // 扣减后 < 0,被并发线程抢走了,归还
        increaseRemainingQuota(ops, key, 1);
        return false;
    }
}
return false;

配额恢复算法

// 关键:支持"跳周期"恢复(如果上一次检查距今已过多个周期)
while (nextRestoreTime <= now) {
    int numOfPeriod = (int) floorDivision(now - nextRestoreTime, restorePeriod);
    int numOfRestoreOperation = Math.min(numOfPeriod, (maxQuota - remainingQuota) / restoreCount);
    int actualRestore = numOfRestoreOperation * restoreCount;

    // 原子 INCR
    remainingQuota = (int) increaseRemainingQuota(ops, key, actualRestore);
    nextRestoreTime += numOfRestoreOperation * restorePeriod;
}

支持"跳周期"的目的是:应用重启后,之前积累的恢复周期可以一次性补回来,不浪费等待时间。

被动校准机制(updateHourlyQuota)

// 平台响应头解析 → Feign ResponseInterceptor 回调
public void updateHourlyQuota(String limitIdentifier, int quotaRemaining, long quotaResetsAtTime) {
    // 只有平台返回值比本地更保守时才修正(避免平台数据延迟导致超限)
    if (quotaRemaining < localRemaining) {
        ops.put(key, "hourlyRemainingQuota", quotaRemaining);
    }
    if (localResetsAt != quotaResetsAtTime) {
        ops.put(key, "hourlyResetsOn", quotaResetsAtTime);
    }
}

分布式初始化锁

初始化和更新限流记录时,通过 Redisson 分布式锁(throttling:_init_lock:<identifier>)保证并发安全,避免多实例同时初始化导致数据不一致。

4. 技术栈与亮点归纳

技术栈清单

  • Java / Spring Boot / Spring AOP
  • Redis Hash(多字段原子读写)
  • Redisson 分布式锁
  • Feign Client(RequestInterceptor + ResponseInterceptor)
  • 设计模式:策略模式(CallThrottling 接口)、模板方法、代理模式

技术亮点

  • 双重限流:主动控速 + 被动校准,既不超限也不空等
  • Redis Hash 单次读取:整个限流状态一次 GET 搞定,减少网络往返
  • 原子操作:INCR 扣减/恢复,天然并发安全
  • 跳周期恢复:应用重启后配额快速补满,不浪费等待
  • 被动校准:用平台真实响应头修正本地配额,应对平台临时调整限制
  • 分布式协调:多实例共享 Redis 配额状态,全局限流一致

5. 难点与亮点剖析

业务难点

  • 平台差异巨大:Amazon 按秒级恢复,eBay 按小时级配额,TikTok Shop 有双重限制(秒级 + 小时级),框架需要同时支持这三种模式
  • 响应延迟补偿:平台返回 429 时,配额已耗尽,单纯等待恢复不够,需要从响应头中读取精确的重置时间点

技术难点

  • Redis 读写一致性:doTryAcquire 中"检查→扣减"两步操作,虽然用了 INCR 的原子性,但仍有 TOCTOU(Time-of-check to time-of-use)窗口——通过 INCR 后判断返回值是否 < 0 来处理并发抢配额的情况(扣减失败则归还)
  • 初始化竞态:多实例同时启动时,可能同时初始化同一接口的限流记录——通过 runInLock() 分布式锁解决
  • 配额溢出处理:remainingQuota 可能因跳周期恢复而超过 maxQuota,需要主动扣减回 maxQuota 值

实现亮点

  1. Hash 结构多字段:将一个接口的所有限流状态放在一个 Hash 中,一次 Redis 请求获取全部状态,避免多次往返
  2. INCR 原子恢复/扣减:用 Redis 自增实现配额变更,无需 Lua 脚本,天然原子
  3. 平台响应头校准:这是本框架最核心的设计——框架主动权控速,但平台响应头是最终真相来源,两者结合实现最精准的限流控制
  4. groupId 共享计数器:同组接口共享配额(如同一店铺的多个 Amazon API 共享同一配额上限)

6. 替代方案对比

方案一:Guava RateLimiter(已排除)

  • 实现:RateLimiter.create(10).acquire()
  • 优劣:本地限流,零集成成本;但多实例不共享,无法协调全局流量
  • 结论:仅适合单机场景,不适合 ERP 的多实例部署

方案二:Sentinel / Hystrix(已排除)

  • 实现:Sentinel 的 FlowRule 或 Hystrix 的信号量限流
  • 优劣:功能强大,但主要是服务保护(保护本服务不被冲垮),而非保护第三方 API 不被超限
  • 结论:Sentinel 更适合"我保护我自己",而非"我不超调用别人的限制"

方案三:固定窗口计数器(已排除)

  • 实现:Redis INCR + EXPIRE 实现固定窗口(如每小时 5000 次)
  • 优劣:简单,但窗口切换时容易产生"突刺"(窗口末尾耗尽 + 新窗口重置,立即涌入大量请求)
  • 结论:滑动窗口/令牌桶更平滑,但实现更复杂。本框架的"跳周期恢复"实际上是一种更精细的滑动窗口

最终方案

采用 Redis Hash + INCR 原子操作 + 平台响应头校准 的双重限流方案,最大程度兼顾控速精度与实现简洁性。

7. 技术选型与方案抉择复盘

选型思路

团队在 2022~2023 年集中对接多个跨境电商平台时,各平台限流策略差异极大(Amazon 的按秒恢复、eBay 的按小时配额、TikTok Shop 的秒级 + 小时级双重限制),需要一个统一框架来封装这些差异。同时,平台 API 响应头中包含精确的配额信息(remaining + resetsAt),这些信息不能浪费。

优点

  • 统一抽象:不同平台的限流策略通过 RateLimit 注解 + CallThrottling 接口统一封装
  • 平台感知:被动校准机制利用平台真实数据,比纯本地计算更准确
  • 零侵入:业务方只需在 Feign 方法上加注解,不需要改业务逻辑

缺点 / 已知不足

  • Redis 单点依赖:限流状态全存在 Redis,Redis 挂了则限流功能失效(可降级为本地限流)
  • 时钟漂移:nextRestoreTime 用的是应用机器本地时间,多实例时钟不同步时可能略有偏差(但影响很小)
  • 注解参数冗余:creeks-common-services 的 RateLimit 和 Amazon SDK 中的简化版 @RateLimit 两套并存,有一定维护成本

可优化点

  • Redis 故障时降级为本地令牌桶(本地保底,不完全失效)
  • nextRestoreTime 改用 Redis server 时间(TIME 命令),消除多实例时钟漂移
  • 统一简化两套 @RateLimit 注解,减少维护成本
  • 增加限流指标暴露(Micrometer),方便监控告警

8. 成果与数据

  • 接入平台:Amazon、eBay、TikTok Shop、Shopify 等 8+ 平台接入限流框架
  • 调用量:日均处理 百万级平台 API 调用,全部通过限流框架控速
  • 零超限:接入后平台 429 错误率从"偶发"降为 0(平台响应头校准机制持续生效)
  • 吞吐量提升:限流框架上线后,相比盲等模式,同等配额下 API 吞吐量提升约 40%(配额充足时不空等)

9. 面试题清单

  1. 【基础】常见的限流算法有哪些?滑动窗口和令牌桶的区别是什么?
    • 考察点:固定窗口、滑动窗口、令牌桶、漏桶;本框架用的是哪种(常规配额=类令牌桶 + 平台校准)
  2. 【基础】Redis INCR 操作是原子的吗?用它做限流有什么优势?
    • 考察点:Redis 单命令原子性、INCR 返回值判断、避免锁开销
  3. 【进阶】为什么用 Redis Hash 而不是 String 来存储限流状态?Hash 字段过多会有什么问题?
    • 考察点:Hash vs String、HMGET vs Pipeline、Hash 过大时的性能问题(建议不超过 100 个字段)
  4. 【进阶】doTryAcquire 中的"检查→扣减"两步操作,在并发场景下可能出现什么问题?框架是如何处理的?
    • 考察点:TOCTOU 问题、INCR 后判断返回值 < 0 则归还、并发扣减失败的处理
  5. 【进阶】限流记录初始化时,多实例同时启动可能产生什么问题?如何解决?
    • 考察点:竞态条件、分布式锁(runInLock)、"检查再初始化"的两次检查模式(DCL)
  6. 【原理】平台响应头校准(updateHourlyQuota)的设计意图是什么?为什么只在校准值"更保守"时才更新?
    • 考察点:平台数据延迟问题;校准值比本地小时才更新,防止平台延迟数据导致本地误判
  7. 【原理】跳周期恢复算法(numOfPeriod 计算)中,floorDivision 的作用是什么?为什么最多只恢复 20 个周期?
    • 考察点:长时间未检查时的批量恢复逻辑;避免死循环保护
  8. 【场景】如果 Redis 挂了,限流框架会怎样?应该怎么设计降级策略?
    • 考察点:Fail-open vs Fail-closed;降级为本地令牌桶 or 直接放行(带告警)
  9. 【设计】如果需要支持"不同店铺共享不同配额"(比如同一平台多个店铺各自独立限额),如何改造 key 设计?
    • 考察点:key = throttling:platform:shopId:interface,groupId 机制
  10. 【延伸】和 Sentinel 的限流相比,这个框架有什么本质区别?
    • 考察点:本框架是"保护第三方 API 不超限",Sentinel 是"保护本服务不被冲垮";目标不同导致设计差异

10. 简历提炼版

设计并实现跨平台 API 限流与熔断框架,基于 Redis Hash 存储接口配额状态,原子 INCR 实现并发安全的配额扣减与恢复;创新性引入平台响应头被动校准机制,实时用 Amazon/eBay 等平台的真实配额数据修正本地状态,实现精准控速;接入 8+ 跨境电商平台,日均处理百万级调用,平台 429 错误率归零,API 吞吐量提升 40%。


关键源码路径

文件 路径
限流注解(通用版) creeks-common-services/.../throttling/RateLimit.java
限流实现 creeks-common-services/.../throttling/RedisCallThrottling.java
限流定义接口 creeks-common-services/.../throttling/LimitDef.java
Redis 记录结构 creeks-common-services/.../throttling/RedisRecord.java
Amazon SDK 简化注解 esync-amazon/.../annotation/RateLimit.java
使用示例 esync-amazon/.../remote/SellFoxRemoteService.java
相关阅读

评论需要登录。生产环境使用 GitHub 登录,本地可前往 登录页 使用开发登录。

还没有评论,来抢沙发。