分布式速率限制与背压:确保系统的稳定性

4,146 阅读5分钟

假设情景如下:我们有100个服务,每个服务提供30个API。每个API的平均请求超时时间为1分钟,而平均请求大小为1KB。

问题1:数据库中请求限额需要多少内存?

首先,我们需要考虑每个API的请求超时时间,以确定存储需求。假设每个API需要一个计时器轮,因此我们需要与每个API的请求超时时间成比例的存储桶。由于请求超时时间为1分钟,即60秒,我们需要60个桶来覆盖这段时间。

接下来,假设每个API平均每秒处理10个请求,总共有(10 * API数量)个请求,即(10 * 30 * 100)= 30,000个请求。由于我们不需要将整个请求存储在Oracle中,只需存储请求ID。一个8字节的ID可以唯一标识每个请求,因此我们的需求是8字节 * 30,000 = 240KB。

考虑到计时器轮有60个桶,我们可能需要的总内存为240KB * 60,约为1MB * 15,合计15MB。这个数字相对容易记忆。然而,为了确保一致性和容错性,最好将这些记录存储在数据库中(需要注意,预言机是一个分布式服务)。

问题2:API的超时时间为10秒,每个请求至少需要1秒来处理。最长排队等待时间是多少?

请求-响应流程可以分成三个部分:a) 请求到达服务所花费的时间,b) 处理请求所花费的时间,以及c) 响应到达客户端所需的时间。

A) 请求发送到服务器 -> B) 请求在队列中等待 -> C) 请求正在处理 -> D) 响应发送到客户端处理时间已经包括在队列中的等待时间中。假设客户端到服务的往返时间为100毫秒,这意味着我们有10秒 - 200毫秒 = 9800毫秒来处理每个请求。在扣除原始处理时间后,我们有8800毫秒,这就是最长等待时间。

问题3:在上述场景中,每秒允许1000个请求的队列大小应该是多少?

每秒我们允许1000个请求,假设每个请求的平均大小为1KB。这意味着我们每秒需要处理1000 * 1KB = 1MB的数据。考虑到最大等待时间为8.8秒,我们需要的队列大小为8.8秒 * 1MB/秒 = 8.8MB

前面所提到的三个情境并不是唯一需要进行速率限制的情况。为了确保系统的稳健运行,我们必须认真考虑实施速率限制。

  1. 速率限制并不仅仅是在特定场景下的必要条件,它可以根据业务需求(例如,每分钟不超过10个订单)或者为了减少锁争用的情况(比如票务预订系统)而使用。有时候,为了维持服务级别协议(SLA),包括响应时间和故障率等,速率限制也是不可或缺的。正如视频开头所展示的那样,速率限制也可以防止级联故障的发生。
  2. 整个系统是否都使用单一的速率限制组件?这是否会成为系统瓶颈和单点故障的源头呢?速率限制器通常是一个可以水平扩展的分布式服务。因此,它可以处理所有通用的使用情况。但是,服务本身可能需要更多的自定义速率限制策略。在这种情况下,服务可以自行实现这些策略。例如,聊天服务可能需要特殊的速率限制逻辑和行为,这可能与其他服务不兼容。在这种情况下,聊天服务可以内部实现自己的速率限制器
  3. 针对每个服务如何设置限额呢?容量估算为我们提供了一个很好的起点。举例来说,如果您拥有3台内存为2GB的计算机,并希望它们用于2KB配置文件的缓存,那么您可以在缓存中存储300万个配置文件。如果您的系统有1000万活跃用户,其中90%的请求可以通过缓存满足,那么平均配置文件查找时间将为0.9 * memory_lookup_time + 0.1 * db_lookup_time。每次配置文件查找大约需要0.9 * 0.01毫秒 + 0.1 * 1毫秒 = 0.009 + 0.1毫秒 = 0.11毫秒。因此,我们可以预计每毫秒可以处理1/0.11 = 9个请求,即每秒9000个请求。接下来,我们可以进行负载测试,保守估计每秒需要处理5000个请求。通过负载测试后,我们就可以有信心地说,我们的服务能够处理多少请求,并且保持良好的响应时间。
  4. 速率限制是否会增加每个请求的内存需求和延迟呢?确实,速率限制是一项额外的功能,需要进行精心设计、维护和性能调整。尽管如此,由于其带来的优点远大于不足之处,所以值得投入时间和精力。
  5. 我听说过背压这个术语,它是什么意思呢?背压是一种根据当前需要处理的请求数量动态调整请求接收速度的机制。如果您想深入了解背压以及相关概念,请查阅以下链接以获取更详细的信息:Backpressure背压

时间轮(Time Wheel)Java实现

import exceptions.RateLimitExceededException;
import models.Request;
import utils.Timer;

import java.util.Map;
import java.util.concurrent.*;

public class TimerWheel {
    private final int timeOutPeriod;
    private final int capacityPerSlot;
    private final TimeUnit timeUnit;
    private final ArrayBlockingQueue<Request>[] slots;
    private final Map<String, Integer> reverseIndex;
    private final Timer timer;
    private final ExecutorService[] threads;

    public TimerWheel(final TimeUnit timeUnit,
                      final int timeOutPeriod,
                      final int capacityPerSlot,
                      final Timer timer) {
        this.timeUnit = timeUnit;
        this.timeOutPeriod = timeOutPeriod;
        this.capacityPerSlot = capacityPerSlot;
        if (this.timeOutPeriod > 1000) {
            throw new IllegalArgumentException();
        }
        this.slots = new ArrayBlockingQueue[this.timeOutPeriod];
        this.threads = new ExecutorService[this.timeOutPeriod];
        this.reverseIndex = new ConcurrentHashMap<>();
        for (int i = 0; i < slots.length; i++) {
            slots[i] = new ArrayBlockingQueue<>(capacityPerSlot);
            threads[i] = Executors.newSingleThreadExecutor();
        }
        this.timer = timer;
        final long timePerSlot = TimeUnit.MILLISECONDS.convert(1, timeUnit);
        Executors.newSingleThreadScheduledExecutor()
                .scheduleAtFixedRate(this::flushRequests,
                        timePerSlot - (this.timer.getCurrentTimeInMillis() % timePerSlot),
                        timePerSlot, TimeUnit.MILLISECONDS);
    }

    public Future<?> flushRequests() {
        final int currentSlot = getCurrentSlot();
        return threads[currentSlot].submit(() -> {
            for (final Request request : slots[currentSlot]) {
                if (timer.getCurrentTime(timeUnit) - request.getStartTime() >= timeOutPeriod) {
                    slots[currentSlot].remove(request);
                    reverseIndex.remove(request.getRequestId());
                }
            }
        });
    }

    public Future<?> addRequest(final Request request) {
        final int currentSlot = getCurrentSlot();
        return threads[currentSlot].submit(() -> {
            if (slots[currentSlot].size() >= capacityPerSlot) {
                throw new RateLimitExceededException();
            }
            slots[currentSlot].add(request);
            reverseIndex.put(request.getRequestId(), currentSlot);
        });
    }

    public Future<?> evict(final String requestId) {
        final int currentSlot = reverseIndex.get(requestId);
        return threads[currentSlot].submit(() -> {
            slots[currentSlot].remove(new Request(requestId, 0));
            reverseIndex.remove(requestId);
        });
    }

    private int getCurrentSlot() {
        return (int) timer.getCurrentTime(timeUnit) % slots.length;
    }
}