资讯动态

Redis-Cell实现分布式限流:从令牌桶原理到Spring Boot落地实践

发布时间:2026/10/15 5:34:51 来源:尧图企业网站定制
在处理限流问题的时候我一直有个习惯先在脑子里过一遍场景再动手写方案。最近给一套微服务网关做全局限流折腾了一圈最终选定了Redis-Cell这个模块。这套方案用起来是真省事一条CL.THROTTLE命令就把令牌桶算法的活全干了而且分布式环境下多实例共享状态不像单机限流那样各算各的。这篇就把我从选型、安装、命令拆解到Spring Boot接线的完整过程写出来给正在做分布式限流又拿不准方案的同学一个参考。先交代一下背景。我们当时要限流的是一批面向C端的查询接口单机QPS峰值能到几千线上部署了多个实例前面还有网关入口统一接流量。最初用的Guava RateLimiter后来发现多实例部署之后限流完全失真A节点的判断对B节点毫无约束力。后来又试过在Nginx层做limit_req但业务侧的限流维度复杂——既要按用户维度控制频率又要按接口维度控制总量网关层做越搞越复杂。最终决定在业务代码上一层分布式限流调研了Redis-Cell、Redisson的RRateLimiter、自写Lua脚本这几个方向最后选了Redis-Cell。1. 为什么分布式限流要选Redis-Cell1.1 单机限流在微服务下的尴尬限流这件事单机场景下其实很成熟。无论是Guava的RateLimiter还是Semaphore本质上都是进程内的计数器或者令牌桶内存维护状态性能极高。可一旦服务水平扩容成多实例这套方案的问题就暴露了每个实例各自维护各自的计数用户请求经过负载均衡落到不同节点每个节点的通过率都不一样。举个具体例子。某个接口配置了每用户每分钟最多10次请求你有4个实例负载均衡轮询分发。用户1秒内连发4个请求可能正好分到4个不同实例上每个实例都检查自己的本地计数——都发现“当前用户这分钟还没请求过”于是全部放行。实际请求量是配置的4倍。反过来如果流量集中在某个节点用户明明还有剩余额度却因为那个节点计数超了被拒掉。这种错乱在低并发时问题不大但一旦流量上来限流本身就变成了随机拒请求完全没法跟业务方交代。所以分布式限流的本质诉求就一句话所有实例共享同一份限流状态判断逻辑在全局维度上保持一致。1.2 Redis-Cell的令牌桶到底做了什么Redis-Cell本质上是一个Redis模块注册了一条CL.THROTTLE命令。这条命令实现的是标准的令牌桶算法系统按固定速率往桶里放令牌桶容量有限请求来了必须拿到令牌才能通过拿不到就拒绝或者等待。相比手写Lua脚本Redis-Cell的核心优势是算法逻辑已经用C语言实现好了不占业务进程的内存不消耗额外的CPU去解析脚本而且命令本身是单线程Redis中的原子操作天然不存在并发竞争问题。另外它的返回值非常讲究不仅告诉你这次请求是否被允许还直接把“还需要等多少秒”都算好了这在做平滑限流、排队等待场景时非常有用。讲一下令牌桶和漏桶的区别。漏桶是均匀流出不管上游怎么突发下游永远是恒定速率适合保护数据库这类对流量平滑要求极高的场景。令牌桶则允许一定程度的突发——桶里有令牌就能一次性全部拿走适合接口本身能扛短时突发的场景。Redis-Cell默认走令牌桶思路大多数业务接口用令牌桶更合适因为它不会把正常的小峰值流量直接砍死。2. 环境准备编译安装Redis-Cell模块2.1 Redis 4.0的模块机制Redis从4.0开始引入了模块系统允许用C语言写扩展功能编译成一个.so动态库Redis启动时加载进去就行。Redis-Cell就是这样一种模块不是Redis本身内置的功能所以你得先把它编译出来放到Redis服务器上告诉Redis“加载它”。安装模块有两种常见方式一是在redis.conf里配置loadmodule /path/to/libredis_cell.soRedis启动时自动加载二是Redis运行中执行MODULE LOAD /path/to/libredis_cell.so动态加载。生产环境我建议走配置文件方式原因很简单——一旦Redis实例重启动态加载的模块会丢失而配置文件的设置是持久的。补充一点如果你用的是云数据库Redis很多云厂商已经在控制台内置了Redis-Cell模块直接控制台开启就行不需要自己编译。自建Redis的话才需要走下面这套流程。2.2 安装与验证流程编译Redis-Cell需要先确认服务器上有gcc、make这些基础工具Redis的源码头文件也要有。我一般在服务器上拉取源码编译git clone https://github.com/brandur/redis-cell.git cd redis-cell make编译完会在源码目录下生成一个libredis_cell.so文件。注意不同Linux发行版的glibc版本会影响兼容性我遇到过在一台CentOS 7上编译的.so文件拷贝到另一台CentOS 8上加载失败的情况。解决办法是直接在目标服务器上编译或者保证操作系统环境一致。然后修改redis.confloadmodule /usr/local/redis-cell/libredis_cell.so重启Redis之后用redis-cli验证模块是否加载成功MODULE LIST能看到redis_cell模块信息说明加载成功。再顺手验证一下命令是否可用 CL.THROTTLE test_key 1 1 60 1 1) (integer) 0 2) (integer) 1 3) (integer) -1 4) (integer) 2能正常返回这一串结果说明CL.THROTTLE命令已经生效可以开始配置限流策略了。3. 核心命令CL.THROTTLE参数与返回值逐一拆解3.1 命令参数及其含义CL.THROTTLE这条命令的完整格式是这样的CL.THROTTLE key max_burst count period initial_burstkey限流的维度说白了就是你按什么做隔离。按用户限流就是user:{userId}按接口限流就是api:{接口路径}按IP限流就是ip:{IP地址}。key设计直接决定限流策略的粒度后面会展开说。max_burst桶的最大容量也就是允许的最大突发量。比如设成10就意味着桶里最多攒10个令牌请求一次性最多可以拿走10个。count和period组成速率。count表示在period秒内放多少个令牌进桶。比如5 60表示每60秒放入5个令牌也就是速率是每分钟5个。initial_burst初始化时桶里的令牌数。设成max_burst的值表示一开始桶就是满的服务刚启动时可以接受突发流量。设成0表示桶初始是空的请求要等令牌慢慢生成。这里有个坑很多人在测试时把initial_burst设成0结果第一次请求直接被拒还以为命令用错了。实际上这是正常的令牌还没生成呢。举个实际的配置例子。假设要做“每个用户每分钟最多10次请求允许一次性突发5个”CL.THROTTLE user:123 5 10 60 5这个配置可以理解为第一秒内最多能通过5个请求之后每秒补充10/60个令牌一分钟内总量不会超过10个。突发额度和总速率是两回事需要根据业务特性分别调整。3.2 返回值怎么读CL.THROTTLE命令的返回值是四个整数构成的数组这四个数每个都有明确用途 CL.THROTTLE user:123 5 10 60 5 1) (integer) 0 2) (integer) 5 3) (integer) -1 4) (integer) 11第一个值是“本次请求是否被允许”0表示允许其他值表示拒绝。第二个值是“当前桶里剩余的令牌数”。第三个值是“如果请求被拒绝建议等待多少秒后再重试”未被拒绝时是-1这是做“请稍后再试”提示的关键数据。第四个值是“令牌桶恢复到满状态还需要多少秒”这个数据在做监控和容量评估时很有参考价值。我实际用下来最实用的就是第三个返回值。当用户触发限流时直接把retry_after_seconds返回给前端前端可以倒计时展示“操作太频繁请X秒后再试”体验比硬生生拒绝好得多。自写Lua脚本实现同样效果也不难但要把时间计算逻辑写对免不了反复调试Redis-Cell直接省掉了这一步。4. Spring Boot工程里的落地方案4.1 工程依赖与Redis客户端配置服务端是Java技术栈Spring Boot项目Redis客户端用的是Lettuce。Redis-Cell在Java这边没有什么专属的SDK底层就是执行一条命令所以直接用Lettuce的原生命令接口即可。如果你的项目用的是Spring Data Redis可以用RedisTemplate的execute方法发送原生命令。关键的配置就一块确保RedisTemplate使用的是String序列化器或者干脆用一个单独的StringRedisTemplate来执行限流命令。原因很简单CL.THROTTLE的key和参数都是字符串用JDK序列化器会在key前面加一串乱码前缀导致限流key跟预期不一致查问题的时候非常难定位。我推荐单独注入一个StringRedisTemplate专用于限流操作避免影响到其他业务Redis操作Configuration public class RedisConfig { Bean public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) { return new StringRedisTemplate(factory); } }Lettuce连接池参数也得留意。限流操作是高频调用每次请求都走一次Redis往返如果连接池上限太小流量上来后线程可能阻塞在获取连接上反而拖慢接口响应。我一般把maxTotal调到Redis实例能承受的上限maxIdle和minIdle保持合理值具体看压测结果调整。4.2 用注解切面封装限流逻辑直接在每个业务方法里写CL.THROTTLE调用代码代码会非常冗余而且限流维度混在业务逻辑里很难维护。我的做法是定义注解和AOP切面业务代码只需要打一个注解限流逻辑全部沉淀在切面里。先定义一个注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RateLimit { // 限流维度key支持SpEL表达式 String key(); // 令牌桶最大突发量 int maxBurst() default 5; // 限制周期内允许的请求数 int count() default 10; // 周期秒数 int period() default 60; }切面类核心逻辑Aspect Component public class RateLimitAspect { Autowired private StringRedisTemplate redisTemplate; Around(annotation(rateLimit)) public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String limitKey parseKey(rateLimit.key(), joinPoint); Long retryAfter doRateLimit(limitKey, rateLimit.maxBurst(), rateLimit.count(), rateLimit.period()); if (retryAfter ! null retryAfter 0) { throw new RateLimitExceededException(操作过于频繁请 retryAfter 秒后重试); } return joinPoint.proceed(); } private Long doRateLimit(String key, int maxBurst, int count, int period) { ListObject results redisTemplate.execute( (RedisCallbackListObject) connection - connection.execute(CL.THROTTLE, key.getBytes(), String.valueOf(maxBurst).getBytes(), String.valueOf(count).getBytes(), String.valueOf(period).getBytes(), String.valueOf(maxBurst).getBytes()) ); // 返回值是否允许、剩余令牌数、重试等待秒数、桶满还需秒数 Long allowed (Long) results.get(0); if (allowed ! null allowed 0) { return 0L; } return (Long) results.get(2); } }这段逻辑已经可以直接用了。需要说明的是切面里我把initial_burst设成了maxBurst保证服务刚启动时桶是满的业务不至于因为冷启动被误伤。如果你的场景要求启动即严格限流改成0即可。4.3 多维度限流按用户、按IP、按接口限流key的设计是分布式限流最核心的部分。同一个接口、同一套注解key选什么维度效果完全不同。按用户限流key是rate:user:{userId}。适合需要控制单用户操作频率的场景比如登录验证码、评论点赞、提交订单。这种限流可以防止单个用户刷接口。按IP限流key是rate:ip:{ip}。适合做网关层防护防止某个IP地址疯狂爬数据。但要注意很多用户是共享出口IP的比如公司局域网、校园网按IP限流阈值设得太低会误伤一大批正常用户。按接口限流key是rate:api:{接口路径}。适合控制某个接口整体的调用总量比如下游服务只能承受每秒1000次调用不管多少用户来总量超过就拒。实际情况往往是多个维度叠加使用。我在项目里就是把三层层级串联起来的先做接口总控再做用户维度细控IP维度单独开在网关入口处。每一层的限流参数根据压测结果独立配置互不干扰。一个很容易被忽略的设计点是key的过期时间。CL.THROTTLE在redis-cell内部会为key维护令牌桶状态但这个状态太久不用应该被回收否则Redis里会积累大量废弃key。我在切面里加了定时清理逻辑每周扫一次删除超过24小时没有被请求过的限流key。如果你的业务量不大也可以依赖Redis的主动过期机制给key设置TTL但要注意CL.THROTTLE并不会自动帮你设置过期时间。5. 生产环境里的坑与经验记录5.1 常见问题排查速查表Redis-Cell使用过程中遇到的问题不少都是配置或者环境层面导致的。我整理了下面这些典型场景可以直接对照排查现象可能原因解决思路执行CL.THROTTLE报未知命令模块未加载成功MODULE LIST查看模块检查redis.conf的loadmodule路径所有请求都被拒绝initial_burst设成了0改为maxBurst或确认业务是否真的需要冷启动限流限流不生效多实例还是能突破阈值key设计不是全局维度检查限流key是否落到了不同的Redis实例上集群模式要关注hash tag响应时不时超时Redis连接池耗尽或Redis本身瓶颈调整Lettuce连接池参数给限流Redis单独部署实例Spring Boot项目执行命令报序列化错误RedisTemplate序列化器不匹配统一使用StringRedisTemplate突发流量一过来Redis CPU飙升限流操作频率过高考虑本地预过滤分布式兜底的两级限流这里重点说下集群模式下的问题。如果你的Redis是Cluster模式CL.THROTTLE命令的key会按哈希槽分布到不同节点这本身没问题因为单key操作在哪个节点执行都是原子的。但有一点必须注意所有Redis节点都要加载redis_cell模块否则key恰好路由到没加载模块的节点时命令直接报错。我建议在做集群变更时把模块加载检查纳入自动化运维脚本别等线上暴雷再临时补。5.2 几个值得强调的实践细节第一限流参数必须压测之后再定。很多人喜欢把限流阈值拍脑袋定一个“看起来合理”的值上线第一天就疯狂误伤用户。正确的做法是先对接口做全链路压测摸清单实例和集群整体的真实吞吐能力再倒推限流阈值。比如压测得出某接口集群只能扛500 QPS你有5个实例那单实例限流阈值就该在100 QPS左右而不是随便填个1000。第二限流后的降级策略比限流本身更重要。我见过不少团队只做了“拒绝请求”这一层用户看到报错就以为系统挂了。建议至少做三级降级第一级是提示重试返回retry_after给前端倒计时第二级是缓存兜底限流期间直接返回最近一次的成功结果第三级才是真正的报错提示。这三级的优先级可以针对不同接口灵活配置核心交易类接口建议只降级到第二级不能直接返回缓存数据。第三Redis-Cell做分布式限流性能和Redis实例本身挂钩。如果你们Redis集群本身还承担着大量缓存读写业务限流的网络开销和CPU消耗会跟其他业务争抢资源。我在压测时发现单纯为了限流把Redis的QPS打高了20%左右这不算小数目。有条件的话建议给限流单独拉一个Redis实例或者用云厂商提供的代理模式隔离掉周边影响。第四一定要保留限流触发日志和监控指标。CL.THROTTLE命令返回的第三、第四个字段是很宝贵的监控数据源。我在切面里把retry_after和bucket_full_after记录到日志中同时上报到监控系统。一旦在监控面板上发现某个接口的限流拒绝率异常走高就能第一时间定位到是哪个维度、哪个key在被刷方便快速调整策略。第五一个小技巧对于注册、登录这类高频关键接口不要让用户在触发限流的那一刻才等待而是要做“预热式”限流。比如限制每分钟最多请求10次但第5次之后就开始返回稍长一点的提示语。这种做法叫作渐进式限流用户感知上比突然拒绝要平滑很多。Redis-Cell的令牌桶机制本身就带有这种平滑特性你只需要在业务代码里根据剩余令牌数做不同的提示文案即可。最后再分享一个我在实际项目里的体会。Redis-Cell这套方案落地起来非常快快到我一度担心它是不是“太简单了反而不可靠”。但压测和上线后的表现证明这个担心是多余的。分布式限流真正难的从来不是工具本身而是你对自己的业务流量特征有没有清晰的认知——哪些接口需要严格限流哪些接口可以适当放行限流的惩罚策略怎么定才能既保护系统又不伤用户体验。把这些想清楚了Redis-Cell就是那把趁手的工具想清楚之前换哪个组件都白搭。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑