cover_image

新东方技术实践之Django throttle限流模块

钟仕骏 新东方技术
2023年03月13日 02:26

上篇文章介绍了防抖和节流的理论概念,此篇将基于后端服务进行实践,并针对Django RestFramework框架的相关源码进行语义剖析和流程解读。

软件包版本

本次测试了两个新旧两个后端版本,均可兼容使用:

软件旧版新版
python3.8.163.11.2
django3.0.54.1.5
djangorestframework3.11.03.14.0

附源码阅读顺序:

1.python3.11/site-packages/rest_framework/throttling.py

2.python3.11/site-packages/rest_framework/views.py

3.python3.11/site-packages/rest_framework/exceptions.py

一、简易实例代码

假设有个安全需求,要求在某敏感接口设置限流,每个认证用户访问不得超过每分钟30次,否则输出超限等待提示,并执行钉钉告警。先画一个简易的请求步骤流程图:

图片

配置方面很简单,先在settings.py的REST_FRAMEWORK定义全局默认限流触发条件(此处意为服务端收到认证用户请求达到 30次/分钟 即触发):

图片

接着自定义UserDisabledRateThrottle类用于限流处置,继承自UserRateThrottle类:

图片

接着在接口对应的视图类重写关键字属性throttle_classes,指向刚刚自定义的UserDisabledRateThrottle类:

图片

至此限流代码就已完成,下面跟踪一下Django RestFramework底层是如何实现限流功能的。

二、源码剖析(rest_framework/throttling.py)

前面自定义的UserDisabledRateThrottle类继承了UserRateThrottle类,故从这里入手 打开UserRateThrottle源码,看到其继承自SimpleRateThrottle类,其中get_cache_key()是在子类必须要实现的方法,使用scope+ident(即应用范围+UID)将cache_format拼接(生成访问缓存cache_key):

图片

查看SimpleRateThrottle,里面定义了cache_format和需要子类实现的方法get_cache_key(),这里可以看到之所以必须要子类实现,是因为scope和ident都是字符串变量(%代入):

图片

继续向下看,核心方法allow_request()中调用了这个get_cache_key()生成缓存key,并算出是否要触发限流(返回布尔值):

图片

有几个参数很重要,key(缓存的键),history(访问历史),duration(持续时间),num_requests(请求数量),下面一一说明:key是通过self.get_cache_key()获取的,这就是为什么继承SimpleRateThrottle类就必须要实现重写get_cache_key()的原因;history就是上面图中的cache.get返回的这个数据列表,因为符合FIFO原则,可以把它当成队列结构;duration和num_requests是在parse_rate设置的,是从settings.py的DEFAULT_THROTTLE_RATES中split分割出来的固定数值。本文中DEFAULT_THROTTLE_RATES为“30/minute”,所以num_requests为30(单位:次),duration为60(单位:秒)。

了解完参数,接下来看执行赋值的两个重要判断逻辑:第一,128~129行,请求过期时(self.history[-1] <= self.now - self.duration)则删除(self.history.pop())历史记录的对应数据。这里用pop()是因为最新请求在cache中是插入队首的(见138~140行的self.history.insert(0, self.now)),所以pop会删除最老的数据(见上面的cache.get结果)。第二,130~132行,当history的length大于num_requests时,访问频次到达限流阈值,触发throttle_failure限流逻辑(本次重写的正是此部分)返回False;否则插入缓存队列,返回True,继续APIView的后续操作。

可以测试下cache_key的生成过程,当throttle对应的接口未触发限流时,每次请求会在数据库cachetable表中更新cache_key(在settings.py中设置的是CACHE_BACKEND = 'db://cachetable')

通过SQL语句查看库中的cache_key值:
(py3) mbp:src $ ./manage.py dbshell
mysql> desc cachetable;
+-----------+--------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+-----------+--------------+------+-----+---------+-------+
| cache_key | varchar(255) | NO | PRI | NULL | |
| value | longtext | NO | | NULL | |
| expires | datetime(6) | NO | MUL | NULL | |
+-----------+--------------+------+-----+---------+-------+
3 rows in set (0.00 sec)

访问一下接口,缓存里出现一条数据:
mysql> select * from cachetable where cache_key like '%throttle_%';
+----------------------+----------------------------------+----------------------------+
| cache_key | value | expires |
+----------------------+----------------------------------+----------------------------+
| :1:throttle_user_274 | gAWVDQAAAAAAAABdlEdB2PZuE43BZWEu | 2023-02-01 08:55:38.000000 |
+----------------------+----------------------------------+----------------------------+
1 row in set (0.00 sec)

通过代码获取value值:
(py3) mbp:src $ ./manage.py shell_plus
# 设置datatable为cache
>>> from django.core.cache import cache as default_cache
>>> cache = default_cache

# 设置key值为上面的cache_key值:
>>> key = "throttle_user_274"

# 查看用来测试的用户user_id:
>>> User.objects.filter(username='admin').first().id
274
# 注意:我在访问接口时使用的用户是admin,此时查到的admin的id是274,与上面cache_key中的274吻合
# scope的值:UserRateThrottle()中已将其设置为"user"
# ident的值:用户admin的user_id,即为274
# 将上述2个值作为变量代入 cache_format = 'throttle_%(scope)s_%(ident)s',则get_cache_key()结果值为"throttle_user_274",与上面的cache_key值结果相符

获取访问的1次接口的对应value:
>>> cache.get(key, [])
[1675212878.2149289]

再访问3次接口,列表数据内容变成4条(从队头插入):
>>> cache.get(key, [])
[1675212918.3631868, 1675212917.43252, 1675212915.0148559, 1675212878.2149289]

综上所述,throttling.py的核心方法是allow_request(),它实现了用于限流的cache_key和触发逻辑。

三、源码剖析(rest_framework/views.py)

接着看下APIviews里是在哪里、如何调用及捕获allow_request的。再次回到视图类,这里继承了ReadOnlyModelViewSet方法:

图片

打开 rest_framework/viewsets.py 文件,发现如下继承关系:ReadOnlyModelViewSet -> GenericViewSet -> generics.GenericAPIView:

图片

这里generics.GenericAPIView(位置rest_framework/generics.py)又继承自API视图基类views.APIView:

图片

来到views.APIView(位置rest_framework/views.py),它封装了throttle的常用关键字属性(还包含了authentication_classes、permission_classes等常用模块),这里相当于一个钩子:

图片

APIView类在初始化方法中会调用check_throttles()函数:

图片

关键点来了,check_throttles()函数用到了前面提到的限流核心方法allow_request(),当它返回布尔值为False(到达限流阈值)时,throttle_durations列表非空,继而调用self.throttle()模块:

图片

之后self.throttle()会主动抛出一个限流异常:

图片

限流异常捕获类(位置rest_framework/exceptions.py)定义了拼接文案和返回码:

图片

到此,限流的整体核心代码就都关联起来了。

四、测试限流

代码上线后开始测试,频繁访问接口会触发限流,直到下一个统计周期前,页面都将返回如下(返回文案与上图拼接的一致):

图片

验证下日志里的请求返回码(对应 Throttled类中 status_code = status.HTTP_429_TOO_MANY_REQUESTS):

图片

触发后的自定义逻辑(发送钉钉机器人告警)正常执行:

图片

限流功能测试完成,符合预期处置结果。

总结

回顾下刚刚的限流代码,整体流程见下图:

图片

所以实现一个限流功能只需三步:1.设置限流阈值 2.编写处置逻辑 3.应用到视图类

源码方面,有以下几个重点文件:

1.throttling.py文件:实现了访问频次统计的cache_key结构及限流判断逻辑; 
2.views.py文件:实现了用户视图基类的限流钩子; 
3.exceptions.py文件:捕获限流异常并生成文案。
至此,Django throttle限流模块的代码和源码讲解已结束,如有其他疑问欢迎钉我,感谢大家支持。


继续滑动看下一个
新东方技术
向上滑动看下一个