一、为什么API网关需要Redis参与限流与缓存
API网关作为所有客户端请求的统一入口,承担着路由转发、协议转换、安全校验、流量管控等核心职责。当突发流量或者恶意攻击到来时,网关必须有能力快速拦截异常请求,否则后端服务会因资源耗尽而崩溃。限流和缓存正是网关层最常用的两道防线:限流控制单位时间内的请求量,缓存则减少对后端服务的直接调用。这两项能力如果只依赖网关实例自身的本地内存,会面临两个严重问题:一是多实例部署时每个节点独立计数,无法实现全局统一的限流阈值;二是缓存数据分散在各节点,不仅浪费内存,还会因数据不一致导致业务错误。

Redis的出现恰好弥补了本地内存方案的短板。作为高性能的键值存储,Redis将数据集中存放在独立进程中,所有网关实例共享同一份数据。它的单线程命令执行模型天然支持原子操作,配合Lua脚本可以完成复杂的判断逻辑而不会出现竞态。同时Redis支持多种数据结构,例如字符串、哈希、有序集合等,能够灵活实现计数限流、令牌桶、滑动窗口等算法。在缓存场景中,Redis毫秒级的读写延迟和丰富的过期策略,也使其成为网关层缓存的理想选择。
但仅仅引入Redis还不够,如何合理设计键的命名、过期时间、数据一致性,以及如何避免缓存穿透、击穿、雪崩等经典问题,都需要针对网关场景做细致规划。下文将从限流算法和缓存策略两个维度,给出可直接落地的实现方案。
二、基于Redis的限流算法实现
限流算法的目标是在给定时间窗口内限制请求次数,常见的实现包括固定窗口、滑动窗口、令牌桶和漏桶。每种算法都有其适用场景和复杂度,Redis可以分别用不同的数据结构和命令来完成。
固定窗口计数器是最简单的方案,核心思想是使用Redis的字符串键存储当前窗口内的请求计数,并设置与窗口长度相等的过期时间。每当请求到达时执行INCR命令,如果返回值大于阈值则拒绝请求。伪代码如下:
-- 固定窗口限流 key: rate:fixed:用户ID, limit: 100, window: 60秒
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0
else
return 1
end
固定窗口的缺陷十分明显:窗口边界处可能出现请求量突刺。例如限制每分钟100次,但前59秒没有请求,最后1秒突发100次,下一秒又是新的窗口,实际系统可能在2秒内承受200次请求。为了平滑流量,需要改用滑动窗口算法。
滑动窗口利用Redis的有序集合(ZSET)记录每个请求的时间戳,每次请求时删除窗口外的旧记录,然后统计剩余记录数来判断是否超限。时间戳作为score,成员可以使用唯一ID(如UUID)避免覆盖。Lua脚本保证原子性:
-- 滑动窗口限流 key: rate:slide:用户ID, limit: 100, window: 60秒
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window * 1000)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
redis.call('ZADD', KEYS[1], now, now .. ':' .. math.random(100000))
redis.call('PEXPIRE', KEYS[1], window)
return 1
else
return 0
end
滑动窗口虽然精确,但每个请求都要写入一个成员,高并发下会消耗较多内存。如果对内存敏感,可以考虑令牌桶算法。令牌桶允许一定的突发流量,同时保持长期平均速率稳定。实现上用Redis哈希存储令牌数量和上次填充时间,每次请求先计算应补充的令牌,再判断是否足够。下面的Lua脚本实现了单桶逻辑:
-- 令牌桶限流 key: rate:bucket:用户ID, capacity: 100, rate: 10 tokens/秒, requested: 1
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local values = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(values[1])
local last_refill = tonumber(values[2])
if tokens == nil then
tokens = capacity
last_refill = now
end
local delta = math.max(0, now - last_refill)
local refill = delta * rate / 1000
tokens = math.min(capacity, tokens + refill)
if tokens >= requested then
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000))
return 1
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
return 0
end
在实际网关中,上述Lua脚本可以通过Jedis、Lettuce等客户端执行。以Java为例,使用Jedis加载脚本并传入参数:
// 使用Jedis执行限流Lua脚本
String script = "..."; // 脚本内容
String key = "rate:slide:user:123";
List<String> keys = Collections.singletonList(key);
List<String> args = Arrays.asList(
String.valueOf(System.currentTimeMillis()),
"60",
"100"
);
Object result = jedis.eval(script, keys, args);
if (result.equals(1L)) {
// 放行请求
} else {
// 触发限流,返回429或降级
}
选择哪种算法取决于业务容忍度:固定窗口适合粗略限制,滑动窗口适合严格平滑控制,令牌桶则兼顾突发与平均速率。无论哪种方案,建议统一封装为网关的一个Filter或插件,例如Spring Cloud Gateway的GatewayFilter、Kong的Plugin,或者APISIX的limit-req插件,这些框架均已内置Redis集成方式。
三、基于Redis的网关缓存策略与防护
网关缓存可以减少对后端服务的调用次数,尤其适合读取频繁但更新不频繁的接口,例如配置信息、字典数据、热点查询结果等。缓存策略通常采用Cache-Aside(旁路缓存)模式:请求到达网关后先查Redis,命中则直接返回,未命中则调用后端服务,并将响应写入Redis再返回。代码逻辑简单,但需要注意缓存更新的时机——一般由业务方在数据变更时主动删除缓存,而不是由网关维护。
网关缓存最大的风险在于缓存穿透、击穿和雪崩。缓存穿透指请求一个不存在的key,由于后端也没有数据,缓存始终无法建立,导致每次请求都打到后端。解决方案有两个:一是缓存空值,设置较短的过期时间(如30秒);二是使用布隆过滤器,在网关层先判断key是否可能存在,不存在则直接拒绝。Redis 4.0以上可以通过模块方式支持布隆过滤器,命令为BF.ADD和BF.EXISTS。
缓存击穿指某个热点key在过期瞬间,大量请求同时涌入后端。可以通过互斥锁解决,只有获取到锁的请求才能调用后端并重建缓存,其他请求等待锁释放后读取新缓存。使用Redis的SETNX命令实现分布式锁:
-- 获取锁并重建缓存(简化版)
local lockKey = KEYS[1]
local cacheKey = KEYS[2]
local requestId = ARGV[1]
local lockExpire = tonumber(ARGV[2])
local result = redis.call('SET', lockKey, requestId, 'NX', 'PX', lockExpire)
if result then
-- 调用后端服务获取数据,此处省略
local data = fetchDataFromBackend()
redis.call('SET', cacheKey, data, 'EX', 300)
redis.call('DEL', lockKey)
return data
else
-- 短暂休眠后重试读缓存
return redis.call('GET', cacheKey)
end
另一种击穿解决方案是“逻辑过期”,不设置Redis key的物理过期时间,而是在value中存入一个逻辑过期时间戳。所有请求都能读到旧缓存,但发现逻辑过期后,由后台线程异步重建缓存,避免请求阻塞。这种方式需要网关支持异步任务,实现复杂度稍高,但性能更好。
缓存雪崩是指大量key在同一时刻过期,导致所有请求穿透到后端。预防手段包括:为每个key的过期时间加上随机偏移量(例如基础时间300秒,随机增加0~60秒);使用多级缓存,网关本地缓存加Redis缓存,避免全量失效;对后端服务进行熔断降级,当后端不可用时返回兜底数据。
四、实际集成与性能调优
将Redis集成到API网关时,首先要考虑客户端连接池的配置。以Lettuce为例,合理设置最大连接数、最大空闲连接、连接超时和命令超时,避免因连接耗尽导致请求排队。对于高并发网关,建议使用Pipeline批量发送多个Redis命令,减少网络往返次数。例如在限流场景中,如果需要对同一用户执行多次检查,可以将多个eval命令打包发送。
Redis的数据结构选择也直接影响性能。对于固定窗口限流,字符串类型加INCR是最快的;滑动窗口由于需要维护有序集合,写入和删除操作稍重,建议限制窗口时间不要太长,或者定期清理历史数据。令牌桶的哈希操作相对轻量,但需要注意浮点数精度,建议将速率和令牌数放大为整数存储,避免浮点运算误差。
网关层限流与缓存可以协同工作:当限流触发时,不直接返回错误,而是尝试从缓存中返回上一次成功响应的数据作为降级内容。例如对商品详情接口限流,如果用户在1分钟内请求超过10次,第11次直接返回缓存的商品信息,并附带一个“数据可能不是最新”的标记。这样既保护了后端,又提升了用户体验。实现上可以在网关Filter中先查缓存,若缓存命中且限流拒绝,则返回缓存内容;若缓存未命中且限流拒绝,才返回429状态码。
监控方面,需要关注Redis的内存使用、命中率、慢查询日志以及限流拒绝率。当发现某个限流key频繁触发时,可以动态调整阈值或临时拉黑该用户。Redis的INFO命令和MONITOR命令(生产环境慎用)能提供实时状态,也可以配合Prometheus + Grafana进行可视化。最后,建议对Redis做持久化配置(RDB或AOF),防止重启后限流状态丢失导致瞬时流量冲击。