调用外部 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 证书 |
| 所有请求打到同一 Pod | K8s 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 应用调用外部世界的完整防线——把它们配置对,比写多少业务代码都更能决定系统的稳定性。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。