在现代互联网应用中,API
接口的稳定性和可用性至关重要。面对高并发的请求量,如何有效地管理和控制流量成为了开发者必须解决的问题。接口节流(Rate Limiting
)作为一种流量控制手段,可以帮助我们防止系统过载,保护服务免受恶意请求的影响,并确保资源的公平使用。
什么是接口节流 接口节流的定义 接口节流(Rate Limiting
)是一种控制系统流量的方法,用于限制特定时间段内允许访问API或服务的请求数量。通过限制请求速率,可以防止系统过载、提高系统的稳定性和可靠性,并确保所有用户能够公平地使用系统资源。接口节流通常通过限制单个客户端或用户在指定时间窗口内的请求次数来实现。
为什么需要接口节流 接口节流的主要目的是保护系统免受过载和滥用,其重要性体现在以下几个方面 接口节流的主要目的是保护系统免受过载和滥用,其重要性体现在以下几个方面
防止滥用和恶意攻击 :接口节流可以有效地限制单个用户在单位时间内的请求次数,从而防止恶意用户对系统进行DDOS攻击或暴力破解等操作
保护系统资源 :通过限制请求速率,可以避免某些关键资源被过度消耗,确保系统的稳定性和可靠性
提升用户体验 :合理的接口节流策略可以避免因过载导致的服务不可用,从而提高整体用户体验
公平分配资源 :在多用户环境中,接口节流可以确保每个用户都能公平地使用系统资源,防止个别用户的过度消耗
常见的接口节流策略 固定窗口计数器(Fixed Window Counter)原理 :在固定时间窗口内(如1分钟),对请求次数进行计数。如果请求次数超过预设的限制,则拒绝后续请求
滑动窗口(Sliding Window)原理 :将时间窗口细分为多个小窗口,并记录每个小窗口的请求次数。通过滑动窗口计算当前时间段内的总请求次数,确保请求速率的平滑限制
优点 :可以更平滑地控制请求速率,避免瞬时请求突增的问题
令牌桶(Token Bucket)原理 :系统维护一个令牌桶,按固定速率向桶中添加令牌。每次请求需要消耗一个令牌,如果桶中没有令牌则拒绝请求。桶的容量和添加速率决定了请求速率的限制
优点 :可以处理突发请求,同时保证长期请求速率的限制
漏桶(Leaky Bucket)原理 :系统维护一个漏桶,按固定速率漏出请求。每个请求会加入到桶中,如果桶满则拒绝请求。漏桶的容量和漏出速率决定了请求速率的限制
优点 :能够平滑处理请求,适用于需要严格控制请求速率的场景
以上是几种常见的接口节流策略,各有优缺点,开发者可以根据具体需求选择合适的策略来实现接口节流。在接下来的章节中,我们将具体介绍如何单纯利用 Redis
实现这些节流策略 使用 Redis 实现接口节流 工具类及枚举类 Redis 工具类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 @Component public class RedisUtil { @Autowired private RedisTemplate<String, Object> redisTemplate; public boolean hasKey (String key) { try { return redisTemplate.hasKey(key); } catch (Exception e) { e.printStackTrace(); return false ; } } public boolean expire (String key, long time) { try { if (time > 0 ) { redisTemplate.expire(key, time, TimeUnit.SECONDS); } return true ; } catch (Exception e) { e.printStackTrace(); return false ; } } public boolean expire (String key, long time, TimeUnit timeUnit) { try { if (time > 0 ) { redisTemplate.expire(key, time, timeUnit); } return true ; } catch (Exception e) { e.printStackTrace(); return false ; } } public Object get (String key) { return key == null ? null : redisTemplate.opsForValue().get(key); } public long incr (String key, long delta) { ExceptionTypeEnum.PARAMS.isTrue(delta > 0 , "递增因子必须大于0" ); return redisTemplate.opsForValue().increment(key, delta); } public Object hget (String key, String item) { return redisTemplate.opsForHash().get(key, item); } public boolean hset (String key, String item, Object value) { try { redisTemplate.opsForHash().put(key, item, value); return true ; } catch (Exception e) { e.printStackTrace(); return false ; } } public boolean shaskey (String key, Object value) { try { return redisTemplate.opsForSet().isMember(key, value); } catch (Exception e) { e.printStackTrace(); return false ; } } public long lgetListSize (String key) { try { return redisTemplate.opsForList().size(key); } catch (Exception e) { e.printStackTrace(); return 0 ; } } public boolean lset (String key, Object value) { try { redisTemplate.opsForList().rightPush(key, value); return true ; } catch (Exception e) { e.printStackTrace(); return false ; } } public long lremove (String key, long count, Object value) { try { Long remove = redisTemplate.opsForList().remove(key, count, value); return remove; } catch (Exception e) { e.printStackTrace(); return 0 ; } } public void zremrangeByScore (String key, double min, double max) { redisTemplate.opsForZSet().removeRangeByScore(key, min, max); } public void zadd (String key, double score, Object member) { redisTemplate.opsForZSet().add(key, member, score); } public long zcard (String key) { return redisTemplate.opsForZSet().zCard(key); } }
时间类型枚举类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @Getter @AllArgsConstructor public enum TimeUnitEnum { SECONDS("4" , TimeUnit.SECONDS), MINUTES("3" , TimeUnit.MINUTES), HOURS("2" , TimeUnit.HOURS), DAY("1" , TimeUnit.DAYS); private final String value; private final TimeUnit label; public static TimeUnit getLabelByValue (String value) { if (ObjectUtils.isEmpty(value)) { return MINUTES.label; } for (TimeUnitEnum unit : TimeUnitEnum.values()) { if (unit.value.equals(value)) { return unit.label; } } return MINUTES.label; } }
(为了更清晰地理解代码,以下示例将直接使用硬编码的值。未来在实际应用中,可自行将这些硬编码的值调整为常量或配置文件写入)
固定窗口计数器(Fixed Window Counter) 思路 :在固定的时间窗口内对请求进行计数,例如每分钟最多允许 100
个请求。使用 Redis
的字符串(String
)类型来存储计数器,每次请求时递增计数器,并设置一个到期时间以便在窗口结束时重置计数器。
实现步骤 :
使用 Redis
的字符串类型存储计数器 使用 Redis
的 INCR
命令增加计数器 使用 EXPIRE
命令设置计数器的到期时间,例如 1
分钟 每次请求时检查计数器的值是否超过限制,如果超过则拒绝请求 代码实现 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 @Component @Slf4j @AllArgsConstructor public class RateLimitInterceptor implements HandlerInterceptor { private final RedisUtil redisUtil; @Override public boolean preHandle (@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) { try { if (redisUtil.shaskey("whitelist" , request.getRequestURI())) { return true ; } } catch (Exception e) { log.error("Redis 接口节流异常" , e); return true ; } int maxRequestPer = redisUtil.hasKey("rateLimit" ) ? (Integer) redisUtil.get("rateLimit" ) : 100 ; String ipAddress = request.getRemoteAddr(); String key = String.format("%s::%s" , "rateLimit" , ipAddress); long count = redisUtil.incr(key, 1 ); if (count == 1 ) { TimeUnit timeUnit = redisUtil.hasKey("timeUnit" ) ? TimeUnitEnum.getLabelByValue((String) redisUtil.get("timeUnit" )) : TimeUnitEnum.getLabelByValue("3" ); int expirationTime = redisUtil.hasKey("expirationTime" ) ? (int ) redisUtil.get("expirationTime" ) : 1 ; redisUtil.expire(key, expirationTime, timeUnit); } ExceptionTypeEnum.BUSINESS.isTrue(count <= maxRequestPer, "请求过于频繁,请稍后重试" ); return true ; } }
这里的滑动窗口实现思路:每次处理请求时,从 Redis
获取最大请求数限制,并记录当前 IP
的请求次数。如果是第一次请求,则设置 Redis
键的过期时间。检查请求次数是否超过限制;如果超出则抛出异常,否则继续处理请求。 滑动窗口(Sliding Window) 思路 :将时间窗口细分为多个小窗口,并记录每个小窗口的请求次数,通过滑动窗口计算当前时间段内的总请求次数。可以使用 Redis
的列表(List
)或有序集合(Sorted Set
)来记录请求的时间戳
实现步骤 :
使用 Redis
的 ZADD
命令将请求的时间戳添加到有序集合中。 使用 ZREMRANGEBYSCORE
命令移除超出时间窗口的旧请求。 使用 ZCOUNT
/ ZCARD
命令统计当前时间窗口内的请求次数,并根据结果决定是否拒绝请求。 代码实现 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 @Component @Slf4j @AllArgsConstructor public class RateLimitInterceptor implements HandlerInterceptor { private final RedisUtil redisUtil; @Override public boolean preHandle (@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) { try { if (redisUtil.shaskey("whitelist" , request.getRequestURI())) { return true ; } } catch (Exception e) { log.error("Redis 接口节流异常" , e); return true ; } int maxRequestPer = redisUtil.hasKey("rateLimit" ) ? (Integer) redisUtil.get("rateLimit" ) : 10 ; String ipAddress = request.getRemoteAddr(); String key = String.format("%s::%s" , "rateLimit" , ipAddress); long now = System.currentTimeMillis(); TimeUnit timeUnit = redisUtil.hasKey("timeUnit" ) ? TimeUnitEnum.getLabelByValue((String) redisUtil.get("timeUnit" )) : TimeUnitEnum.getLabelByValue("4" ); int expirationTime = redisUtil.hasKey("expirationTime" ) ? (int ) redisUtil.get("expirationTime" ) : 6 ; long windowSizeInMillis = timeUnit.toMillis(expirationTime); redisUtil.zremrangeByScore(key, 0 , now - windowSizeInMillis); redisUtil.zadd(key, now, now); long count = redisUtil.zcard(key); ExceptionTypeEnum.BUSINESS.isTrue(count <= maxRequestPer, "请求过于频繁,请稍后重试" ); redisUtil.expire(key, windowSizeInMillis, TimeUnit.MILLISECONDS); return true ; } }
这里的滑动窗口实现思路:每次处理请求时,首先获取当前的时间戳,并清理掉窗口之外的过期请求记录。然后,将当前请求的时间戳添加到 Redis 的有序集合中。接着,统计滑动窗口内的请求数量。如果请求数超过了设定的限制,则抛出异常;否则,继续处理请求。为了避免 Redis
中长期存储无效数据,还需要为键设置过期时间。 令牌桶(Token Bucket) 思路 :通过维护一个令牌桶,按固定速率向桶中添加令牌,每次请求需要消耗一个令牌。如果桶中没有令牌则拒绝请求。
实现步骤 :
检查Redis中是否存在该 key
。如果不存在,则初始化令牌桶,设置令牌数为maxTokens
,并记录当前时间戳 从 Redis
中获取当前令牌数和上次请求的时间戳,计算自上次请求以来经过的时间,根据补充率计算新令牌数 如果新令牌数大于 0
,消耗一个令牌,并更新Redis中的令牌数和时间戳,如果没有足够的令牌,抛出异常,拒绝请求 代码实现 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 @Component @Slf4j @AllArgsConstructor public class RateLimitInterceptor implements HandlerInterceptor { private final RedisUtil redisUtil; @Override public boolean preHandle (@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) { try { if (redisUtil.shaskey("whitelist" , request.getRequestURI())) { return true ; } } catch (Exception e) { log.error("Redis 接口节流异常" , e); return true ; } int maxTokens = redisUtil.hasKey("rateLimit" ) ? (int ) redisUtil.get("rateLimit" ) : 10 ; int refillRate = redisUtil.hasKey("refillRate" ) ? (int ) redisUtil.get("refillRate" ) : 1 ; String ipAddress = request.getRemoteAddr(); String key = String.format("%s::%s" , "rateLimit" , ipAddress); long now = System.currentTimeMillis() / 1000 ; if (!redisUtil.hasKey(key)) { redisUtil.hset(key, "tokens" , String.valueOf(maxTokens)); redisUtil.hset(key, "timestamp" , String.valueOf(now)); } String tokensStr = (String) redisUtil.hget(key, "tokens" ); String lastRefillTimestampStr = (String) redisUtil.hget(key, "timestamp" ); int tokens = tokensStr != null ? Integer.parseInt(tokensStr) : maxTokens; long lastRefillTimestamp = lastRefillTimestampStr != null ? Long.parseLong(lastRefillTimestampStr) : now; long elapsedSeconds = now - lastRefillTimestamp; int newTokens = (int ) Math.min(maxTokens, tokens + (elapsedSeconds * refillRate)); ExceptionTypeEnum.Business.isTrue(newTokens > 0 , "请求过于频繁,请稍后重试" ); redisUtil.hset(key, "tokens" , String.valueOf(newTokens - 1 )); redisUtil.hset(key, "timestamp" , String.valueOf(now)); int expirationTime = maxTokens / refillRate + 1 ; redisUtil.expire(key, expirationTime, TimeUnit.SECONDS); return true ; } }
令牌桶算法的实现思路如下:次请求时初始化令牌桶,包括设置令牌数量和记录上次请求的时间戳。每次请求时,根据当前时间和上次请求时间计算需要补充的令牌数。如果令牌数大于零,则允许请求并消耗一个令牌;否则,拒绝请求。最后,更新令牌桶的状态,并为 Redis
键设置过期时间。 漏桶(Leaky Bucket) 思路 :通过维护一个漏桶,按固定速率处理请求。每个请求会加入到桶中,如果桶满则拒绝请求
实现步骤 :
使用 Redis
的列表存储请求。 定期通过后台任务按固定速率处理列表中的请求。 每次请求时尝试加入到列表中,如果列表长度超过容量则拒绝请求。 代码实现 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 @Component @Slf4j @AllArgsConstructor public class RateLimitInterceptor implements HandlerInterceptor { private final RedisUtil redisUtil; @Override public boolean preHandle (@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) { try { if (redisUtil.shaskey("whitelist" , request.getRequestURI())) { return true ; } } catch (Exception e) { log.error("Redis 接口节流异常" , e); return true ; } int bucketCapacity = redisUtil.hasKey("bucketCapacity" ) ? (int ) redisUtil.get("bucketCapacity" ) : 100 ; int leakRate = redisUtil.hasKey("leakRate" ) ? (int ) redisUtil.get("leakRate" ) : 1 ; String ipAddress = request.getRemoteAddr(); String key = String.format("%s::%s" , "rateLimit" , ipAddress); long now = System.currentTimeMillis(); long windowSizeInMillis = TimeUnit.SECONDS.toMillis(1 ); redisUtil.lremove(key, 0 , String.valueOf(now - windowSizeInMillis)); long bucketSize = redisUtil.lgetListSize(key); ExceptionTypeEnum.BUSINESS.isTrue(bucketSize < bucketCapacity, "请求过于频繁,请稍后重试" ); redisUtil.lset(key, String.valueOf(now)); redisUtil.expire(key, bucketCapacity / leakRate + 1 , TimeUnit.SECONDS); return true ; } }
漏桶算法的实现思路如下:每次请求时,先移除超出时间窗口的旧请求,然后检查桶中当前的请求数是否超过了容量限制。如果没有超过,就将当前请求时间戳加入桶中;否则,拒绝请求。同时,设置Key的过期时间以清理Redis中的过期数据 使用 lua 脚本优化 使用上述方法虽然可以实现接口节流,但效率较低。因为每次 Java
应用与 Redis
通信时都会有时间开销,比如一个 get/set
操作可能需要 20
毫秒。如果需要执行接近 10
个这样的操作,总时间可能就会增加到 200
毫秒。为了提高效率,我们可以选择使用 Lua
脚本进行优化 补充 RedisUtil 工具类 1 2 3 4 5 6 7 8 9 10 11 12 public <T> T executeScript (DefaultRedisScript<T> redisScript, Collection<String> keys, Object... args) { return redisTemplate.execute(redisScript, (List<String>) keys, args); }
将上述代码补充进 RedisUtil 工具类中 使用 Lua 优化滑动窗口算法方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 @Component @Slf4j @AllArgsConstructor public class RateLimitInterceptor implements HandlerInterceptor { private final RedisUtil redisUtil; @Override public boolean preHandle (@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) { String luaScript = "local DEFAULT_MAX_REQUESTS_PER_WINDOW = 10\n" + "local DEFAULT_EXPIRATION_TIME = 6\n" + "local DEFAULT_TIME_UNIT = \"SECONDS\"\n" + "local requestKey = KEYS[1]\n" + "local requestUri = ARGV[1]\n" + "local WHITELIST_KEY = \"whitelist\"\n" + "local MAX_REQUESTS_KEY = \"rateLimit\"\n" + "local TIME_UNIT_KEY = \"timeUnit\"\n" + "local EXPIRATION_TIME_KEY = \"expirationTime\"\n" + "local isWhitelisted = redis.call(\"SISMEMBER\", WHITELIST_KEY, requestUri)\n" + "if isWhitelisted == 1 then\n" + " return true\n" + "end\n" + "local maxRequestsPerWindow = tonumber(redis.call(\"GET\", MAX_REQUESTS_KEY) or DEFAULT_MAX_REQUESTS_PER_WINDOW)\n" + "local timeUnit = redis.call(\"GET\", TIME_UNIT_KEY) or DEFAULT_TIME_UNIT\n" + "local expirationTime = tonumber(redis.call(\"GET\", EXPIRATION_TIME_KEY) or DEFAULT_EXPIRATION_TIME)\n" + "local windowSizeInMillis\n" + "if timeUnit == \"SECONDS\" then\n" + " windowSizeInMillis = expirationTime * 1000\n" + "elseif timeUnit == \"MINUTES\" then\n" + " windowSizeInMillis = expirationTime * 60 * 1000\n" + "elseif timeUnit == \"HOURS\" then\n" + " windowSizeInMillis = expirationTime * 3600 * 1000\n" + "elseif timeUnit == \"DAYS\" then\n" + " windowSizeInMillis = expirationTime * 86400 * 1000\n" + "else\n" + " windowSizeInMillis = expirationTime * 1000\n" + "end\n" + "local now = tonumber(redis.call(\"TIME\")[1]) * 1000\n" + "redis.call(\"ZREMRANGEBYSCORE\", requestKey, 0, now - windowSizeInMillis)\n" + "redis.call(\"ZADD\", requestKey, now, now)\n" + "local requestCount = redis.call(\"ZCARD\", requestKey)\n" + "if requestCount > maxRequestsPerWindow then\n" + " return false\n" + "else\n" + " redis.call(\"PEXPIRE\", requestKey, windowSizeInMillis + 1000)\n" + " return true\n" + "end" ; Boolean rateLimit; try { rateLimit = redisUtil.executeScript(new DefaultRedisScript <>(luaScript, Boolean.class), Collections.singletonList(String.format("%s::%s" , "rate_limit" , request.getRemoteAddr())), request.getRequestURI()); } catch (Exception e) { log.error("redis执行脚本异常" , e); return true ; } ExceptionTypeEnum.BUSINESS.isTrue(Boolean.TRUE.equals(rateLimit), "请求过于频繁,请稍后重试" ); return true ; } }
这样,我们可以减少 Java
与 Redis
之间的通信开销,从而提升性能 建议将 Lua
脚本代码提取到一个专门的文件夹中,以便于管理和维护。
创建 RateLimit.lua 文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 local DEFAULT_MAX_REQUESTS_PER_WINDOW = 10 local DEFAULT_EXPIRATION_TIME = 6 local DEFAULT_TIME_UNIT = "SECONDS" local requestKey = KEYS[1 ]local requestUri = ARGV[1 ]local WHITELIST_KEY = "whitelist" local MAX_REQUESTS_KEY = "rateLimit" local TIME_UNIT_KEY = "timeUnit" local EXPIRATION_TIME_KEY = "expirationTime" local isWhitelisted = redis.call("SISMEMBER" , WHITELIST_KEY, requestUri)if isWhitelisted == 1 then return true end local maxRequestsPerWindow = tonumber (redis.call("GET" , MAX_REQUESTS_KEY) or DEFAULT_MAX_REQUESTS_PER_WINDOW)local timeUnit = redis.call("GET" , TIME_UNIT_KEY) or DEFAULT_TIME_UNITlocal expirationTime = tonumber (redis.call("GET" , EXPIRATION_TIME_KEY) or DEFAULT_EXPIRATION_TIME)local windowSizeInMillisif timeUnit == "SECONDS" then windowSizeInMillis = expirationTime * 1000 elseif timeUnit == "MINUTES" then windowSizeInMillis = expirationTime * 60 * 1000 elseif timeUnit == "HOURS" then windowSizeInMillis = expirationTime * 3600 * 1000 elseif timeUnit == "DAYS" then windowSizeInMillis = expirationTime * 86400 * 1000 else windowSizeInMillis = expirationTime * 1000 end local now = tonumber (redis.call("TIME" )[1 ]) * 1000 redis.call("ZREMRANGEBYSCORE" , requestKey, 0 , now - windowSizeInMillis) redis.call("ZADD" , requestKey, now, now) local requestCount = redis.call("ZCARD" , requestKey)if requestCount > maxRequestsPerWindow then return false else redis.call("PEXPIRE" , requestKey, windowSizeInMillis + 1000 ) return true end
调整 RateLimitInterceptor
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Component @Slf4j @AllArgsConstructor public class RateLimitInterceptor implements HandlerInterceptor { private final RedisUtil redisUtil; @Override public boolean preHandle (@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) { DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript <>(); redisScript.setLocation(new ClassPathResource ("com/youfeng/blog/lua/RateLimit.lua" )); redisScript.setResultType(Boolean.class); Boolean rateLimit; try { rateLimit = redisUtil.executeScript(new DefaultRedisScript <>(luaScript, Boolean.class), Collections.singletonList(String.format("%s::%s" , "rate_limit" , request.getRemoteAddr())), request.getRequestURI()); } catch (Exception e) { log.error("redis执行脚本异常" , e); return true ; } ExceptionTypeEnum.BUSINESS.isTrue(Boolean.TRUE.equals(rateLimit), "请求过于频繁,请稍后重试" ); return true ; } }
需要注意的是,如果将时间窗口边界作为参数传递给 Lua
脚本,可能会在初始阶段导致超频问题。原因大致如下:在大量请求初次涌入时,每个线程生成的边界时间可能非常接近。例如,A
的时间窗口是 09:00:00.000 ~ 09:00:01.000
,而 B
的时间窗口是 09:00:00.100 ~ 09:00:01.100
。如果 B
先从 Jedis
连接池中获取了 Redis
连接,并在 09:00:01.100
时更新了计数器,而 A
在判断时则可能会漏掉 B
的计数,导致 A
的请求也被错误地允许通过
使用 Lua 优化固定窗口算法方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 local key = KEYS[1 ]local requestUri = ARGV[1 ]local WHITELIST_KEY = "whitelist" local MAX_REQUESTS_KEY = "rateLimit" local TIME_UNIT_KEY = "timeUnit" local EXPIRATION_TIME_KEY = "expirationTime" local defaultMaxRequestPer = 100 local defaultExpirationTime = 1 local defaultTimeUnit = "SECONDS" if redis.call("SISMEMBER" , WHITELIST_KEY, requestUri) == 1 then return true end local maxRequestPer = tonumber (redis.call("GET" , MAX_REQUESTS_KEY) or defaultMaxRequestPer)local expirationTime = tonumber (redis.call("GET" , EXPIRATION_TIME_KEY) or defaultExpirationTime)local timeUnit = redis.call("GET" , TIME_UNIT_KEY) or defaultTimeUnitlocal count = redis.call("INCR" , key)if count == 1 then if timeUnit == "SECONDS" then redis.call("EXPIRE" , key, expirationTime) elseif timeUnit == "MINUTES" then redis.call("EXPIRE" , key, expirationTime * 60 ) elseif timeUnit == "HOURS" then redis.call("EXPIRE" , key, expirationTime * 3600 ) elseif timeUnit == "DAYS" then redis.call("EXPIRE" , key, expirationTime * 86400 ) else redis.call("EXPIRE" , key, expirationTime) end end if count > maxRequestPer then return false end return true
使用 Lua 优化令牌桶算法方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 local key = KEYS[1 ]local requestUri = ARGV[1 ]local WHITELIST_KEY = "whitelist" local defaultMaxTokens = 10 local defaultRefillRate = 1 local now = tonumber (redis.call("TIME" )[1 ]) * 1000 if redis.call("SISMEMBER" , WHITELIST_KEY, requestUri) == 1 then return true end local maxTokens = tonumber (redis.call("HGET" , key, "maxTokens" )) or defaultMaxTokenslocal refillRate = tonumber (redis.call("HGET" , key, "refillRate" )) or defaultRefillRatelocal tokensStr = redis.call("HGET" , key, "tokens" )local lastRefillTimestampStr = redis.call("HGET" , key, "timestamp" )local tokens = tokensStr and tonumber (tokensStr) or maxTokenslocal lastRefillTimestamp = lastRefillTimestampStr and tonumber (lastRefillTimestampStr) or nowlocal elapsedSeconds = now - lastRefillTimestamplocal newTokens = math .min (maxTokens, tokens + (elapsedSeconds * refillRate))if newTokens <= 0 then return false end redis.call("HSET" , key, "tokens" , tostring (newTokens - 1 )) redis.call("HSET" , key, "timestamp" , tostring (now)) local expirationTime = (maxTokens / refillRate) + 1 redis.call("EXPIRE" , key, expirationTime) return true
使用 Lua 优化漏桶算法方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 local key = KEYS[1 ]local requestUri = ARGV[1 ]local WHITELIST_KEY = "whitelist" local BUCKET_CAPACITY = "bucketCapacity" local LEAK_RATE = "leakRate" local defaultBucketCapacity = 100 local defaultLeakRate = 1 local windowSizeInMillis = 1000 local now = tonumber (redis.call("TIME" )[1 ]) * 1000 if redis.call("SISMEMBER" , WHITELIST_KEY, requestUri) == 1 then return true end local bucketCapacity = tonumber (redis.call("GET" , BUCKET_CAPACITY)) or defaultBucketCapacitylocal leakRate = tonumber (redis.call("GET" , LEAK_RATE)) or defaultLeakRatelocal minTimestamp = now - (windowSizeInMillis)redis.call("LTRIM" , key, 0 , -1 ) redis.call("RPUSH" , key, now) redis.call("LREM" , key, 0 , minTimestamp) local bucketSize = redis.call("LLEN" , key)if bucketSize > bucketCapacity then return false end local expirationTime = bucketCapacity / leakRate + 1 redis.call("EXPIRE" , key, expirationTime) return true
固定窗口、滑动窗口、令牌桶、漏桶算法的应用场景 固定窗口计数器 固定窗口计数器简单易实现,计算量较小,只需维护一个计数器,并通过简单的过期机制来重置计数。然而,在时间窗口的边界处,可能会出现请求突增的问题。例如,在窗口结束时段大量请求会被允许,从而导致瞬时流量过大。此外,固定窗口的时间精度较低,可能无法平滑处理请求流量。
固定窗口计数器适用于流量波动不大的系统,如简单的 API
接口限制。它可以用于对负载较低的服务进行基础的节流控制。
滑动窗口 滑动窗口算法通过精确计算时间窗口内的请求次数,能够平滑地控制流量,避免瞬时流量突增,并提供更高的时间精度。虽然滑动窗口能更有效地控制流量,但需要额外清理超出时间窗口的数据,这可能增加计算和存储开销。此外,滑动窗口的实现复杂度相对较高。
滑动窗口算法适合对流量控制要求较高的场景,如需要平滑处理请求流量的实时系统。它适用于用户请求有显著突发性和高波动的服务,如在线游戏、金融交易系统等。
令牌桶 令牌桶算法允许桶中的令牌积累,因此可以处理突发流量。通过设置令牌的添加速率和桶的容量,令牌桶算法能够平滑控制流量。尽管令牌桶算法灵活处理突发流量,但实现较为复杂,需要处理令牌的补充和消耗逻辑,同时可能需要额外的内存来存储令牌信息。
令牌桶算法适合需要灵活处理突发流量的应用,如 API
服务、下载管理、视频流等。此外,它也适用于高并发场景中,对请求速率有严格控制要求的服务
漏桶 漏桶算法以固定速率处理请求,从而平滑流量,避免短时间内的请求突增,保证请求处理的稳定性,并防止请求处理的不均匀。然而,漏桶算法对突发流量处理较差,如果请求超出桶的容量,可能会被拒绝。此外,漏桶算法的实现较为复杂,需要维护漏桶的状态,并实现漏水和请求处理逻辑。
漏桶算法适用于对处理速率有严格要求的场景,如流量控制系统和请求速率平滑的网络服务。它还可以用于实时数据处理、API
速率限制等需要稳定输出的应用
结语 在系统设计中,接口节流是确保服务稳定性和性能的关键手段。通过有效的节流策略,可以控制请求流量、平滑处理负载,并防止系统过载。本文详细探讨了如何利用 Redis
实现接口节流,包括介绍了固定窗口计数器、滑动窗口、令牌桶和漏桶四种常见节流算法的优劣及应用场景。
每种节流算法都有其独特的优势和适用场景。在实际应用中,选择合适的节流算法不仅可以有效地控制请求流量,还可以优化系统性能和用户体验。固定窗口计数器适合简单的流量控制,滑动窗口算法提供更平滑的控制,令牌桶算法灵活处理突发流量,而漏桶算法保证了稳定的处理速率。根据具体业务需求和系统特点,结合 Redis
的强大功能,能够实现最佳的节流效果。
事实上,很多开源的库已经帮我们实现了节流逻辑,例如 Bucket4j
(一个 Java
的令牌桶算法实现库、Resilience4j
(提供了令牌桶和滑动窗口的实现) 还有 Guava RateLimiter
(Google
的 Guava
库中的令牌桶算法实现),我们也可以直接通过这些库来帮助我们在 Java
应用中实现节流控制。
最后,希望通过本文的介绍,能够帮助读者更好地理解和实现接口节流策略,构建高效、稳定的系统架构。接口节流不仅是系统设计中的重要环节,更是提升用户体验和服务质量的关键一步。