主题
第十七章:限流与计数器
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 里实现这两个算法要注意
- 必须用 Lua:」补充令牌/放水 + 判断 + 写入」三件事要原子完成,否则并发下令牌会被重复消费;
- 时间单位要统一:
rate和now的单位要一致,避免算错; - 状态要设过期:桶长期无请求时要能自动清理,避免 key 堆积;
- 高频场景优先本地内存:Redis 版本的令牌桶每次请求都要一次
EVAL,RTT 开销较大,超高 QPS 场景可以先在本地限流,Redis 做跨实例兜底。
Q55:点赞、签到、UV 统计这些「计数类」场景,分别该用 Redis 的什么结构?
答:
计数类场景五花八门,但它们的需求差异很清晰:有的要「精确计数」,有的要「去重计数」,有的要「压缩存储布尔状态」。选对结构能兼顾正确性和内存效率。
一、场景与结构对照
| 场景 | 需求性质 | 推荐结构 | 关键命令 |
|---|---|---|---|
| 点赞数、播放量 | 精确计数,可增减 | String | INCR / DECR |
| 点赞用户列表 | 去重 + 判断某用户是否点过 | Set | SADD / SISMEMBER / SCARD |
| 用户签到 | 大量「是/否」布尔状态,按天统计 | Bitmap | SETBIT / GETBIT / BITCOUNT |
| UV(独立访问) | 去重计数,只需数量不关心具体是谁 | HyperLogLog | PFADD / PFCOUNT |
| 视频热度榜 | 按分数排序取 Top N | ZSet | ZINCRBY / ZREVRANGE |
| 高频词 / 热点 | 近似频次统计 | Count-Min Sketch / TopK | CMS.INCRBY / TOPK.ADD |
二、点赞:计数 + 去重要分开
点赞这个动作其实包含两个诉求:
- 点赞总数:需要一个可以
INCR的计数器; - 谁点了赞:需要去重,判断「我点没点过」「取消点赞时会不会重复扣」。
所以通常是「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 | 天然去重,可判断成员 |
| 大量布尔状态、内存敏感 | Bitmap | 1 用户占 1 bit,极致压缩 |
| 只需去重数量、不关心明细 | HyperLogLog | 固定内存估算海量去重数 |
| 按分数排序取 Top N | ZSet | score 排序,ZINCRBY 增量更新 |
| 近似频次 / 热门 | CMS / TopK | 有损但内存可控 |
六、小结
- 点赞、收藏、关注:String 计数 + Set 去重;
- 签到:Bitmap 用 bit 压缩存储布尔状态;
- UV:HyperLogLog 用固定内存估算去重数量;
- 排行榜、热度:ZSet 按 score 排序;
- 热门词、频次:Count-Min Sketch / TopK 近似统计。