程式設計隨想:基於歷史狀態的演算法

Ljzn發表於2022-04-09

最近想到一個關於限流的問題, 比如一個 api 限制每秒最多請求 100 次,那麼我們在本地就需要做這樣一個限制機制,來保證任意一個 1 秒的時間段內,請求次數都小於 100 次。

我們設時間段為 T (這裡是 1 秒),請求次數為 R (這裡是 100 次),然後取正整數 n (n > 1),表示將時間段平均分成 n 份。定義 t = T / n 為每份的時長。定義 r(t(i)) = R - sum(r‘(t(i-n))...r’(t(i-1))), 表示任意一份時間片中的最大請求次數,等於 R 減去過去 n 份時間片的請求次數總和。抱歉我還沒有開始學 Latex,沒法準確地寫出公式。總之按照這種方法可以滿足我們的需求,即任意一個 T 的時間段內,請求次數都小於 R 次。

下面是一個簡單的實現:

defmodule Limit do
  use GenServer

  @n 30
  @_R 100
  # ms
  @_T 1000

  def req() do
    GenServer.call(__MODULE__, :req)
  end

  def start() do
    if GenServer.whereis(__MODULE__) do
      GenServer.stop(__MODULE__)
    end

    GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
  end

  def init(_) do
    :timer.send_interval(div(@_T, @n), :update)

    {:ok,
     %{
       queue: :queue.from_list(List.duplicate(0, @n)),
       r_max: @_R,
       r: 0
     }}
  end

  def handle_call(:req, _from, state = %{r: r, r_max: r_max}) do
    r = r + 1

    if r <= r_max do
      {:reply, :ok, %{state | r: r}}
    else
      {:reply, {:error, :limit}, state}
    end
  end

  def handle_info(:update, state = %{r: r, queue: q}) do
    ## debug
    # :queue.to_list(q) |> IO.inspect()

    {_, q} = :queue.out(q)
    q = :queue.in(r, q)
    s = :queue.fold(fn x, acc -> x + acc end, 0, q)
    r_max = @_R - s
    {:noreply, %{state | r: 0, r_max: r_max, queue: q}}
  end
end

測試用例:

defmodule LimitTest do
  use ExUnit.Case

  test "100 request at same time should all return ok" do
    Limit.start()

    r =
      for _ <- 1..100 do
        Limit.req()
      end
      |> Enum.all?(fn x -> x == :ok end)

    assert r
  end

  test "101 request at same time should return 100 ok and 1 error at last req" do
    Limit.start()

    r =
      for _ <- 1..100 do
        Limit.req()
      end
      |> Enum.all?(fn x -> x == :ok end)

    assert r

    assert Limit.req() == {:error, :limit}
  end

  test "request capacity should re-fill after 1 second (500 ms more to avoid race)" do
    Limit.start()

    for _ <- 1..100 do
      Limit.req()
    end

    :timer.sleep(1500)

    assert Limit.req() == :ok
  end
end

這種基於歷史狀態的演算法在很多領域都用被應用,例如比特幣網路中會根據過去一段時間的出塊速度來調整當前的工作量證明難度,以使得出塊時間保持在 10 分鐘左右。

相關文章