Do you want to pick up from where you left of?
Take me there

Erlang 項式儲存 (ETS)

Erlang 項式儲存 (Erlang Term Storage) 通常被稱為 ETS,它是內建於 OTP 中的強大儲存引擎,可用於 Elixir。在本課程中,將介紹如何與 ETS 連接以及如何在應用程式中使用它。

概述

ETS 是包含 Elixir 和 Erlang 物件的強大內部記憶體 (in-memory ) 儲存。ETS 能夠儲存大量資料並提供常數時間 (constant time) 資料存取。

ETS 中的表格 (Tables) 由各個處理程序建立並擁有。當擁有者處理程序終止時,其表格也被銷毀。 預設情況下,ETS 限制為每個節點 1400 個表格。

建立表格

使用 new/2 建立表格,它接受一個表格名稱 (name) 和一組選項 (options),並回傳一個可以在後續操作中使用的表格識別碼 (identifier)。

在範例中,將建立一個表格來儲存和查詢使用者暱稱:

iex> table = :ets.new(:user_lookup, [:set, :protected])
8212

就像 GenServers 一樣,有一種通過名稱 (name) 而不是識別碼 (identifier) 存取 ETS 表格的方法。 為此,需要包含 :named_table 選項。然後就可以直接通過名稱存取表格:

iex> :ets.new(:user_lookup, [:set, :protected, :named_table])
:user_lookup

表格類型

ETS 中有四種類型的表格:

存取控制

ETS 中的存取控制與模組內的存取控制類似:

競爭條件 (Race Conditions)

如果一個以上的處理程序可以寫入一個表格內 - 無論是通過 :public 存取還是通過向擁有者處理程序發送訊息 - 競爭條件 (race conditions) 都是可能的。例如,兩個處理程程序皆讀取一個值為 0 的計數器,遞增它並寫入 1;最終只會反映一個單一的遞增。

特別是對於計數器, :ets.update_counter/3 提供了 atomic 的更新與讀取。對於其他情況,擁有者處理程序可能需要執行自定的 atomic 操作來回應訊息,例如 “add this value to the list at key :results“:

插入資料

ETS 沒有結構描述(schema)。唯一的限制是,資料必須以 tuple 儲存,tuple 的第一個元素為資料的鍵(key)。為了加入新的資料,可以使用 insert/2

iex> :ets.insert(:user_lookup, {"doomspork", "Sean", ["Elixir", "Ruby", "Java"]})
true

當和 setordered_set 一起使用 insert/2 時,現有的資料將被替換。 為了避免這種情況,當鍵存在時 insert_new/2 回傳 false

iex> :ets.insert_new(:user_lookup, {"doomspork", "Sean", ["Elixir", "Ruby", "Java"]})
false
iex> :ets.insert_new(:user_lookup, {"3100", "", ["Elixir", "Ruby", "JavaScript"]})
true

資料檢索

ETS 提供了一些方便而靈活的方式來檢索 (retrieve) 儲存的資料。接著看看如何通過鍵和通過不同形式的模式比對來檢索資料。

最有效、最理想的檢索方法是鍵 (key) 查找。很有用,但比對會疊代整個表格,特別是在非常大的資料集上,應保守的使用。

查找特定鍵

給定一個鍵,可以使用 lookup/2 來檢索具有該鍵的所有記錄:

iex> :ets.lookup(:user_lookup, "doomspork")
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]}]

簡易比對

ETS 是為 Erlang 構建的,所以要注意的是比對變數可能會讓人覺得 有一點 笨重。

要在比對中指定一個變數,使用 atoms :"$1":"$2":"$3" 等等。變數數字反映結果位置而不是比對位置。對於不感興趣的值,使用 :_ 變數。

值也可用於比對,但只有變數將作為結果的一部分回傳。現在全部都放在一起,看看它是如何運作的:

iex> :ets.match(:user_lookup, {:"$1", "Sean", :_})
[["doomspork"]]

現在來看另一個範例,看看變數如何影響結果列表次序:

iex> :ets.match(:user_lookup, {:"$99", :"$1", :"$3"})
[["Sean", ["Elixir", "Ruby", "Java"], "doomspork"],
 ["", ["Elixir", "Ruby", "JavaScript"], "3100"]]

如果想要原始物件,而不是列表呢?可以使用 match_object/2 ,它不管變數而是回傳整個物件:

iex> :ets.match_object(:user_lookup, {:"$1", :_, :"$3"})
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]},
 {"3100", "", ["Elixir", "Ruby", "JavaScript"]}]

iex> :ets.match_object(:user_lookup, {:_, "Sean", :_})
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]}]

進階查詢

現在了解了簡易比對的情況,但是如果想要更類似於 SQL 查詢的東西呢?值得慶幸的是,我們有著更強大的語法。 要用 select/2 查找資料,需要建立一個有引數 3 (arity 3) 的 tuple 列表。 這些 tuples 表示我們的模式 (pattern)、零 (zero) 或更多的監視 (guard) 和一個回傳值格式。

可以使用比對變數和兩個新變數 :"$$":"$_" 來建立回傳值。 這些新變數是到結果格式的捷徑; :"$$" 得到列表的結果; :"$_" 得到原始資料物件。

現在來看一個以前的 match/2 範例,並將它變成一個 select/2

iex> :ets.match_object(:user_lookup, {:"$1", :_, :"$3"})
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]},
 {"3100", "", ["Elixir", "Ruby", "JavaScript"]}]

{% raw %}iex> :ets.select(:user_lookup, [{{:"$1", :_, :"$3"}, [], [:"$_"]}]){% endraw %}
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]},
 {"spork", 30, ["ruby", "elixir"]}]

雖然 select/2 允許更好地控制檢索記錄的方式和內容,但是語法非常不友善,而且只會變得更加如此。 為了處理這個問題,ETS 模組包含 fun2ms/1,將函數轉換為 match_specs。經由 fun2ms/1 ,就可以使用熟悉的函數語法建立查詢。

現在使用 fun2ms/1select/2 來查找所有有超過 2 種語言的使用者名稱:

iex> fun = :ets.fun2ms(fn {username, _, langs} when length(langs) > 2 -> username end)
{% raw %}[{{:"$1", :_, :"$2"}, [{:>, {:length, :"$2"}, 2}], [:"$1"]}]{% endraw %}

iex> :ets.select(:user_lookup, fun)
["doomspork", "3100"]

想要了解有關比對規範的更多資訊?請查看 match_spec 的 Erlang 官方文件。

刪除資料

移除記錄

刪除語法與 insert/2lookup/2 一樣簡單。使用 delete/2 ,只需要表格欄位和鍵。 這會刪除鍵及其值:

iex> :ets.delete(:user_lookup, "doomspork")
true

移除表格

除非父母 (parent) 被終止,否則 ETS 表格不會被垃圾回收 (garbage collection)。 有時可能需要在不終止擁有者處理程序下刪除整個表格。為此可以使用 delete/1:

iex> :ets.delete(:user_lookup)
true

ETS 使用範例

鑑於我們上面學到的,現在把所有東西放在一起,為代價高的操作建立一個簡單的快取 (cache)。 將實現一個 get/4 函數來獲取模組、函數、引數和選項。現在唯一擔心的是選項是 :ttl

對於這個範例,假定 ETS 表格已經被建立為另一個處理程序的一部分,比如 supervisor:

defmodule SimpleCache do
  @moduledoc """
  A simple ETS based cache for expensive function calls.
  """

  @doc """
  Retrieve a cached value or apply the given function caching and returning
  the result.
  """
  def get(mod, fun, args, opts \\ []) do
    case lookup(mod, fun, args) do
      nil ->
        ttl = Keyword.get(opts, :ttl, 3600)
        cache_apply(mod, fun, args, ttl)

      result ->
        result
    end
  end

  @doc """
  Lookup a cached result and check the freshness
  """
  defp lookup(mod, fun, args) do
    case :ets.lookup(:simple_cache, [mod, fun, args]) do
      [result | _] -> check_freshness(result)
      [] -> nil
    end
  end

  @doc """
  Compare the result expiration against the current system time.
  """
  defp check_freshness({mfa, result, expiration}) do
    cond do
      expiration > :os.system_time(:seconds) -> result
      :else -> nil
    end
  end

  @doc """
  Apply the function, calculate expiration, and cache the result.
  """
  defp cache_apply(mod, fun, args, ttl) do
    result = apply(mod, fun, args)
    expiration = :os.system_time(:seconds) + ttl
    :ets.insert(:simple_cache, {[mod, fun, args], result, expiration})
    result
  end
end

為了展示快取,將使用一個回傳系統時間和 TTL 10 秒的函數。正如將在下面範例中看到的,我們得到快取的結果,直到值過期:

defmodule ExampleApp do
  def test do
    :os.system_time(:seconds)
  end
end

iex> :ets.new(:simple_cache, [:named_table])
:simple_cache
iex> ExampleApp.test
1451089115
iex> SimpleCache.get(ExampleApp, :test, [], ttl: 10)
1451089119
iex> ExampleApp.test
1451089123
iex> ExampleApp.test
1451089127
iex> SimpleCache.get(ExampleApp, :test, [], ttl: 10)
1451089119

如果 10 秒後再試一次,應該得到一個新的結果:

iex> ExampleApp.test
1451089131
iex> SimpleCache.get(ExampleApp, :test, [], ttl: 10)
1451089134

正如所看到的,無需任何外部耦合關係即能夠實現可擴展 (scalable) 和快取 (fast cache),而這只是 ETS 的眾多用途之一。

磁碟式 ETS

現在知道 ETS 是用於內部記憶體中的項式儲存,但是如果需要基於磁碟 (disk-based) 的儲存呢? 為此,有基於磁碟的項式儲存 (簡稱 DETS)。 除了建立表格之外,ETS 和 DETS 的 API 是可互換的。DETS 依賴 open_file/2 並且不需要 :named_table 選項:

iex> {:ok, table} = :dets.open_file(:disk_storage, [type: :set])
{:ok, :disk_storage}
iex> :dets.insert_new(table, {"doomspork", "Sean", ["Elixir", "Ruby", "Java"]})
true
iex> select_all = :ets.fun2ms(&(&1))
[{:"$1", [], [:"$1"]}]
iex> :dets.select(table, select_all)
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]}]

如果退出 iex 並查看本地資料夾,會看到一個新的檔案 disk_storage

$ ls | grep -c disk_storage
1

最後要注意的是 DETS 不像 ETS 那樣支援 ordered_set,只支援 setbagduplicate_bag

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!