Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
- Loading branch information
Showing
1 changed file
with
285 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,285 @@ | ||
--- | ||
layout: page | ||
title: Erlang 项式存储 (ETS) | ||
category: specifics | ||
order: 4 | ||
lang: cn | ||
base_commit: 035ee14 | ||
--- | ||
|
||
Erlang 项式存储 (Erlang Term Storage,通常简称 ETS) 是 OTP 中内置的一个功能强大的存储引擎,我们在 Elixir 中也可以很方便地使用。本文将介绍如何使用 ETS 以及如何在我们的应用中使用它。 | ||
|
||
{% include toc.html %} | ||
|
||
## 概览 | ||
|
||
ETS 是一个针对 Elixir 和 Erlang 对象的健壮的内存 (in-memory) 存储,并且内置于 OTP 中。ETS 可以存储大量的数据,同时维持常数时间的数据访问。 | ||
|
||
ETS 中的「表」 (table) 是由单独的进程创建并拥有的。当这个进程退出时,这张表也就销毁了。默认情况下 ETS 限制每个节点最多有 1400 张表。 | ||
|
||
## 建表 | ||
|
||
新的表由 `new/2` 创建,该函数接受一个表名以及一组选项作为参数,返回一个表标识符 (table identifier),用于其他的操作。 | ||
|
||
我们创建一个通过昵称来存取用户的表来做例子: | ||
|
||
```elixir | ||
iex> table = :ets.new(:user_lookup, [:set, :protected]) | ||
8212 | ||
``` | ||
|
||
类似 `GenServer`,我们也可以直接通过名字而不是标识符来访问 ETS 表。这需要我们添加 `:named_table` 选项。然后我们就可以用名字来访问这张表了: | ||
|
||
```elixir | ||
iex> :ets.new(:user_lookup, [:set, :protected, :named_table]) | ||
:user_lookup | ||
``` | ||
|
||
### 表的类型 | ||
|
||
ETS 提供了四种类型的表: | ||
|
||
- `set` - 默认的表类型。每个键(key)对应一个值(value)。键是唯一的。 | ||
- `ordered_set` - 与 `set` 类似,但是按照 Erlang/Elixir 项式来排序。需要注意的是这里键的比较方式。键可以不同,只要「相等」即可,例如 1 和 1.0 就是「相等」的。 | ||
- `bag` - 每个键可以包括多个对象,但一个对象在一个键中只能有一个实例。 | ||
- `duplicate_bag` - 每个键可以包括多个对象,也允许对象重复。 | ||
|
||
### 访问控制 | ||
|
||
ETS 提供的访问控制机制跟模块差不多: | ||
|
||
- `public` - 所有进程都可以读/写。 | ||
- `protected` - 所有进程都可读。只有拥有者可以写。这是默认的配置。 | ||
- `private` - 只有拥有者可以读/写。 | ||
|
||
## 插入数据 | ||
|
||
ETS 没有模式 (Schema) 的概念。唯一的限制是数据需要以元组的形式存放,并且将第一个元素作为键。我们使用 `insert/2` 来添加新数据: | ||
|
||
```elixir | ||
iex> :ets.insert(:user_lookup, {"doomspork", "Sean", ["Elixir", "Ruby", "Java"]}) | ||
true | ||
``` | ||
|
||
在 `set` 或 `ordered_set` 上直接执行 `insert/2` 会覆盖掉已经存在的数据。使用 `insert_new/2` 可以避免数据覆盖的情况,该函数会在键已经存在时返回 `false`: | ||
|
||
```elixir | ||
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`,我们可以看到一个键对应的所有记录: | ||
|
||
```elixir | ||
iex> :ets.lookup(:user_lookup, "doomspork") | ||
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]}] | ||
``` | ||
|
||
### 简单的匹配 | ||
|
||
ETS 源自 Erlang,所以匹配的语法可能有"一点点"笨重. | ||
|
||
我们使用原子 `:"$1"`、`:"$2"`、`:"$3"` 等等来表示匹配中所使用的变量。其中的数字只用来表示其在返回值中的位置,而非匹配时的位置。不想要的部分我们可以用 `:"_"` 来忽略掉。 | ||
|
||
匹配表达式里也可以直接写书面值,但只有变量表示的部分会作为结果返回。说起来太抽象了不如实际试试看: | ||
|
||
```elixir | ||
iex> :ets.match(:user_lookup, {:"$1", "Sean", :"_"}) | ||
[["doomspork"]] | ||
``` | ||
|
||
我们再看看变量如何影响结果的顺序: | ||
|
||
```elixir | ||
iex> :ets.match(:user_lookup, {:"$99", :"$1", :"$3"}) | ||
[["Sean", ["Elixir", "Ruby", "Java"], "doomspork"], | ||
["", ["Elixir", "Ruby", "JavaScript"], "3100"]] | ||
``` | ||
|
||
假如我们想要本来存储的记录而不是重新排序之后的列表呢?那可以用 `match_object/2`,这个函数不管变量而直接返回原本的对象: | ||
|
||
```elixir | ||
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` 看看: | ||
|
||
```elixir | ||
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_spec`)。`fun2ms/1` 让我们可以用更熟悉的函数写法来构建具体的查询逻辑。 | ||
|
||
我们试试用 `fun2ms/1` 和 `select/2` 来找出所有会两种以上语言的用户: | ||
|
||
```elixir | ||
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"] | ||
``` | ||
|
||
想更深入地了解匹配规范请参考 Erlang 有关 [`match_spec` 的官方文档](http://www.erlang.org/doc/apps/erts/match_spec.html)。 | ||
|
||
## 删除数据 | ||
|
||
### 删除记录 | ||
|
||
跟 `insert/2` 和 `lookup/2` 差不多,我们用 `delete/2` 来删除某个键对应的记录。这个函数会同时删除键和值: | ||
|
||
```elixir | ||
iex> :ets.delete(:user_lookup, "doomspork") | ||
true | ||
``` | ||
|
||
### 删除表 | ||
|
||
只要拥有者没有退出,ETS 表就不会被垃圾回收。有时我们需要在保留拥有者进程的同时删除整张表。这个操作要用到 `delete/1`: | ||
|
||
```elixir | ||
iex> :ets.delete(:user_lookup) | ||
true | ||
``` | ||
|
||
## ETS 的用例 | ||
|
||
讲了这么多,我们接下来把学到的东西组合起来做一个简单的缓存试试。我们要实现一个 `get/4` 的函数,接受模块、函数、参数以及(针对缓存的)选项。目前我们只实现 `:ttl` 这一个选项。 | ||
|
||
这个例子假定 ETS 表已经由其他的进程(例如一个监督者)启动好了: | ||
|
||
```elixir | ||
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 中保存的结果: | ||
|
||
```elixir | ||
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秒后我们就可以拿到新的结果了: | ||
|
||
```elixir | ||
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` 选项: | ||
|
||
```elixir | ||
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`: | ||
|
||
```shell | ||
$ ls | grep -c disk_storage | ||
1 | ||
``` | ||
|
||
最后要注意的一点,DETS 不支持 `ordered_set`,只支持 `set`、`bag` 和 `duplicate_bag`。 |