拓冰建站拓冰建站
首页 / 资讯中心 / 正文

限流器系统设计:从算法原理到Redis分布式实现

在接口开发和服务治理中经常遇到的一个问题某个核心接口在活动开始时被瞬间打爆数据库连接池耗尽应用直接假死。排查下来调用量是平时的几十倍而很多请求其实是重复的、恶意的或者根本不具备调用资格。这时不会限流就只能看着服务雪崩。本文围绕 Rate Limiter限流器的系统设计与实现展开从限流算法原理、单机实现、分布式实现到生产环境接入和踩坑排查给出一个可以落地的完整方案。无论你是准备系统设计面试还是要在项目中亲手实现限流能力这篇文章都可以作为一份闭环参考。1. 背景与核心概念1.1 什么是限流器限流器是一种控制请求速率的机制它决定在单位时间内系统允许通过多少个请求超出的请求会被拒绝、排队或降级。举个例子一个短信验证码接口业务上每秒钟最多只能处理 100 个请求。如果实际每秒钟来了 500 个请求限流器就要保证只有 100 个请求能进入后端逻辑其余 400 个直接返回“请求过于频繁”或进入等待队列。限流器解决的核心问题有三个保护后端资源避免瞬时流量压垮数据库、缓存或第三方服务。保障高优先级请求的可用性防止少数异常调用占用全部资源。实现配额管理比如每个 API Key 每分钟最多调用 60 次。1.2 限流与熔断、降级的区别这三个概念经常一起出现但职责不同概念核心作用触发条件处理动作限流控制请求速率请求量超过阈值拒绝、排队、丢弃熔断切断故障链路错误率过高、耗时过长直接返回兜底结果降级降低服务等级资源不足或依赖异常返回默认值、简化逻辑可以这样理解限流管的是“入口流量”熔断管的是“下游故障”降级管的是“资源不足时的取舍”。实际项目中经常组合使用但限流通常是最先触发的一道防线。1.3 限流器的应用场景API 网关层对每个调用方 AppId 进行 QPS 配额限制。电商秒杀系统防止秒杀接口被脚本或机器人刷爆。短信、邮件通知服务限制同一手机号在固定时间内的发送次数。微服务内部保护核心数据库连接池避免慢 SQL 叠加放大。第三方开放平台为付费用户提供不同等级的调用额度。了解完限流器是做什么的接下来重点拆解限流器的设计核心限流算法。2. 限流算法核心原理拆解限流算法的选择直接决定限流器的准确性和资源消耗。下面逐一分析四种常见算法。2.1 固定窗口计数算法固定窗口是最直观的限流算法。把时间划分为固定的窗口比如 1 秒、1 分钟每个窗口维护一个计数器请求到达时计数器加一超过阈值就拒绝。窗口结束后计数器清零。# 固定窗口计数算法核心逻辑 import time class FixedWindowCounter: def __init__(self, max_requests, window_seconds): self.max_requests max_requests self.window_seconds window_seconds self.current_window_start time.time() self.count 0 def allow(self): now time.time() if now - self.current_window_start self.window_seconds: # 进入新的时间窗口计数器重置 self.current_window_start now self.count 0 if self.count self.max_requests: self.count 1 return True return False这个算法实现简单但有一个明显问题临界突刺。假设限制每分钟 100 次。第 59 秒来了 100 个请求第 60 秒窗口重置后第 61 秒又来了 100 个请求。在这 2 秒内系统实际承受了 200 个请求超过预期一倍。对于瞬时流量敏感的系统这个缺陷不可接受。2.2 滑动窗口计数算法滑动窗口是对固定窗口的改进。它将窗口进一步划分为多个小格子比如 1 分钟的窗口划分为 6 个 10 秒的格子。判断请求是否放行时统计当前时间点往前一个完整窗口内所有格子的计数总和。// 滑动窗口计数算法核心逻辑 public class SlidingWindowCounter { private final int windowSize; // 窗口大小单位毫秒 private final int limit; // 窗口内最大请求数 private final MapLong, Integer counts new ConcurrentHashMap(); public SlidingWindowCounter(int windowSize, int limit) { this.windowSize windowSize; this.limit limit; } public synchronized boolean allow() { long now System.currentTimeMillis(); long windowStart now - windowSize; // 清理窗口外的计数 counts.keySet().removeIf(timestamp - timestamp windowStart); int total counts.values().stream().mapToInt(Integer::intValue).sum(); if (total limit) { counts.merge(now, 1, Integer::sum); return true; } return false; } }滑动窗口避免了固定窗口的临界突刺问题但维护多个子计数需要额外内存。在分布式环境下还需要考虑多个实例之间的计数同步。2.3 漏桶算法漏桶算法的思想很形象请求像水一样倒入桶中桶底以固定速率漏水也就是匀速处理请求。如果桶满了新请求直接溢出丢弃。漏桶算法的特点是强制平滑流量无论上游怎么突发下游看到的都是匀速请求。// 漏桶算法核心逻辑 public class LeakyBucket { private final int capacity; // 桶容量 private final double leakRate; // 漏出速率单位请求/毫秒 private double water; // 当前桶中水量 private long lastLeakTime; // 上次漏水时间 public LeakyBucket(int capacity, double leakRate) { this.capacity capacity; this.leakRate leakRate; this.water 0; this.lastLeakTime System.currentTimeMillis(); } public synchronized boolean allow() { long now System.currentTimeMillis(); // 先漏水根据时间差计算漏掉的水量 water Math.max(0, water - (now - lastLeakTime) * leakRate); lastLeakTime now; if (water capacity) { water 1; return true; } return false; } }漏桶算法适合对流量平滑性要求高的场景比如保护数据库写入、调用第三方接口。缺点是无法应对突发流量即使系统有能力处理突发请求漏桶也不允许。2.4 令牌桶算法令牌桶算法是生产中应用最广泛的限流算法。它的核心思想是系统以固定速率生成令牌放入桶中桶中最多存放一定数量的令牌。请求到达时必须从桶中取到一个令牌才能通过如果桶中没有令牌请求被拒绝。令牌桶算法的特点是允许一定的突发流量。因为桶中积累的令牌可以让系统在短时间内处理超过平均速率的大量请求同时又不至于完全失控。// 令牌桶算法核心逻辑 public class TokenBucket { private final int capacity; // 桶容量即最大令牌数 private final double refillRate; // 令牌生成速率单位令牌/毫秒 private double tokens; // 当前令牌数 private long lastRefillTime; // 上次补充令牌的时间 public TokenBucket(int capacity, double refillRate) { this.capacity capacity; this.refillRate refillRate; this.tokens capacity; this.lastRefillTime System.currentTimeMillis(); } public synchronized boolean allow() { long now System.currentTimeMillis(); // 计算这段时间应该补充的令牌数 tokens Math.min(capacity, tokens (now - lastRefillTime) * refillRate); lastRefillTime now; if (tokens 1) { tokens - 1; return true; } return false; } }2.5 四种算法对比算法允许突发流量实现复杂度空间占用适用场景固定窗口否且有临界问题低低简单接口防刷滑动窗口否更平滑中中需要较精确控制漏桶否强制平滑中低保护下游系统令牌桶是中低绝大多数业务场景设计限流器时如果系统需要同时满足“限制平均速率”和“允许一定突发”优先选择令牌桶算法。这也是 Guava RateLimiter 和很多网关默认限流器采用令牌桶思路的原因。3. 单机限流实战基于 Guava RateLimiter从工程实践出发先看最简单的单机限流方案。Google Guava 提供了现成的速率限制器内部基于令牌桶算法实现适合单体应用或单机部署的接口限流。3.1 引入依赖dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version33.0.0-jre/version /dependency3.2 使用示例package com.example.ratelimit; import com.google.common.util.concurrent.RateLimiter; public class GuavaRateLimiterDemo { public static void main(String[] args) { // 创建限流器每秒生成 2 个令牌允许突发最多 2 个请求立即通过 RateLimiter rateLimiter RateLimiter.create(2.0); // 模拟 10 个并发请求 for (int i 0; i 10; i) { boolean allowed rateLimiter.tryAcquire(); if (allowed) { System.out.println(请求 i 被放行时间 System.currentTimeMillis()); } else { System.out.println(请求 i 被限流时间 System.currentTimeMillis()); } } } }第一次执行时会发现前 2 个请求立即被放行这就是令牌桶积累的初始令牌发挥了作用。后续请求因为没有令牌而被拒绝。需要注意的是RateLimiter.create(2.0)表示每秒平均放行 2 个请求。tryAcquire()是非阻塞尝试拿不到令牌立即返回 false。如果需要等待令牌可以使用acquire()方法它会阻塞直到获取到令牌。3.3 在 Spring Boot 接口中使用把限流器应用到具体的接口中可以通过拦截器方式实现。下面是一个简单的 Web 接口限流示例。package com.example.ratelimit.config; import com.google.common.util.concurrent.RateLimiter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; Component public class RateLimitInterceptor implements HandlerInterceptor { // 每秒钟允许 1 个请求突发能力由 Guava 内部自动控制 private final RateLimiter rateLimiter RateLimiter.create(1.0); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (rateLimiter.tryAcquire()) { return true; } response.setStatus(429); // 429 Too Many Requests response.getWriter().write({\code\:429,\message\:\请求过于频繁请稍后重试\}); return false; } }package com.example.ratelimit.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class WebConfig implements WebMvcConfigurer { Autowired private RateLimitInterceptor rateLimitInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(rateLimitInterceptor) .addPathPatterns(/api/**); } }单机限流能解决单实例部署的问题但在微服务和多实例部署场景下Guava RateLimiter 失效了。因为每个实例各自维护一个令牌桶假设限流阈值是每秒 100部署了 5 个实例实际每秒最多能放行 500 个请求完全偏离预期。这时需要引入分布式限流方案。4. 分布式限流实战基于 Redis 与 Lua分布式限流的核心思路是把限流计数器的存储从本地内存迁移到集中式存储中让所有实例共享同一份计数。Redis 是首选方案原因有三个单线程模型执行命令原子性强特别适合处理计数这类操作。Lua 脚本可以在 Redis 服务端原子执行避免并发下的竞态问题。性能足够高单实例 QPS 可以支撑数万级别。4.1 系统架构分布式限流器的请求链路如下客户端请求 ↓ 负载均衡 / API 网关 ↓ 业务应用实例 A、B、C ↓ 统一访问 Redis 中的 Lua 限流脚本 ↓ 返回 allow 或 deny所有业务实例执行同一个 Lua 脚本脚本原子性地完成计数与判断不用加分布式锁也不会有超卖或计数错乱。4.2 Redis 令牌桶 Lua 脚本下面实现一个基于令牌桶算法的 Redis 限流器。核心思路是在 Redis 中保存三个 keyhash存储当前令牌数和上次刷新时间。last_refresh_time上次补充令牌的时间戳。current_tokens当前可用令牌数。Lua 脚本如下-- 文件路径src/main/resources/rate_limit.lua -- KEYS[1]限流器 key例如 rate_limiter:user_123 -- ARGV[1]桶容量 -- ARGV[2]每秒生成令牌数 -- ARGV[3]当前时间戳毫秒 local key KEYS[1] local capacity tonumber(ARGV[1]) local refill_rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) -- 获取当前令牌数和上次刷新时间 local data redis.call(HMGET, key, tokens, last_refill) local tokens tonumber(data[1]) local last_refill tonumber(data[2]) -- 初始化 if tokens nil then tokens capacity last_refill now end -- 计算这段时间应该补充的令牌数 local elapsed math.max(0, now - last_refill) local refill_tokens elapsed * refill_rate / 1000.0 tokens math.min(capacity, tokens refill_tokens) -- 更新上次刷新时间 last_refill now -- 判断是否放行 local allowed 0 if tokens 1 then tokens tokens - 1 allowed 1 end -- 写回 Redis redis.call(HSET, key, tokens, tokens, last_refill, last_refill) redis.call(PEXPIRE, key, 60000) return allowed脚本关键点解释HMGET批量获取 token 数量和上次刷新时间减少 Redis 往返次数。elapsed * refill_rate / 1000.0计算从上次刷新到现在补充的令牌数支持毫秒级精度。math.min保证令牌数不超过桶容量。PEXPIRE设置 60 秒过期时间避免永远不使用的 key 占用内存。整个脚本在 Redis 中原子执行不用担心多实例并发问题。4.3 Java 端封装编写一个 Spring Boot 下的 Redis 限流器工具类。package com.example.ratelimit.redis; import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.stereotype.Component; import jakarta.annotation.PostConstruct; import java.util.Collections; Component public class RedisRateLimiter { private final StringRedisTemplate redisTemplate; private DefaultRedisScriptLong rateLimitScript; public RedisRateLimiter(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } PostConstruct public void init() { rateLimitScript new DefaultRedisScript(); rateLimitScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(rate_limit.lua))); rateLimitScript.setResultType(Long.class); } /** * 尝试获取许可 * * param key 限流器 key 前缀 * param identifier 业务标识如用户 ID 或 IP * param capacity 桶容量 * param refillRate 每秒补充令牌数 * return true 表示允许通过 */ public boolean tryAcquire(String key, String identifier, int capacity, double refillRate) { String redisKey key : identifier; long now System.currentTimeMillis(); Long result redisTemplate.execute( rateLimitScript, Collections.singletonList(redisKey), String.valueOf(capacity), String.valueOf(refillRate), String.valueOf(now) ); return result ! null result 1L; } }4.4 在业务代码中接入package com.example.ratelimit.controller; import com.example.ratelimit.redis.RedisRateLimiter; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; RestController public class OrderController { private final RedisRateLimiter redisRateLimiter; public OrderController(RedisRateLimiter redisRateLimiter) { this.redisRateLimiter redisRateLimiter; } GetMapping(/api/order) public String createOrder(RequestHeader(value X-User-Id, defaultValue anonymous) String userId) { // 每个用户每秒最多 5 个请求允许突发 10 个 boolean allowed redisRateLimiter.tryAcquire(rate_limiter:order, userId, 10, 5.0); if (!allowed) { return {\code\:429,\message\:\操作过于频繁请稍后重试\}; } // 业务处理逻辑 return {\code\:200,\message\:\下单成功\}; } }这段代码实现了一个“每个用户维度”的限流器。X-User-Id是请求头中携带的用户标识实际项目中也可以从 Token 中解析。4.5 运行与验证使用 JMeter 或 curl 模拟并发请求进行验证。for i in $(seq 1 20) do curl -s -X GET http://localhost:8080/api/order -H X-User-Id: 1001 done wait上面的命令会并发发起 20 个请求。由于限流器配置为容量 10、每秒补充 5 个初始阶段只有前 10 个请求能拿到令牌其余请求会收到 429 响应。观察 Redis 中的 key127.0.0.1:6379 HGETALL rate_limiter:order:1001 1) tokens 2) 0 3) last_refill 4) 1717000000000tokens被消耗为 0说明限流生效。5. 常见问题与排查思路分布式限流在落地过程中会遇到一些问题下面整理高频问题及排查方法。5.1 限流不生效所有请求都放行问题现象常见原因解决思路所有请求都能通过Lua 脚本未正确加载检查脚本路径和DefaultRedisScript配置所有请求都能通过Redis key 没有设置成功在 Redis 中手动执行脚本排查语法错误所有请求都能通过阈值设置过大检查容量和补充速率参数是否符合预期排查步骤开启 Redis 命令监控确认每次请求是否真正调用了 Redis。在 Redis 客户端手动执行一次 Lua 脚本检查返回值。打印限流器的入参确认key和identifier是否拼接正确。5.2 并发高时计数出现偏差虽然 Lua 脚本原子执行可以保证 Redis 内的计数准确但如果使用了非原子方式实现比如先GET再SET在并发下就会出现计数错乱。解决方案必须使用 Lua 脚本保证计数与判断的原子性不要在 Java 代码中先查再写。5.3 Redis 超时导致接口堵塞Redis 在极端情况下会出现慢查询或网络抖动。如果限流器同步等待 Redis 响应可能拖慢整个接口。解决方案为 Redis 操作设置超时时间比如 50ms超时后走“放行”策略避免限流器自身成为系统瓶颈。采用多级限流本地限流做主防线Redis 分布式限流做集群维度兜底。5.4 限流粒度选择不当按 IP 限流时同一个公司出口 IP 下的所有用户会共享配额导致正常用户被误伤。按用户 ID 限流时匿名用户无法识别。解决方案优先选择用户 ID、设备 ID、AppId 等业务维度的标识。无法获取用户身份时再降级为 IP 限流但阈值需要适当放大。支持多个 key 维度组合比如先按用户限流再按 IP 全局限流。5.5 Lua 脚本报错常见报错信息ERR Error running script (call to f_xxx): user_script:1: user_script:1: attempt to compare nil with number原因通常是对 Lua 的nil处理不正确。当 Redis key 不存在时HMGET返回空值需要显式判断并初始化。if tokens nil then tokens capacity last_refill now end6. 最佳实践与工程建议6.1 限流阈值要留有余量限流阈值不能直接取系统的理论最大 QPS应该基于压测数据设置。一般建议预留 20% 到 30% 的余量。例如压测显示服务最大支持 1000 QPS限流阈值可以设置为 700 到 800避免限流器放行过多请求后服务依然被打垮。6.2 设计多级限流架构生产环境推荐采用多级限流第一层接入层Nginx / API 网关 ↓ 按 IP、AppId 限流 第二层应用层过滤器 / 拦截器 ↓ 按用户、业务维度限流 第三层核心服务层本地限流 ↓ 保护数据库、第三方调用第一层过滤掉大部分恶意流量第二层处理业务维度的配额第三层兜底保护数据库等关键资源。6.3 限流响应要规范限流器拒绝请求时响应体应该包含以下信息HTTP 状态码429 Too Many Requests。错误码如429001。错误信息说明被限流的原因。重试时间告诉调用方什么时候可以重试例如Retry-After: 10。{ code: 429001, message: 请求过于频繁, retryAfterSeconds: 10 }6.4 限流器降级策略限流器自身也可能出现问题。比如 Redis 故障、网络分区、Lua 脚本执行超时等。设计时必须考虑降级策略超时降级Redis 调用超时后默认放行请求让后端的熔断机制去保护资源。故障降级Redis 不可用时降级为本地限流模式避免全局限流失效。手动开关通过配置中心动态开关分布式限流功能紧急情况下可以一键关闭。6.5 监控与告警限流量需要监控否则无法感知限流是否生效、是否误伤正常用户。建议监控以下指标指标说明总请求量进入限流器的请求总数放行请求量通过限流器的请求数拒绝请求量被限流的请求数限流触发率拒绝量 / 总请求量Redis 平均耗时限流器自身性能开销当限流触发率突然升高时需要判断是正常流量高峰还是限流阈值设置不合理及时调整。6.6 安全与权限涉及限流配置变更时需要注意限流规则属于系统配置应通过配置中心发布避免直接修改生产代码。不同业务方应使用不同的 key 前缀和 Redis 实例避免相互影响。对限流器管理接口增加权限校验禁止未授权人员调整限流阈值。7. 总结与学习路线本文从限流器的定义出发对比了固定窗口、滑动窗口、漏桶和令牌桶四种核心算法分析了各自的适用场景随后基于 Guava RateLimiter 完成了单机限流实现最后以 Redis Lua 实现了分布式令牌桶限流器并讨论了生产落地中的排查手段和工程规范。如果是在准备面试建议重点掌握以下内容能手写令牌桶算法伪代码并能说明它为什么允许突发流量。能说清 Redis Lua 限流为什么能解决多实例下的计数一致性问题。能分析限流器和熔断器、降级策略的配合关系。能结合实际业务场景选择合适限流粒度。如果是在实际项目中落地下一步可以从这几个方向继续深入使用 Resilience4j 或 Sentinel 等成熟框架替代手写限流器完善控制台和规则配置能力。在 Nginx 层实现 IP 级限流前置拦截恶意流量。结合压测工具完成限流阈值调优记录不同阈值下的成功率与响应时间。为限流器补充完整的监控大盘和告警规则让限流成为可观测的系统能力。动手写一个自己的限流器并把代码跑起来是理解限流关键细节最有效的方式。可以先从单机版开始再扩展为 Redis 分布式版逐步完善异常处理和降级逻辑这个过程本身就是一个完整的系统设计训练。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门