限流算法详解 - 令牌桶算法分布式场景代码实现
2026/5/23 11:30:32 网站建设 项目流程

概述

在分布式场景下,令牌桶算法需要全局共享状态(当前令牌数、上次补充时间),因为限流的“总速率”是所有节点共同遵守的。常见解决方案是使用Redis + Lua 脚本来保证原子性,或者使用成熟的分布式限流组件(如阿里 Sentinel 的集群限流)。

以下是一个基于Redis的分布式令牌桶完整 Java 实现,使用Lua 脚本完成令牌补充和消费的原子操作。


一、核心思路

  1. Redis 数据结构
    使用两个 Hash 字段存储桶状态:
    • last_time:上一次补充令牌的时间戳(毫秒)
    • tokens:当前桶中的令牌数(浮点数或整数,为简化用整数)
  2. Lua 脚本
    在 Redis 服务端执行,完成:
    • 计算距离上次补充的时间差,按速率生成新令牌(不超过容量)
    • 判断令牌数是否足够,如果足够则减去并返回成功,否则返回失败
  3. Java 客户端
    使用 Jedis / Lettuce 调用 Lua 脚本,获得限流结果。

二、完整 Java 实现(基于 Jedis)

1. 添加依赖(Maven)

<dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId><version>4.4.3</version></dependency>

2. Lua 脚本(rate_limiter.lua

-- KEYS[1] = bucket key-- ARGV[1] = capacity (桶容量)-- ARGV[2] = tokens_per_second (每秒生成令牌数)-- ARGV[3] = required_tokens (本次请求需要的令牌数,默认为1)-- ARGV[4] = now (当前时间戳,毫秒)localkey=KEYS[1]localcapacity=tonumber(ARGV[1])localrate=tonumber(ARGV[2])localrequired=tonumber(ARGV[3]or1)localnow=tonumber(ARGV[4])locallast_time=redis.call('HGET',key,'last_time')or0localtokens=redis.call('HGET',key,'tokens')iftokens==falsethentokens=capacity-- 第一次填充满桶elsetokens=tonumber(tokens)endlocalelapsed=now-tonumber(last_time)ifelapsed>0thenlocaladded=math.floor(elapsed*rate/1000)-- 根据时间差生成令牌tokens=math.min(capacity,tokens+added)end-- 判断是否允许通过iftokens>=requiredthentokens=tokens-required redis.call('HSET',key,'tokens',tokens,'last_time',now)return1-- 成功elsereturn0-- 限流end

3. Java 客户端封装

importredis.clients.jedis.Jedis;importredis.clients.jedis.JedisPool;importjava.util.Collections;publicclassDistributedTokenBucket{privatefinalJedisPooljedisPool;privatefinalStringbucketKey;privatefinallongcapacity;privatefinallongtokensPerSecond;privatefinalStringluaScriptSha;// 脚本的 SHA1 缓存publicDistributedTokenBucket(JedisPooljedisPool,StringbucketKey,longcapacity,longtokensPerSecond){this.jedisPool=jedisPool;this.bucketKey=bucketKey;this.capacity=capacity;this.tokensPerSecond=tokensPerSecond;// 加载 Lua 脚本到 Redis,获取 SHA1Stringscript=loadLuaScript();try(Jedisjedis=jedisPool.getResource()){this.luaScriptSha=jedis.scriptLoad(script);}}privateStringloadLuaScript(){return"local key = KEYS[1]\n"+"local capacity = tonumber(ARGV[1])\n"+"local rate = tonumber(ARGV[2])\n"+"local required = tonumber(ARGV[3] or 1)\n"+"local now = tonumber(ARGV[4])\n"+"local last_time = redis.call('HGET', key, 'last_time') or 0\n"+"local tokens = redis.call('HGET', key, 'tokens')\n"+"if tokens == false then\n"+" tokens = capacity\n"+"else\n"+" tokens = tonumber(tokens)\n"+"end\n"+"local elapsed = now - tonumber(last_time)\n"+"if elapsed > 0 then\n"+" local added = math.floor(elapsed * rate / 1000)\n"+" tokens = math.min(capacity, tokens + added)\n"+"end\n"+"if tokens >= required then\n"+" tokens = tokens - required\n"+" redis.call('HSET', key, 'tokens', tokens, 'last_time', now)\n"+" return 1\n"+"else\n"+" return 0\n"+"end";}/** * 尝试获取一个令牌(非阻塞) * @return true 允许通过,false 限流 */publicbooleantryAcquire(){returntryAcquire(1);}/** * 尝试获取指定数量的令牌 * @param required 需要的令牌数 * @return true 允许通过,false 限流 */publicbooleantryAcquire(intrequired){longnow=System.currentTimeMillis();try(Jedisjedis=jedisPool.getResource()){Objectresult=jedis.evalsha(luaScriptSha,Collections.singletonList(bucketKey),Arrays.asList(String.valueOf(capacity),String.valueOf(tokensPerSecond),String.valueOf(required),String.valueOf(now)));return(Long)result==1L;}}}

4. 使用示例

importredis.clients.jedis.JedisPool;publicclassDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{JedisPoolpool=newJedisPool("localhost",6379);// 创建全局令牌桶:容量100,每秒生成20个令牌(即平均间隔50ms)DistributedTokenBucketlimiter=newDistributedTokenBucket(pool,"api:global:ratelimit",100,20);// 模拟多个线程(分布式节点)并发请求for(inti=0;i<30;i++){booleanallowed=limiter.tryAcquire();System.out.printf("请求 %2d : %s%n",i+1,allowed?"通过":"限流");Thread.sleep(30);// 每个请求间隔30ms}pool.close();}}

三、分布式实现的要点与挑战

问题解决方式
原子性使用 Redis 单线程执行 Lua 脚本,避免竞态条件
性能每次请求都要网络 IO 到 Redis,可用evalsha减少带宽;或采用“批量预取”优化
时钟偏差以 Redis 服务器的时间为准,传入调用方时间now可能会有偏差。建议使用 Redis 的TIME命令获取服务端时间,但会增加一次调用。也可以接受毫秒级误差
令牌精度上述脚本用math.floor(elapsed * rate / 1000)生成整数令牌,速率较低时误差较大。可改用浮点数(Redis 4.0+ 支持)或提高时间精度到微秒
容量/速率动态调整脚本参数已支持,调用时传入新值即可;但注意桶状态last_timetokens需重置的逻辑(可扩展脚本)
高并发热点单个 Redis key 会成为热点,可考虑分片(如按用户ID分桶)或本地预处理

四、优化方案:批量预取(减少 Redis 调用)

如果业务允许一定程度的“本地突发”,可以让每个节点一次性从 Redis 申请一批令牌(例如 10 个),然后在本地使用令牌桶消耗,用完后再去申请。
这类似于胆小鬼算法(Token Bucket with Batch Fetch):

publicclassLocalTokenBucket{privatefinalDistributedTokenBucketremote;privatefinalintbatchSize;// 每次批量获取的令牌数privateintlocalTokens=0;// 本地缓存令牌数publicbooleantryAcquire(){if(localTokens>0){localTokens--;returntrue;}// 本地令牌耗尽,向 Redis 批量申请booleansuccess=remote.tryAcquire(batchSize);if(success){localTokens=batchSize-1;// 消耗一个,剩余 batchSize-1returntrue;}returnfalse;}}

注意:批量预取会降低限流的实时精确性,但能大幅提升吞吐量,适用于对精度要求不高的场景。


五、生产级推荐

  • 使用现成组件:阿里 Sentinel 的集群限流、Redis 官方模块RedisRateLimiter(基于令牌桶)、Spring Cloud Gateway 的 Redis RateLimiter。
  • 基于 Redis + Lua 脚本是自己实现分布式令牌桶最经典且可靠的方式,多数公司生产环境验证过。

以上示例完整展示了分布式令牌桶的 Java 实现,可直接用于高并发分布式系统的限流需求。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询