Sentinel限流实战:QPS与线程数模式选型及Nacos动态配置
发布时间:2026/10/9 6:01:10 锦皓数字建站

1. 先从一次线上事故说起QPS 限流为什么会误伤慢请求大概去年年中我们有个核心接口因为下游依赖抖动单次响应时间从 50ms 暴涨到 3s 左右。当时这套系统用的是 Sentinel QPS 限流阈值设的 1000。表面上看这个数字远高于平时的 100 QPS 均值按理说不该触发限流。结果恰恰是那个时刻接口被限流得厉害一堆请求直接拿到 Blocked 提示业务方反馈系统崩了。复盘的时候发现一个很扎心的事实QPS 限流拦的不是请求总量多而是瞬时流量超过阈值。响应变慢之后每条请求在 Tomcat 线程池里占着线程不释放QPS 虽然可能没到 1000因为处理不过来但线程池被打满新的请求进不来Sentinel 统计的 QPS 自然就瞬间爆表。这就像一个餐厅只有 20 张桌子每桌客人吃完要走 3 小时哪怕你每小时只放 10 桌进来门口排队的人也已经把大厅挤爆了。那之后我开始认真研究 QPS 限流和线程数限流到底该怎么选。结论是如果你的接口响应时间相对稳定QPS 限流好用且性能开销小如果你的接口会因外部依赖、慢 SQL、锁竞争等因素出现响应时间大幅波动线程数限流才是更贴近系统真实承载能力的方案。这篇文章我会把两种限流模式的控制原理、适用场景、性能差异和踩坑经验一次讲透涉及到 Sentinel 整合 Nacos 动态配置的部分也会配上可以复制的样例方便你直接落地到自己项目里。2. 两种限流模式到底在限什么QPS 控制速率线程数控制并发占用2.1 QPS 限流的核心逻辑计数器 滑动窗口QPS 限流是大多数人最先接触的限流方式。Sentinel 默认采用滑动窗口来统计每秒通过的请求数你可以把它理解成每秒最多放行多少个请求进入处理逻辑。核心判断逻辑用伪代码表示是这样的// 伪代码QPS 限流判断逻辑 public boolean canPass() { long currentQps slidingWindowCounter.getCurrentQps(); // 当前窗口内的 QPS 统计值 if (currentQps 1 rule.getCount()) { // rule.getCount() 即阈值 return false; // 拒绝 } return true; // 放行 }这里有几个关键参数值得说清楚阈值类型QPS 模式下count表示每秒允许通过的请求数量。比如设成 1000含义是任意一秒内最多放行 1000 个请求。统计窗口Sentinel 默认窗口长度是 1 秒分成多个格子默认 2 个格子可通过windowIntervalMs和sampleCount调整格子越多统计越平滑但内存开销也越大。流量效果默认是直接拒绝也就是超出阈值直接返回 Blocked。也可以配成匀速排队RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER让请求以固定速率通过适合削峰填谷的场景。QPS 限流的核心假设是每个请求消耗的系统资源大致相同。在这个假设成立的前提下控制 QPS 就等于控制系统负载。2.2 线程数限流的核心逻辑信号量隔离线程数限流走的是另一条路。它不关心你一秒进来多少个请求只关心当前有多少个请求正在同时执行。Sentinel 在调用链路上埋了一个信号量Semaphore进入业务方法前acquire()方法执行完无论成功还是异常后release()。伪代码如下// 伪代码线程数限流判断逻辑 public boolean canPass() { if (atomicCounter.getCurrentCount() 1 rule.getCount()) { return false; // 当前并发数已达阈值拒绝 } atomicCounter.increment(); // 占用一个信号量 return true; }注意一个细节Sentinel 的线程数限流实现里acquire()默认不支持排队等待超了就立即返回失败。这和 QPS 限流里的匀速排队模式不一样设计理念是与其让请求排队占着线程不如直接快速失败让上游感知到压力。线程数限流的核心假设是系统真正的瓶颈资源是线程。Tomcat 默认 200 个线程每个线程同一时刻只能处理一个请求所以并发线程数直接决定系统能同时处理多少请求。只要并发数被控制住响应时间再长、慢请求再多也不会无限挤压线程池。2.3 两者的本质差异速率控制 vs 资源占用控制用一张表把两者的关键差异列清楚维度QPS 限流线程数限流控制对象单位时间内的请求数量速率正在执行中的请求数量并发核心统计量每秒通过的请求数当前持有的信号量计数底层实现滑动窗口计数器信号量 原子计数对慢请求的敏感度低只数数量不管耗时高请求耗时越长占用越久响应时间波动时表现可能误判线程积压但 QPS 不高稳定只约束并发上限内存开销中等滑动窗口数组低一个计数器典型场景网关入口、短平快接口业务逻辑复杂、依赖下游的接口一个特别容易混淆的点QPS 限流并不能真正保护线程池。原因很简单限流器只判断这一秒能不能放进第 N 个请求但每个请求会执行多久它完全不管。1000 QPS 的阈值如果每个请求要执行 2 秒那 Tomcat 的 200 个线程瞬间就被占满后续请求就算通过了限量检查也没线程可用照样超时堆积。而线程数限流直接约束正在执行的请求数比如设成 100那么无论每秒进来多少请求同时最多只有 100 个在跑另外的会被快速拒绝。线程池始终有余量系统不会死。3. 什么场景该选 QPS什么场景该选线程数先判断你的瓶颈在哪3.1 适合 QPS 限流的场景接口耗时稳定、资源消耗均匀我个人的判断标准是先问自己这个接口最底层依赖的资源是什么如果控制住请求进来速度就能控制住资源消耗那 QPS 限流就够用。典型场景有三类纯计算型接口比如加解密服务、图片缩略图生成、本地缓存查询。这类接口不依赖外部系统内存和 CPU 消耗基本恒定执行时间波动很小。把 QPS 压到单个节点能承受的范围内系统就很稳。网关层限流在 Nginx、Spring Cloud Gateway、Sentinel Gateway 这一层做限流目的不是保护某个业务服务而是保护整个后端集群。这时用 QPS 限流最直观语义清晰业务方也容易理解。流量尖峰平滑秒杀、抢购、活动预热场景瞬间流量可能是平时的几十倍。如果你希望多余流量被排成队慢慢放行而不是直接拒绝配合匀速排队模式用 QPS 限流很合适。3.2 适合线程数限流的场景响应时间波动大、有外部依赖线程数限流的适用场景有一个共同特征你无法准确预知每个请求会执行多久。下游 RPC 依赖多的接口一个接口调用三四个下游服务任何一个抖动都会导致整体耗时翻倍。此时 QPS 限流基本失灵——你以为限制住了请求量但积压的请求还是会把线程池耗尽。数据库慢查询场景慢 SQL 或者连接池打满时所有走这条 SQL 的接口都会变慢。线程数限流可以保证即使数据库快挂了业务线程也不会积压太多给其他接口留活路。第三方 API 调用对接外部接口的响应时间你完全不可控用线程数限流可以防止对方抖动拖垮你的整个应用。3.3 一个很容易踩的误区把 QPS 限流当作万能方案我在很多项目里见过一种配置方式所有接口统一加一个 QPS 限流阈值拍脑袋填个 500 或 1000之后就再也不管了。这种做法的隐患在于接口耗时一旦上升限流阈值立刻失真限不住真正的问题。多个接口共享线程池时一个慢接口的请求即使被限制在 QPS 阈值内也可能会占完大部分线程拖垮其他快接口。拍脑袋定的阈值没经过压测验证要么太宽松等于没配要么太严格天天误伤。更好的做法是上线前先压测拿到基线数据。用压测工具把接口压到 CPU 80% 左右记录对应的 QPS 和并发线程数然后按这个数据打折设置限流阈值。同时对不同危险程度的接口分配不同的限流策略——依赖多的用线程数纯计算的用 QPS。3.4 两者混用也不冲突先线程数兜底再 QPS 控制入口速率如果你拿不准选哪个可以参考我目前比较推荐的组合方式线程数限流作为第一道保险设置一个接近线程池上限的安全值比如 Tomcat 线程池 200那就设 160。这样无论什么情况下业务线程都不会被打满。QPS 限流作为第二道控制设置一个正常流量下的业务阈值比如基线 QPS 的 80%。用来提前拦截突发流量避免大量请求涌入后被线程数限流批量拒绝造成频繁的上下文切换和资源浪费。两道配合比单选任意一种都稳。我最近在一个核心交易链路上就是这么配的下游抖动时线程数限流先兜底正常流量高峰时 QPS 限流提前削峰效果明显好于之前只用 QPS 的方案。4. 性能差异的底层原因计数器、滑动窗口和信号量的开销对比4.1 QPS 限流的开销来源滑动窗口的数据结构很多人以为 QPS 限流只是一个简单的AtomicLong.incrementAndGet()其实不是。Sentinel 的 QPS 统计基于滑动窗口Sliding Window内部维护了一个环形数组每个格子WindowWrap里保存着独立的LongAdder计数器。为什么不用一个AtomicLong因为 QPS 是每秒的统计量你需要能区分出不同时间段的计数。滑动窗口的做法是把时间轴切分成固定长度的小格比如 500ms 一格一个窗口保留最近 2 格的统计值。每次判断限流时累加当前窗口所有格子的计数和阈值比较。这个结构的开销主要有三个每次请求的写入操作需要定位当前时间属于哪个格子然后调用LongAdder.add(1)。LongAdder在低并发下性能接近AtomicLong高并发下通过分段 CAS 降低竞争所以写入开销并不大。定期格子过期和重置时间窗口滑动时旧的格子会被复用或重置这个过程会有数组遍历的操作但频率很低每个格子周期一次。内存占用每个格子包含一个LongAdder内部有一个或多个 Cell假设配置了 2 个格子、每个LongAdder内部 4 个 Cell一次资源维度就要 8 个计数单元。规则多、资源维度多时内存开销会线性增长。实测下来单机 5000 QPS 的情况下QPS 限流的额外开销占整个请求处理时间的比例通常在 2% 以内属于可接受的范围。4.2 线程数限流的开销来源信号量与原子计数线程数限流的统计模型简单得多——只需要维护一个当前并发数的计数器。Sentinel 的实现里用的是AtomicInteger配合compareAndSet做递增// Sentinel 线程数限流的核心计数器逻辑 private final AtomicInteger currentConcurrency new AtomicInteger(0); public boolean tryAcquire(int count) { while (true) { int current currentConcurrency.get(); if (current count maxConcurrency) { return false; } if (currentConcurrency.compareAndSet(current, current count)) { return true; } } }注意这里不会真的创建一个java.util.concurrent.Semaphore对象而是用自旋 CAS 维护一个计数值。为什么这样设计因为真正的Semaphore内部有同步队列、许可管理等复杂逻辑在高并发下可能涉及线程挂起和唤醒开销远高于一个AtomicInteger的自旋 CAS。线程数限流的开销主要有两个每次请求的 acquire 和 release各是一次 CAS 操作开销极低。即使 10000 QPS 也就两万次 CAS纳秒级操作对整体性能几乎没有影响。无额外内存开销一个AtomicInteger4 个字节加上一个 int 阈值比滑动窗口省太多了。所以单从性能角度讲线程数限流比 QPS 限流更轻量。这个结论可能和不少人的直觉相反——大家总觉得线程数限流要跟踪每个线程很重实际上 Sentinel 只是维护一个并发计数并没有真的去监控每一个线程的生命周期。4.3 不同并发量级的实测对比参考用我个人在一个低配测试环境4C8G 虚拟机、OpenJDK 8、单接口无下游依赖跑过的对比数据作为参考压测并发QPS 限流开销额外耗时/请求线程数限流开销额外耗时/请求100 QPS约 0.05ms约 0.01ms1000 QPS约 0.08ms约 0.02ms5000 QPS约 0.15ms约 0.04ms这个数据不是严格基准测试但趋势很明确线程数限流在耗时和内存上的优势都更明显。不过要注意单机性能差异在实际生产环境通常不是决定性因素选型更应该看业务场景的匹配度而不是纠结这零点几毫秒的差距。5. 动态配置实操Sentinel 整合 Nacos 实现 QPS 和线程数限流热更新5.1 为什么推荐用 Nacos 做 Sentinel 规则持久化先回答一个常见问题Sentinel 的规则默认是存在内存里的应用重启就丢了。虽然控制台可以手动推规则但生产环境多实例部署时手动推送很容易漏发或者覆盖而且一旦应用重启规则就全部清空非常危险。用 Nacos 做规则持久化有几个实际好处规则可以走 Git 管理变更可审计回滚也方便。多个实例只要订阅同一个 dataId规则变更自动推送到所有节点不需要逐台操作。应用重启后会自动从 Nacos 拉取规则不用每次上线重新配置。我自己在多个项目里用的都是这种方式稳定跑了很久没有遇到过规则丢失的问题。5.2 引入依赖与基础配置假设你的项目已经接了 Spring Cloud Alibaba需要额外引入 Sentinel 数据源适配 Nacos 的依赖!-- Sentinel 核心依赖 -- dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-sentinel/artifactId version2021.0.5.0/version /dependency !-- Sentinel Nacos 数据源依赖 -- dependency groupIdcom.alibaba.csp/groupId artifactIdsentinel-datasource-nacos/artifactId version1.8.7/version /dependency如果你的项目没有用 Spring Cloud Alibaba而是原生 Spring Boot Sentinel那就单独引入这两个依赖dependency groupIdcom.alibaba.csp/groupId artifactIdsentinel-core/artifactId version1.8.7/version /dependency dependency groupIdcom.alibaba.csp/groupId artifactIdsentinel-datasource-nacos/artifactId version1.8.7/version /dependency版本方面我用的是 1.8.7目前生产环境也用的这个版本比较稳定。如果你用的是更新的 1.8.8 或 2.0 系列接口基本兼容但建议先看官方文档确认FlowRule类的变化。5.3 配置限流规则的 Nacos 样例Nacos 控制台里新建一个配置Data IDsentinel-flow-rules分组DEFAULT_GROUP配置格式JSON内容如下这里我同时写了 QPS 限流规则和线程数限流规则的样例[ { resource: submitOrder, limitApp: default, grade: 1, count: 1000, strategy: 0, controlBehavior: 0, clusterMode: false }, { resource: queryUserOrders, limitApp: default, grade: 1, count: 500, strategy: 0, controlBehavior: 1, clusterMode: false }, { resource: callDownstreamApi, limitApp: default, grade: 0, count: 50, strategy: 0, clusterMode: false } ]字段含义说明resource资源名对应代码里SphU.entry(resource)或SentinelResource(value ...)的名称。grade限流模式0表示线程数限流1表示 QPS 限流。最容易搞错的一个字段。count阈值。grade1时表示每秒最大 QPSgrade0时表示最大并发线程数。controlBehavior流量控制效果0直接拒绝1匀速排队仅 QPS 模式支持2预热模式。strategy流控策略0直接按资源本身限流1按关联资源限流2按链路入口限流。上面样例里我配置了三条规则submitOrder是 QPS 限流直接拒绝默认行为queryUserOrders是 QPS 限流匀速排队适合削峰callDownstreamApi是线程数限流保护下游依赖接口。实际项目中你可以按接口特征分别调整。5.4 Java 侧加载 Nacos 数据源代码在 Spring Boot 里配置一个FlowRuleDataSource的 Bean启动时让它从 Nacos 拉取规则并监听变更import com.alibaba.cloud.sentinel.datasource.converter.SentinelConverter; import com.alibaba.csp.sentinel.datasource.ReadableDataSource; import com.alibaba.csp.sentinel.datasource.nacos.NacosDataSource; import com.alibaba.csp.sentinel.slots.block.flow.FlowRule; import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.TypeReference; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.List; Configuration public class SentinelNacosConfig { Bean public ReadableDataSourceString, ListFlowRule flowRuleDataSource() { // 参数serverAddr, groupId, dataId, 转换器 // 注意这里的 groupId 是 Nacos 的配置分组dataId 是配置的 Data ID ReadableDataSourceString, ListFlowRule dataSource new NacosDataSource( 127.0.0.1:8848, DEFAULT_GROUP, sentinel-flow-rules, source - JSON.parseObject(source, new TypeReferenceListFlowRule() {}) ); FlowRuleManager.register2Property(dataSource.getProperty()); return dataSource; } }有一个容易踩坑的点如果你是 Spring Cloud Alibaba 版本推荐直接用它的SentinelDataSource注解方式代码可以更简洁import com.alibaba.cloud.sentinel.annotation.SentinelDataSource; Configuration public class SentinelNacosConfig { SentinelDataSource(sentinel-flow-rules) private ReadableDataSourceString, ListFlowRule flowRuleDataSource; }但有个前提Spring Cloud Alibaba 的SentinelDataSource只支持流控规则FlowRule如果你要配置降级规则DegradeRule、热点规则ParamFlowRule还是要手动写数据源并注册到对应的 Manager。我的做法是流控规则用注解其他规则手动注册各用各的方式互不干扰。5.5 规则变更验证与可视化配置好之后启动应用你会看到控制台日志里出现类似这样的输出[Sentinel] register property with property name: sentinel-flow-rules, data source: NacosDataSource接着去 Nacos 控制台改一条规则比如把count从 1000 改成 800保存后Sentinel 控制台对应资源的限流阈值会立刻变化不用重启应用。通过接口压测验证在 Nacos 把阈值调低到 10一秒钟发 20 个请求后者就有 10 个被限流证明动态生效。这里有个小经验Nacos 推规则到应用是最终一致模型正常情况下是秒级生效但极端情况下网络抖动、Nacos 短暂不可用会有延迟。核心链路别依赖实时性太强建议在业务侧做好兜底比如应用启动时加载本地备份规则。5.6 控制台与 Nacos 之间的规则一致性坑很多人会在 Sentinel 控制台上手动调整规则同时又配了 Nacos 数据源然后发现规则莫名其妙被还原。原因是Nacos 数据源注册后Nacos 成为规则的唯一事实来源控制台的修改只是改了内存态一旦 Nacos 推送一次哪怕内容没变内存态就被覆盖恢复成 Nacos 的配置。我的建议很简单选择了 Nacos 持久化就认准 Nacos 这一个配置入口。控制台只用来观测实时流量和规则生效情况不要在控制台改规则。有规则变更需求直接改 Nacos 配置走代码评审和发布流程这样更安全。6. 从压测到上线限流参数怎么定才靠谱6.1 先做容量评估再定阈值限流参数我始终坚持一个原则不压测、不设阈值。拍脑袋出来的数字哪怕是经验丰富的架构师也容易估算偏差。具体操作流程单机压测找拐点用压测工具慢慢增加并发观察 CPU、内存、RT、错误率。找到再往上加压RT 开始线性暴涨、错误率上升的那个点这就是系统的软极限。按软极限打折QPS 限流取软极限的 70%80%线程数限流取线程池容量的 70%80%比如 Tomcat 200 线程就设 140160。预留高峰期 buffer结合业务日常峰值、大促峰值预留 30% 左右的余量。限流的作用是防系统过载不是把系统永远压在 100% 负载。灰度验证先在低流量机房或少量节点上线规则观察一周确认没有误杀再全量。6.2 压测环境容易忽略的点慢请求制造很多人压测用的是固定并发短请求这对 QPS 限流参数的定标来说其实不够。我建议专门做两轮压测第一轮常规压测也就是大家都做的测出正常耗时下的 QPS 基线。第二轮慢请求压测用工具给部分请求注入延迟比如通过 mock 下游增加 2s 延迟看看 QPS 限流模式下线程池会不会被打满。这一步非常能说明该不该上线程数限流。我试过一个小实验单机 4C8GTomcat 默认 200 线程一个接口正常耗时 30msQPS 限流设 1000压测时平均 RT 到了 3000ms——结果线程池耗尽P99 飙到 5000ms大量请求超时。后来给这个接口加了线程数限流 150同样模拟慢请求P99 稳定在 800ms 以内错误率也大幅下降。6.3 限流后的响应处理别让客户端一脸懵限流触发后Sentinel 默认抛BlockException如果你的全局异常处理器没接住用户看到的就是一堆堆栈信息非常不友好。实际开发中建议统一处理import com.alibaba.csp.sentinel.slots.block.BlockException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; ControllerAdvice public class SentinelBlockHandler { ExceptionHandler(BlockException.class) ResponseBody public String handleBlock(BlockException e) { // 这里可以根据 e.getRule().getGrade() 判断是 QPS 限流还是线程数限流 int grade e.getRule().getGrade(); String mode (grade 0) ? 并发数超限 : 访问量超限; // 业务侧可以返回一个友好的 JSON比如 {code: 429, message: 系统繁忙请稍后再试} return {\code\:429,\message\:\系统繁忙( mode )请稍后再试\}; } }还有一点限流返回的 HTTP 状态码最好用 429Too Many Requests别用 500。500 会让上游误以为系统故障而触发重试风暴429 则明确表达请求过多合理的调用方会做退避。如果你用的是 OpenFeign 或 Dubbo 调用也要保证调用方对 Block 结果有兜底处理比如降级缓存或走备用通道。6.4 压测工具推荐与参数参考常规压测工具我常用的三款JMeter老牌工具适合多协议压测脚本复用性好。wrk轻量级 HTTP 压测工具压单接口很方便秒级就能看结果。Locustpython 编写适合编排复杂场景模拟不同用户行为。压测参数参考单机 4C8G一个简单 HTTP 接口# wrk 压测示例8 线程模拟连接持续 30 秒逐步加并发观察 wrk -t8 -c100 -d30s --latency http://your-service:8080/api/test wrk -t8 -c200 -d30s --latency http://your-service:8080/api/test wrk -t8 -c500 -d30s --latency http://your-service:8080/api/test把三次结果的 QPS、RT、错误率记录下来绘制一条并发 vs QPS的曲线拐点就是容量极限。7. 我们项目最终采用的配置方案与后续扩展最后分享一下目前项目里的实际用法可以当成参考模板。核心交易链路的三个接口配置如下接口模式阈值原因下单提交submitOrderQPS 线程数双限流QPS 800线程数 150核心入口既要防瞬时峰值又要防下游变慢拖死线程池订单查询queryOrderQPS 限流2000纯查询依赖的 Redis 和 DB 性能稳定外部物流状态回写logisticsCallback线程数限流60外部依赖不可控只用线程数保护配置方式规则统一放 Nacos由代码仓库管理改动走 MR Review。监控方面Sentinel 控制台显示实时流量另外把 Blocked QPS 指标接入 Prometheus Grafana设置了告警当某资源 Blocked QPS 超过正常流量的 10% 时告警避免限流都默默发生了还没有人发现。后续扩展方向我在考虑两件事熔断降级细化目前只做了限流降级规则慢调用比例、异常比例还没完全铺开下一步想对依赖的下游接口逐个加上。热点参数限流下单接口里某些热门商品 ID 的流量远超其他商品单靠资源级 QPS 限流不够精细热点参数限流可以只对个别 key 生效更精准。根据我在实际项目中的体会QPS 限流和线程数限流不是二选一的关系更像是入口守门员和内部容量保护者的分工。初期系统简单、接口耗时稳定时QPS 限流完全够用当系统复杂度上来了外部依赖一多线程数限流几乎成了必备。配置前多做一轮压测上线后盯几天监控你会发现这两道防线配好了系统的稳定性会有一个非常明显的提升。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。