单体服务不等于一个故障域
单体服务不等于一个故障域
很多团队觉得限流熔断是微服务才需要的东西,单体应用一个进程,挂了就重启。真实情况是:单体里所有接口共享同一个 Tomcat 线程池(server.tomcat.max-threads 默认 200)和同一个 HikariCP 连接池。一个运营后台的批量导出接口卡住 30 秒,200 个线程被占满,剩下的下单、登录接口全部排队超时,P99 从 80ms 涨到 12s。这就是雪崩,而它发生在单体里。更麻烦的是,单体里没有服务网格帮你兜底,任何保护都得自己在代码层做。
入口限流:按接口维度设闸门
在 Servlet Filter 里做最省事,不要用 Guava RateLimiter——它的 acquire() 是阻塞的,本身就会占线程。用 Bucket4j 的非阻塞接口:
Bucket.builder().capacity(500).refillGreedy(500, Duration.ofSeconds(1)).build(),调用tryConsume(1)返回 false 就直接回 429,不排队。- 维度选「接口路径 + 用户 ID」两级:单用户防刷,单接口防热点。别只做全局限流,一个热点接口就能吃光全局配额。
- 阈值不要拍脑袋。压测拿到该接口 CPU 65%、P99 稳定时的 QPS,取 70%~80% 作为阈值。比如下单接口压到 800 QPS 时开始劣化,阈值设 550。
- 再加一层全局兜底配额(比如整机 2000 QPS),防止几十个接口同时小幅超限叠加成灾难。
熔断:粒度是依赖,不是接口
最常见的错误是给每个接口配一个熔断器。20 个接口都在调同一个支付网关,各自统计失败率,每个都不到 50% 阈值,熔断永远不触发。正确做法是一个下游依赖共用一个熔断器:同一个 OkHttp Client、同一个 Redis 集群、同一个第三方 API,各自一个实例。
Resilience4j 的典型配置:滑动窗口 COUNT_BASED 100 次,failureRateThreshold 50,slowCallDurationThreshold 1s,slowCallRateThreshold 60,waitDurationInOpenState 30s,半开只放 permittedNumberOfCallsInHalfOpenState 10 次。注意慢调用也要计入失败,很多故障不是报错而是变慢。
熔断之前必须先把超时设对,否则熔断没有意义:连接超时 200~500ms,读超时 1~2s,用 OkHttp 的 callTimeout 或 Resilience4j 的 TimeLimiter 卡住总耗时。默认的无限等待会让熔断器永远统计不到失败。
隔离与降级:让故障只烂在一个角落
- 慢依赖用 Bulkhead 线程池隔离,信号量模式适合低延迟本地调用,线程池模式适合外部 HTTP。给导出、报表这类重接口单独一个线程池,别和主链路抢。
- 降级要写在熔断回调里,返回缓存快照或空列表,并打一个
degraded=true的 tag 到 metrics,否则降级了没人知道。 - 重试和熔断不要叠在同一个调用上。熔断打开时重试会把流量放大 3 倍,直接打穿下游。重试放在熔断器外层,且只对幂等接口开。
落地顺序与踩坑
按这个顺序做,收益递减但风险也递减:先补超时 → 再上熔断 → 再上入口限流 → 最后做线程池隔离。前两步能挡住 80% 的事故。
两个坑值得提前说:一是所有阈值必须走配置中心(Nacos / Apollo)动态下发,出事时能现场调,不能靠发版;二是把熔断器状态、限流拒绝数、线程池队列长度暴露到 Prometheus,Grafana 上单独一个面板看 circuitbreaker_state,半开状态放量失败要能第一时间看到。限流熔断不是加个注解就完事,它需要被观测、被演练,否则只是代码里的一段装饰。
- 点赞
- 收藏
- 关注作者
评论(0)