多实例各自计数限流总量对不上,用 Redis 加 Lua 做原子限流

举报
海是岛思念的泪 发表于 2026/09/24 17:23:59 2026/09/24
【摘要】 单机限流挡不住多实例,各实例自己计数总量就对不上,用 Redis 加 Lua 脚本把判断和扣减做成原子操作,含令牌桶实现、集成示例和算法对比。

接口被刷是线上常见问题。单机限流用Guava RateLimiter就行,分布式环境下多个实例各自计数,总量对不上。Redis做共享存储,Lua脚本保证原子性,这套组合是分布式限流的标准方案。
image.png

为什么需要Lua脚本

限流的核心逻辑是"读计数器、判断阈值、写计数器"三步。如果分三条Redis命令发,中间有并发间隙,两个请求同时读到旧值,都放行,限流就失效了。Lua脚本在Redis里单线程原子执行,三步打包成一个不可分割的操作。

令牌桶限流

-- ratelimit.lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

-- 补充令牌
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill / 1000)

if tokens < requested then
    return 0
end

tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('PEXPIRE', key, capacity / refill * 1000 * 2)

return 1

KEYS[1]是限流key,ARGV依次是桶容量、补充速率(个/秒)、当前时间戳、请求消耗令牌数。脚本先补充按时间流逝应得的令牌,再判断够不够消耗,够就扣减并返回1,不够返回0。

Java集成

@Service
public class RateLimiter {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private DefaultRedisScript<Long> script;
    
    @PostConstruct
    public void init() throws IOException {
        script = new DefaultRedisScript<>();
        script.setScriptSource(new ResourceScriptSource(
            new ClassPathResource("lua/ratelimit.lua")));
        script.setResultType(Long.class);
    }
    
    public boolean allow(String key, int capacity, int refill, int requested) {
        long now = System.currentTimeMillis();
        Long result = redisTemplate.execute(
            script,
            Collections.singletonList("ratelimit:" + key),
            String.valueOf(capacity),
            String.valueOf(refill),
            String.valueOf(now),
            String.valueOf(requested)
        );
        return result != null && result == 1L;
    }
}
@RestController
public class ApiController {
    
    @Autowired
    private RateLimiter rateLimiter;
    
    @GetMapping("/api/data")
    public ResponseEntity<?> getData(@RequestHeader("X-User-Id") String userId) {
        // 每用户每秒10个请求,桶容量20
        if (!rateLimiter.allow("user:" + userId, 20, 10, 1)) {
            return ResponseEntity.status(429).body("rate limit exceeded");
        }
        return ResponseEntity.ok("data");
    }
}

Python集成

Python版本用redis-py:

import redis
import time

r = redis.Redis(host='localhost', port=6379)

LUA_SCRIPT = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill / 1000)

if tokens < requested then
    return 0
end

tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('PEXPIRE', key, capacity / refill * 1000 * 2)
return 1
"""

script = r.register_script(LUA_SCRIPT)

def allow(user_id, capacity=20, refill=10):
    now = int(time.time() * 1000)
    result = script(keys=[f"ratelimit:user:{user_id}"],
                    args=[capacity, refill, now, 1])
    return result == 1

限流算法对比

算法 原理 优点 缺点 适用场景
固定窗口 时间段内计数 简单 窗口边界突刺 粗粒度限流
滑动窗口 时间窗口滑动 平滑 内存占用大 精确限流
令牌桶 按速率补充令牌 允许突发 实现稍复杂 API限流
漏桶 固定速率出水 严格匀速 不允许突发 流量整形

令牌桶允许短时间突发(桶里有攒的令牌),又限制长期平均速率,API限流最常用。

不同维度限流

// 按用户限流
rateLimiter.allow("user:" + userId, 100, 50, 1);

// 按IP限流
rateLimiter.allow("ip:" + clientIp, 200, 100, 1);

// 按接口限流
rateLimiter.allow("api:" + endpoint, 1000, 500, 1);

// 组合维度:用户+接口
rateLimiter.allow("user:" + userId + ":api:" + endpoint, 50, 20, 1);

key的设计决定了限流粒度。按用户限流防单用户刷接口,按IP限流防爬虫,按接口限流保护下游服务,组合维度最精细。

监控和调优

// 暴露限流指标到Prometheus
@GetMapping("/metrics/ratelimit")
public Map<String, Object> rateLimitMetrics() {
    // 读取各key的剩余令牌数
    Set<String> keys = redisTemplate.keys("ratelimit:*");
    Map<String, Object> metrics = new HashMap<>();
    for (String key : keys) {
        String tokens = redisTemplate.opsForHash().get(key, "tokens");
        metrics.put(key, tokens);
    }
    return metrics;
}

限流参数别拍脑袋定,看实际流量数据调。QPS平时500,峰值1500,桶容量设1000、补充速率600,能扛住突发又不让系统过载。

分布式限流用Redis+Lua这套方案,核心就两点:Redis做共享存储解决分布式计数,Lua脚本保证原子性解决并发竞争。代码量不大,但解决了线上真实问题。令牌桶算法允许突发流量的特性对API场景特别友好,比固定窗口那种边界突刺问题好很多。

标签: Redis,Lua,分布式限流,令牌桶,微服务

总结

分布式限流的难点不在算法,而在原子性:判断和扣得分开写,并发一来就会超卖,所以整段逻辑得塞进 Lua 脚本一次执行。令牌桶适合要容忍突发的场景,固定窗口够简单但要小心边界突刺。脚本本身别写重循环,Redis 是单线程,一个脚本跑久了后面的请求全排队。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。