Elixir HTTP 客户端与连接池:Mint、Finch 与 Req 实战

从 Mint 的底层连接模型讲到 Finch 的连接池策略与调优,再到 Req 的高层 API 与重试退避:系统覆盖 Elixir 调用外部 HTTP 服务的完整技术栈,包含超时分层、熔断降级、流式响应、HTTP/2 复用与 Telemetry 可观测。

调用外部 HTTP 服务看起来是编程中最简单的事:发个请求,读个响应。但当这个「外部服务」开始抖动——延迟从 50ms 涨到 5s、偶发返回 502、连接被中间设备悄悄掐断——你的应用会以各种匪夷所思的方式崩掉:进程池被慢请求占满、重试风暴把下游彻底打死、内存因为响应体没读完而持续增长。

问题不在于 HTTPoison.get! 写错了,而在于缺少一层把「网络的不确定性」封装起来的架构。Elixir 生态给出了分层清晰的答案:Mint 负责「连接」这一最底层的抽象,Finch 在其上提供进程池与连接复用,Req 再在其上提供面向业务的请求编排。理解这三层各自解决什么问题,是写出健壮外部调用的前提。

一、HTTP 客户端的层次:Mint / Finch / Req

1.1 三层职责

层次代表库解决的问题暴露的抽象
连接层Mint建立/维护 HTTP/1.1 与 HTTP/2 连接,处理帧与流Mint.HTTP 的 conn 结构
池化层Finch进程池、连接复用、并发限制Finch.request/3
编排层Req重试、编解码、认证、插件管道Req.get!/2 等

关键区别在于谁持有连接。Mint 的连接是「无进程」的纯数据结构,由调用方自己拥有——这意味着你必须自己实现池化,否则每条请求都要重新握手。Finch 用一个独立进程持有连接,调用方通过消息与之交互,从而把连接的生存期与业务进程解耦。Req 则完全不关心连接,它只编排「一次请求应该经历哪些步骤」。

1.2 选型建议

  • 直接写业务代码:用 Req,它内置了重试、JSON、超时、插件;
  • 需要精细控制池与 HTTP/2:用 Finch,Req 底层也是 Finch;
  • 实现自定义协议或需要极致的连接复用:用 Mint;
  • 一次性脚本:Req.get! 足矣。

二、Mint 底层连接与 HTTP/2

2.1 建立连接

{:ok, conn} = Mint.HTTP.connect(:https, "api.example.com", 443,
  protocols: [:http2, :http1],
  transport_opts: [verify: :verify_peer, cacerts: :public_key.cacerts_get()]
)

IO.inspect(Mint.HTTP.protocol(conn))   # :http2 或 :http1

protocols 的顺序即优先级。HTTP/2 在多路复用上有绝对优势:一个连接可以并行承载成百上千个请求,而 HTTP/1.1 需要为每个并发请求开一条 TCP 连接。

2.2 发送请求与被动接收

{:ok, conn, request_ref} =
  Mint.HTTP.request(conn, "GET", "/v1/users", [{"accept", "application/json"}], nil)

receive_loop(conn, request_ref, %{}).

defp receive_loop(conn, ref, acc) do
  receive do
    message ->
      {:ok, conn, responses} = Mint.HTTP.stream(conn, message)

      case Enum.reduce(responses, acc, &handle_response(&1, ref, &2)) do
        {:done, body} -> {conn, body}
        acc -> receive_loop(conn, ref, acc)
      end
  after
    5_000 -> {:error, :timeout}
  end
end

defp handle_response({:status, ^ref, status}, _ref, acc), do: Map.put(acc, :status, status)
defp handle_response({:headers, ^ref, h}, _ref, acc), do: Map.put(acc, :headers, h)
defp handle_response({:data, ^ref, chunk}, _ref, acc), do: Map.update(acc, :body, chunk, &(&1 <> chunk))
defp handle_response({:done, ^ref}, _ref, acc), do: {:done, Map.get(acc, :body, "")}
defp handle_response(_, _, acc), do: acc

这段代码揭示了 Mint 的核心模型:连接是一个状态机,stream/2 把收到的 TCP 消息转换成协议事件。调用方必须在自己的进程里跑这个 receive 循环——这正是 Mint 难以直接用于业务代码的原因。

2.3 连接的所有权

Mint 的连接绑定在「接收消息的进程」上。跨进程使用同一个连接会丢失消息,因此:

  • 每个业务进程自己 connect 会导致连接数爆炸;
  • 用一个 GenServer 持有连接、其他进程通过 call 请求,会让该 GenServer 成为瓶颈。

Finch 的存在就是为了解决这个两难:它用固定数量的连接进程构成池,调用方通过 checkout 借用,用完归还。

三、Finch 连接池策略与调优

3.1 启动与池配置

children = [
  {Finch,
   name: MyFinch,
   pools: %{
     default: [size: 50, count: 1],
     "https://api.example.com" => [size: 100, count: 4],
     "https://slow-partner.com" => [size: 10, count: 1]
   }}
]
参数含义调优方向
size每个池的连接数HTTP/1 下即最大并发请求数
count池的个数分散竞争,size × count 是总连接上限
conn_opts传给 Mint 的连接选项超时、TLS、代理
pool_max_idle_time空闲连接回收时间太长会占用服务端资源

3.2 HTTP/1 与 HTTP/2 的池语义差异

这是 Finch 最容易被误解的一点:

协议size 的含义count 的作用
HTTP/1.1每个池的连接数,即并发请求上限池的数量,扩大总并发
HTTP/2每个池的连接数(通常 1 就够)意义不大,一个 HTTP/2 连接即可多路复用

HTTP/2 下把 size 设得很大是反模式:多个连接会削弱多路复用的收益(每个连接都要单独的流控窗口与拥塞控制),正确的做法是 size: 1 配合合理的 count,或者直接依赖单连接的流控。

3.3 每个域名独立配置

不同下游的容量天差地别,用一套默认配置会互相伤害:

pools: %{
  default: [size: 10, count: 1],
  # 内部服务:低延迟、高容量
  "http://internal-svc.default.svc.cluster.local" => [size: 100, count: 2],
  # 外部支付网关:严格限流,池要小
  "https://api.payment.com" => [size: 5, count: 1,
                                conn_opts: [transport_opts: [timeout: 3_000]]]
}

把「慢而少」的下游与「快而多」的下游分开配池,是避免一个下游拖垮全部调用的关键。

3.4 请求与流式响应

# 普通请求
request = Finch.build(:get, "https://api.example.com/v1/users", [{"accept", "application/json"}])
{:ok, %Finch.Response{status: 200, body: body}} = Finch.request(request, MyFinch, receive_timeout: 5_000)

# 流式处理:避免把大响应体整个读进内存
Finch.stream(request, MyFinch, [], fn
  {:status, status}, acc -> [{:status, status} | acc]
  {:headers, headers}, acc -> [{:headers, headers} | acc]
  {:data, chunk}, acc -> [{:data, byte_size(chunk)} | acc]
end)

Finch.stream/5 是处理大文件下载或 SSE 的正确姿势。用 Finch.request/3 拉一个 1GB 的文件,会把整个响应体读进调用进程的堆,直接触发内存告警。

四、Req 高层 API 与重试退避

4.1 基本用法

# GET + 自动 JSON 解码
{:ok, %Req.Response{status: 200, body: %{"users" => users}}} =
  Req.get("https://api.example.com/v1/users")

# POST JSON
Req.post!("https://api.example.com/v1/users",
  json: %{name: "Alice", email: "a@b.c"},
  headers: [{"authorization", "Bearer #{token}"}],
  receive_timeout: 5_000)

Req 默认会:设置 user-agent、跟随重定向(最多 10 次)、根据 content-type 自动解码 JSON、在 4xx/5xx 时返回 {:ok, response}(不抛异常,除非用 ! 版本)。

4.2 重试策略

Req.get!(url,
  retry: :transient,        # 只重试「瞬时错误」:5xx、超时、连接错误
  max_retries: 3,
  retry_delay: fn attempt -> trunc(:math.pow(2, attempt) * 100) + :rand.uniform(50) end,
  retry_log_level: :warning
)
retry 取值行为
:safe_transient只重试幂等方法(GET/HEAD/OPTIONS)的瞬时错误
:transient所有方法的瞬时错误
false不重试

retry: :transient 用在 POST 上是有风险的:请求可能已经到达服务端并产生了副作用,只是响应丢失了。若下游不支持幂等键,应改用 :safe_transient 并在业务层做补偿。

退避函数必须带随机抖动(jitter)。固定间隔的重试会让所有客户端在同一时刻同时重试,形成「重试风暴」,把刚刚恢复的下游再次打垮。

4.3 请求管道与插件

Req 的核心理念是「请求是一系列步骤(step)的管道」,可以插入自定义逻辑:

Req.new(url: "https://api.example.com/v1/users")
|> Req.Request.append_request_steps(sign: &sign_request/1)
|> Req.Request.append_response_steps(record: &record_metrics/1)
|> Req.get!()

defp sign_request(request) do
  signature = :crypto.mac(:hmac, :sha256, secret(), request.body || "")
  Req.Request.put_header(request, "x-signature", Base.encode16(signature, case: :lower))
end

内置插件覆盖了常见需求:

Req.get!(url, plugins: [
  {Req.Finch, finch: MyFinch},              # 指定 Finch 实例
  {Req.Steps.retry, retry: :transient},     # 重试
  &Req.Steps.put_base_url/1                 # 相对路径
])

4.4 认证与鉴权

# Basic Auth
Req.get!(url, auth: {:basic, "user:pass"})

# Bearer Token
Req.get!(url, auth: {:bearer, token})

# AWS SigV4(通过插件)
Req.get!(url, aws_sigv4: [service: :s3, region: "ap-northeast-1"])

令牌刷新这类横切逻辑适合做成自定义插件,在 append_request_steps 中检查令牌有效期并按需刷新,避免每个调用点都写一遍。

五、超时、熔断与故障隔离

5.1 超时的四个层次

HTTP 调用链路上有四种超时,混淆它们是线上事故的常见来源:

超时配置位置覆盖阶段
连接超时conn_opts: [transport_opts: [timeout: 3000]]TCP/TLS 握手
请求池等待pool_timeout: 5000等待空闲连接
接收超时receive_timeout: 5000从发出到收到完整响应
整体超时Req 的 connect_options + receive_timeout 组合端到端

推荐配置比例:连接超时 23 秒(网络问题应快速失败),池等待 5 秒(池耗尽说明并发配置有问题),接收超时按下游 P99 的 23 倍设定。

Req.get!(url,
  connect_options: [timeout: 3_000],
  pool_timeout: 5_000,
  receive_timeout: 10_000)

5.2 熔断降级

超时只能保护「单次调用」,熔断保护的是「整个下游」:

defmodule MyApp.CircuitBreaker do
  use GenServer

  @threshold 5           # 连续失败阈值
  @reset_after 30_000    # 熔断后多久尝试恢复

  def call(fun) do
    case :ets.lookup(:breaker, :payment) do
      [{:payment, :open, opened_at}] when opened_at + @reset_after > now() ->
        {:error, :circuit_open}
      _ ->
        case fun.() do
          {:ok, _} = ok -> record_success(); ok
          {:error, _} = err -> record_failure(); err
        end
    end
  end
end

成熟方案是 :fuse 库,它提供 :fuse.install/2 与 :fuse.ask/2,语义与 Hystrix 类似。熔断打开期间直接返回降级结果(缓存值、默认值、友好错误),避免把有限资源浪费在必然失败的调用上。

5.3 舱壁隔离

即使有熔断,一个下游的慢请求仍可能耗尽共享的资源。舱壁(Bulkhead)的思路是给每个下游分配独立的资源配额:

pools: %{
  "https://api.payment.com" => [size: 5, count: 1],     # 最多 5 个并发
  "https://api.search.com"  => [size: 50, count: 2]     # 最多 100 个并发
}

Finch 的池天然就是舱壁:支付网关的池满了,只会阻塞调用支付网关的进程,不会影响搜索调用。这比全局设置一个「最大并发」有效得多。

六、流式响应与可观测

6.1 处理大响应与 SSE

# 下载大文件:边收边写盘,内存恒定
File.open!("big.zip", [:write])
|> then(fn file ->
  Finch.stream(Finch.build(:get, url), MyFinch, file, fn
    {:data, chunk}, file -> IO.binwrite(file, chunk); file
    _, file -> file
  end)
end)
# SSE:逐事件消费
Finch.stream(request, MyFinch, "", fn
  {:data, chunk}, acc ->
    case String.split(acc <> chunk, "\n\n") do
      [rest] -> rest
      parts -> handle_events(Enum.drop(parts, -1)); List.last(parts)
    end
  _, acc -> acc
end)

流式处理的关键是不要在中间累积完整响应。上面 SSE 的例子中,只有不完整的尾部片段被保留,已解析的事件立即处理并丢弃。

6.2 Telemetry 事件

Finch 与 Req 都发出标准 Telemetry 事件:

事件触发时机关键测量值
[:finch, :request, :start]请求发出system_time、name
[:finch, :request, :stop]请求完成duration、status
[:finch, :request, :exception]请求抛异常kind、reason
[:finch, :queue, :start] / :stop等待池中连接duration
:telemetry.attach_many("http-metrics",
  [[:finch, :request, :stop], [:finch, :request, :exception]],
  fn event, measurements, metadata, _cfg ->
    MyApp.Metrics.record(event, measurements, metadata.request.host)
  end, nil)

6.3 必须监控的指标

  • [:finch, :queue, :stop] 的 duration:等待连接的时间。它持续升高说明池太小或下游变慢,这是最灵敏的早期预警信号;
  • 按 host 分组的 P99 延迟:定位是哪个下游在劣化;
  • 状态码分布:5xx 比例上升通常是下游故障的第一个迹象;
  • 重试次数分布:大量请求需要重试说明下游已经不稳定,此时应主动降级而非加大重试;
  • 连接池利用率:长期 100% 占用意味着容量不足。

这套指标与 https://plumephp.com/erlang-logging-telemetry-observability/ 中的采集管道可以直接复用。

6.4 常见故障模式

现象根因对策
大量 :timeout 但下游正常池太小,请求在排队增大 size,检查 queue 指标
内存持续增长大响应体未流式处理改用 Finch.stream/5
重试风暴打垮下游无抖动、无熔断加 jitter、装熔断器
偶发 :closed中间设备回收空闲连接缩短 pool_max_idle_time
TLS 握手失败证书链或 SNI 配置问题检查 transport_opts 与 CA 证书
所有请求打到同一 PodK8s ClusterIP + HTTP/2 长连接用 headless Service

七、最佳实践与总结

  • 默认用 Req,需要控制池时降到 Finch:Req 的插件机制足以覆盖绝大多数场景,不必从 Mint 起步;
  • 按下游独立配置连接池:这是舱壁隔离最廉价的实现,一个下游的故障不应波及另一个;
  • HTTP/2 下不要盲目加大 size:多路复用的收益会被多连接的流控开销抵消,size: 1 + 合理 count 通常更优;
  • 重试必须带抖动且区分幂等性:retry: :safe_transient 是安全默认值,非幂等的 POST 要在业务层用幂等键兜底;
  • 超时分四层设置:连接、池等待、接收、端到端各有语义,混淆会导致排障时看不出瓶颈在哪一层;
  • 大响应一律流式:Finch.stream/5 不是高级技巧,而是处理超过几 MB 响应的默认做法;
  • 盯住 queue 指标:等待连接的时间比请求本身的延迟更早暴露容量问题;
  • 熔断与降级成对出现:只有熔断没有降级,用户看到的仍然是一个错误页面。

外部依赖是分布式系统中最不可控的部分。你无法让别人的服务变快,但可以让自己在对方变慢时优雅地退化:池化让连接可复用,超时让等待有边界,重试让偶发失败可自愈,熔断让持续故障不再放大,舱壁让局部问题不扩散。这五件事构成了 BEAM 应用调用外部世界的完整防线——把它们配置对,比写多少业务代码都更能决定系统的稳定性。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「erlang」更多文章

  1. Erlang/Elixir 容器化与集群部署:Release、Docker 与 libcluster
  2. Elixir 认证授权实战:JWT、Guardian 与 Phoenix.Token
  3. Erlang/Elixir gRPC 与 Protobuf:从编码原理到 grpcbox 实战