用Elixir解决速率限制的外部apis的问题
你有没有遇到过限制你在一个时间段内的调用次数的API服务?这其实是现在非常普遍的现象,为了不淹没服务,也为了减少成本,开发者不得不绕过这个限制。对我们大多数人来说,这个限制和解决方案都不新鲜,但每种语言都有自己的实现方式。我们将看看如何使用redis的缓存和elixir的模式匹配来解决这个问题。
假设我们有一个应用程序 "oscar",它使用API从5个评论门户网站获取电影评分。这个问题听起来并不复杂,所以让我们给它增加一些层次:
- 用户可以请求对一部电影进行评级,但结果是由Oscar异步策划的。
- 5个API都有速率限制,频繁调用会产生429个http状态码
让我们从一个基本的Elixir模块开始吧
defmodule Oscar do
alias Oscar.{Imdb, RottenTomatoes, Sify, Netflix, AmazonPrime}
def rate_movie(name) do
Logger.info("Fetching movie ratings for #{name}")
ratings = %{
imdb: Imdb.get_rating!(name),
rt: RottenTomatoes.get_rating!(name),
sify: Sify.get_rating!(name),
netflix: Netflix.get_rating!(name),
amazon: AmazonPrime.get_rating!(name)
}
save(name, ratings)
end
end
这里没有什么花哨的东西。我们有5个客户端,即Imdb、RottenTomatoes、Sify、Netflix和AmazonPrime,它们都允许我们对一部电影进行 "获取评级":
defmodule Oscar.Imdb do
def get_rating!(name) do
case HTTPoison.get!("https://imdb.com/movies/#{name}") do
%HTTPoison.Response{status_code: 200, body: body} ->
Logger.debug("Got Imdb rating for #{name}")
parse_rating(body)
_ ->
raise HTTPoison.Error, reason: "Oops! we got a non 200 status code from Imdb"
end
end
# ... other functions omitted for brevity
end
正如你所看到的,200状态代码对于我们解析评级是很重要的。就本博客而言,所有其他状态代码对我们来说都是不可接受的。假设我们已经实现了其他4个客户端,我们就可以在一个后台工作者中调用这些API。
与Ruby不同,你会使用Sidekiq,或者Python中的Celery,大多数情况下,elixir不需要这么重的后台工作者。但我们的假设是,这个问题需要我们在最后使用Redis缓存方法,所以让我们使用ExQ作为后台工作者。
案例1:所有的API都成功了
iex> {:ok, job_id} = Exq.enqueue(Exq, "default", OscarWorker, ["interstellar"])
[info] Fetching movie ratings for interstellar
[debug] Got Imdb rating for interstellar
[debug] Got RottenTomatoes rating for interstellar
[debug] Got Sify rating for interstellar
[debug] Got Netflix rating for interstellar
[debug] Got AmazonPrime rating for interstellar
[info] Saved rating for interstellar
[info] Elixir.Oscar.OscarWorker[e90f871a-a2dd-4a86-8914-6e94dfde09b3] success: 1250ms
棒极了!工作得很顺利。
案例2:AmazonPrime阻断了429
iex> {:ok, job_id} = Exq.enqueue(Exq, "default", OscarWorker, ["interstellar"])
[info] Fetching movie ratings for interstellar
[debug] Got Imdb rating for interstellar
[debug] Got RottenTomatoes rating for interstellar
[debug] Got Sify rating for interstellar
[debug] Got Netflix rating for interstellar
** (HTTPoison.Error) "Oops! we got a non 200 status code from AmazonPrime"
...
[info] Elixir.Oscar.OscarWorker[e90f871a-a2dd-4a86-8914-6e94dfde09b3] fail: 1270ms
[info] Queueing job e90f871a-a2dd-4a86-8914-6e94dfde09b3 to retry in 54.0 seconds
.
.
[info] Fetching movie ratings for interstellar
[debug] Got Imdb rating for interstellar
[debug] Got RottenTomatoes rating for interstellar
[debug] Got Sify rating for interstellar
[debug] Got Netflix rating for interstellar
[debug] Got AmazonPrime rating for interstellar
[info] Saved rating for interstellar
[info] Elixir.Oscar.OscarWorker[e90f871a-a2dd-4a86-8914-6e94dfde09b3] success: 2750ms
在这里,工作者不得不重试一次以获得所有的评级。当我们有5个服务时,很多时候所有的服务都有可能在一个工作中达到速率限制,延迟结果,甚至最后成为一个死工作。
我们怎样才能减少对同一电影的同一服务的调用次数,使其在一项工作中只调用一次?
当我们看缓存选项时,还有其他方法可以将我们已经查询的数据存储到我们的redis中。然而,在这篇博客中,我们要考虑的是 "以工作为基础"。意思是说,每次我们从一个门户网站上获取一个评分,我们都会缓存它。基于电影名称的缓存也是可行的,但这意味着评论可能会过时,而我们必须根据时间戳来管理重新获取。在这篇博客中我们就不谈这些了。
简单的缓存并不能真正帮助我们。我们需要在调用API之前查找缓存。让我们做一些改变来使用缓存。
defmodule Oscar do
alias Oscar.{Imdb, RottenTomatoes, Sify, Netflix, AmazonPrime}
alias Oscar.RedisCache
@default %{
imdb: nil,
rotten_tomatoes: nil,
sify: nil,
netflix: nil,
amazon_prime: nil,
uid: nil
}
def rate_movie(uid, name) do
Logger.info("Fetching movie ratings for #{name}", uid: uid)
metadata = get_cached_or_new_metadata(uid)
metadata
|> get_rating("imdb", name)
|> cache()
|> get_rating("rt", name)
|> cache()
|> get_rating("sify", name)
|> cache()
|> get_rating("netflix", name)
|> cache()
|> get_rating("amazon_prime", name)
|> cache()
|> save(name)
end
defp get_cached_or_new_metadata(uid) do
case RedisCache.get(uid) do
{:ok, nil} -> Map.put(@default, :uid, uid)
{:ok, cached} -> Jason.decode!(cached, keys: :atoms)
end
end
defp cache(metadata) do
:ok = RedisCache.set(metadata.uid, Jason.encode!(metadata))
metadata
end
defp get_rating(%{imdb: imdb, uid: uid} = metadata, "imdb", name) when is_float(imdb) do
Logger.debug("Imdb rating for #{name} exists in cache", uid: uid)
metadata
end
defp get_rating(metadata, "imdb", name) when is_float(imdb) do
Map.put(metadata, :imdb, Imdb.get_rating!(name))
end
defp get_rating(%{rt: rt, uid: uid} = metadata, "rt", name) when is_float(rt) do
Logger.debug("RottenTomatoes rating for #{name} exists in cache", uid: uid)
metadata
end
defp get_rating(metadata, "rt", name) when is_float(rt) do
Map.put(metadata, :rt, RottenTomatoes.get_rating!(name))
end
# ...other similar functions omitted for brevity
end
现在我们再一次重温我们的案例,看看这些改变是否有帮助。假设我们已经在ExQOscarWorker 中包装好了Oscar.rate_movie/2 调用,我们可以这样排队作业。
案例1:所有API都成功
iex> {:ok, job_id} = Exq.enqueue(Exq, "default", OscarWorker, ["interstellar"])
[info] Fetching movie ratings for interstellar
[debug] Got Imdb rating for interstellar
[debug] Got RottenTomatoes rating for interstellar
[debug] Got Sify rating for interstellar
[debug] Got Netflix rating for interstellar
[debug] Got AmazonPrime rating for interstellar
[info] Saved rating for interstellar
[info] Elixir.Oscar.OscarWorker[ab7f871a-c4ae-4a86-8914-6e94dfde09b3] success: 1250ms
正如预期的那样,这里没有任何问题,让我们继续下一个案例。
案例2:Sify和AmazonPrime阻断了429
iex> {:ok, job_id} = Exq.enqueue(Exq, "default", OscarWorker, ["interstellar"])
[info] Fetching movie ratings for interstellar
[debug] Got Imdb rating for interstellar
[debug] Got RottenTomatoes rating for interstellar
** (HTTPoison.Error) "Oops! we got a non 200 status code from Sify" <---- first failure
...
[info] Elixir.Oscar.OscarWorker[ab7f871a-c4ae-4a86-8914-6e94dfde09b3] fail: 1270ms
[info] Queueing job ab7f871a-c4ae-4a86-8914-6e94dfde09b3 to retry in 54.0 seconds
.
.
[info] Fetching movie ratings for interstellar
[debug] Imdb rating for interstellar exists in cache
[debug] RottenTomatoes rating for interstellar exists in cache
[debug] Got Sify rating for interstellar
[debug] Got Netflix rating for interstellar
** (HTTPoison.Error) "Oops! we got a non 200 status code from AmazonPrime" <---- second failure
...
[info] Elixir.Oscar.OscarWorker[ab7f871a-c4ae-4a86-8914-6e94dfde09b3] fail: 1570ms
[info] Queueing job ab7f871a-c4ae-4a86-8914-6e94dfde09b3 to retry in 125.0 seconds
.
.
[info] Fetching movie ratings for interstellar
[debug] Imdb rating for interstellar exists in cache
[debug] RottenTomatoes rating for interstellar exists in cache
[debug] Sify rating for interstellar exists in cache
[debug] Netflix rating for interstellar exists in cache
[debug] Got AmazonPrime rating for interstellar
[info] Saved rating for interstellar
[info] Elixir.Oscar.OscarWorker[ab7f871a-c4ae-4a86-8914-6e94dfde09b3] success: 250ms
iex> {:ok, cached} = Oscar.RedisCache.get(uid)
iex> Jason.decode!(cached, keys: :atoms)
%{
imdb: 4.90,
rt: 4.75,
sify: 4.8,
netflix: 4.85,
amazon_prime: 4.90
}
使用缓存是直截了当的,解决方案按预期进行。Oscar在两次重试之间不会调用Imdb/RottenTomatoes/Netflix超过一次。这有助于我们为即将到来的工作保留API调用,而不是为完成这个特定的工作耗费更多的调用。
更让我兴奋的是,这在Elixir中是多么容易编写啊!这些函数非常精简,而且没有if-else块来检查我们是否已经缓存了我们的进度!这就是Elixir。
defp get_rating(%{imdb: imdb, uid: uid} = metadata, "imdb", name) when is_float(imdb) do
Logger.debug("Imdb rating for #{name} exists in cache", uid: uid)
metadata
end
defp get_rating(metadata, "imdb", name) when is_float(imdb) do
Map.put(metadata, :imdb, Imdb.get_rating!(name))
end
get_rating/3 函数的这两个模式负责从缓存或API中获取数据。
由于我们将metadata (到目前为止的评分)存储为json字符串,并将其解码为Elixir地图,这使得针对地图键的模式匹配非常简单。如果我们的键中有一个浮点值,这意味着我们已经获取了该特定门户(在本例中是imdb)的评级。我们可以简单地返回当前的metadata ,而不需要调用Imdb的API。
此外,如果这个模式匹配失败,默认的回退模式是从API中获取评级,我们把这个值放到我们在下一次迭代中寻找的同一个键中(如果有的话)。
这不是很酷吗?
这是我最近帮助一个客户解决的一个大问题的最小版本。该应用程序进行了大约10-15个外部服务调用,如果我们开始积极地打击它们,其中大部分会被阻止。由于我们查询的数据在整个特定的工作中是相同的,所以我们总是可以保存当前的进度,并在崩溃时从我们离开的地方接续。但正如我在一开始提到的,你并不总是需要Redis或一个专门的后台工作者与Elixir。你可以用GenServer来实现这种非常相同的行为!至于我,这个应用已经在使用ExQ的许多好的功能,而ExQ需要Redis。