文档目录

7.5 背压与韧性:保护自己也保护下游

上一节:7.4 缓存 | 下一节:7.6 池化与复用 配套代码:07-optimization-and-validation/05-backpressure-and-resilience


一句话结论

这五种手段(限流、熔断、隔板、超时、重试)共同回答一个问题:当压力超过承载能力时,系统该"怎么坏"。 好的设计是快速失败、局部失败、可恢复;坏的设计是排队到崩、全局崩、需要重启。


一、用「水库」理解背压

一座水库遇到暴雨(流量激增):

措施 作用 对应
溢洪道 超过容量就放掉多余的水 限流(拒绝多余请求)
水位警戒线 到线就开闸,不等到决堤 熔断(提前失败)
分区堤坝 一个区决堤不影响其他区 隔板(资源隔离)
泄洪时间 给下游留出消化时间 超时(及时放弃)
重试 放掉的水再试一次 重试(要有退避和上限)

关键洞察:水库不会等到决堤才行动——它在警戒水位就开始泄洪。这就是"提前失败"的价值。


二、五种手段的分工

手段 防什么 关键参数 失败时的行为
限流 自己被打垮 速率上限 快速返回 429/503
熔断 被下游拖垮 错误率阈值 + 半开探测 快速失败(不调用下游)
隔板 一个依赖拖垮全部 每个依赖的独立资源池 该依赖的请求排队,其他正常
超时 资源被长期占用 逐层递减的超时 主动放弃并释放资源
重试 偶发失败 次数上限 + 退避 + 抖动 放大压力(必须约束)

注意最后一行:重试是唯一一个会"放大压力"的手段——所以它必须有严格约束。


三、超时预算(最重要,也最容易做错)

核心规则

本层的超时 < 上游给你的超时
且逐层递减
客户端 500ms
  └→ 网关 400ms
       └→ 应用 300ms
            ├→ 数据库 80ms
            ├→ 缓存 10ms
            └→ 下游服务 100ms

为什么这是"最救命"的一条

❌ 错误配置:应用给数据库的超时是 30 秒(默认值,没人改)

① 数据库变慢,每个查询要 3 秒
② 应用线程被占住 3 秒(因为超时是 30 秒,不会提前放弃)
③ 网关在 1 秒时超时返回 504 给用户
④ 但应用线程还在等数据库(还有 2 秒)
⑤ 新请求继续进来,占用新线程 → 线程池耗尽
⑥ 所有接口开始超时 → 服务雪崩
⑦ 流量回落后,那些连接还在等 → 【无法自愈】

超时不是「容忍时间」,而是「放弃的时机」——放弃得早,系统才能自愈(第 3 章 3.6 节)。

实现

// 统一的超时包装
class BudgetExceededException(message: String, cause: Throwable?) : RuntimeException(message, cause)

suspend fun <T> withBudget(ms: Long, name: String, block: suspend () -> T): T =
    try {
        withTimeout(ms) { block() }
    } catch (e: TimeoutCancellationException) {
        throw BudgetExceededException("$name exceeded ${ms}ms", e)
    }

// 使用:每层都有明确的预算
suspend fun getOrder(id: String): Order {
    val cached = withBudget(10, "redis") { cache.get(id) }
    if (cached != null) return cached

    return withBudget(80, "postgres") {
        repo.findById(id) ?: throw NotFoundException(id)
    }
}

超时审计

// 检查超时链是否递减
data class TimeoutLayer(val name: String, val upstreamMs: Long, val selfMs: Long)

fun audit(layers: List<TimeoutLayer>): List<String> {
    val problems = mutableListOf<String>()
    layers.forEach { l ->
        if (l.selfMs > l.upstreamMs) {
            problems += "❌ ${l.name}: 本层超时 ${l.selfMs}ms > 上游给的 ${l.upstreamMs}ms"
        }
    }
    return problems
}

四、限流(防自己被打垮)

两种算法

算法 特点 适用
令牌桶 允许突发(桶里积累的令牌) 大多数场景
漏桶 严格恒定速率 需要平滑输出

限流维度

// ① 全局限流(保护自己)
val globalLimiter = RateLimiter.create(10_000.0)   // 每秒 1 万

// ② 按租户限流(防止单个租户占满)
val tenantLimiters = ConcurrentHashMap<String, RateLimiter>()
fun limiterFor(tenant: String) = tenantLimiters.computeIfAbsent(tenant) {
    RateLimiter.create(100.0)
}

// ③ 按接口限流(保护重接口)

关键设计

要点 说明
要有明确的拒绝响应 返回 429 + Retry-After,而不是静默丢弃
拒绝要计数 限流命中数是重要的容量指标
阈值要有依据 来自压测的拐点(第 3 章 3.4),不是拍脑袋
留 headroom 限流阈值应低于拐点(例如拐点的 80%)

五、熔断(防被下游拖垮)

三个状态

关闭(正常)
  ↓ 错误率超过阈值
打开(快速失败,不调用下游)
  ↓ 等待一段时间
半开(放少量请求探测)
  ├─ 成功 → 回到关闭
  └─ 失败 → 回到打开

参数

参数 建议值 说明
错误率阈值 50% 超过就打开
最小请求数 20 样本太少不触发(避免误判)
打开时长 10–30 秒 给下游恢复时间
半开探测数 3–5 少量试探

⚠️ 一个常见错误

// ❌ 熔断器包得太外层——把参数校验失败也算作"下游故障"
circuitBreaker.execute {
    validateParams(params)      // 参数错误不应该触发熔断!
    callDownstream()
}

// ✅ 只对真正的下游调用熔断
validateParams(params)
circuitBreaker.execute { callDownstream() }

原则:熔断器只应该统计「下游的失败」,不包括「自己的参数错误」——否则一次参数错误潮就会误触发熔断。


六、隔板(防一个依赖拖垮全部)

问题场景

服务 A 调用下游 X 和 Y
X 变慢 → A 的线程池被 X 的调用占满
→ A 无法处理任何请求(包括不需要 X 的)
→ Y 也被牵连

解法:为每个下游分配独立资源

// ① 每个下游独立的信号量
val xLimiter = Semaphore(32)
val yLimiter = Semaphore(32)

suspend fun callX() = xLimiter.withPermit { httpGetX() }
suspend fun callY() = yLimiter.withPermit { httpGetY() }

// ② 或者每个下游独立的线程池 / 调度器
private val xDispatcher = Dispatchers.IO.limitedParallelism(32)
private val yDispatcher = Dispatchers.IO.limitedParallelism(32)

// ③ 数据库侧:读写分离、不同库用不同连接池

效果:X 变慢时,只有 X 相关的请求排队,其他接口不受影响。


七、重试(最容易做错的一个)

三个必须的约束

// ❌ 无约束的重试:放大故障
repeat(3) { callDownstream() }

// ✅ 有约束的重试
suspend fun <T> retryWithBackoff(
    maxAttempts: Int = 3,
    initialDelayMs: Long = 100,
    maxDelayMs: Long = 2_000,
    block: suspend (attempt: Int) -> T,
): T {
    var lastException: Throwable? = null
    repeat(maxAttempts) { attempt ->
        try {
            return block(attempt)
        } catch (e: RetryableException) {          // ← 只重试可重试的异常
            lastException = e
            if (attempt < maxAttempts - 1) {
                // 指数退避 + 随机抖动
                val base = minOf(initialDelayMs shl attempt, maxDelayMs)
                val jitter = Random.nextLong(0, base / 2)
                delay(base + jitter)
            }
        }
    }
    throw lastException!!
}

四条纪律

纪律 为什么
只重试幂等操作 非幂等的重试会造成重复下单/重复扣款
只重试可重试的异常 参数错误、404 重试没有意义
指数退避 + 抖动 避免"所有客户端同时重试"的惊群
有次数上限 + 重试预算 无上限的重试等于放大故障

重试预算

如果每个请求最多重试 2 次,那么最坏情况下:
  下游承受的流量 = 正常流量 × 3

所以在限流和容量规划时,要按 ×3 算(或限制重试的总比例)

一个实用的做法:给重试设置全局预算——例如「重试请求总数不超过正常请求的 10%」,超过就不再重试。


八、一个完整的韧性配置

class DownstreamClient(
    private val http: HttpClient,
    private val limiter: Semaphore,                  // 隔板
    private val rateLimiter: RateLimiter,            // 限流
    private val circuitBreaker: CircuitBreaker,      // 熔断
) {
    suspend fun fetch(id: String): Result<Data> {
        // ① 限流(自己这一侧的速率控制)
        if (!rateLimiter.tryAcquire()) {
            return Result.error(BusinessException("请求过于频繁"))
        }

        // ② 熔断(快速失败,不调用下游)
        if (circuitBreaker.isOpen()) {
            return Result.error(BusinessException("下游暂时不可用"))
        }

        return try {
            // ③ 隔板 + 超时 + 重试
            retryWithBackoff(maxAttempts = 2) {
                limiter.withPermit {
                    withBudget(100, "downstream") {
                        http.get("$BASE/$id")
                    }
                }
            }.let {
                circuitBreaker.recordSuccess()
                Result.success(it)
            }
        } catch (e: BudgetExceededException) {
            circuitBreaker.recordFailure()
            Result.error(RemoteException("下游超时", e))
        } catch (e: Exception) {
            circuitBreaker.recordFailure()
            Result.error(RemoteException("下游失败", e))
        }
    }
}

九、本节小结

  1. 五种手段回答同一个问题:压力超限时"怎么坏"——快速失败、局部失败、可恢复。
  2. 超时预算最重要也最容易做错:本层超时必须小于上游,且逐层递减;超时决定了能否自愈。
  3. 限流要有明确的拒绝响应、计数指标、有依据的阈值。
  4. 熔断只应统计下游失败,不要把自己的参数错误算进去。
  5. 隔板:为每个下游分配独立的资源池——防止一个慢依赖拖垮全部。
  6. 重试是唯一会放大压力的手段,四条纪律:只重试幂等、只重试可重试异常、指数退避+抖动、有次数上限与预算。
  7. 重试要算进容量规划:最多重试 2 次意味着下游可能承受 3 倍流量。

十、自测

  1. 应用给数据库设了 30 秒超时,而网关给应用的超时是 1 秒。请描述数据库变慢时会发生什么,以及为什么系统可能无法自愈。
  2. 一个服务的重试策略是「失败就重试 3 次,间隔 100 ms」。请指出三个问题以及改进方向。
  3. 服务 A 调用下游 X 和 Y。X 变慢后,A 的所有接口(包括不用 X 的)都变慢。请说出根因和修法。
  1. 发生的过程:① 数据库变慢,每个查询要 3 秒;② 应用线程开始等数据库——因为超时是 30 秒,应用不会提前放弃,线程被占住 3 秒;③ 网关在 1 秒时超时返回 504 给用户,但应用线程仍在等待(资源没释放);④ 新请求继续到来,占用新的线程,线程池逐渐被耗尽;⑤ 所有接口开始超时,服务整体雪崩。为什么无法自愈:⑥ 即使流量回落,那些被占住的线程仍在等数据库(还要等 30 秒才超时);更糟的是,如果新请求持续到来(哪怕是少量),它们会立刻占用释放出来的线程,池子始终处于满状态——系统永远回不到健康状态,必须重启。修复:把数据库超时缩短到 3 秒以内(例如 80 ms),让请求快速失败并释放线程——这样流量一回落,系统就能自己恢复。核心原则:超时不是"容忍时间",而是"放弃的时机";放弃得早,系统才能自愈。
  2. 三个问题:① 间隔固定 100 ms(没有退避)——如果下游是因为过载而失败,固定间隔的重试会在同一时间窗口内反复打击它,加剧过载;改进:指数退避(100ms → 200ms → 400ms)+ 随机抖动(避免所有客户端同时重试的惊群)。② 可能重试非幂等操作——如果这个调用是"创建订单"或"扣款",重试可能造成重复下单/重复扣款;改进:只对幂等操作重试,或使用幂等键(idempotency key)保证重复请求只生效一次。③ 没有区分可重试异常——参数错误(400)、资源不存在(404)重试 3 次毫无意义,只浪费资源;改进:只重试可重试的异常(超时、连接失败、5xx)。还可以加第四点:没有全局重试预算——所有请求都重试 3 次意味着下游最坏承受 4 倍流量(首次 + 3 次重试),在容量规划时必须算进去。
  3. 根因:没有资源隔离(隔板)——A 处理请求用的线程池/连接池是共享的,X 变慢后调用 X 的请求占满了这些资源,导致 A 无法处理任何请求(包括不需要 X 的那些)。修法:① 为每个下游分配独立的资源池——例如 xLimiter = Semaphore(32) 和 yLimiter = Semaphore(32),或使用独立的 Dispatchers.IO.limitedParallelism(32);这样 X 变慢时只有 X 相关的请求排队。② 配合熔断——X 持续失败时快速失败,不再消耗资源去调用它。③ 配合超时——给 X 的调用设置更短的超时(例如 100 ms),避免线程被长期占用。④ 验证方法:让 X 变慢(比如用 tc 加延迟或用 mock),然后观察「不调用 X 的接口」的延迟是否受影响——修好后应该完全不受影响。