Taocarts 知识

限流选型:单机 vs 分布式,精度 vs 性能

📅 2026-01-25 海外仓管理

一次API限流问题,我总结了这些坑

大促前夜,运营把活动链接发出去,流量瞬间涌进来。然后——1688的订单创建接口开始返回429,紧接着物流轨迹接口超时,最后连汇率拉取都挂了。客服群里客户在刷屏:“我的订单怎么卡在采购中半小时了?” 技术群里更热闹:“第三方接口限流了,谁去扩容?”

其实扩容解决不了问题。上游限的是你,不是你自己的机器。

那次大促,因为限流熔断没做完善,订单状态卡住的就有几十单。客户在群里炸锅,最后靠手动补单才解决。回头复盘,核心问题就两个:限流策略只做了单机版的令牌桶,没考虑分布式环境;熔断只有超时没有错误率阈值,接口半死不活时还在疯狂重试。

限流选型:单机 vs 分布式,精度 vs 性能

方案A:单机令牌桶(Guava RateLimiter)。实现简单,不依赖外部存储,性能最高(纯内存操作)。致命缺陷:流量分发不均时,不同节点承受的请求量可能差几倍,整体限流阈值形同虚设。比如给每个节点设100 QPS,3个节点总上限300,但负载均衡如果偏斜,某个节点冲到200才触发限流,上游收到的是200,早已超过对方的额度。

方案B:分布式令牌桶(Redis + Lua)。所有节点共享同一个令牌桶,精确控制全局QPS。代价是每次限流判断都要访问Redis,增加几毫秒延迟,且Redis故障时所有节点的限流失效。大促期间Redis一旦抖动,限流逻辑会连带拖慢正常请求。

方案C:双层限流(本地 + 分布式)。在方案B基础上,每个节点再加一层本地令牌桶做快速过滤。本地桶放过大部分请求,只有接近阈值时才去Redis同步校验。实测可以把Redis访问量压到原来的十分之一左右,同时保留分布式精确控制。这是目前生产环境用的方案。

踩坑记录:令牌桶参数不是随便设的

坑一:桶容量设太小,正常峰值被误杀。某次活动,用户点击集中在开售后前10秒,瞬时QPS冲到平时5倍。代码里固定了桶容量=填充速率×1,意味着任何突刺超过平均值都会被限。结果正常用户疯狂刷出429。隐性知识点:桶容量(burst)应该根据业务能承受的最大等待时间来定。如果接口平均响应20ms,客户端超时设3秒,那么桶容量可以放宽到填充速率的10到20倍,允许短暂的流量尖峰过去。

-- Redis令牌桶Lua脚本(分布式限流核心)
local key = KEYS[1]
local rate = tonumber(ARGV[1])

-- 填充速率(令牌/秒)
local capacity = tonumber(ARGV[2]) -- 桶容量(允许burst)
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4]) or 1

local last_refill = redis.call('GET', key':last')
local tokens = redis.call('GET', key':tokens')

if not last_refill then

tokens = capacity - requested

redis.call('SET', key':tokens', tokens)

redis.call('SET', key':last', now)

return 1
end

local elapsed = math.max(0, now - last_refill)
local new_tokens = math.min(capacity, tokens + elapsed * rate)

if new_tokens >= requested then

redis.call('SET', key':tokens', new_tokens - requested)

redis.call('SET', key':last', now)

return 1
else

return 0
end

在 taocarts 的 API 网关层,这套脚本封装在 RateLimiterMiddleware 中,同时支持按商户ID、按接口路径、按用户等级等多维度限流。

坑二:填充速率依赖固定时间窗口,忽略了上游接口的RT抖动。1688的订单接口正常时50ms返回,大促时可能2秒才回。如果限流阈值还是按平时调,积压的请求会快速耗尽令牌桶,导致正常请求也被限。改进方案:动态调整速率——根据最近一分钟的接口平均RT和错误率,自动降低或恢复限流阈值。

熔断器:不只是超时,更要看错误率

限流是主动保护上游,熔断是保护自己不被拖垮。早期版本只对超时熔断:连续超时5次就开路。但有一次1688接口频繁返回500(服务器内部错误),响应很快,不超时,系统还在不停地重试,每次重试都失败,线程池被占满,连带其他接口也挂了。

正确的熔断策略要综合考虑超时率、错误率、慢调用比例

// 熔断器状态机 + 滑动窗口计数
enum CircuitState {

CLOSED, OPEN, HALF_OPEN
}

class CircuitBreaker {

private CircuitState state = CLOSED;

private int failureThreshold = 5;

// 失败次数阈值

private double errorRateThreshold = 0.5; // 错误率阈值(50%)

private long timeout = 3000;

// 超时阈值3秒

private long openTime = 60000;

// 开路持续时间60秒

// 滑动窗口(最近60秒内,每10秒一个桶)

private SlidingWindow window = new SlidingWindow(6, 10);

public boolean allowRequest() {

if (state == OPEN) {

if (System.currentTimeMillis() - openAt > openTime) {

state = HALF_OPEN;

return true;

}

return false;

}

return true;

}

public void recordResult(Result result) {

window.add(result);

if (state == HALF_OPEN) {

if (result.isSuccess()) {

state = CLOSED;

window.reset();

} else {

state = OPEN;

openAt = System.currentTimeMillis();

}

return;

}

// CLOSED状态下统计错误率

if (window.getErrorRate() > errorRateThreshold ||

window.getSlowCallRatio(3000) > 0.5) {

state = OPEN;

openAt = System.currentTimeMillis();

}

}
}

关键细节:半开状态时,只放一个请求通过,成功则关闭熔断,失败则继续开路。这避免了恢复瞬间大量请求再次压垮下游。

兜底降级:缓存 + 默认值

限流和熔断之后,不能直接返回500。用户下单看到“系统繁忙”就流失了。降级策略至少准备三层:

  1. 本地缓存:商品信息、汇率等非强一致性数据,半小时内的旧值也能接受
  2. 默认值/静态页:运费估算失败时,展示“请联系客服获取报价”
  3. 异步补偿:订单状态同步失败时,先写本地消息表,后台重试

那次大促之后,把所有对接第三方API的地方都加上了熔断和降级。后来有一次1688接口真的挂了半小时,系统自动切换到缓存数据,用户在页面上只看到“部分商品信息更新延迟”,没有产生恐慌性投诉。

结语

API限流与熔断不是加个中间件就能搞定的事。参数调错了,正常流量被限;阈值设宽了,雪崩照来。把上游当敌人,把下游当病人——限流是为了不让敌人把你打死,熔断是为了不让病人把你拖垮。这两个机制配合降级兜底,才算一套完整的防护体系。

你的系统扛过最高多少QPS?限流熔断踩过哪些坑?评论区聊聊。

wechat wechat qr