有一个假设的Web服务器只支持一个非常简单的API - 在过去的小时,分钟和秒中收到的请求数。 该服务器在世界上非常流行,每秒收到数千个请求。
目标是找到如何准确地将这3个计数返回到每个请求?
请求一直在进行,因此每个请求的一小时,一分钟和一秒的窗口是不同的。 如何根据请求管理不同的窗口,以便每个请求的计数正确?
答案 0 :(得分:26)
如果需要100%准确度:
拥有所有请求和3个计数的链接列表 - 包括最后一小时,最后一分钟和最后一秒。
你将有两个指向链表的指针 - 一分钟前和一秒钟前。
一小时前将在列表的末尾。每当最后一次请求的时间超过当前时间之前一小时,将其从列表中删除并减少小时数。
分针和秒针将指向分别在一分钟和一秒钟之后发生的第一个请求。每当请求的时间超过当前时间之前的一分钟/秒时,向上移动指针并减少分钟/秒计数。
当有新请求进入时,将其添加到所有3个计数中并将其添加到链接列表的前面。
对计数的请求只涉及返还计数。
以上所有操作均按摊销常数计算。
如果准确度低于100%:
上述空间复杂度可能有点多,具体取决于您通常每秒获得的请求数量;您可以通过以下方式牺牲精确度来减少这种情况:
如上所述有链接列表,但仅限于最后一秒。还有3个计数。
然后有一个包含60个元素的圆形数组,表示最后60秒的计数。每当第二次通过时,从分钟计数中减去数组的最后一个(最旧的)元素,并将最后一个第二个计数添加到数组中。
在过去60分钟内有一个类似的圆形阵列。
准确度下降:一分钟内所有请求都可以关闭分钟计数,一分钟内所有请求都可以关闭小时数。
显然,如果你每秒只有一个或更少的请求,这就没有意义。在这种情况下,您可以将最后一分钟保留在链接列表中,并且在过去的60分钟内只有一个圆形数组。
此外还有其他变化 - 可根据需要调整空间使用率的准确度。
删除旧元素的计时器:
如果仅在新元素进入时删除旧元素,它将按常量时间分摊(某些操作可能需要更长时间,但会平均到常数时间)。
如果你想要真正的恒定时间,你还可以运行一个定时器来移除旧的元素,每次调用它(当然插入和检查计数)只需要一段时间,因为你最多需要删除自上一次计时器打勾以来在常量时间内插入的许多元素。
答案 1 :(得分:12)
要在T秒的时间窗口内执行此操作,请使用队列数据结构,在该结构中,您可以将各个请求到达时的时间戳排队。当您想要读取在最近的T秒窗口期间到达的请求数时,首先从队列的“旧”端删除那些早于T秒的时间戳,然后读取队列的大小。每当向队列添加新请求时,您也应该删除元素以保持其大小有限(假设传入请求的速率有限)。
此解决方案可达到任意精度,例如毫秒精度。如果您对回复大致答案感到满意,可以参考,例如对于T = 3600(一小时)的时间窗口,将同一秒内的请求合并到一个队列元素中,使队列大小受到3600的限制。我认为这样会更好,但理论上会失去准确性。对于T = 1,如果需要,可以在毫秒级进行合并。
在伪代码中:
queue Q
proc requestReceived()
Q.insertAtFront(now())
collectGarbage()
proc collectGarbage()
limit = now() - T
while (! Q.empty() && Q.lastElement() < limit)
Q.popLast()
proc count()
collectGarbage()
return Q.size()
答案 2 :(得分:6)
为什么不使用圆形阵列? 我们在该阵列中有3600个元素。
index = 0;
Array[index % 3600] = count_in_one_second.
++index;
如果你想要最后一秒,返回这个数组的最后一个元素。 如果你想要最后一分钟,返回最后60个元素的总和。 如果你想要最后一小时,返回整个数组的总和(3600个元素)。
这不是一个简单而有效的解决方案吗?
由于
Deryk
答案 3 :(得分:4)
一种解决方案是这样的:
1)使用长度为3600 的圆形阵列(每小时60 * 60秒)来保存最后一小时内每秒的数据。
要记录新数据的数据,请通过移动圆形阵列的头指针将最后一秒的数据放入圆形数组中。
2)在圆形数组的每个元素中,我们不记录特定秒数内的请求数,而是记录我们之前看到的请求数量的累积和,以及可以通过requests_sum.get(current_second) - requests_sum.get(current_second - number_of_seconds_in_this_period)
increament()
,getCountForLastMinute()
,getCountForLastHour()
等所有操作均可在O(1)
时间内完成。
=============================================== ==========================
这是一个如何运作的例子。
如果我们在最近3秒内有这样的请求计数:
1st second: 2 requests
2nd second: 4 requests
3rd second: 3 requests
圆形数组如下所示:
sum = [2, 6, 9]
其中6 = 4 + 2和9 = 2 + 4 + 3
在这种情况下:
1)如果你想获得最后一秒的请求数(第3秒的请求数),只需计算sum[2] - sum[1] = 9 - 6 = 3
2)如果你想获得最后两秒的请求数(第三秒的请求数和第二秒的请求数),只需计算sum[2] - sum[0] = 9 - 2 = 7
答案 4 :(得分:1)
您可以在一小时内为每秒创建一个大小为60x60的数组,并将其用作循环缓冲区。每个条目包含给定秒数的请求数。当你移动到下一秒时,清除它并开始计数。当你在阵列结束时,你再次从0开始,所以在1小时之前有效地清除所有计数。
所以这三个都有O(1)空间和时间复杂度。唯一的缺点是,它忽略了毫秒,但你也可以应用相同的概念来包括毫秒。
答案 5 :(得分:1)
Following代码在JS中。它将返回O(1)中的计数。我写了这个程序进行面试,时间预先定义为5分钟。但您可以修改此代码的秒数,分钟数等。让我知道它是怎么回事。
在clean_hits方法中,从我们创建的对象中删除每个条目(在我们的时间范围之外),并在删除条目之前从totalCount中减去该计数
this.hitStore = { "totalCount" : 0};
答案 6 :(得分:1)
我必须在Go中解决这个问题,而且我认为我还没有看到这种方法,但它也可能对我的用例非常具体。
由于它连接到第三方API并且需要限制自己的请求,我只是保留了最后一秒的计数器和最后2分钟的计数器(我需要的两个计数器)
var callsSinceLastSecond, callsSinceLast2Minutes uint64
然后,当呼叫计数器低于允许的限制时,我会在单独的例行程序中启动我的请求
for callsSinceLastSecond > 20 || callsSinceLast2Minutes > 100 {
time.Sleep(10 * time.Millisecond)
}
在每个例行程序结束时,我会原子地减少计数器。
go func() {
time.Sleep(1 * time.Second)
atomic.AddUint64(&callsSinceLastSecond, ^uint64(0))
}()
go func() {
time.Sleep(2 * time.Minute)
atomic.AddUint64(&callsSinceLast2Minutes, ^uint64(0))
}()
到目前为止,到目前为止这似乎没有任何问题,但到目前为止还有一些非常繁重的测试。
答案 7 :(得分:1)
这是一种通用的Java解决方案,可以跟踪最后一分钟的事件数。
我之所以使用ConcurrentSkipListSet
是因为它保证了搜索,插入和删除操作的平均时间复杂度为O(log N)。您可以轻松地更改以下代码,以使持续时间(默认为1分钟)可配置。
如以上答案中所建议,例如,使用调度程序定期清理陈旧条目是个好主意。
@Scope(value = "prototype")
@Component
@AllArgsConstructor
public class TemporalCounter {
@Builder
private static class CumulativeCount implements Comparable<CumulativeCount> {
private final Instant timestamp;
private final int cumulatedValue;
@Override
public int compareTo(CumulativeCount o) {
return timestamp.compareTo(o.timestamp);
}
}
private final CurrentDateTimeProvider currentDateTimeProvider;
private final ConcurrentSkipListSet<CumulativeCount> metrics = new ConcurrentSkipListSet<>();
@PostConstruct
public void init() {
Instant now = currentDateTimeProvider.getNow().toInstant();
metrics.add(new CumulativeCount(now, 0));
}
public void increment() {
Instant now = currentDateTimeProvider.getNow().toInstant();
int previousCount = metrics.isEmpty() ? 0 : metrics.last().cumulatedValue;
metrics.add(new CumulativeCount(now, previousCount + 1));
}
public int getLastCount() {
if (!metrics.isEmpty()) {
cleanup();
CumulativeCount previousCount = metrics.first();
CumulativeCount mostRecentCount = metrics.last();
if (previousCount != null && mostRecentCount != null) {
return mostRecentCount.cumulatedValue - previousCount.cumulatedValue;
}
}
return 0;
}
public void cleanup() {
Instant upperBoundInstant = currentDateTimeProvider.getNow().toInstant().minus(Duration.ofMinutes(1));
CumulativeCount c = metrics.lower(CumulativeCount.builder().timestamp(upperBoundInstant).build());
if (c != null) {
metrics.removeIf(o -> o.timestamp.isBefore(c.timestamp));
if (metrics.isEmpty()) {
init();
}
}
}
public void reset() {
metrics.clear();
init();
}
}
答案 8 :(得分:0)
简单的时间戳列表怎么样?每次您发出请求时,都会将当前时间戳附加到列表中。每次要检查是否在速率限制之下时,首先要删除超过1小时的时间戳,以防止堆栈溢出(呵呵),然后计算最后一秒,分钟,等等的时间戳数。 / p>
可以在Python中轻松完成:
import time
requestsTimestamps = []
def add_request():
requestsTimestamps.append(time.time())
def requestsCount(delayInSeconds):
requestsTimestamps = [t for t in requestsTimestamps if t >= time.time() - 3600]
return len([t for t in requestsTimestamps if t >= time.time() - delayInSeconds])
我想这可以优化,但你看到了这个想法。
答案 9 :(得分:0)
我的解决方案:
维护一个散列值3600,其中包含一个计数,时间戳记作为字段。
对于每个请求:
Case(1):如果i / p timestamp == hash [idx] .timestamp,hash [count] ++;
Case(2):如果i / p时间戳> hash [idx] .timestamp,则hash [idx] .count = 1并 hash [idx] .timestamp = inputTimeStamp
Case(3)::如果i / p timestamp
现在可以查询过去第二秒,分钟,小时: 如上找到idx,只要时间戳与给定的秒/范围/分钟匹配,请继续以循环方式从idx返回。