08-应用 6:断尾求生 —— 简单限流
课程
1
开篇:授人以鱼不若授人以渔 —— Redis 可以用来做什么?
学习时长: 5分21秒
2
基础:万丈高楼平地起 —— Redis 基础数据结构
上次学到
学习时长: 16分14秒
3
应用 1:千帆竞发 —— 分布式锁
学习时长: 7分47秒
4
应用 2:缓兵之计 —— 延时队列
学习时长: 8分9秒
5
应用 3:节衣缩食 —— 位图
学习时长: 8分52秒
6
应用 4:四两拨千斤 —— HyperLogLog
学习时长: 14分17秒
7
应用 5:层峦叠嶂 —— 布隆过滤器
学习时长: 17分54秒
8
应用 6:断尾求生 —— 简单限流
学习时长: 4分37秒
9
应用 7:一毛不拔 —— 漏斗限流
学习时长: 7分22秒
10
应用 8:近水楼台 —— GeoHash
学习时长: 7分52秒
11
应用 9:大海捞针 —— Scan
学习时长: 8分42秒
12
原理 1:鞭辟入里 —— 线程 IO 模型
学习时长: 4分1秒
13
原理 2:交头接耳 —— 通信协议
学习时长: 3分34秒
14
原理 3:未雨绸缪 —— 持久化
学习时长: 5分27秒
15
原理 4:雷厉风行 —— 管道
学习时长: 3分51秒
16
原理 5:同舟共济 —— 事务
学习时长: 6分36秒
17
原理 6:小道消息 —— PubSub
学习时长: 7分7秒
18
原理 7:开源节流 —— 小对象压缩
学习时长: 7分14秒
19
原理 8:有备无患 —— 主从同步
学习时长: 4分9秒
20
集群 1:李代桃僵 —— Sentinel
学习时长: 3分52秒
21
集群 2:分而治之 —— Codis
学习时长: 7分28秒
22
集群 3:众志成城 —— Cluster
学习时长: 8分38秒
23
拓展 1:耳听八方 —— Stream
学习时长: 13分40秒
24
拓展 2:无所不知 —— Info 指令
学习时长: 4分4秒
25
拓展 3:拾遗补漏 —— 再谈分布式锁
学习时长: 2分18秒
26
拓展 4:朝生暮死 —— 过期策略
学习时长: 2分21秒
27
拓展 5:优胜劣汰 —— LRU
学习时长: 4分34秒
28
拓展 6:平波缓进 —— 懒惰删除
学习时长: 2分13秒
29
拓展 7:妙手仁心 —— 优雅地使用 Jedis
学习时长: 6分35秒
30
拓展 8:居安思危 —— 保护 Redis
学习时长: 2分19秒
31
拓展 9:隔墙有耳 —— Redis 安全通信
学习时长: 6分34秒
32
拓展 10:法力无边 —— Redis Lua 脚本执行原理
学习时长: 9分24秒
33
拓展 11:短小精悍 —— 命令行工具的妙用
学习时长: 9分21秒
34
源码 1:丝分缕析 —— 探索「字符串」内部
学习时长: 5分20秒
35
源码 2:循序渐进 —— 探索「字典」内部
学习时长: 7分24秒
36
源码 3:挨肩迭背 —— 探索「压缩列表」内部
学习时长: 10分42秒
37
源码 4:风驰电掣 —— 探索「快速列表」内部
学习时长: 3分49秒
38
源码 5:凌波微步 —— 探索「跳跃列表」内部
学习时长: 9分57秒
39
源码 6:破旧立新 —— 探索「紧凑列表」内部
学习时长: 2分42秒
40
源码 7:金枝玉叶 —— 探索「基数树」内部
学习时长: 5分36秒
41
源码 8:精益求精 —— LFU vs LRU
学习时长: 8分4秒
42
源码 9:如履薄冰 —— 懒惰删除的巨大牺牲
学习时长: 9分53秒
43
源码 10:跋山涉水 —— 深入字典遍历
学习时长: 9分24秒
44
源码 11:见缝插针 —— 探索 HyperLogLog 内部
学习时长: 13分3秒
45
尾声:百尺竿头 —— 继续深造指南
学习时长: 2分32秒
juejin_logo copyCreated with Sketch.

应用 6:断尾求生 —— 简单限流

限流算法在分布式领域是一个经常被提起的话题,当系统的处理能力有限时,如何阻止计划外的请求继续对系统施压,这是一个需要重视的问题。老钱在这里用 “断尾求生” 形容限流背后的思想,当然还有很多成语也表达了类似的意思,如弃卒保车、壮士断腕等等。

除了控制流量,限流还有一个应用目的是用于控制用户行为,避免垃圾请求。比如在 UGC 社区,用户的发帖、回复、点赞等行为都要严格受控,一般要严格限定某行为在规定时间内允许的次数,超过了次数那就是非法行为。对非法行为,业务必须规定适当的惩处策略。

如何使用 Redis 来实现简单限流策略?

首先我们来看一个常见 的简单的限流策略。系统要限定用户的某个行为在指定的时间里只能允许发生 N 次,如何使用 Redis 的数据结构来实现这个限流的功能?

我们先定义这个接口,理解了这个接口的定义,读者就应该能明白我们期望达到的功能。

# 指定用户 user_id 的某个行为 action_key 在特定的时间内 period 只允许发生一定的次数 max_count
def is_action_allowed(user_id, action_key, period, max_count):
    return True
# 调用这个接口 , 一分钟内只允许最多回复 5 个帖子
can_reply = is_action_allowed("laoqian", "reply", 60, 5)
if can_reply:
    do_reply()
else:
    raise ActionThresholdOverflow()

先不要继续往后看,想想如果让你来实现,你该怎么做?

解决方案

这个限流需求中存在一个滑动时间窗口,想想 zset 数据结构的 score 值,是不是可以通过 score 来圈出这个时间窗口来。而且我们只需要保留这个时间窗口,窗口之外的数据都可以砍掉。那这个 zset 的 value 填什么比较合适呢?它只需要保证唯一性即可,用 uuid 会比较浪费空间,那就改用毫秒时间戳吧。

如图所示,用一个 zset 结构记录用户的行为历史,每一个行为都会作为 zset 中的一个 key 保存下来。同一个用户同一种行为用一个 zset 记录。

为节省内存,我们只需要保留时间窗口内的行为记录,同时如果用户是冷用户,滑动时间窗口内的行为是空记录,那么这个 zset 就可以从内存中移除,不再占用空间。

通过统计滑动窗口内的行为数量与阈值 max_count 进行比较就可以得出当前的行为是否允许。用代码表示如下:

# coding: utf8

import time
import redis

client = redis.StrictRedis()

def is_action_allowed(user_id, action_key, period, max_count):
    key = 'hist:%s:%s' % (user_id, action_key)
    now_ts = int(time.time() * 1000)  # 毫秒时间戳
    with client.pipeline() as pipe:  # client 是 StrictRedis 实例
        # 记录行为
        pipe.zadd(key, now_ts, now_ts)  # value 和 score 都使用毫秒时间戳
        # 移除时间窗口之前的行为记录,剩下的都是时间窗口内的
        pipe.zremrangebyscore(key, 0, now_ts - period * 1000)
        # 获取窗口内的行为数量
        pipe.zcard(key)
        # 设置 zset 过期时间,避免冷用户持续占用内存
        # 过期时间应该等于时间窗口的长度,再多宽限 1s
        pipe.expire(key, period + 1)
        # 批量执行
        _, _, current_count, _ = pipe.execute()
    # 比较数量是否超标
    return current_count <= max_count


for i in range(20):
    print is_action_allowed("laoqian", "reply", 60, 5)

Java 版:

public class SimpleRateLimiter {

  private Jedis jedis;

  public SimpleRateLimiter(Jedis jedis) {
    this.jedis = jedis;
  }

  public boolean isActionAllowed(String userId, String actionKey, int period, int maxCount) {
    String key = String.format("hist:%s:%s", userId, actionKey);
    long nowTs = System.currentTimeMillis();
    Pipeline pipe = jedis.pipelined();
    pipe.multi();
    pipe.zadd(key, nowTs, "" + nowTs);
    pipe.zremrangeByScore(key, 0, nowTs - period * 1000);
    Response<Long> count = pipe.zcard(key);
    pipe.expire(key, period + 1);
    pipe.exec();
    pipe.close();
    return count.get() <= maxCount;
  }

  public static void main(String[] args) {
    Jedis jedis = new Jedis();
    SimpleRateLimiter limiter = new SimpleRateLimiter(jedis);
    for(int i=0;i<20;i++) {
      System.out.println(limiter.isActionAllowed("laoqian", "reply", 60, 5));
    }
  }

}

这段代码还是略显复杂,需要读者花一定的时间好好啃。它的整体思路就是:每一个行为到来时,都维护一次时间窗口。将时间窗口外的记录全部清理掉,只保留窗口内的记录。zset 集合中只有 score 值非常重要,value 值没有特别的意义,只需要保证它是唯一的就可以了。

因为这几个连续的 Redis 操作都是针对同一个 key 的,使用 pipeline 可以显著提升 Redis 存取效率。但这种方案也有缺点,因为它要记录时间窗口内所有的行为记录,如果这个量很大,比如限定 60s 内操作不得超过 100w 次这样的参数,它是不适合做这样的限流的,因为会消耗大量的存储空间。

小结

本节介绍的是限流策略的简单应用,它仍然有较大的提升空间,适用的场景也有限。为了解决简单限流的缺点,下一节我们将引入高级限流算法——漏斗限流。

留言
Ctrl + Enter
全部评论(212)
BinK_1783的头像
删除
心存感激所遇即温柔
点赞
回复
optional的头像
删除
开发狗 @ haha
pipeline用的不错,先zrem,zcard,再zadd
点赞
回复
一只社会主义光环加成的头像
删除
-- LUA脚本如下 下标从 1 开始
local key = KEYS[1]
local now = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local expired = tonumber(ARGV[3])
-- 最大访问量
local max = tonumber(ARGV[4])

-- 清除过期的数据
-- 移除指定分数区间内的所有元素,expired 即已经过期的 score
-- 根据当前时间毫秒数 - 超时毫秒数,得到过期时间 expired
redis.call('zremrangebyscore', key, 0, expired)

-- 获取 zset 中的当前元素个数
local current = tonumber(redis.call('zcard', key))
local next = current + 1

if next > max then
-- 达到限流大小 返回 0
return 0;
else
-- 往 zset 中添加一个值、得分均为当前时间戳的元素,[value,score]
redis.call("zadd", key, now, now)
-- 每次访问均重新设置 zset 的过期时间,单位毫秒
redis.call("pexpire", key, ttl)
return next
end
展开
点赞
回复
danny1908的头像
删除
在main里边的20次循环里边最好加上一个模拟业务操作的sleep,否则demo代码里边容易超过限制
点赞
回复
亽閑屁亊哆的头像
删除
Java Siege Lion @ IT Zoo
使用zset,时间戳排序(滑动窗口)
缺点:数据量太大空间大
点赞
回复
SmartCat的头像
删除
Python工程师
如果是静态的时间窗口可以利用hash
点赞
回复
亦之的头像
删除
二进制搬运工 @ 火星
比较简单的方式,可以使用incr + 过期时间,incr每次都会返回结果值,根据结果值作出相应判断就行
4
4
删除
key 是 user_id + behavior_id + ts 吧,然后把过期时间设置成时间窗口大小
点赞
回复
删除
回复
不过这种做法有个问题,每次在时间窗口开始的时候有个流量尖峰
key 是 user_id + behavior_id + ts 吧,然后把过期时间设置成时间窗口大小
点赞
回复
查看更多回复
dotai的头像
删除
Web backend
# coding:utf-8
import redis
client = redis.StrictRedis(port=6479)
# 指定用户 user_id 的某个行为 action_key 在特定的时间内 period 只允许发生一定的次数 max_count
def is_action_allowed(user_id, action_key, period, max_count):
result1 = client.get(action_key)
if result1:
if max_count > 0:
client.incrby(action_key, -1)
return True
else:
client.setex(action_key, period, max_count - 1)
return True
return False
# 调用这个接口 , 一分钟内只允许最多回复 5 个帖子
can_reply = is_action_allowed("laoqian", "reply", 60, 5)
if can_reply:
do_reply()
else:
raise ActionThresholdOverflow()
展开
1
回复
moyuhunrizi的头像
删除
时间窗口是滑动的,靠设置过期时间等于是固定的时间窗口,就会出现文中提到的问题,建议再仔细看下
2
2
删除
是的
点赞
回复
删除
key加上分钟就可以了:incr uid:act:minute
点赞
回复
朋朋爱学习47448的头像
删除
青岛海尔
例子中已经设置了用户每个操作的具体过期时间,为什么不在进入方法时直接查询数量,如果数量小于最大数量就直接返回是否可以执行呢?
点赞
2
删除
赞同
点赞
回复
删除
限流和权限控制还是有区别的,限流的目的是控制在一段时间内用户执行操作的数量,防止恶意重复调用api资源
点赞
回复
美猴王六小龄童的头像
删除
1小时内限制短信发送的次数,应该也可以看做是一种限流策略吧
点赞
回复
Flame的头像
删除
看了下一章回过头来看 作者将add操作放前面也许就是为了避免并发导致2个请求同时判断了处理 之后相当于多处理了一个请求 不过先加也就出现了大家说的问题。无效的请求不应该被算在请求容器中
7
5
删除
是的,我也觉得应该先计算,再放入更合理呢。
点赞
回复
删除
先计算,再放入的话。后面下次再来请求你的那些无效请求无法被计算在窗口内
点赞
回复
查看更多回复
yesido的头像
删除
pipe.zadd(key, nowTs, "" + nowTs);
用nowTs来作为value,在高并发情况下会出现相同nowTs吧,这样多个请求就只统计到一次?
点赞
2
删除
key是UserId+actionId。一个用户的操作不会这么快,也不会雷同。
1
回复
删除
value一样的存不进去的,已实操。@yesido说得对
点赞
回复
迹_Jason的头像
删除
永远学习者 @ Zeros Tech
这种不适合大数据量的时候,毕竟不是O(1)
点赞
回复
刀客的头像
删除
有个疑问,问什么不采用简单键值对的方式,key为用户id,value为访问次数,设置一个过期时间。每次用户请求的时候都判断一下访问次数,如果次数超限就拒绝
17
16
删除
数据过期后key就清除了,下次访问的时候又从0开始计数了。比如说需求是1分钟之内最多访问10次,你设置过期时间是1分钟,这样在第59秒访问了9次,第60秒key清除了。接下来的1秒如果访问了2次,你的方法不会拒绝,但是实际上是在连续的两秒内访问了11次,已经超过阈值了,理应被拒绝的。
1
回复
删除
有道理
数据过期后key就清除了,下次访问的时候又从0开始计数了。比如说需求是1分钟之内最多访问10次,你设置过期时间是1分钟,这样在第59秒访问了9次,第60秒key清除了。接下来的1秒如果访问了2次,你的方法不会拒绝,但是实际上是在连续的两秒内访问了11次,已经超过阈值了,理应被拒绝的。
点赞
回复
查看更多回复
大大大桃子的头像
删除
Java开发
1、清除一下时间窗口之外的数据。
2、统计时间窗口内的数量。如果大于等于限制次数,直接返回false,否则3
3、添加此次请求到时间窗口,同时返回true.
这样避免插入脏数据,防止出现一直访问不了的问题。
10
回复
赵彻的头像
删除
时间戳 除以 period 为key,incre
点赞
回复
金龟的头像
删除
这个限流感觉不是很精确,因为会把被限制的请求,也放进时间窗口。下次请求来的时候,又把上次失败的请求拿来计算,有可能永远都不会成功。
8
10
删除
先移除不在时间窗口的请求, 然后才计算的总数
点赞
回复
删除
和时间窗口没关系,只是说把被限制的请求也加入了计算当中
点赞
回复
查看更多回复
邢灿举的头像
删除
无效的请求会影响时间段内有效请求的次数,是不是应该在插入之前判断是否超过阈值,如果超过就不写进去了
10
回复
聚丙烯树脂的头像
删除
老钱我觉得你对这本书的定义有点不对,你说这本书适合有一定redis的基础的人看,在我看来完全适合小白看呀。我这个小白看你的书看的津津有味,这是我的入门书籍,是我看的的第一本有关redis的书籍
2
2
删除
大佬带我
点赞
回复
删除
目前看的,都比较简单。
HyperLogLog原理在最后面讲,还可以。
队列,讲的很笼统,lumen实现的redis队列感觉很强。
分布式锁,讲的也是最基础的,可以看论坛里的RedLock的讨论,也很强。
点赞
回复

查看全部 212 条回复