Redission分布式

发布时间:2026/7/24 12:38:23
Redission分布式 技术点三个key的分工三个 Key 的分工Key作用rate_limit:xxx(Hash)存储限流器的配置参数速率、时间窗口等。{rate_limit:xxx}:value(String)当前可用令牌数允许通过的请求数。{rate_limit:xxx}:permits(ZSet)滑动窗口的请求时间戳记录用于精准计数。为什么value会一次少一次在你的代码中tryAcquire(1)每次调用时Redisson 会读取当前value例如2。如果大于 0就减 1然后允许请求通过。同时它还会更新permits里的时间戳记录用于滑动窗口。所以当你看到value从2→1→0就是令牌在逐次消耗。当value变成0时下一次请求就会因为无令牌可用而被拒绝抛出RateLimitException。为什么看起来是两种算法同时存在是的Redisson 的RRateLimiter为了兼顾灵活性和高性能内部同时使用了令牌桶的思想用value存储当前令牌数保证平滑限流。滑动窗口的精准计数用permitsZSet 来消除固定窗口的突刺问题。但具体实现是通过 Lua 脚本原子化地结合两者对外暴露的只是tryAcquire()一个接口你只需关心是否能取到令牌即可。tryAcquire(1)tryAcquire(1)的源码是不是读取这个key然后减掉1不是简单地“读取value然后减1”而是通过一段Lua 脚本在 Redis 服务端原子执行同时操作多个 Key。简化后的逻辑大致如下-- 1. 获取配置从 rate_limit:xxx Hash 里读取速率参数 local rate redis.call(hget, KEYS[1], rate) local interval redis.call(hget, KEYS[1], interval) -- 2. 清理过期的 permits从 ZSet 里删除窗口外的旧记录 local now redis.call(time)[1] * 1000 redis.call(time)[2] / 1000 redis.call(zremrangebyscore, KEYS[3], 0, now - interval) -- 3. 从 value 里读取当前可用令牌数或用 ZSet 的计数直接算 local permits redis.call(zcard, KEYS[3]) -- 当前窗口内请求数 local max_permits rate -- 4. 判断是否还能放行 if permits max_permits then -- 在 ZSet 里插入一个新成员分数为当前时间戳 redis.call(zadd, KEYS[3], now, ARGV[1]) -- 更新 value 键实际上 Redisson 可能不直接用 value 计数而是用 ZSet 的 size redis.call(set, KEYS[2], max_permits - permits - 1) return 1 -- 允许 else return 0 -- 拒绝 end为什么你看到value在减少{rate_limit:xxx}:value是 Redisson为了提高读取效率而做的缓存它会随着每次成功获取令牌而递减但它不是唯一的真实来源。真正的计数依据是permits这个 ZSet 的大小。这套机制的好处原子性多个操作在同一个 Lua 脚本里完成避免并发问题。滑动窗口通过 ZSet 的时间戳插门精确控制任意时间段的流量消除毛刺。集群兼容所有相关 Key 都在同一个 Slot通过{}Hash Tag 保证。所以你看到的现象是tryAcquire(1)会减少value但背后实际上运行了一个复杂的原子判断过程。trySetRatelimiter.trySetRate(RateType.OVERALL, count, time, RateIntervalUnit.SECONDS);解释一下这个limiter.trySetRate(RateType.OVERALL, count, time, RateIntervalUnit.SECONDS)的作用是配置这个限流器的“规则”。它相当于在说“我要让这个限流器在time秒内最多允许count个请求”。参数含义参数含义RateType.OVERALL限流类型OVERALL表示所有客户端加起来的总速率也就是全局共享这count个许可。如果换成PER_CLIENT每个客户端会有自己独立的许可数。count窗口内允许的最大请求数也就是令牌桶的容量。time时间窗口的长度。RateIntervalUnit.SECONDS时间窗口的单位这里是秒。比如time1就表示 1 秒。例如trySetRate(RateType.OVERALL, 10, 1, RateIntervalUnit.SECONDS)就定义了1 秒内最多 10 个请求。它做了什么向 Redis 写入配置它会创建一个 Hash 结构键就是rate_limit:xxx比如你例子里的rate_limit:10.18.65.33。这个 Hash 里会存放速率rate、时间窗口interval等参数供后续tryAcquire的 Lua 脚本读取。初始化数据结构如果尚未存在如果对应的{rate_limit:xxx}:value和{rate_limit:xxx}:permits还没创建它也会一并初始化。比如{rate_limit:xxx}:value会被初始化为count比如10{rate_limit:xxx}:permits会被清空或准备就绪。幂等性如果限流器已经存在且相同的配置相同速率、相同窗口trySetRate不会重复创建直接返回true。如果配置不同它会更新成新配置覆盖旧的 Hash 字段重置令牌等。为什么你在代码里每次都调用它你现在的写法limiter.trySetRate(...); if (limiter.tryAcquire(1)) { ... }每次请求都先调用一次trySetRate然后再尝试获取令牌。这是安全的但有些浪费。因为trySetRate只在第一次需要创建配置之后配置不变时它只做一个简单的存在性检查Redis 操作非常快几乎无开销。如果想优化可以在初始化时例如PostConstruct统一设置切面里只调用tryAcquire。不过保持现状也没问题Redis 不会因此产生性能瓶颈。举个例子形象理解trySetRate 告诉高速公路收费站“这辆车最多允许每 10 秒通过 5 辆”制定规则写在牌子上。tryAcquire(1) 每次有车来收费站检查规则并决定是否放行扣一枚令牌。如果每次车来你都重新喊一遍“规则是 10 秒 5 辆”收费站只会点头说“知道了”但不会重新竖牌子所以无伤大雅。你之前看到的三个 Redis Key 的由来rate_limit:10.18.65.33←trySetRate创建的存放规则。{rate_limit:10.18.65.33}:value←trySetRate初始化tryAcquire修改。{rate_limit:10.18.65.33}:permits←trySetRate初始化tryAcquire增删记录。因此第一次调用trySetRate时这三个 Key 就同时出现了。键仍然存在当我把项目关闭之后,限流里面的内容都超时然后被清理之后,我发现redis仍然存在这些键你观察到的现象是正确的即使项目关闭那些 Key 依然“永久”存在于 Redis 中不会自动消失。这和你预想的“超时清理”有出入原因如下1. 这几个 Key 本身并没有设置过期时间TTLrate_limit:xxxHash 配置{rate_limit:xxx}:valueString 令牌数{rate_limit:xxx}:permitsZSet 窗口记录它们由trySetRate()创建但Redisson 默认不会给它们加 TTL。即使项目关闭这些 Key 也会一直留在 Redis 里除非你手动删除或 Redis 重启没有持久化的情况下。2. “清理”到底清理了什么清理的是 ZSet 的成员不是 Key 本身每次请求时Lua 脚本会执行ZREMRANGEBYSCORE来删除permits这个 ZSet 里已经过期的成员时间戳超出窗口的记录。但这只是删除集合内部的数据并不会把整个{rate_limit:xxx}:permits这个 ZSet 键删除掉。举例窗口 1 秒当前时间 T。Lua 脚本删除 T-1000ms的成员让 ZSet 里只剩下最近 1 秒内的请求。如果有一段时间没有任何请求所有旧成员会被删光此时{rate_limit:xxx}:permits变成了一个空的 ZSet但它依然是一个有效的 Redis Key。同理{rate_limit:xxx}:value会在请求消耗后变成 0但它仍然存在。3. 为什么 Redisson 不自动删除这些 Key复用性下次再有请求时可以直接复用已有的配置和数据结构无需重新创建减少 Redis 开销。设计简洁Redis 的 Key 通常由业务自行管理生命周期客户端库不会主动删除你没有明确要求删除的 Key。如果每次过期就删 Key那么高并发场景下频繁创建和销毁 Key反而可能影响性能。这对系统有影响吗基本没有。这三个键的体积非常小Hash 只有几个字段String 是个数字空 ZSet 开销极小对 Redis 内存的占用几乎可以忽略。下次再有请求过来时会直接复用它们反而省去了创建成本。但如果你有强迫症或者出于安全/审计要求希望它们在一定时间后自动消失可以参考下面的方案。如何让它们自动过期方案一在trySetRate后设置 TTL推荐修改你的切面代码在第一次创建或每次请求时顺带给键加一个合理的过期时间例如 1 小时。可以用 Redisson 提供的 API 直接设置RRateLimiter limiter redissonClient.getRateLimiter(key); limiter.trySetRate(RateType.OVERALL, count, time, RateIntervalUnit.SECONDS); // 给相关键设置 TTL long ttlSeconds time * 2; // 比如窗口的 2 倍时间可根据业务调整 redissonClient.getBucket({rate_limit: key }:value).expire(Duration.ofSeconds(ttlSeconds)); redissonClient.getScoredSortedSet({rate_limit: key }:permits).expire(Duration.ofSeconds(ttlSeconds)); // 如果配置键也想过期同样操作 RMapString, String configMap redissonClient.getMap(rate_limit: key); configMap.expire(Duration.ofSeconds(ttlSeconds)); if (limiter.tryAcquire(1)) { ... }注意每次请求都设置一次expire是幂等的不会延长太多时间但为了减少不必要的 Redis 调用可以只在限流器首次创建时设置 TTL例如通过检查configMap.isExists()不过这需要额外的判断逻辑。简单起见直接每次设置影响也不大。方案二利用 Redis 自身的键空间通知或定时任务清理不推荐因为增加复杂度。方案三不做清理接受现状正如前面所说这些小键占用的内存微乎其微并不会影响 Redis 性能。除非你创建了海量不同的限流 key否则完全可以不管。总结你的写法没有错残留的 Key 是 Redisson 的正常行为。如果不介意就留着如果想清理给这些键加上expire即可。查询zset类型的数据ZRANGE {rate_limit:abcd}:permits 0 -1 WITHSCORES疑惑点清理数据比如在0.1秒和0.5秒和0.9秒的时候都请求了一次 那么在1秒时会清理掉0.1秒还是清理掉这三个全部的滑动窗口是动态的不是固定区间你设置的窗口是1秒意思是“任意时刻往前推1秒内的请求总数”。在0.1秒时窗口是 [ -0.9 , 0.1 ]里面只有 0.1 这一个请求。在0.5秒时窗口是 [ -0.5 , 0.5 ]里面有 0.1 和 0.5 两个请求。在0.9秒时窗口是 [ -0.1 , 0.9 ]里面三个请求都在。到了1.0秒窗口变成 [ 0.0 , 1.0 ]0.1、0.5、0.9 依然全部在窗口内因为它们都 ≥ 0.0所以一个都不会被清理。什么时候才会清理清理条件是“钉子”的时间当前时间-窗口大小。例如当时间走到1.2秒时窗口变成 [ 0.2 , 1.2 ]此时 0.1 秒的请求小于 0.2会被删除。当时间走到1.6秒时0.5 秒的请求也会被删除依此类推。所以不会在整秒点突然清空一批而是随着时间推移哪个钉子“滑出”窗口的左边界就删哪个。这就是滑动窗口和固定窗口的最大区别——它是连续平滑的不会出现“1秒到立刻清零”的突刺问题。permits127.0.0.1:6379 ZRANGE {rate_limit:10.18.65.33}:permits 0 -1 WITHSCORES 1) \x10\xf8\x19\xcf\x99\x94\xe0\xb3\x14\xf7\x16\xdfh\xfb\\xab\x05\x01\x00\x00\x00 2) 1784768675624我这个记录的是相当于在0.1秒时的一个请求是吗不是这样的。你看到的1784768675624是一个绝对时间戳毫秒不是相对时间。解读这个数值1784768675624毫秒换算成日期是2026年7月3日 14:24:35.624 UTC具体时区可能不同这意味着你之前做的那次请求发生在这一时刻真实现实时间。如果系统一直没再请求过了一段时间后其他更早的记录可能已经被清理只剩下这一条相对较新的记录。滑动窗口是以绝对时间为基准的比如窗口设为1秒那么在1784768675624这个时刻窗口就是[1784768674624, 1784768675624]它只会和自己比。为什么你只有一条记录可能的原因距离上次请求已经过了很久窗口外的旧记录都被清掉了只剩下这条还在窗口内或者它本身就是最新一条而窗口尚未过期。也可能你总共就只请求了一次。之前举例的0.1秒、0.5秒只是比喻那些数字是为了让你更容易理解滑动窗口的逻辑实际存储的都是真实的Unix时间戳。所以这条记录不是“在0.1秒的那个请求”而是“在真实世界某个时刻的那个请求”。滑动窗口算法会拿当前时间比如T_now与这条记录的时间戳1784768675624做减法如果差值超过设定的窗口比如1秒就会被删除。可以这样验证在 Redis 里执行TIME命令看看当前服务器时间然后和1784768675624一对比就能知道这个请求是多久以前发生的了。permits【实践】我改为了根据userid在一分钟内限流三次。用户名为abcd分别用abcd用户调用三次同一个接口127.0.0.1:6379 flushall OK 127.0.0.1:6379 keys * (empty list or set) 127.0.0.1:6379 keys * 1) rate_limit:abcd 2) {rate_limit:abcd}:permits 3) {rate_limit:abcd}:value 127.0.0.1:6379 ZRANGE {rate_limit:abcd}:permits 0 -1 WITHSCORES 1) \x10\x99\x95\x8d2\x131\xea\x9c\xf7s\xa7I\xe65]\x0b\x01\x00\x00\x00 2) 1784770102662 3) \x10\xf1h\x9b\x13\xbb\xd7\x1e\x8b\xa2\xd7\xd3B\ru\x87y\x01\x00\x00\x00 4) 1784770105549 127.0.0.1:6379 ZRANGE {rate_limit:abcd}:permits 0 -1 WITHSCORES 1) \x10\x99\x95\x8d2\x131\xea\x9c\xf7s\xa7I\xe65]\x0b\x01\x00\x00\x00 2) 1784770102662 3) \x10\xf1h\x9b\x13\xbb\xd7\x1e\x8b\xa2\xd7\xd3B\ru\x87y\x01\x00\x00\x00 4) 1784770105549 5) \x10D\x98\x8ax52\xfa\xb6\xf5\x8e\xec_\xed\xce?\xb6\x01\x00\x00\x00 6) 1784770119316 127.0.0.1:6379 ZRANGE {rate_limit:abcd}:permits 0 -1 WITHSCORES 1) \x10\x99\x95\x8d2\x131\xea\x9c\xf7s\xa7I\xe65]\x0b\x01\x00\x00\x00 2) 1784770102662 3) \x10\xf1h\x9b\x13\xbb\xd7\x1e\x8b\xa2\xd7\xd3B\ru\x87y\x01\x00\x00\x00 4) 1784770105549 5) \x10D\x98\x8ax52\xfa\xb6\xf5\x8e\xec_\xed\xce?\xb6\x01\x00\x00\x00 6) 1784770119316 127.0.0.1:6379- 此时是第一次和第二次调用永远只会是这三条127.0.0.1:6379 keys * 1) rate_limit:abcd 2) {rate_limit:abcd}:permits 3) {rate_limit:abcd}:value然后查看具体的值此时因为我请求了两次所以有两个数据127.0.0.1:6379 ZRANGE {rate_limit:abcd}:permits 0 -1 WITHSCORES 1) \x10\x99\x95\x8d2\x131\xea\x9c\xf7s\xa7I\xe65]\x0b\x01\x00\x00\x00 2) 1784770102662 3) \x10\xf1h\x9b\x13\xbb\xd7\x1e\x8b\xa2\xd7\xd3B\ru\x87y\x01\x00\x00\x00 4) 1784770105549【其中一个数据中是包含了一个 uuid和对应的时间戳】- 接着是第三次和第四次调用因为在一分钟之内所以只会是三条数据127.0.0.1:6379 ZRANGE {rate_limit:abcd}:permits 0 -1 WITHSCORES 1) \x10\x99\x95\x8d2\x131\xea\x9c\xf7s\xa7I\xe65]\x0b\x01\x00\x00\x00 2) 1784770102662 3) \x10\xf1h\x9b\x13\xbb\xd7\x1e\x8b\xa2\xd7\xd3B\ru\x87y\x01\x00\x00\x00 4) 1784770105549 5) \x10D\x98\x8ax52\xfa\xb6\xf5\x8e\xec_\xed\xce?\xb6\x01\x00\x00\x00 6) 1784770119316 127.0.0.1:6379 ZRANGE {rate_limit:abcd}:permits 0 -1 WITHSCORES 1) \x10\x99\x95\x8d2\x131\xea\x9c\xf7s\xa7I\xe65]\x0b\x01\x00\x00\x00 2) 1784770102662 3) \x10\xf1h\x9b\x13\xbb\xd7\x1e\x8b\xa2\xd7\xd3B\ru\x87y\x01\x00\x00\x00 4) 1784770105549 5) \x10D\x98\x8ax52\xfa\xb6\xf5\x8e\xec_\xed\xce?\xb6\x01\x00\x00\x00 6) 1784770119316 127.0.0.1:6379