当前位置:首页 > 问答 > 正文

用Redis咋整滑动窗口计数,感觉这功能挺实用的,不知道性能咋样

(主要思路来自《Redis实战》和Redis官方文档关于INCR和EXPIRE的用法)

用Redis搞滑动窗口计数,这玩意儿说白了就是看某个东西(比如用户请求)在最近一段时间内发生了多少次,为啥用Redis?因为它快,所有操作都在内存里完成,而且自带好用的数据过期功能,正好符合咱们的需求。

核心就两步:记录每次事件 + 清理过期数据。

最直接的法子就是用一个Redis的键(key)来代表这个事件(比如用户ID+接口名),值(value)就是个计数器,每次事件来了,就给这个键执行一个 INCR 命令(原子性加1),但光加不行啊,窗口是滑动的,老数据得扔掉,这时候就用上过期时间了,给这个键设置一个过期时间(TTL),比如窗口长度是60秒,就设个60秒后过期,这样,超过60秒的旧计数就会自动被Redis清掉。

用Redis咋整滑动窗口计数,感觉这功能挺实用的,不知道性能咋样

听着挺完美是吧?但这里有个坑,如果你每次 INCR 后都重新设置一次过期时间,EXPIRE key 60,那这个键就永远不过期了,因为每次访问都会刷新TTL,正确的做法是,只在键第一次创建的时候设置过期时间,可以用Redis的 MULTI 命令(开启事务)把 INCREXPIRE 包起来原子性执行,或者用更简单的 SET 命令组合:SET key 1 EX 60 NX(当key不存在时设置值为1并过期时间60秒),如果键已存在,就只用 INCR

这个方法简单粗暴,对于精度要求不高、时间窗口不是特别长的场景(比如一分钟内的防刷)完全够用,但它有个缺点:它统计的是“从第一次事件开始往后的一段时间”,而不是“严格意义上的最近60秒”,用户在0秒时第一次请求,然后在第59秒和第61秒各请求一次,在第61秒时,计数器显示是3(0秒、59秒、61秒),但严格来说0秒的请求已经不在“最近60秒”这个窗口内了(当前是61秒,60秒内是从第1秒到第61秒),这种误差在窗口较大或者事件分布不均时可能不太准。

想要更精确咋办?那就得上“桶”了。

用Redis咋整滑动窗口计数,感觉这功能挺实用的,不知道性能咋样

这个思路在Redis Labs的博客里提到过(就是做Redis那个公司),咱们不只记一个总数,而是把时间窗口切成更小的片段,比如60秒的窗口,切成60个1秒的“桶”,每个桶只负责记录它那1秒钟内发生的事件次数。

具体做的时候,可以用一个Redis的哈希(Hash)结构,哈希的键名还是代表这个事件(比如用户:接口),哈希里面的字段(field)就用时间戳来表示是哪个桶,timestamp_floor(时间戳取整到秒),每次事件来了,先算出当前时间对应的桶(比如当前秒数),然后对这个哈希里对应的字段执行 HINCRBY 命令(给哈希里某个字段的值加1)。

那滑动窗口怎么体现呢?在每次计数之前,咱们先算一下当前时间戳减去窗口大小(比如60秒)的时间点,把这个时间点之前的所有旧桶(对应的字段)从哈希里删掉(用 HDEL 命令),这样,哈希里剩下的就全是最近60秒内的桶了,把所有剩余桶的计数加起来(用 HVALS 命令取出所有值再求和),就是精确的滑动窗口计数。

用Redis咋整滑动窗口计数,感觉这功能挺实用的,不知道性能咋样

这个方法精确是精确了,但明显比第一种方法费劲,每次操作不止是加1,还得先清理旧桶,再计算总和,清理和求和如果桶很多(比如窗口是一小时,用秒做桶就有3600个),可能会有点慢,虽然Redis本身很快,但毕竟操作变复杂了,不过你可以根据实际需求调整桶的粒度,比如一分钟的窗口,用10秒作为一个桶,就只有6个桶,性能就好很多,精度牺牲一点也能接受。

性能到底咋样?

Redis干这个事性能非常顶,因为这些都是最基础的 INCREXPIREHDEL 等命令,Redis处理这些内存操作都是微秒级别的,性能瓶颈通常不在Redis本身,而在于你的网络延迟(比如你的应用服务器和Redis服务器之间的网络速度),只要你的Redis实例别太拉胯,内存够用,撑住每秒几万甚至几十万次的这种计数请求问题不大。

第一种简单方法性能最好,因为就一两个命令,第二种精确方法,性能取决于你窗口划分的粒度,桶越多,清理和计算总和的开销就越大,所以在设计的时候,要在精度性能之间做个权衡,别为了追求极致的精确,把窗口切得特别细,结果Redis大部分时间都在帮你删旧桶和算加法,那就本末倒置了。

实际用的时候还得注意点啥?

  1. 键的设计:键名要能唯一标识你要统计的对象和动作,rate_limit:user123:api_login,避免不同用户或不同接口的数据混在一起。
  2. 原子性:第二种方法里的“清理旧桶”和“增加新桶”最好能用Redis的Lua脚本打包成一个原子操作,避免并发时数据错乱。
  3. 内存:如果被统计的对象非常多(比如上百万个不同的用户),每个对象都占一个键(或一个哈希),那内存消耗会增长得很快,需要提前规划好Redis的内存大小。

如果这功能对你特别重要,又怕自己写不好,可以直接瞅瞅Redis官方推荐的Redis模块,RedisCell,它就是专门用Redis做限流的,里面应该就实现了更完善的滑动窗口算法,直接拿来用可能更省心。