基于 Redis 实现分布式令牌桶限流算法(Java 实战)

基于 Redis 实现分布式令牌桶限流算法(Java 实战)

一、什么是令牌桶限流?

        令牌桶(Token Bucket)算法是一种常用的流量控制机制,用于限制系统在单位时间内的请求处理速率,以防止突发流量导致服务过载。

        其核心思想是:系统以恒定速率向桶中添加令牌,桶具有最大容量。当桶满时,新生成的令牌将被丢弃。每个请求需消耗一个令牌,若桶中无可用令牌,则请求被拒绝。

        与漏桶(Leaky Bucket)算法相比,令牌桶允许一定程度的突发流量(只要桶中有足够令牌),因此在实际业务场景中更为常用。

二、代码实现

1、常量定义

public class RedisConstants {
    // 令牌桶 Redis Key 模板:rate_limit:bucket:{userId}:{fieldId}
    public static final String TOKEN_BUCKET_KEY = "rate_limit:bucket:%d:%d"; 
}

2、限流方法

@Service
public class RateLimitService {

    @Autowired
    private RedisTemplate redisTemplate;

    // 每秒生成令牌数,可根据业务需求调整
    private static final double RATE = 2.0;

    // 令牌桶的最大容量,容量越大,允许的突发容量越高
    private static final double CAPACITY = 10.0; // 桶最大容量

    /**
     * 判断是否允许执行请求操作(消耗一个令牌)
     * @param userId 用户ID
     * @param fieldId 参数ID
     * @return true 表示允许,false 表示被限流
     */
    public boolean tryAcquire(Long userId, Long fieldId) {
        // 构造令牌桶唯一键
        String key = String.format(RedisConstants.TOKEN_BUCKET_KEY, userId, fieldId);
        // 获取当前系统时间
        long now = System.currentTimeMillis();

        Boolean result = redisTemplate.execute((RedisCallback<Boolean>) connection -> {
            // Redis底层使用byte[]
            byte[] rawKey = key.getBytes(StandardCharsets.UTF_8);
            // 当前令牌数
            byte[] tokensField = "tokens".getBytes(StandardCharsets.UTF_8);
            // 上次请求时间戳
            byte[] lastTimeField = "last_time".getBytes(StandardCharsets.UTF_8);

            // 从 Redis Hash 中读取当前令牌数和上次更新时间
            byte[] tokensBytes = connection.hGet(rawKey, tokensField);
            byte[] lastTimeBytes = connection.hGet(rawKey, lastTimeField);

            // 解析当前令牌数:若 key 不存在(首次访问),则初始化为桶的最大容量
            double currentTokens = (tokensBytes != null) 
                ? Double.parseDouble(new String(tokensBytes, StandardCharsets.UTF_8)) 
                : CAPACITY;
            // 解析上次更新时间:若不存在,则从当前时间开始计时
            long lastTime = (lastTimeBytes != null) 
                ? Long.parseLong(new String(lastTimeBytes, StandardCharsets.UTF_8)) 
                : now;

            // 计算自上次以来新增的令牌数(按秒为单位)
            // 注意:此处按秒取整,牺牲毫秒级精度以简化逻辑,适用于大多数业务场景
            long deltaSeconds = (now - lastTime) / 1000;
            // 新令牌数 = 当前令牌 + 新增令牌,但不超过桶的最大容量
            double newTokens = Math.min(CAPACITY, currentTokens + deltaSeconds * RATE);

            // 尝试消费一个令牌
            if (newTokens >= 1) {
                newTokens -= 1;
                // 更新令牌数和时间戳
                connection.hSet(rawKey, tokensField,             String.valueOf(newTokens).getBytes(StandardCharsets.UTF_8));
                connection.hSet(rawKey, lastTimeField, String.valueOf(now).getBytes(StandardCharsets.UTF_8));
                // 设置过期时间:略大于桶填满所需时间,避免长期不活跃的 key 永久占用内存
                connection.expire(rawKey, (int) (CAPACITY / RATE + 2));
                return true;
            } else {
                // 令牌不足,拒绝请求
                return false;
            }
        });

        return Boolean.TRUE.equals(result);
    }
}

3、请求示例

@Autowired
private RateLimitService rateLimitService;

@PostMapping("/save")
public Result<String> acquire(@RequestParam Long userId, @RequestParam Long fieldId) {
    if (!rateLimitService.tryAcquire(userId, fileId)) {
        throw new BaseException("请求过于频繁,请稍后再试!")
    }
    // 执行保存逻辑...
    return Result.su***ess("请求成功!")
}

三、核心概念

1. 为什么使用令牌桶(Token Bucket),而不是漏桶(Leaky Bucket)?

        漏桶算法以恒定速率处理请求,即使系统空闲也无法应对突发流量,而令牌桶允许在桶未满时积累令牌,从而支持短时间内的突发请求(burst traffic)。因此,令牌桶在保证平均速率的同时,提供了更好的用户体验和系统弹性

2. 为什么选择 Redis Hash 存储令牌桶状态,而不是 String、List 或多个独立 Key?

        首先明确一点,令牌桶需要维护两个状态,分别是当前令牌数量(tokens)上次有效请求时间戳(last_time)。因此对于 String 就无法保证两个 Key 操作的原子性,在并发状态下会导致状态覆盖

        Redis List 是有序的字符串集合,无法通过字段名(tokens)访问特定值,只能通过索引进行访问,若希望修改某个值需事先知道其结构,如位置0是 tokens,因此语义不清晰、易出错。并且List 本质是为序列数据设计的,而非结构化状态。

        综上,Hash 是存储结构化状态的最优选择。

3、为什么整个逻辑要放在 RedisCallback 中执行,而不是多次调用 redisTemplate.opsForHash()

        redisTemplate 的高层 API(如 opsForHash().get()每次调用都会创建新的连接和序列化开销,且无法保证多步操作的原子性,而 RedisCallback 允许我们在同一个 Redis 连接中连续执行多条原生命令,不仅减少网络往返(RTT),更重要的是在 Redis 单线程模型下,这些命令不会被其他客户端打断,逻辑上等效原子,而且避免了“读-改-写”过程中的竞态条件(race condition)。

四、拓展

1、Lua 脚本

        当前实现依赖 RedisCallback 在 Java 端计算逻辑,仍存在多次 Redis 命令往返,更优方案是将整个令牌桶逻辑封装为 Lua 脚本,从而实现在 Redis 服务端原子执行。

-- token_bucket.lua
local key = KEYS[1]
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])

local tokens = redis.call('HGET', key, 'tokens')
local last_time = redis.call('HGET', key, 'last_time')

tokens = tokens and tonumber(tokens) or capacity
last_time = last_time and tonumber(last_time) or now

local delta = math.floor((now - last_time) / 1000)
local new_tokens = math.min(capacity, tokens + delta * rate)

if new_tokens >= 1 then
    new_tokens = new_tokens - 1
    redis.call('HSET', key, 'tokens', tostring(new_tokens))
    redis.call('HSET', key, 'last_time', tostring(now))
    redis.call('EXPIRE', key, math.ceil(capacity / rate) + 2)
    return 1  -- 允许请求
else
    return 0  -- 拒绝请求
end

2、拒绝策略优化

        在系统开发中我们需要明白:限流的最终目的不是阻止用户,而是保护系统的同时提供可预期的反馈。

        当请求被限流时,若直接返回 false,用户将面临“操作失败”却不知如何应对,体验较差。
更友好的做法是:

  • 返回明确的重试提示(如 HTTP 429 状态码 + Retry-After 头);
  • 启用降级策略,例如将请求暂存至延迟队列,稍后自动重试;
  • 引入熔断机制,当某用户或接口在短时间内连续触发限流超过阈值时,可暂时关闭非核心功能或引导用户稍后再试,避免系统持续承压。

本文基于 Redis 实现了一个轻量、高效、可扩展的分布式令牌桶限流方案,无需引入复杂中间件,仅依赖 Spring 与 Redis 即可满足大多数业务场景下的流量控制需求。通过合理使用 Redis Hash 结构、原子化操作与时间窗口计算,在保证线程安全的同时,兼顾了性能与语义清晰性。

限流不是终点,而是系统韧性设计的起点。结合监控、动态配置与友好反馈,才能构建真正用户友好且稳定可靠的后端服务。

转载请说明出处内容投诉
CSS教程网 » 基于 Redis 实现分布式令牌桶限流算法(Java 实战)

发表评论

欢迎 访客 发表评论

一个令你着迷的主题!

查看演示 官网购买