ETS
本章是Mix和OTP指南的一部分,它取决于本指南的前几章。有关更多信息,请阅读简介指南或查看边栏中的章节索引。
每当我们需要查找存储桶时,我们都需要向注册表发送消息。如果我们的注册表被多个进程同时访问,注册表可能会成为瓶颈!
在本章中,我们将学习ETS(Erlang Term Storage)以及如何将其用作缓存机制。
警告!不要过早使用ETS作为缓存!记录并分析您的应用程序性能并确定哪些部分是瓶颈,以便知道您是否应该缓存以及应该缓存什么。一旦你确定了需求,本章仅仅是ETS如何使用的一个例子。
ETS作为缓存
ETS允许我们将任何Elixir术语存储在内存表中。使用ETS表格通过Erlang的:ets
模块完成:
iex> table = :ets.new(:buckets_registry, [:set, :protected]) 8207 iex> :ets.insert(table, {"foo", self()}) true iex> :ets.lookup(table, "foo") [{"foo", #PID<0.41.0>}]
创建ETS表时,需要两个参数:表名和一组选项。从可用选项中,我们传递了表类型及其访问规则。我们选择了:set
类型,这意味着密钥不能被复制。我们还设置了表的访问权限:protected
,这意味着只有创建该表的进程才能写入该表,但所有进程都可以读取该进程。这些实际上是默认值,所以我们将从现在开始跳过它们。
ETS表也可以命名,允许我们通过给定名称访问它们:
iex> :ets.new(:buckets_registry, [:named_table]) :buckets_registry iex> :ets.insert(:buckets_registry, {"foo", self()}) true iex> :ets.lookup(:buckets_registry, "foo") [{"foo", #PID<0.41.0>}]
让我们改变KV.Registry
使用ETS表格。第一个变化是修改我们的注册表以要求名称参数,我们将用它来命名ETS表和注册表进程本身。ETS名称和进程名称存储在不同的位置,所以不存在冲突的可能性。
打开lib/kv/registry.ex
,让我们改变它的实现。我们在源代码中添加了评论以突出显示我们所做的更改:
defmodule KV.Registry do use GenServer ## Client API @doc """ Starts the registry with the given options. `:name` is always required. """ def start_link(opts) do # 1. Pass the name to GenServer's init server = Keyword.fetch!(opts, :name) GenServer.start_link(__MODULE__, server, opts) end @doc """ Looks up the bucket pid for `name` stored in `server`. Returns `{:ok, pid}` if the bucket exists, `:error` otherwise. """ def lookup(server, name) do # 2. Lookup is now done directly in ETS, without accessing the server case :ets.lookup(server, name) do [{^name, pid}] -> {:ok, pid} [] -> :error end end @doc """ Ensures there is a bucket associated with the given `name` in `server`. """ def create(server, name) do GenServer.cast(server, {:create, name}) end ## Server callbacks def init(table) do # 3. We have replaced the names map by the ETS table names = :ets.new(table, [:named_table, read_concurrency: true]) refs = %{} {:ok, {names, refs}} end # 4. The previous handle_call callback for lookup was removed def handle_cast({:create, name}, {names, refs}) do # 5. Read and write to the ETS table instead of the map case lookup(names, name) do {:ok, _pid} -> {:noreply, {names, refs}} :error -> {:ok, pid} = KV.BucketSupervisor.start_bucket() ref = Process.monitor(pid) refs = Map.put(refs, ref, name) :ets.insert(names, {name, pid}) {:noreply, {names, refs}} end end def handle_info({:DOWN, ref, :process, _pid, _reason}, {names, refs}) do # 6. Delete from the ETS table instead of the map {name, refs} = Map.pop(refs, ref) :ets.delete(names, name) {:noreply, {names, refs}} end def handle_info(_msg, state) do {:noreply, state} end end
请注意,在我们的更改KV.Registry.lookup/2
向服务器发送请求之前,现在它直接从ETS表中读取,该表在所有进程之间共享。这是我们正在实施的缓存机制背后的主要思想。
为了使缓存机制起作用,创建的ETS表需要具有访问权限:protected
(缺省值),因此所有客户端都可以从中读取数据,而只有KV.Registry
进程写入该数据。我们还设置了read_concurrency: true
何时启动表格,针对并发读取操作的常见场景优化表格。
我们上面执行的更改已打破我们的测试,因为注册表:name
在启动时需要选项。此外,某些注册表操作(例如lookup/2
要求将名称作为参数提供)而不是PID,因此我们可以执行ETS表查找。让我们改变设置功能test/kv/registry_test.exs
来解决这两个问题:
setup context do {:ok, _} = start_supervised({KV.Registry, name: context.test}) %{registry: context.test} end
一旦我们改变了setup
,一些测试将会继续失败。您甚至可能会注意到测试在运行之间通过和失败不一致。例如,“spawns buckets”测试:
test "spawns buckets", %{registry: registry} do assert KV.Registry.lookup(registry, "shopping") == :error KV.Registry.create(registry, "shopping") assert {:ok, bucket} = KV.Registry.lookup(registry, "shopping") KV.Bucket.put(bucket, "milk", 1) assert KV.Bucket.get(bucket, "milk") == 1 end
可能会失败:
{:ok, bucket} = KV.Registry.lookup(registry, "shopping")
如果我们刚刚在上一行创建了存储桶,那么这条线如何失败?
这些失败发生的原因是,为了教学目的,我们犯了两个错误:
- 我们正在过早地优化(通过添加这个缓存层)
2. 我们正在使用cast/2
(虽然我们应该使用call/2
)
比赛条件?
在Elixir中开发并不会让你的代码免受竞争条件的影响。然而,Elixir的抽象概念在默认情况下没有共享,因此更容易发现竞争条件的根本原因。
我们测试中发生的事情是,在操作和我们可以在ETS表中观察到这种变化的时间之间存在延迟。这是我们期待的事情:
- 我们调用
KV.Registry.create(registry, "shopping")
2. 注册表创建存储桶并更新缓存表
3. 我们从表格中获取信息 KV.Registry.lookup(registry, "shopping")
4. 上面的命令返回 {:ok,bucket}
但是,由于KV.Registry.create/2
是一个强制操作,所以在我们真正写入表之前,命令将会返回!换句话说,这发生了:
- 我们调用
KV.Registry.create(registry, "shopping")
2. 我们从表格中获取信息 KV.Registry.lookup(registry, "shopping")
3. 上面的命令返回 :error
4. 注册表创建存储桶并更新缓存表
为了解决这个问题,我们需要KV.Registry.create/2
使用call/2
而不是使用同步cast/2
。这将保证客户只有在对表格进行更改后才能继续。让我们改变函数和它的回调,如下所示:
def create(server, name) do GenServer.call(server, {:create, name}) end def handle_call({:create, name}, _from, {names, refs}) do case lookup(names, name) do {:ok, pid} -> {:reply, pid, {names, refs}} :error -> {:ok, pid} = KV.BucketSupervisor.start_bucket() ref = Process.monitor(pid) refs = Map.put(refs, ref, name) :ets.insert(names, {name, pid}) {:reply, pid, {names, refs}} end end
我们将回调从更改handle_cast/2
为handle_call/3
并更改为使用创建的桶的pid进行回复。一般来说,Elixir开发人员更喜欢使用,call/2
而不是cast/2
因为它也提供了背压 - 你会阻止,直到你得到答复。cast/2
在不必要时使用也可以被认为是不成熟的优化。
让我们再次运行测试。这一次,我们会通过--trace
选项:
$ mix test --trace
--trace
当您的测试死锁或存在竞争条件时,该选项非常有用,因为它同步运行所有测试(async: true
无效)并显示有关每个测试的详细信息。这次我们应该下降到一两次间歇性的失败:
1) test removes buckets on exit (KV.RegistryTest) test/kv/registry_test.exs:19 Assertion with == failed code: KV.Registry.lookup(registry, "shopping") == :error lhs: {:ok, #PID<0.109.0>} rhs: :error stacktrace: test/kv/registry_test.exs:23
根据失败消息,我们期望该表不再存在于表格中,但它仍然存在!这个问题与我们刚刚解决的问题相反:以前在创建存储桶和更新表的命令之间存在延迟,现在存储桶过程死亡和从表中删除表项之间存在延迟。
不幸的是,这次我们不能简单地将handle_info/2
负责清洗ETS表的操作改为同步操作。相反,我们需要找到一种方法来保证注册管理机构处理:DOWN
在存储桶崩溃时发送的通知。
一个简单的方法是向注册中心发送一个同步请求:因为消息是按顺序处理的,如果注册中心回复Agent.stop
调用后发送的请求,这意味着:DOWN
消息已被处理。Agent.stop
在两次测试中,通过创建一个“伪造”存储桶来实现这一点,这是一个同步请求:
test "removes buckets on exit", %{registry: registry} do KV.Registry.create(registry, "shopping") {:ok, bucket} = KV.Registry.lookup(registry, "shopping") Agent.stop(bucket) # Do a call to ensure the registry processed the DOWN message _ = KV.Registry.create(registry, "bogus") assert KV.Registry.lookup(registry, "shopping") == :error end test "removes bucket on crash", %{registry: registry} do KV.Registry.create(registry, "shopping") {:ok, bucket} = KV.Registry.lookup(registry, "shopping") # Stop the bucket with non-normal reason Agent.stop(bucket, :shutdown) # Do a call to ensure the registry processed the DOWN message _ = KV.Registry.create(registry, "bogus") assert KV.Registry.lookup(registry, "shopping") == :error end
我们的测试现在应该(总是)通过!
这就结束了我们的优化章节。我们使用ETS作为缓存机制,可以从任何进程读取数据,但写入操作仍然通过单个进程序列化。更重要的是,我们还了解到,一旦数据可以异步读取,我们需要知道它可能引入的竞争条件。
在实践中,如果您发现自己处于需要动态流程的流程注册表的位置,则应该使用作为Elixir一部分提供的Registry
模块。它提供的功能类似于我们使用GenServer +构建的功能,:ets
同时还能够同时执行写入和读取操作。即使在有40个内核的机器上,它也可以扩展到所有内核。
接下来,让我们讨论外部和内部依赖关系以及Mix如何帮助我们管理大型代码库。