Fork me on GitHub

Erlang 项式存储 (ETS)

Some contents of this translation may be outdated.
Several major changes were applied to the original lesson since the last update.

Erlang 项式存储 (Erlang Term Storage,通常简称 ETS) 是 OTP 中内置的一个功能强大的存储引擎,我们在 Elixir 中也可以很方便地使用。本文将介绍如何使用 ETS 以及如何在我们的应用中使用它。

目录

概览

ETS 是一个针对 Elixir 和 Erlang 对象的健壮的内存 (in-memory) 存储,并且内置于 OTP 中。ETS 可以存储大量的数据,同时维持常数时间的数据访问。

ETS 中的「表」 (table) 是由单独的进程创建并拥有的。当这个进程退出时,这张表也就销毁了。默认情况下 ETS 限制每个节点最多有 1400 张表。

建表

新的表由 new/2 创建,该函数接受一个表名以及一组选项作为参数,返回一个表标识符 (table identifier),用于其他的操作。

我们创建一个通过昵称来存取用户的表来做例子:

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

类似 GenServer,我们也可以直接通过名字而不是标识符来访问 ETS 表。这需要我们添加 :named_table 选项。然后我们就可以用名字来访问这张表了:

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

表的类型

ETS 提供了四种类型的表:

访问控制

ETS 提供的访问控制机制跟模块差不多:

插入数据

ETS 没有模式 (Schema) 的概念。唯一的限制是数据需要以元组的形式存放,并且将第一个元素作为键。我们使用 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 提供了一些方便好用的方法来获取我们储存于其中的数据。我们来看看如何通过查询键和几种不同形式的形式匹配来获取数据。

最常用,效率也最高的方法是直接根据键来查询。匹配的方法虽然也有用,但这种方法要遍历整张表,在较大的数据集上使用时要特别谨慎。

查询键

使用 lookup/2,我们可以看到一个键对应的所有记录:

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

简单的匹配

ETS 源自 Erlang,所以匹配的语法可能有”一点点”笨重.

我们使用原子 :"$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 来做更高级的查询。这个三元组中的元素分别表示我们的匹配模式,一些「卫兵」语句 (guard),以及返回结果的格式。

我们可以使用简单匹配中讲到的变量形式在加上 :"$$" 以及 :"$_" 来构建返回值的格式。前者将结果变成列表形式返回,后者直接返回原始数据的格式。

我们把前面用 match/2 的例子换成 select/2 看看:

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

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

虽然 select/2 可以让我们分别控制匹配和返回的格式,这个语法还是很不友好,而且表达能力也有限。其实 ETS 还为我们提供了 fun2ms/1,可以直接将一个函数转换成查询时需要用的「匹配规范」 (match_spec)。fun2ms/1 让我们可以用更熟悉的函数写法来构建具体的查询逻辑。

我们试试用 fun2ms/1select/2 来找出所有会两种以上语言的用户:

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

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

想更深入地了解匹配规范请参考 Erlang 有关 match_spec 的官方文档

删除数据

删除记录

insert/2lookup/2 差不多,我们用 delete/2 来删除某个键对应的记录。这个函数会同时删除键和值:

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

删除表

只要拥有者没有退出,ETS 表就不会被垃圾回收。有时我们需要在保留拥有者进程的同时删除整张表。这个操作要用到 delete/1

iex> :ets.delete(:user_lookup)
true

ETS 的用例

讲了这么多,我们接下来把学到的东西组合起来做一个简单的缓存试试。我们要实现一个 get/4 的函数,接受模块、函数、参数以及(针对缓存的)选项。目前我们只实现 :ttl 这一个选项。

这个例子假定 ETS 表已经由其他的进程(例如一个监督者)启动好了:

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秒。你可以看到我们在缓存过期之前拿到的都是 ETS 中保存的结果:

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

综上所述,我们可以不引入任何依赖就实现一个可扩展的高速缓存,而且这只是 ETS 的诸多应用场景之一。

基于磁盘的 ETS (DETS)

我们现在了解了 ETS 这个内存存储,那有没有基于磁盘的存储呢?没错,我们有「基于磁盘的项式存储」 (Disk Based Term Storage),简称 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 不支持 ordered_set,只支持 setbagduplicate_bag


分享本页面