Skip to content

第十七章:限流与计数器 ​

Q53:接口限流有哪些常见算法?用 Redis 怎么实现固定窗口和滑动窗口限流? ​

答:

限流是保护后端不被突发流量打垮的核心手段。常见算法有固定窗口、滑动窗口、漏桶、令牌桶四类,其中固定窗口和滑动窗口最适合直接用 Redis 实现。

一、几种限流算法概览 ​

算法核心思想特点
固定窗口以固定时间段(如 1 秒)为单位计数简单,但存在「临界突刺」问题
滑动窗口窗口随时间平滑滑动,统计最近一段时间内的请求数更精确,避免临界突刺
漏桶请求先进桶,以固定速率流出平滑输出,但无法应对突发流量
令牌桶以固定速率放令牌,请求需消费令牌允许一定突发,兼顾平滑,最常用

二、固定窗口(计数器法) ​

思路:用一个 key 记录当前时间窗口内的请求数,每次请求 INCR,超过阈值就拒绝。窗口结束自动进入下一个窗口。

python
def fixed_window_limit(user_id: str, limit: int, window_seconds: int = 1) -> bool:
    now = int(time.time())
    window_start = now // window_seconds * window_seconds   # 当前窗口起点
    key = f"rate:{user_id}:{window_start}"

    count = redis_client.incr(key)
    if count == 1:
        # 第一次请求时设置过期,窗口结束自动清理
        redis_client.expire(key, window_seconds)

    return count <= limit   # True = 放行,False = 限流

缺点:临界突刺。 假设限制是「每秒最多 100 次」,攻击者可以卡在两个窗口的边界:在窗口 A 的最后 0.5 秒打 100 次,再在窗口 B 的最初 0.5 秒打 100 次,实际 1 秒内通过了 200 次,突破限制。

text
窗口 A(0~1s)      窗口 B(1~2s)
     │                   │
  0.5~1.0s 打 100 次   1.0~1.5s 打 100 次
     └─────── 实际 1 秒内(0.5~1.5s)通过了 200 次 ───────┘

三、滑动窗口(更精确) ​

滑动窗口不再按整点划分,而是统计「当前时刻往前 N 秒内」的请求数,窗口随时间平滑滑动,从而避免临界突刺。

用 ZSet 实现:每个请求以「当前时间戳 + 微秒随机数」作为 member 和 score 写入 ZSet,然后删除窗口外的旧记录,用 ZCARD 统计窗口内剩余请求数。

python
import time

def sliding_window_limit(user_id: str, limit: int, window_seconds: int = 1) -> bool:
    key = f"rate:{user_id}:sliding"
    now = time.time()
    window_start = now - window_seconds
    window_end = now + window_seconds   # 给 score 留点缓冲,避免同毫秒冲突

    # 用 pipeline 把「加当前请求 + 删窗口外旧请求 + 统计」打包
    pipe = redis_client.pipeline()
    member = f"{now}-{uuid.uuid4()}"
    pipe.zadd(key, {member: now})
    pipe.zremrangebyscore(key, 0, window_start)   # 删除 N 秒之前的记录
    pipe.zcard(key)                                # 统计窗口内剩余数量
    pipe.expire(key, window_seconds)               # 防止 key 永久存在
    _, _, count, _ = pipe.execute()

    return count <= limit

这段逻辑如果要求更严格的原子性和一致性,可以放进 Lua 脚本里整体执行(因为 ZADD、ZREMRANGEBYSCORE、ZCARD 三个操作需要原子完成,否则并发下统计会不准)。

优点:精度高,没有固定窗口的边界突刺问题。 缺点:每个请求都要写一条 ZSet 记录,内存占用和命令开销比固定窗口高。

四、如何选择 ​

场景推荐原因
对精度要求不高、追求简单固定窗口实现简单、内存占用低
要精确、防止边界突刺滑动窗口精度高,能平滑统计
允许一定突发、要平滑输出令牌桶 / 漏桶需借助 Lua 或本地内存实现,Redis 可作分布式令牌桶
高并发、极致性能本地内存限流 + Redis 兜底本地限流快,Redis 保证跨实例

Q54:令牌桶和漏桶算法如何用 Redis 实现? ​

答:

漏桶和令牌桶都是「以固定速率处理流量」的经典算法,比计数器更平滑。它们的区别在于对「突发」的态度:

  • 漏桶:请求先进入一个「桶」,以恒定速率流出,桶满则溢出拒绝。完全不支持突发,输出速率恒定。
  • 令牌桶:以恒定速率向桶里放「令牌」,请求来时要先取令牌,取到才放行。允许一定突发(桶里积攒的令牌可以被一波消耗)。

一、漏桶(Leaky Bucket) ​

漏桶可以理解为「固定速率的队列」。用 Redis 模拟时,记录「上一次漏水时间」和「当前水位」,每次请求先「放水」(按时间流逝扣掉水位),再判断能不能进水。

lua
-- KEYS[1] 桶 key;ARGV[1] 桶容量;ARGV[2] 漏水速率(每单位时间放掉的水);ARGV[3] 当前时间
local capacity = tonumber(ARGV[1])
local rate     = tonumber(ARGV[2])   -- 每单位时间漏水量
local now      = tonumber(ARGV[3])
local interval = 1                    -- 单位时间 = 1 秒

local bucket = redis.call('HMGET', KEYS[1], 'water', 'last_time')
local water = tonumber(bucket[1] or '0')
local last_time = tonumber(bucket[2] or tostring(now))

-- 先放水:按流逝的时间减掉水位
local elapsed = now - last_time
water = math.max(0, water - elapsed * rate)

if water + 1 <= capacity then
    water = water + 1
    redis.call('HMSET', KEYS[1], 'water', water, 'last_time', now)
    redis.call('EXPIRE', KEYS[1], interval * 2)
    return 1   -- 放行
else
    redis.call('HMSET', KEYS[1], 'water', water, 'last_time', now)
    redis.call('EXPIRE', KEYS[1], interval * 2)
    return 0   -- 拒绝(桶满溢出)
end

二、令牌桶(Token Bucket) ​

令牌桶关键变量是「当前令牌数」和「上次补充令牌的时间」。每次请求先按时间补充令牌,再判断是否够消费一个。

lua
-- KEYS[1] 桶 key;ARGV[1] 桶容量;ARGV[2] 令牌补充速率;ARGV[3] 当前时间
local capacity = tonumber(ARGV[1])
local rate     = tonumber(ARGV[2])   -- 每单位时间补充的令牌数
local now      = tonumber(ARGV[3])
local interval = 1

local tokens    = tonumber(redis.call('HGET', KEYS[1], 'tokens') or tostring(capacity))
local last_time = tonumber(redis.call('HGET', KEYS[1], 'last_time') or tostring(now))

-- 先补充令牌:按流逝时间增加,但不超过容量
local elapsed = now - last_time
tokens = math.min(capacity, tokens + elapsed * rate)

if tokens >= 1 then
    tokens = tokens - 1
    redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_time', now)
    redis.call('EXPIRE', KEYS[1], interval * 2)
    return 1   -- 放行
else
    redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_time', now)
    redis.call('EXPIRE', KEYS[1], interval * 2)
    return 0   -- 拒绝(令牌不够)
end

三、两者的差异对比 ​

维度漏桶令牌桶
输出速率恒定,固定速率流出平均恒定,但允许突发消耗积累的令牌
是否支持突发不支持,桶满即溢出丢弃支持,令牌攒够可一次性放行一波
典型场景严格平滑输出(如报文整形)允许突发 + 平滑(如 API 限流)
关键状态水位 water + 上次漏水时间令牌数 tokens + 上次补充时间

选型:大多数业务限流用令牌桶更合适,因为它允许合理的突发(如缓存预热、消息集中消费),又不失平均速率的约束。

四、Redis 里实现这两个算法要注意 ​

  1. 必须用 Lua:」补充令牌/放水 + 判断 + 写入」三件事要原子完成,否则并发下令牌会被重复消费;
  2. 时间单位要统一:rate 和 now 的单位要一致,避免算错;
  3. 状态要设过期:桶长期无请求时要能自动清理,避免 key 堆积;
  4. 高频场景优先本地内存:Redis 版本的令牌桶每次请求都要一次 EVAL,RTT 开销较大,超高 QPS 场景可以先在本地限流,Redis 做跨实例兜底。

Q55:点赞、签到、UV 统计这些「计数类」场景,分别该用 Redis 的什么结构? ​

答:

计数类场景五花八门,但它们的需求差异很清晰:有的要「精确计数」,有的要「去重计数」,有的要「压缩存储布尔状态」。选对结构能兼顾正确性和内存效率。

一、场景与结构对照 ​

场景需求性质推荐结构关键命令
点赞数、播放量精确计数,可增减StringINCR / DECR
点赞用户列表去重 + 判断某用户是否点过SetSADD / SISMEMBER / SCARD
用户签到大量「是/否」布尔状态,按天统计BitmapSETBIT / GETBIT / BITCOUNT
UV(独立访问)去重计数,只需数量不关心具体是谁HyperLogLogPFADD / PFCOUNT
视频热度榜按分数排序取 Top NZSetZINCRBY / ZREVRANGE
高频词 / 热点近似频次统计Count-Min Sketch / TopKCMS.INCRBY / TOPK.ADD

二、点赞:计数 + 去重要分开 ​

点赞这个动作其实包含两个诉求:

  1. 点赞总数:需要一个可以 INCR 的计数器;
  2. 谁点了赞:需要去重,判断「我点没点过」「取消点赞时会不会重复扣」。

所以通常是「String 计数 + Set 去重」配合:

python
def like(user_id: str, video_id: str):
    # 1. 用 Set 判断是否已点赞,天然去重
    already = redis_client.sismember(f"video:{video_id}:likes", user_id)
    if already:
        return "已点过赞"

    # 2. 加入点赞集合(去重)
    redis_client.sadd(f"video:{video_id}:likes", user_id)
    # 3. 点赞数 +1
    redis_client.incr(f"video:{video_id}:like_count")

def unlike(user_id: str, video_id: str):
    # 只有确实点过赞才移除并 -1,否则会算错
    removed = redis_client.srem(f"video:{video_id}:likes", user_id)
    if removed:
        redis_client.decr(f"video:{video_id}:like_count")

这里 String 的 INCR/DECR 负责精确计数,Set 的 SADD/SREM/SISMEMBER 负责去重和幂等。这种「计数 + 去重集合」的组合是点赞、收藏、关注等场景的通用模型。

三、签到:Bitmap 压缩存储 ​

签到只有「是/否」两种状态,且用户 id 通常是连续数字,用 Bitmap 把「每个用户占用 1 bit」来存储,内存极省。

python
def sign(user_id: str, date_str: str):
    redis_client.setbit(f"signin:{date_str}", int(user_id), 1)

def is_signed(user_id: str, date_str: str) -> bool:
    return bool(redis_client.getbit(f"signin:{date_str}", int(user_id)))

def signed_count(date_str: str) -> int:
    return redis_client.bitcount(f"signin:{date_str}")
  • SETBIT key offset 1:把第 offset 位(对应用户 id)置 1,表示签到;
  • GETBIT:判断某用户是否签到;
  • BITCOUNT:统计某天签到总人数。

注意:Bitmap 的 offset 是 bit 下标,用户 id 很大(如 1 亿)时,稀疏的 Bitmap 会浪费空间。此时应把用户 id 映射成紧凑的连续偏移量,或改用其他方案。

四、UV 统计:HyperLogLog 去重计数 ​

UV(独立访问人数)只关心「有多少不同的人来过」,不关心具体是谁。用 HyperLogLog 可以用极小的固定内存(约 12KB)估算千万级甚至亿级的去重数量。

python
def record_uv(video_id: str, user_id: str):
    redis_client.pfadd(f"uv:{video_id}", user_id)

def get_uv(video_id: str) -> int:
    return redis_client.pfcount(f"uv:{video_id}")

def merge_uv(dest: str, *sources: str):
    redis_client.pfmerge(dest, *sources)   # 合并多天的 UV 到一个 key

注意:HyperLogLog 是近似估算,存在约 0.81% 的标准误差,且不能列出具体是哪些用户。如果需要精确的、可列举的去重,还是要用 Set(但 Set 内存随用户数线性增长,量大时不可行)。

五、计数场景选型口诀 ​

需求关键词结构原因
精确数值、可增减String INCR简单、精确
去重 + 判断存在Set天然去重,可判断成员
大量布尔状态、内存敏感Bitmap1 用户占 1 bit,极致压缩
只需去重数量、不关心明细HyperLogLog固定内存估算海量去重数
按分数排序取 Top NZSetscore 排序,ZINCRBY 增量更新
近似频次 / 热门CMS / TopK有损但内存可控

六、小结 ​

  • 点赞、收藏、关注:String 计数 + Set 去重;
  • 签到:Bitmap 用 bit 压缩存储布尔状态;
  • UV:HyperLogLog 用固定内存估算去重数量;
  • 排行榜、热度:ZSet 按 score 排序;
  • 热门词、频次:Count-Min Sketch / TopK 近似统计。