尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Redis ZSET实现滑动窗口限流:原理、Lua脚本与生产实践

Redis ZSET实现滑动窗口限流:原理、Lua脚本与生产实践 1. 项目概述为什么需要滑动窗口限流在分布式系统和高并发场景下限流是一个老生常谈但又至关重要的基础保障。想象一下你运营着一个热门秒杀活动的API接口或者一个实时推送消息的服务如果没有一道“闸门”来控制流量瞬间涌入的请求就像洪水一样很容易冲垮你的数据库或应用服务器导致服务雪崩。常见的限流算法有计数器、漏桶、令牌桶而滑动窗口限流尤其是基于Redis ZSET的实现因其精度高、能平滑处理流量突刺的特点在需要精细控制QPS每秒查询率的场景下备受青睐。简单来说滑动窗口限流不再以固定的1秒或1分钟为整块时间进行计数而是将一个大的时间窗口比如1分钟划分为多个更小的时间片比如10秒一个片。限流判断时只统计当前时间点往前回溯一个完整窗口时长内的请求数量。这样请求的计数会随着时间“滑动”避免了固定窗口算法在窗口切换瞬间可能承受两倍流量冲击的问题控制更加平滑和精确。而Redis的ZSET有序集合数据结构凭借其按分数Score排序和范围查询的能力天生就是实现滑动窗口计数的绝佳载体。今天我们就来彻底拆解这个方案从原理到实现再到生产环境里的那些坑手把手带你搞懂如何用Redis ZSET搭建一个稳健的滑动窗口限流器。2. 核心原理ZSET如何化身时间滑动窗口要理解这个方案首先得吃透Redis ZSET和滑动窗口算法是如何完美结合的。2.1 滑动窗口算法精讲假设我们的限流规则是每分钟最多允许100个请求。传统的固定窗口算法是从每分钟的第0秒到第59秒作为一个桶来计数。这会导致一个问题如果在第59秒瞬间来了100个请求在第60秒下一个窗口的第0秒又瞬间来了100个请求那么在这短短两秒内系统实际处理了200个请求这显然超出了我们每分钟100次的限制但对固定窗口算法来说这两个时间点分属两个窗口都是合法的。滑动窗口算法解决了这个问题。它把1分钟的时间窗口“滑动”起来。例如当前时间是第65秒那么滑动窗口统计的就是从第5秒到第65秒这60秒内的请求总数。无论请求何时到来我们统计的都是最近60秒的请求量。这样上面那种跨窗口的流量突刺就会被准确地纳入统计并拒绝掉限流效果更加平滑。2.2 Redis ZSET 的核心能力解析Redis的ZSETSorted Set是一个有序的、元素不重复的集合。每个元素Member都会关联一个分数Score。ZSET的核心命令为我们实现滑动窗口提供了原子操作ZADD key score member添加一个元素。我们将时间戳作为score将一个唯一标识如UUID或微秒时间戳随机数作为member。这样ZSET就按时间顺序排列了所有请求记录。ZREMRANGEBYSCORE key min max移除指定分数区间的所有元素。这是我们实现“滑动”的关键。每次执行限流判断前我们可以移除窗口开始时间之前的所有旧记录只保留窗口内的有效记录。ZCARD key或ZCOUNT key min max获取集合基数元素总数或指定分数区间内的元素数量。获取完数量我们就能判断当前窗口内的请求数是否超过了阈值。通过组合这些命令我们就能在Redis中维护一个随时间自动清理的请求记录队列。2.3 方案架构与数据流转整个限流逻辑可以抽象为一个简单的流程请求到达一个请求触发限流判断。清理旧数据使用ZREMRANGEBYSCORE删除当前时间减去窗口大小之前的所有记录。例如当前时间戳是ts_now窗口大小是window_size单位秒则删除分数小于ts_now - window_size的记录。记录新请求使用ZADD将当前请求的时间戳ts_now和一个唯一值member添加到ZSET中。判断是否限流使用ZCOUNT或先ZCARD再判断计算当前ZSET中的元素总数即窗口内请求数。如果数量大于阈值则拒绝请求否则允许通过。设置过期时间为整个ZSET Key设置一个TTL生存时间例如window_size 60秒防止某些异常情况下Key永远残留占用内存。这是一个非常重要的优化和兜底措施。注意步骤2、3、4需要保证原子性否则在高并发下会出现计数不准的问题。我们可以使用Redis的Lua脚本来将多个命令打包执行确保原子操作。3. 从零实现Lua脚本与代码实战理解了原理我们进入实战环节。我将分别展示使用Redis命令行、Lua脚本以及如何在JavaSpring Boot中集成。3.1 基础命令模拟与验证我们先通过Redis-cli手动模拟一下这个过程这能帮你建立最直观的感受。假设限流规则user:123:api这个资源60秒内最多允许10次请求。# 1. 定义变量 # 当前时间戳毫秒 127.0.0.1:6379 SET current_time 1715000000000 OK # 窗口大小毫秒 127.0.0.1:6379 SET window_size 60000 OK # 限流阈值 127.0.0.1:6379 SET threshold 10 OK # 限流的Key通常由“限流前缀:资源标识”组成 127.0.0.1:6379 SET limit_key “rate_limit:user:123:api” OK # 2. 计算窗口开始时间 127.0.0.1:6379 EVAL “return tonumber(ARGV[1]) - tonumber(ARGV[2])” 0 $current_time $window_size (integer) 1714999940000 # 假设窗口开始时间是 1714999940000 # 3. 清理窗口之前的旧数据 127.0.0.1:6379 ZREMRANGEBYSCORE rate_limit:user:123:api -inf 1714999940000 (integer) 0 # 首次执行没有旧数据 # 4. 获取当前窗口内请求数 127.0.0.1:6379 ZCARD rate_limit:user:123:api (integer) 0 # 5. 判断并添加新记录假设未超限 # 生成一个唯一的member这里用时间戳随机数模拟 127.0.0.1:6379 ZADD rate_limit:user:123:api 1715000000000 “req_1715000000000_abc123” (integer) 1 # 6. 设置Key的过期时间 127.0.0.1:6379 EXPIRE rate_limit:user:123:api 120 (integer) 1通过以上步骤我们完成了一次手动限流判断。但显然这离生产可用差得很远关键问题在于步骤3、4、5、6不是原子性的。在高并发下多个请求可能同时读到旧的ZCARD值都判断为未超限然后都执行ZADD导致实际请求数远超阈值。3.2 原子性保障Lua脚本编写解决原子性问题的银弹就是Redis Lua脚本。脚本在Redis中会被单线程执行能确保整个逻辑的原子性。下面是一个完整的、生产可用的Lua脚本-- 滑动窗口限流 Lua 脚本 -- KEYS[1]: 限流器的Key例如 rate_limit:user:123:api -- ARGV[1]: 窗口大小单位毫秒 -- ARGV[2]: 限流阈值 -- ARGV[3]: 当前时间戳毫秒 -- ARGV[4]: 本次请求的唯一标识可选用于精确删除但通常用时间戳范围删除已足够 local key KEYS[1] local window tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local now tonumber(ARGV[3]) -- 1. 移除窗口开始时间之前的所有记录 local windowStart now - window redis.call(‘ZREMRANGEBYSCORE’, key, ‘-inf’, windowStart) -- 2. 获取当前窗口内的请求数量 local currentCount redis.call(‘ZCARD’, key) -- 3. 判断是否超过阈值 if currentCount limit then -- 超过阈值拒绝请求返回0或false return 0 else -- 未超过阈值允许请求 -- 4. 记录本次请求使用时间戳作为score保证有序使用唯一标识作为member -- 这里使用 now 作为scoreARGV[4] 或一个随机值作为member。 -- 如果不需要精确删除某个请求member可以简单设置为一个递增数字或固定值但为了清晰我们使用时间戳随机数。 local member ARGV[4] or (tostring(now) .. ‘:’ .. math.random()) redis.call(‘ZADD’, key, now, member) -- 5. 刷新Key的过期时间设置为窗口大小 缓冲时间如10秒 redis.call(‘EXPIRE’, key, window / 1000 10) -- 返回1或true以及当前计数可选 return 1 end这个脚本一次性完成了清理、计数、判断、添加记录和设置过期时间的所有操作原子性得到了保证。脚本返回0表示被限流1表示通过。3.3 Java/Spring Boot 集成示例在Spring Boot项目中我们可以使用Jedis或LettuceSpring Boot 2.x默认客户端来调用这个Lua脚本。这里以Lettuce为例首先定义一个限流服务类import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component; import java.util.Collections; Component public class SlidingWindowRateLimiter { private final RedisTemplateString, Object redisTemplate; private final DefaultRedisScriptLong rateLimitScript; public SlidingWindowRateLimiter(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; // 初始化Lua脚本 this.rateLimitScript new DefaultRedisScript(); this.rateLimitScript.setScriptText( “local key KEYS[1]\n” “local window tonumber(ARGV[1])\n” “local limit tonumber(ARGV[2])\n” “local now tonumber(ARGV[3])\n” “local member ARGV[4]\n” “local windowStart now - window\n” “redis.call(‘ZREMRANGEBYSCORE’, key, ‘-inf’, windowStart)\n” “local current redis.call(‘ZCARD’, key)\n” “if current limit then\n” “ return 0\n” “else\n” “ redis.call(‘ZADD’, key, now, member)\n” “ redis.call(‘EXPIRE’, key, window / 1000 10)\n” “ return 1\n” “end” ); this.rateLimitScript.setResultType(Long.class); } /** * 尝试获取一个通行证 * param key 限流资源Key * param windowMs 窗口大小毫秒 * param limit 窗口内最大请求数 * return true 表示通过未限流false 表示被限流 */ public boolean tryAcquire(String key, long windowMs, int limit) { long now System.currentTimeMillis(); // 生成一个唯一的请求标识这里用时间戳UUID简化 String member now “:” java.util.UUID.randomUUID().toString().substring(0, 8); // 执行Lua脚本 Long result redisTemplate.execute( rateLimitScript, Collections.singletonList(key), // KEYS列表 windowMs, limit, now, member // ARGV列表 ); return result ! null result 1L; } }然后在Controller或Service层使用它RestController RequestMapping(“/api”) public class DemoController { Autowired private SlidingWindowRateLimiter rateLimiter; GetMapping(“/resource”) public ResponseEntityString getResource(RequestHeader(“X-User-Id”) String userId) { // 构造限流Key例如按用户ID限流 String limitKey “rate_limit:user:” userId “:api_resource”; // 规则每60秒最多10次请求 boolean allowed rateLimiter.tryAcquire(limitKey, 60000, 10); if (!allowed) { return ResponseEntity.status(429).body(“请求过于频繁请稍后再试”); // HTTP 429 Too Many Requests } // 正常的业务逻辑 return ResponseEntity.ok(“访问成功”); } }3.4 关键参数与配置详解在实现中有几个参数需要根据实际场景仔细考量窗口大小 (windowMs)这是限流的精度。60,000毫秒1分钟是常见选择。更小的窗口如1秒控制更精细但会给Redis带来更大的清理和计算压力。需要根据业务容忍的流量突刺程度来定。限流阈值 (limit)这个值需要结合系统的实际容量如API的QPS、数据库连接数和压测结果来设定。通常可以设置为系统最大承受能力的70%-80%留出余量。Key的设计 (limitKey)这是限流维度的体现。常见的维度有全局限流rate_limit:global:api_name对所有用户统一限制。用户限流rate_limit:user:{userId}:api_name按用户ID区分防止单个用户滥用。IP限流rate_limit:ip:{clientIp}:api_name针对IP地址。组合维度rate_limit:user:{userId}:ip:{clientIp}:api_name更加严格。 Key的设计直接决定了限流的粒度和公平性。过期时间缓冲在Lua脚本中我们设置了EXPIRE key, window / 1000 10。多加10秒或更多是一个重要技巧。因为ZSET的清理依赖于每次请求执行的ZREMRANGEBYSCORE。如果一个Key在窗口结束后很长一段时间没有新请求它可能永远不会被清理导致内存泄漏。设置一个略大于窗口的TTL可以让Redis自动清理这些“僵尸Key”是内存安全的一道保险。4. 生产环境进阶性能、精度与分布式考量一个基础的限流器上线后我们会面临更多实际挑战。下面我们来探讨几个进阶话题。4.1 性能瓶颈分析与优化滑动窗口限流的核心操作是ZREMRANGEBYSCORE和ZADD时间复杂度都是O(log(N))其中N是窗口内的元素数量。在极限高并发下如果窗口内积累了海量请求例如阈值设得极高这个操作可能会成为瓶颈。优化思路1控制窗口内元素数量这是最根本的。如果你的阈值是每分钟100万用ZSET存储100万个成员显然压力巨大。对于这种大流量限流可以考虑分层限流或使用其他算法。例如先用一个粗糙的固定窗口如每秒限流挡掉大部分异常流量再用滑动窗口进行精细控制。或者对于超大规模流量考虑使用基于Redis Cell模块的令牌桶算法或者使用专门的限流中间件如Sentinel。优化思路2使用管道Pipeline或更高效的序列化虽然Lua脚本解决了原子性问题但每次请求都要执行一个脚本。如果限流检查非常频繁网络往返和脚本加载如果未缓存会有开销。确保你的Redis客户端启用了脚本缓存SCRIPT LOAD。对于非原子性要求不极致的场景可以将清理旧数据(ZREMRANGEBYSCORE)的操作频率降低比如每10次请求执行一次但这会牺牲一定的精度。优化思路3内存占用考量每个请求在ZSET中都是一个成员如果member设计得很长比如一个完整的UUID内存消耗会比较大。可以优化member的设计例如使用递增的长整型ID或者将时间戳和序列号拼接成更短的字符串。使用redis-cli --bigkeys或MEMORY USAGE命令定期分析大Key。4.2 时间同步与时钟漂移问题我们的方案严重依赖客户端或应用服务器的时间戳(now)。如果多台应用服务器之间存在时钟不同步几秒甚至几分钟的偏差限流就会出问题服务器时钟快它会认为窗口开始时间更早清理掉更多“有效”的旧记录导致同一时间段内允许的请求数变多限流变松。服务器时钟慢它会保留更多“过期”的记录导致计数偏大限流变严误杀正常请求。解决方案使用Redis服务器时间。Redis提供了TIME命令来获取服务器时间。我们可以在Lua脚本中调用redis.call(‘TIME’)来获取当前时间戳从而保证所有客户端都使用统一的时间源。修改后的脚本关键部分如下-- 使用Redis服务器时间 local timeArray redis.call(‘TIME’) -- 返回一个数组如 {“1715000000”, “54321”}分别是秒和微秒 local nowMs tonumber(timeArray[1]) * 1000 math.floor(tonumber(timeArray[2]) / 1000) -- 后续逻辑使用 nowMs 代替传入的 now这样做消除了客户端时钟不一致的影响是生产环境部署的推荐做法。4.3 分布式场景下的挑战与应对我们的方案在单Redis实例下工作良好。但在Redis集群模式下Lua脚本要求所有操作的Key必须在同一个哈希槽slot中。我们的限流Key如果包含了变量如user:123通过CRC16计算后可能会落到不同的节点上导致脚本执行失败。解决方案1使用哈希标签Hash TagRedis集群允许使用{}来指定哈希标签只有{}内的内容会被用于计算slot。我们可以设计Key为rate_limit:{user:123}:api_name。这样即使用户ID不同只要{}内的部分相同比如我们固定写rate_limit:{global}:api_name做全局限流或者让{}包含我们需要分片到同一slot的标识就能保证Key落在同一节点。但注意这可能会造成数据倾斜。解决方案2客户端分片在应用层根据限流维度如用户ID计算出一个分片ID然后将请求路由到对应的、独立的Redis实例或集群分片上进行限流判断。这增加了客户端的复杂度。解决方案3考虑其他分布式限流方案对于严格的、需要跨节点共享计数器的分布式限流ZSET方案在集群下不够优雅。此时可以考虑Redis Cell模块提供了原子性的令牌桶操作。网关层限流在Nginx、API Gateway如Spring Cloud Gateway、Kong层面做限流这些网关通常是单点或主从架构避开了分布式数据一致性问题。中间件限流使用阿里云Sentinel等专业组件它们内置了集群限流模式。实操心得对于大部分业务场景按用户或IP维度的限流即使是在集群下因为同一个用户的请求通常会在一段时间内落到同一个应用实例通过会话保持或一致性哈希我们可以让每个应用实例使用一个独立的Redis从节点或者使用本地缓存少量同步的方式做一个二级限流来缓解对中心Redis的绝对依赖。这被称为“分层限流”或“本地限流降级”。5. 避坑指南与最佳实践踩过坑才知道路怎么走。下面是我在实践中总结的几个关键点和常见问题。5.1 常见问题排查表问题现象可能原因排查步骤与解决方案限流完全不生效超过阈值的请求依然通过。1. Lua脚本未正确执行或返回值判断逻辑错误。2. 多实例部署下未使用Redis服务器时间客户端时间不同步导致窗口计算错误。3. Key设计不合理不同维度的请求使用了相同的Key导致计数分散。1. 在Redis上使用MONITOR命令观察脚本是否被执行检查脚本返回值0/1在业务代码中是否正确解析。2. 修改脚本使用redis.call(‘TIME’)获取时间。3. 检查限流Key的生成逻辑确保同一限流维度的请求生成相同的Key。限流过于严格在低流量时也频繁被拒。1. 窗口大小(windowMs)设置过小或阈值(limit)设置过低。2.时钟漂移客户端时间比Redis慢导致窗口开始时间计算偏晚保留了过多旧记录。3.ZREMRANGEBYSCORE操作异常未能正确清理旧数据导致历史数据堆积。1. 复核业务需求和系统容量调整参数。可通过日志统计实际QPS。2. 统一使用Redis服务器时间。3. 检查脚本中windowStart的计算逻辑手动执行ZRANGE key 0 -1 WITHSCORES查看ZSET内成员和分数确认旧数据是否被正确清理。Redis内存持续增长疑似内存泄漏。1. Key未设置过期时间TTL。2. 某个限流Key在达到阈值后长期没有新请求触发清理和过期。3. 限流维度太多如按IP限流IP量巨大产生海量Key。1. 确保Lua脚本中包含了EXPIRE命令且过期时间大于窗口。2. 考虑增加一个后台定时任务扫描并删除长时间未访问的限流Key使用SCAN命令。3. 评估限流维度对于IP这类基数大的维度考虑采用更粗粒度的限流如整个IP段或使用布隆过滤器等数据结构先进行恶意IP识别。高并发下偶尔出现计数超出阈值1-2个。竞态条件。虽然Lua脚本是原子的但如果在脚本执行ZCARD和ZADD之间有其他连接也执行了包含ZCARD的脚本并且它们读取到的currentCount都未超限那么它们都会执行ZADD导致最终数量略超阈值。这在极端高并发下可能发生。1. 这是分布式环境下最终一致性的正常体现通常超出的数量很少对于大多数业务可以接受。2. 如果要求绝对精确可以考虑使用Redis的WATCH/MULTI/EXEC事务或者使用INCR和设置过期时间模拟滑动窗口精度会下降或者寻求更严格的分布式锁方案但性能损耗会大增。5.2 最佳实践总结Key设计要清晰且可管理使用统一的命名空间如rate_limit:{维度}:{资源}。便于通过rate_limit:*模式进行监控和清理。务必设置TTL这是防止内存泄漏的生命线。TTL应设置为窗口长度加上一个缓冲期如30-60秒。使用Redis服务器时间在Lua脚本中调用TIME命令避免时钟不同步问题。监控与告警监控Redis中限流Key的数量和内存占用。监控被限流请求的比例429状态码当比例异常升高时触发告警可能是业务量正常增长需要调整阈值或是遭遇了攻击。做好降级和兜底在Redis不可用或限流组件本身出现问题时要有降级策略。例如可以降级为本地Guava RateLimiter或者直接熔断返回一个友好的错误页面而不是让请求堆积导致系统完全不可用。测试测试再测试在上线前必须进行充分的测试。包括单元测试验证逻辑、集成测试验证与Redis的交互、以及压测验证在高并发下的表现和准确性。可以使用JMeter等工具模拟突刺流量观察限流效果是否平滑。滑动窗口限流是一个优雅且实用的模式Redis ZSET为其提供了强大的底层支持。从理解原理到实现再到应对生产环境的复杂性每一步都需要仔细考量。它不是一个“设置即忘”的组件而是一个需要根据业务流量模式、系统架构和运维能力不断调优的活系统。希望这篇详细的拆解能让你在下次需要保护你的系统时心中更有底气。
返回列表