基于哈希的实时日志速率限制器
题目描述
设计一个实时日志速率限制器。系统会接收源源不断的日志请求流(每条请求用一个唯一的 requestId 和发生的时间戳 timestamp 表示,时间戳单位为秒)。我们需要实现一个函数 shouldPrint(requestId, timestamp),其功能是判断给定 requestId 对应的请求在当前 timestamp 是否允许被打印(即通过限制)。
限制规则如下:
- 对于同一个
requestId,如果在过去 10 秒内(包含当前秒)已经打印了超过 10 条消息,那么当前请求应该被拒绝(函数返回false)。 - 否则,允许打印,记录此次请求,并返回
true。
要求高效地支持高频调用。
解题思路分析
这是一个典型的滑动窗口限流问题。我们需要为每个唯一的 requestId 维护一个时间窗口内的请求历史。当一个新的请求到达时,我们需要快速判断在 [timestamp - 9, timestamp] 这个长度为10秒的窗口内(包含当前秒,所以是过去9秒加当前秒,共10秒),该 requestId 的请求数量是否已达到上限10。
核心挑战在于如何高效地维护这个滑动窗口内的请求计数,并能在每次请求时快速清理掉窗口之外的过期请求记录。
哈希表的作用:显然,我们需要一个数据结构来映射每个 requestId 到其对应的请求历史记录。哈希表(HashMap/dict)是实现 O(1) 时间复杂度查找和插入 requestId 的完美选择。
历史记录的数据结构选择:对于每个 requestId 的历史记录,我们有几种选择:
- 存储所有时间戳的列表:每次检查时,需要遍历列表,移除过期的,并计算剩余数量。当请求量大且时间跨度可能很长时,列表会很长,清理和计数的成本
O(n)可能过高。 - 存储一个计数器 + 队列:队列按时间顺序存放时间戳。新请求入队,检查时从队头弹出所有过期时间戳,并更新计数器。这是更优的选择,清理过期记录的摊还时间复杂度为
O(1)。 - 进一步优化 - 存储一个计数器 + 循环队列/时间分片:由于我们的窗口大小固定(10秒),我们可以使用一个固定大小为10的队列或数组,每个位置代表距离当前时间
0~9秒的请求数。通过取模运算来更新。这种方法能保证严格O(1)的操作,且内存使用固定。
我们采用第2种(队列)和第3种(时间分片)相结合的一种清晰、高效且容易理解的实现方式。
循序渐进讲解
我们将设计一个类 Logger。
步骤 1:数据结构设计
我们需要一个哈希表 request_map。它的键是 requestId,值是一个数据结构,用于记录该 requestId 在最近10秒内各个时间点(秒)上的请求次数。
由于时间戳是整数秒,且窗口固定为10秒,我们可以为每个 requestId 维护一个长度为10的环形数组(或称为循环队列) counters。
counters[i]表示在时间戳current_time - i这一秒内收到的请求数量(其中i从0到9,i=0表示当前秒)。- 同时,我们需要记录该
requestId最近一次更新的“基准时间戳”last_time,用于对齐时间窗口。
这样,哈希表的结构就是:requestId -> (last_time, counters)。
步骤 2:shouldPrint 方法流程
假设我们调用 shouldPrint(requestId, timestamp)。
-
获取或初始化记录:
- 从
request_map中查找requestId对应的记录(last_time, counters)。 - 如果不存在,则初始化:
last_time = timestamp,counters是一个长度为10、所有元素为0的数组。然后直接将counters[0]加1(表示在当前秒有一次请求),更新last_time = timestamp,将新记录存入哈希表,并返回true。
- 从
-
计算时间差并清理过期计数:
- 如果记录已存在,计算
time_passed = timestamp - last_time。 - 如果
time_passed >= 10,这意味着自上次更新以来,已经过去了至少一个完整的窗口时间。那么整个counters数组里记录的所有请求都过期了。我们可以直接将整个counters数组重置为全0。 - 如果
0 <= time_passed < 10,这意味着自上次更新后,仍在同一个10秒窗口内,或者部分重叠。我们需要将counters数组中代表“比last_time早了10秒或更久”的那些位置清零。具体操作是:- 从
i = 1到i = time_passed(包含),将counters[i]清零。实际上,更高效且能处理time_passed > 10的统一方法是:遍历counters数组的每个索引i,如果i > time_passed,说明这个位置代表的时间点(timestamp - i)已经不在当前窗口[timestamp-9, timestamp]内了,应该清零。但因为我们之后只会用到i从0到min(9, time_passed)的计数,一种实现技巧是:将数组视为一个环形缓冲区,每次根据时间差移动“当前秒”的指针,并只更新新的“当前秒”对应的位置。不过,为了清晰,我们先采用逻辑上清晰的“重置过期区间”的方法。
- 从
- 如果记录已存在,计算
-
检查当前窗口总请求数:
- 在清理(或重置)了
counters之后,我们计算当前窗口内的总请求数sum(counters)。 - 如果
总和 >= 10,则拒绝本次请求,返回false。 - 注意:由于我们清理后,
counters中只包含窗口内的计数,所以这个总和就是窗口内的请求数。
- 在清理(或重置)了
-
允许请求并更新记录:
- 如果总和
< 10,则允许本次请求。 - 更新
counters中代表timestamp这一秒的计数。由于我们可能清理了数组,并且timestamp可能与last_time相同或不同,我们需要找到timestamp在counters数组中对应的索引。- 索引计算:
index = timestamp % 10。这是关键技巧! 我们用时间戳对10取模,这样每个时间戳都会映射到数组的一个固定位置。同一秒的所有请求会累加到counters[index]上。 - 但是,直接使用
timestamp % 10有个问题:不同时间周期(如第1秒和第11秒)的timestamp取模后都是1,但它们是不同窗口的。为了解决这个问题,我们不仅存储计数,还在每个位置存储其对应的时间戳。更常见的优化方法是:在每次访问时,如果发现counters中某个位置记录的时间戳与当前timestamp不属于同一个10秒周期(即timestamp - stored_timestamp >= 10),则将该位置的计数清零,然后再用于当前请求的累加。我们稍后会看到这种实现。
- 索引计算:
- 如果总和
-
更新
last_time:- 将
last_time更新为timestamp。
- 将
步骤 3:优化实现方案
为了避免复杂的区间清零逻辑,并保证严格 O(1),我们采用一种广泛使用的优化方案:
为每个 requestId 存储的信息简化为:一个长度为10的数组 times。
times[i]记录了在时间戳t满足t % 10 == i时,最近一次在该索引位置(即该秒)发生请求的时间戳t。- 例如,
times[3] = 153表示,最近一次在“秒数模10等于3”的时刻(如第3秒、13秒、23秒...)发出的请求是在时间戳153。
算法流程:
- 计算索引
idx = timestamp % 10。 - 从哈希表中获取
requestId对应的times数组。若不存在,则创建并初始化所有元素为-1(表示从未使用)。 - 检查
times[idx]的值:- 如果
timestamp - times[idx] < 10,说明在最近10秒内,同一个模索引位置(意味着是同一个10秒周期内的同一秒,或者是上一个周期但仍在10秒窗口内)已经有过请求。但我们需要计算的是整个窗口内的总请求数,所以不能只看这一个位置。 - 实际上,我们需要遍历整个
times数组(长度10,常数时间),对于每个位置i:- 如果
timestamp - times[i] < 10,那么times[i]记录的这个请求是在当前10秒窗口内的,将其计入本次窗口的请求数。
- 如果
- 如果
- 统计完窗口内请求数
count。 - 如果
count >= 10,返回false。 - 否则,允许请求:更新
times[idx] = timestamp(无论之前该位置记录的时间戳是什么,都用新的覆盖,因为同一秒的多次请求,我们只需要知道最近一次的时间戳,对于计数,我们是通过遍历整个数组按时间差判断的,所以覆盖旧值不影响统计过去10秒内不同秒的请求次数),然后返回true。
这个方法非常巧妙:
times数组本质上是一个时间戳的环形缓冲区,索引由timestamp % 10决定。- 每次判断时,遍历这个固定大小的数组(10次操作),检查每个位置记录的时间戳是否在当前时间戳的10秒窗口内。是则计数+1。
- 更新时,直接将新时间戳写入对应索引位置。
- 它自动处理了时间窗口的滑动,无需显式地移动或清理数据,因为过期的记录(
timestamp - times[i] >= 10)在遍历时不会被计入。
总结与复杂度分析
我们设计的 Logger 类包含一个哈希表 request_map,键为 requestId,值为一个长度为10的整数数组 times。
- 时间复杂度:
shouldPrint操作是O(1)。虽然需要遍历长度为10的数组,但这是一个常数,与请求流规模无关。哈希表的查找和插入也是平均O(1)。 - 空间复杂度:
O(N * 10),其中N是出现过的唯一requestId的数量。每个requestId存储一个大小为10的数组。
这种方法高效、准确地实现了固定时间窗口(滑动窗口)的速率限制,是处理此类问题的经典哈希表应用。