缓存、限流与熔断:Cachex、Hammer 与降级策略

在 Elixir/BEAM 上构建稳定性三件套:Cachex 的 ETS 缓存架构、TTL 抖动与 fetch 单飞、缓存击穿穿透雪崩的应对、Hammer 的固定窗口/滑动窗口/漏桶/令牌桶四种限流算法与分布式后端、基于 fuse 的熔断状态机与四种降级回退形态,以及三者的组合顺序与 Telemetry 可观测指标。

一个线上服务被打垮,通常不是因为功能有 bug,而是因为没有给「异常流量」留缓冲。缓存、限流、熔断是三道最常见的缓冲:缓存把重复计算变成一次查表,限流把超额请求挡在门外,熔断在依赖不可用时快速失败并降级。三者在实现上彼此独立,但在调用链上必须按固定顺序组合,顺序错了就会互相抵消。

BEAM 生态在这三块都有成熟库:Cachex(缓存)、Hammer(限流)、:fuse(熔断)。它们共同的底层是 ETS 与进程,因此性能与并发特性可以直接用 OTP 的直觉推断。本文逐个讲清机制与参数,最后给出组合顺序与降级策略。

Cachex:ETS 之上的缓存抽象

Cachex 的架构很直白:每个 cache 是一个 ETS 表加一个(或一组)管理进程。ETS 负责存储与并发读写,管理进程负责 TTL 过期、淘汰策略、统计与 warmers。

# mix.exs
{:cachex, "~> 3.6"}
# 启动一个 cache
{:ok, _pid} = Cachex.start_link(:user_cache, [
  limit: 100_000,
  policy: Cachex.Policy.LRW,
  default_ttl: :timer.minutes(15)
])

启动参数决定容量行为:

参数含义默认值
:limit最大条目数,超出触发淘汰:infinity
:policy淘汰策略 Cachex.Policy.LRW(最近最少写入)/ Cachex.Policy.LFUCachex.Policy.LRW
:default_ttl条目默认存活时间(毫秒):infinity
:expiration过期检查方式 :lazy / :purge:lazy

:expiration 的选择影响延迟与内存::lazy 在读取时判断是否过期,省掉后台扫描但过期条目会占内存;:purge 由后台进程定期清理,读延迟稳定但多一个进程与一次扫描开销。写多读少的场景用 :purge,读多写少用 :lazy。

基本操作与 ETS 一一对应,但多了 TTL 与统计:

Cachex.put(:user_cache, user_id, user)
Cachex.put(:user_cache, user_id, user, ttl: :timer.minutes(5))

Cachex.get(:user_cache, user_id)          # {:ok, user} | {:ok, nil}
Cachex.exists?(:user_cache, user_id)
Cachex.ttl(:user_cache, user_id)          # {:ok, ms}
Cachex.del(:user_cache, user_id)
Cachex.incr(:user_cache, :counter, 1)

Cachex.get/3 返回 {:ok, value} 或 {:ok, nil},注意它不是 {:error, :not_found}——这个设计避免了「缓存未命中」被当作错误处理。要区分「值为 nil」与「不存在」,用 Cachex.exists?/2。

fetch:原子化的 cache-aside

手写 cache-aside 的经典错误是「查缓存 → 未命中 → 查库 → 回填」这段存在竞态:并发请求会同时查库。Cachex 的 fetch/4 把这段逻辑做成原子操作,同一个 key 的并发 fetch 只会有一个执行 fallback:

Cachex.fetch(:user_cache, user_id, fn _key ->
  case Repo.get(User, user_id) do
    nil -> {:ignore, nil}
    user -> {:commit, user, ttl: :timer.minutes(10)}
  end
end)

返回值语义是关键:

返回含义
{:commit, value}写入缓存并返回
{:commit, value, opts}写入并带 TTL 等选项
{:ignore, value}只返回,不写缓存
{:error, reason}失败,不写缓存

{:ignore, _} 用于「查询结果为空」这类不该缓存的场景,避免把 nil 写进去造成负缓存污染。

缓存击穿、穿透与雪崩

三个术语对应三种失效模式,Cachex 各有对策:

击穿(breakdown):热点 key 过期的瞬间,大量并发请求同时回源。Cachex.fetch/4 的单飞(single-flight)语义天然解决——同 key 只放一个请求进去。跨节点场景则需要分布式锁,可参考 Redis 分布式锁 的实现思路。

穿透(penetration):请求大量不存在的 key,每次都打到数据库。对策是负缓存:对确实不存在的 key 写入一个短 TTL 的哨兵值。

Cachex.fetch(:user_cache, user_id, fn _key ->
  case Repo.get(User, user_id) do
    nil -> {:commit, :not_found, ttl: :timer.seconds(30)}
    user -> {:commit, user, ttl: :timer.minutes(10)}
  end
end)

雪崩(avalanche):大批 key 在同一时刻集体过期,瞬间回源压垮数据库。对策是给 TTL 加抖动(jitter):

ttl = :timer.minutes(10) + :rand.uniform(120_000)
Cachex.put(:user_cache, key, value, ttl: ttl)

抖动的幅度取基准 TTL 的 10%~20% 即可,太小起不到分散作用,太大会让命中率下降。

主动刷新与 warmers

TTL 到期才回源意味着每次过期都会有一次高延迟请求。对延迟敏感的数据,可以让后台进程在过期前主动刷新:

defmodule UserCacheWarmer do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)

  def init(_) do
    schedule()
    {:ok, %{}}
  end

  def handle_info(:refresh, state) do
    for user <- Repo.all(User) do
      Cachex.put(:user_cache, user.id, user, ttl: :timer.minutes(20))
    end
    schedule()
    {:ok, state}
  end

  defp schedule, do: Process.send_after(self(), :refresh, :timer.minutes(15))
end

Cachex 也内置了 warmer 机制(Cachex.Warmer),支持 :interval 与 :duration 两种模式。warmers 会随 cache 一起启动,适合「全量预热」这类简单场景;增量刷新仍建议自己写 GenServer,控制粒度更细。

Cachex 与直接使用 ETS 的取舍见 ETS 缓存与内存管理 :Cachex 提供 TTL、LRW/LFU 淘汰策略、统计与原子 fetch,代价是多一层进程与若干次 ETS 调用;纯计数器、纯映射这类无过期需求的场景,直接用 ETS 更快。

更新策略与一致性

缓存与数据源之间的更新时机决定了「读到旧数据」的窗口有多大。三种主流策略:

策略写入路径一致性适用
Cache-Aside写库后删缓存最终一致,窗口小读多写少
Write-Through写库同时写缓存较强读写均衡
Write-Behind只写缓存,异步刷库弱,可能丢数据写入极高、可容忍丢失

Cache-Aside 是默认选择,关键是「写库后删除缓存而不是更新缓存」。更新缓存的写法在并发下会产生乱序:两个请求分别写库 A、B,回填缓存的顺序若颠倒,缓存里会长期留下 A 的旧值。删除则没有这个问题——下一次读取自然回源。

def update_user(user_id, attrs) do
  {:ok, user} = Repo.update(user_id, attrs)
  Cachex.del(:user_cache, user_id)
  {:ok, user}
end

「先删缓存再写库」与「先写库再删缓存」也有取舍:前者在删除后、写库前有窗口会被并发读回填旧值;后者在写库后、删除前有窗口会读到旧值。业界普遍选后者,因为它引入的旧值窗口更短,且可用「延迟双删」(写库后删一次,短暂延迟后再删一次)进一步收敛。

跨节点失效是另一个必须处理的问题:节点 A 更新了数据,节点 B 的本地缓存仍是旧值。用 Phoenix.PubSub 广播失效消息是最轻量的做法:

defmodule CacheInvalidator do
  def subscribe, do: Phoenix.PubSub.subscribe(MyApp.PubSub, "cache:invalidate")

  def invalidate(key) do
    Phoenix.PubSub.broadcast(MyApp.PubSub, "cache:invalidate", {:invalidate, key})
  end

  def handle_info({:invalidate, key}, state) do
    Cachex.del(:user_cache, key)
    {:noreply, state}
  end
end

如果应用本来就是分布式 Erlang 集群,也可以直接依赖 :pg 或 :erlang.send/3 投递,省掉 PubSub 这一层。

Hammer:四种限流算法

限流的核心是「在给定时间窗内允许多少次请求」。不同算法在突发容忍度与状态开销上取舍不同,Hammer 把四种都实现了。

# mix.exs
{:hammer, "~> 6.2"}
# config/config.exs
config :hammer,
  backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60, cleanup_interval_ms: 60_000 * 10]}
算法Hammer 函数突发容忍状态开销典型用途
固定窗口check_rate/3边界处可翻倍最低粗粒度配额
滑动窗口check_sliding_window/3平滑中API 配额
漏桶check_leaky_bucket/3无(恒定速率)中平滑输出
令牌桶check_token_bucket/4可累积中允许突发的接口

固定窗口把时间切成离散窗口,实现最简单,但窗口边界存在「双倍突发」问题:窗口末 1 秒发满配额、下一秒再发满,两秒内实际放行了两倍。

case Hammer.check_rate("user:#{user_id}", 60_000, 100) do
  {:allow, count} -> {:ok, count}
  {:deny, limit} -> {:error, {:rate_limited, limit}}
end

滑动窗口维护一个随时间滑动的统计区间,消除了边界突发,代价是需要记录窗口内的事件分布(Hammer 用分片计数近似)。

Hammer.check_sliding_window("api:#{ip}", 60_000, 100)

漏桶以恒定速率放行,桶满则拒绝,适合「下游只能承受固定 QPS」的场景。

Hammer.check_leaky_bucket("outbound:#{service}", 10, 100)

令牌桶按速率补充令牌,桶容量决定可累积的突发额度,是最贴合真实流量形态的算法。

Hammer.check_token_bucket("burst:#{user_id}", 100, 10, 100)

分布式限流

ETS 后端只在单节点内生效。多节点部署时,每个节点的限流计数是独立的,实际放行量会变成 节点数 × 配额。要全局精确,需要共享后端:

config :hammer,
  backend: {Hammer.Backend.Redis,
            [
              redix: :my_redix,
              expiry_ms: 60_000 * 60,
              cleanup_interval_ms: 60_000 * 10
            ]}

Redis 后端的代价是每次限流判断多一次网络往返。混合策略通常更实用:本地 ETS 做第一层粗筛(拦截明显的滥用),Redis 做第二层精确计数。这样绝大多数正常请求只付出一次本地查询,只有接近配额的请求才走 Redis。

限流的键设计

限流的粒度由 key 决定,常见维度:

  • 按用户:"user:#{user_id}"——防止单用户刷接口
  • 按 IP:"ip:#{remote_ip}"——防止单机攻击,注意 NAT 后的共享 IP
  • 按端点:"endpoint:#{method}:#{path}"——保护特定昂贵接口
  • 组合:"user:#{user_id}:#{endpoint}"——最精确,但 key 数量最多

key 数量直接决定内存占用。用 Hammer.Backend.ETS 时,每个活跃 key 都占内存,expiry_ms 决定了 key 的最长存活时间——把它设得远大于窗口长度,让空闲 key 自动回收。

熔断::fuse 与降级回退

限流保护的是自己(不被超额请求压垮),熔断保护的是下游(不把请求持续打向已经故障的依赖)。熔断器是一个状态机:

状态行为转移条件
:ok(闭合)正常放行连续失败达阈值 → :blown
:blown(断开)立即拒绝,不调用下游重置超时到 → :ok
半开放少量请求试探成功 → :ok;失败 → :blown

Erlang 生态的标准实现是 :fuse:

# 安装:5 秒内失败 5 次即熔断,60 秒后自动尝试恢复
:fuse.install(:payment_api, {{:standard, 5, 5_000}, {:reset, 60_000}})
defmodule PaymentClient do
  def charge(amount) do
    case :fuse.check(:payment_api) do
      :ok ->
        case do_request(amount) do
          {:ok, result} ->
            {:ok, result}

          {:error, _} = err ->
            :fuse.melt(:payment_api)
            err
        end

      :blown ->
        # 熔断中,走降级路径
        {:ok, %{status: :queued, reason: :degraded}}
    end
  end

  defp do_request(amount), do: MyHTTP.post("/charge", %{amount: amount})
end

{standard, MaxFailures, Window} 的含义是「在 Window 毫秒内累计 MaxFailures 次 melt 就熔断」;{reset, Timeout} 是熔断后的静默期。两个参数需要按下游的恢复特性调:静默期太短会让下游持续承受试探流量,太长则恢复延迟高。

fuse 还提供手动控制:

:fuse.reset(:payment_api)     # 手动恢复
:fuse.ask(:payment_api)       # 查询当前状态 :ok | :blown

降级策略的四种形态

熔断只是「快速失败」,降级才是真正决定用户体验的部分。常见形态:

  1. 返回缓存快照:支付状态查询失败时返回最近一次成功的结果,标注 stale: true。
  2. 返回默认值:推荐服务不可用时返回热门榜单这类静态兜底数据。
  3. 异步化:写入类操作先落本地队列,等依赖恢复后补偿,用户侧立即返回受理成功。
  4. 功能降级:直接隐藏依赖该服务的功能入口,比返回错误更友好。

选择哪种取决于业务对「正确性」与「可用性」的偏好。支付这类强一致场景宁可失败也不能给错误结果;推荐、统计这类场景则可用性优先。

熔断 + 超时 + 重试的配合

熔断不能替代超时。如果下游是「慢」而不是「错」,熔断器可能一直不触发,请求却持续占用连接与进程。正确的组合是:每个请求设超时 → 超时的请求算作失败计入熔断 → 熔断打开后快速失败。重试则要克制:无退避的重试会把故障放大,且重试请求会加速熔断计数,应当只对幂等操作重试并配指数退避。连接池的配置同样影响这条链路,参见 HTTP 客户端与连接池 。

三者的组合顺序与可观测

调用链上的顺序应当是 限流 → 缓存 → 熔断 → 真实调用:

def get_user(user_id) do
  with :ok <- RateLimit.check(user_id),
       {:ok, nil} <- Cachex.get(:user_cache, user_id) do
    case :fuse.check(:user_api) do
      :ok -> fetch_and_cache(user_id)
      :blown -> fallback(user_id)
    end
  else
    {:ok, cached} -> {:ok, cached}
    {:error, :rate_limited} -> {:error, :too_many_requests}
  end
end

顺序的理由:限流最便宜且必须最先执行(否则超额请求会先浪费缓存查询与熔断检查);缓存命中直接返回,完全不触碰下游;熔断在缓存未命中后才检查,避免熔断打开时缓存命中也被误拒。

指标是这套机制能否被信任的前提。三个组件都应接入 Telemetry 与可观测性 :

指标含义告警阈值建议
cache.hit_rate命中率低于 80% 需排查 key 设计
cache.evictions淘汰次数持续高位说明 :limit 偏小
ratelimit.denied被拒请求数突增说明配额或攻击
fuse.blown熔断触发次数任何触发都需人工确认
fuse.blown_duration熔断持续时间超过静默期说明下游未恢复
Cachex.attach(:user_cache, [])
:telemetry.attach("fuse-events", [:fuse, :blown], &handle_fuse/4, nil)

Cachex.attach/2 会把命中、未命中、淘汰、过期等事件投递到 Telemetry,无需自己埋点。:fuse 则通过 :fuse_event 处理器上报状态变化。

实践建议

  1. TTL 一律加抖动。固定 TTL 是雪崩的直接诱因,10%~20% 的随机偏移就能化解。
  2. 用 Cachex.fetch/4 而不是手写 cache-aside。单飞语义是防击穿的关键,手写几乎必然有竞态。
  3. 限流按维度分层。本地 ETS 粗筛 + 共享后端精算,兼顾性能与准确性。
  4. 熔断必配超时与退避重试。只装熔断器不设超时,对「慢下游」无效。
  5. 降级路径要有明确的产品语义。返回陈旧数据、默认值还是错误码,必须由业务决定而非技术默认。
  6. 三者都要有指标。没有命中率、拒绝数、熔断次数的曲线,调参只能靠猜。
  7. 组合顺序固定为限流 → 缓存 → 熔断。顺序错了会导致配额浪费或缓存失效时误判。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「erlang」更多文章

  1. BEAM 内存剖析与泄漏排查:recon、observer 与堆分析
  2. 分布式一致性与网络分区:CRDT、libcluster 与脑裂治理
  3. Elixir 元编程与宏:AST、quote/unquote 与 DSL 设计