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

Erlang Term Storage (ETS)

Erlang Term Storage, thường được biết như ETS, là hệ thống lưu trữ mạnh mẽ được xây dựng dựa trên OTP và sử dụng được trong Elixir. Trong bài này chúng ta sẽ tìm hiểu làm thế nào để kết nối tới ETS và sử dụng trong ứng dụng của bạn.

Tổng quan

ETS là một kiểu lưu trữ trong bộ nhớ mạnh mẽ cho những objects trong Elixir và Erlang. ETS có khả năng lưu trữ một lượng lớn dữ liệu và hỗ trợ truy cập với thời gian hằng số.

Mỗi một bảng ETS được tạo và sở hữu bởi một process riêng biệt. Khi một process kết thúc, những bảng của nó sẽ bị xoá. Mặc định ETS giới hạn 1400 bảng cho mỗi node.

Tạo bảng

Bảng được tạo với new/2, với tên bảng, các tuỳ chỉnh và trả về một nhận diện để bạn có thể thực hiện các thao tác sau đó.

Ví dụ chúng ta tạo bảng để lưu trữ và tìm những users thông qua nickname của họ:

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

Giống như GenServers, có một cách để truy cập bảng trong ETS bằng tên thay vì nhận diện. Để làm vậy chúng ta cần thêm vào :named_table. Sau đó chúng ta có thể truy cập trực tiếp thông qua tên:

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

Các loại bảng

Có 4 loại bảng trong ETS:

Quản lý truy cập

Quản lý truy cập trong Elixir khá tương tự với những modules khác:

Thêm dữ liệu

ETS không có schema. Giới hạn duy nhất là dữ liệu phải được lưu trữ như tuple với thành phần đầu tiên là khoá. Để thêm dữ liệu chúng ta sử dụng insert/2:

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

Khi sử dụng insert/2 cho set hay ordered_set dữ liệu có sẵn sẽ bị thay thế. Để tránh tình trạng này, câu lệnh insert_new/2 trả về false cho những khoá đã tồn tại:

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

Lấy dữ liệu

ETS hỗ trợ ta một vài phương pháp để lấy dữ liệu đã được lưu trữ. Chúng ta sẽ tìm hiểu làm thế nào để lấy dữ liệu thông qua khoá và pattern matching.

Cách hiệu quả, lý tưởng là tìm kiếm qua khoá. Matching cũng tương đối hữu dụng cho bảng khi sử dụng cho một tập dữ liệu lớn.

Tìm kiếm khoá

Với một khoá, chúng ta có thể sử dụng lookup/2 để lấy tất cả các record tương ứng với khoá đó.

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

Match đơn giản

ETS được xây dựng trên Erlang, vì vậy cẩn thận với match những tham số khá là kì lạ.

Để chỉ ra biến trong việc match, chúng ta sử dụng atoms :"$1", :"$2", :"$3" và tương tự vậy. Biến số phản ánh kết quả không phải là vị trí. Với những giá trị mà chúng ta không quan tâm, chúng ta sử dụng :_.

Những giá trị có thể được sử dụng trong matching, nhưng chỉ duy nhất biến sẽ được trả về như là một phần của kết quả. Đặt nó lại với nhau và xem cách nó hoạt động:

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

Cùng tìm hiểu một ví dụ khác để xem những biến này sẽ ảnh hưởng thứ tự trả về như thế nào:

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

Nếu chúng ta muốn lấy ra object gốc mà không phải một list thì sao? Chúng ta có thể sử dụng match_object/2, mặc dù những biến này trả về toàn bộ object.

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"]}]

Tìm kiếm nâng cao

Chúng ta đã học về cách match những trường hợp đơn giản, nhưng nếu chúng ta muốn thứ gì đó giống với SQL query? Rất may có một syntax mạnh mẽ hơn cho chúng ta. Để tìm kiếm dữ liệu với select/2 chúng ta cần phải tạo một danh sách các tuple với 3 tham số. Những tuple đại diện cho pattern của chúng ta, dữ liệu rỗng hay nâng cao hơn và trả về kiểu giá trị.

:"$$":"$" có thể được sử dụng để tạo nên giá trị trả về. Những biến mới này là những shortcut cho kiểu giá trị; :"$$" lấy kết quả như là một dánh sách và :"$" lấy dữ liệu gốc.

Lấy lại ví dụ trước match/2 và chuyển nó thành 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"]}]

Mặc dù select/2 cho phép điều khiển sâu hơn cách mà chúng ta lấy những kết quả, syntax này không thân thiện và chỉ càng phức tạp hơn. Để xử lý vấn đề này ETS thêm vào fun2ms/1, biến functions thành match_specs. Với fun2ms/1 chúng ta có thể tạo queries với những syntax cho function quen thuộc.

Hãy sử dụng fun2ms/1select/2 để tìm tất cả usernames với nhiều hơn 2 ngôn ngữ:

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"]

Muốn tìm hiểu thêm về các định nghĩa match, đọc qua về tài liệu chính thức của Erlang về match_spec.

Xoá dữ liệu

Xoá records

Xoá terms khá rã ràng với insert/2lookup/2. Với delete/2 chúng ta cần bảng và khoá. Điều này xoá cả khoá và giá trị của nó:

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

Xoá bảng

Bảng trong ETS không được dọn trừ khi parent của nó bị xoá. Thỉnh thoảng nó có thể cần thiết khi xoá toàn bộ một bảng mà không xoá process chứa nó. Để làm vậy chúng ta sử dụng delete/1:

iex> :ets.delete(:user_lookup)
true

Ví dụ về cách sử dụng ETS

Tổng kết lại những gì chúng ta đã học ở trên, kết hợp mọi thứ lại và tạo một cache đơn giản cho những tính toán phức tạp. Chúng ta sẽ cài đặt get/4 với tham số là module, function, arguments và options. Từ đây chúng ta chỉ quan tâm về :ttl

Với ví dụ này chúng ta giả sử bảng ETS đã được tạo như một phần của process như là 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

Để minh hoạ cache chúng ta sẽ sử dụng function cái mà trả về giờ hệ thống và TTL của 10 giây. Bạn sẽ thấy ở ví dụ dưới, chúng ta sẽ lưu lại kết quả cho đến khi giá trị hết hiệu lực:

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

Sau 10 giây nếu chúng ta thử lại chúng ta sẽ nhận một kết quả khác:

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

Như bạn thấy chúng ta có thể cài đặt một hệ thống cache nhanh và có khả năng nhân rộng mà không phụ thuộc bất kì gì ở bên ngoài và đây là một trong nhiều tính năng của ETS.

Disk-based ETS

Chúng ta biết ETS là lưu trữ trong bộ nhớ nhưng nếu chúng ta cần lưu trữ trên disk thì sao? Vì vậy chúng ta có Disk Based Term Storage, hay ngắn gọn DETS. ETS và DETS là như nhau chỉ với khác biệt là cách mà bảng được tạo. DETS dựa vào open_file/2 và không cần :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"]}]

Nếu bạn thoát iex và nhìn vào cây thư mục hiện tại, bạn sẽ thấy file mới disk_storage:

$ ls | grep -c disk_storage
1

Điều cuối cùng cần lưu ý là DETS không support ordered_set giống như ETS, chỉ có set, bagduplicated_bag.

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