神代綺凛の随波逐流

借助 redis 实现访问控制


当前页面是本站的「Baidu MIP」版。查看和发表评论请点击:完整版 »

轻松做到基于 IP 或用户 ID 等的单位时间内访问频率的限制

Head Pic: #明日方舟 双狼 by 卧龙先生的爸爸 - pixiv

做开放 API,访问控制防止滥用是相当重要的一部分

我相信世上好人还是多,但不可避免地总会出现那么几个憨憨,普通 WAF 提供的基于 URL 的访问频率限制已经不顶用了,自己拓展又很麻烦

想了想,不如直接用开发 API 的语言写好了,方便、契合度还高

关于 redis

为什么用 redis

其实也没有什么特别的理由,只是碰巧想了解学习一下 redis,结果无意间发现 redis 很适合用来做访问记录,主要因为以下两点

  1. 支持有序集合 (sorted sets)
  2. 可为某个键设置 TTL (time to live)

这两点,无疑为做单位时间内的访问频率限制提供了巨大便利

另外明确一下,此处单位时间指的是持续时间而不是自然时间:
例如每天允许访问 1000 次,即从 1 天前的时间点到当前时间点的这段时间内最多允许访问 1000 次,而不是每个自然天中最多允许访问 1000 次,过了 0 点就刷新的那种

(如果是自然时间那好办了,直接开干就可以,根本用不着 redis)

redis 中的有序集合

Redis 中的有序集合是 string 类型元素的集合,与一般集合不同的是每个元素都与一个 double 值相关联用于排序,称作 score

有序集合中的元素不可重复,但 score 可以重复

思路及实现

以下例子均为 PHP 下的实现,用到 phpredis 扩展

基本思路

使用 redis 储存访问记录,当访问者访问时通过判断单位时间内该访问者的访问次数是否超出预订值来决定是否 block

确定如何储存

根据对 redis 中有序集合的基础了解,使用它储存访问记录之前我们需要确定三个参数

想一下,我们是要基于 IP用户 ID,记录单位时间内的访问行为,因此可以这么设计

由于 score 可以重复,因此我们不需要在意时间戳的单位,秒还是毫秒还是微秒都无所谓,只需要保证 value 不重复即可

正好,PHP 中有一个函数 uniqid,作用是生成一个基于当前时间微秒数的几乎唯一的 ID

* 通过随机前缀和more_entropy参数可以增加唯一性的概率,足够了,已经可以不用再努力了

确定如何释放

释放过时的记录是必要的,不然一直存下去内存迟早药丸,虽然也可能等不到这一天就是了(

此处就轮到 TTL 派上用场了,前面提到过 redis 支持为键设置 TTL,这样就可以在特定时间后自动释放,我们每次要向有序集合内添加新元素时都更新一次 TTL,将其复原至预设值,这样就可以保证活动状态下的数据不会被释放

但如果一个有序集合一直有数据添加进来怎么办,这样也会越存越多

好办,只要每次向这个集合添加元素前先删掉所有过期元素就好了

这样所有过期记录都只有两种下场:在新元素添加进来之前被删除,或因为其所属集合的 TTL 到期而被删除

简单实现

连接 redis 的步骤均省略

公共变量

$time = time();         // 当前时间戳(秒)
$ttl = 24 * 60 * 60;    // 一天内
$key = 'IP or User ID'; // 这个访问者
$limit = 1000;          // 最多允许访问 1000 次

$line = $time - $ttl;   // TTL 时间线

获取剩余额度信息

// 时间线后的记录数
$count = $redis->zCount($key, $line, '+inf');
// 拿到最接近时间线的 score 对应的 value,返回值是数组,没有就是空数组
$top_value = $redis->zRangeByScore($key, $line, '+inf', ['limit' => [0, 1]]);

// 最终要得到的:剩余额度
$quota = $limit - $count;
// 最终要得到的:额度恢复 1 所需等待的时间,如果没用过就 0
$quota_ttl = count($top_value) > 0 ? $redis->zScore($key, $top_value[0]) + $ttl - $time : 0;

记录使用行为

// 清除旧记录
$redis->zRemRangeByScore($key, '-inf', $line);
// 记录
$redis->zAdd($key, $time, uniqid('', true));
// 更新集合的 TTL
$redis->expire($key, $ttl);

后记

拿什么做key是一个需要三思的问题,对于一个固定的访问者用固定的方式访问,它必须也是固定的

比如,如果你设计了自己的 APIKEY 体系,切忌将 APIKEY 作为key,因为 APIKEY 可能是可变的(取决于你自己的设计),而比较妥善的方法是将 APIKEY 所属的用户的 ID 之类的作为key