一个线上服务被打垮,通常不是因为功能有 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.LFU | Cachex.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
降级策略的四种形态
熔断只是「快速失败」,降级才是真正决定用户体验的部分。常见形态:
- 返回缓存快照:支付状态查询失败时返回最近一次成功的结果,标注
stale: true。 - 返回默认值:推荐服务不可用时返回热门榜单这类静态兜底数据。
- 异步化:写入类操作先落本地队列,等依赖恢复后补偿,用户侧立即返回受理成功。
- 功能降级:直接隐藏依赖该服务的功能入口,比返回错误更友好。
选择哪种取决于业务对「正确性」与「可用性」的偏好。支付这类强一致场景宁可失败也不能给错误结果;推荐、统计这类场景则可用性优先。
熔断 + 超时 + 重试的配合
熔断不能替代超时。如果下游是「慢」而不是「错」,熔断器可能一直不触发,请求却持续占用连接与进程。正确的组合是:每个请求设超时 → 超时的请求算作失败计入熔断 → 熔断打开后快速失败。重试则要克制:无退避的重试会把故障放大,且重试请求会加速熔断计数,应当只对幂等操作重试并配指数退避。连接池的配置同样影响这条链路,参见 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 处理器上报状态变化。
实践建议
- TTL 一律加抖动。固定 TTL 是雪崩的直接诱因,10%~20% 的随机偏移就能化解。
- 用
Cachex.fetch/4而不是手写 cache-aside。单飞语义是防击穿的关键,手写几乎必然有竞态。 - 限流按维度分层。本地 ETS 粗筛 + 共享后端精算,兼顾性能与准确性。
- 熔断必配超时与退避重试。只装熔断器不设超时,对「慢下游」无效。
- 降级路径要有明确的产品语义。返回陈旧数据、默认值还是错误码,必须由业务决定而非技术默认。
- 三者都要有指标。没有命中率、拒绝数、熔断次数的曲线,调参只能靠猜。
- 组合顺序固定为限流 → 缓存 → 熔断。顺序错了会导致配额浪费或缓存失效时误判。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。