
把任务塞进队列几乎是每个后端系统都会做的“省事操作”。接口慢了塞队列异步掉上游抖动了塞队列缓冲一下写库怕并发太高塞队列削削峰。但“会塞”不等于“会控制”很多线上事故恰恰是队列本身被塞爆了积压、超时、内存上涨、拒绝执行一串连锁反应最终把整个服务拖垮。我排查过不少这类问题最后发现十有八九不是队列不够大而是对背压、限流和削峰这三件事缺了整体认知。这篇文章不打算给一堆抽象理论我想从实际问题出发结合参数计算和配置过程把这件事彻底聊透。1. 先搞清楚队列到底是怎么被撑爆的1.1 三个典型现场同一个本质第一个现场是消费者变慢。数据库某个慢查询突然出现或者下游接口响应从 30ms 涨到 300ms消费者处理单条任务的时间拉长了队列里的任务自然只进不出。这个时候你去看线程池监控会发现工作线程全部都处于运行状态但吞吐量反而在掉。第二个现场是流量突增。平时 200QPS 的接口某次活动或者某个热点事件直接冲到 3000QPS生产者不再是你平时看到的节奏而是几倍地往里塞任务。消费者还是原来的速度在消费队列长度肉眼可见地往上涨最终击穿上限。第三个现场是配置不匹配。线程池核心线程设成 5最大线程设成 10队列却给了 10000 的容量。这种配置平时看起来很稳但流量稍微抖动一下你会发现 10000 的队列很快就被填满而且由于队列没有满之前线程池根本不会扩容额外多出来的流量全部压在队列里排队。等到队列慢又是另外一回事了。这三个现场表面上看原因各不相同本质却只有一个生产速率大于消费速率而且这个差值缺乏有效的上限控制。队列在其中扮演的角色其实就像一个压力容器。任务进入的速度快出去的速度慢容器内部的积压量就会持续增大。只要生产端不感知下游的真实处理能力任何容量都有被填满的那一天。1.2 用公式提前判断队列会不会爆判断一个队列会不会被撑爆不要靠感觉拿笔算一下。核心公式其实很简单积压量 Q(t) Q(0) (生产速率 P - 消费速率 C) × t假设某个接口目标是支持 500QPS消费者平均处理一条任务需要 50ms那么消费端需要多线程数这里用 Littles Law并发数 QPS × 平均耗时。算出来就是500 × 0.05 25也就是说至少需要 25 个工作并发才能稳住这个吞吐量。如果你只给了 10 个线程单条处理耗时从 50ms 涨到 200ms消费速率就变成10 / 0.2 50QPS。生产端 500QPS消费端 50QPS每秒积压 450 条。想缓冲 30 秒就需要450 × 30 13500条容量的队列。这些任务背后往往还挂着 HTTP 连接、数据库会话、业务上下文算上这些隐性的资源开销队列越长系统承受的不只是内存压力还有平均等待时间被拉长、请求超时变多、整个链路雪崩的风险。所以队列容量永远不能只从“放得下多少”这个角度去设计。比容量更重要的是积压数据会在多短时间内涨满以及涨满之后系统有没有正确的应对姿势。这就是背压、限流和削峰要解决的问题。2. 背压让慢吞吞的下游把压力传回给上游2.1 背压其实每天都在发生背压这个概念听起来很高级其实你在日常生活里天天都在体验。你排队买咖啡前面的人处理不过来队伍越排越长后面来的顾客看到长队可能会离开或者干脆不再进店这就是一种背压——下游处理速度反向影响到了上游的进入速度。技术里最经典的背压案例是 TCP 的流量控制。传输速度不是由发送方单方面决定的而是取决于接收方的接收能力。接收方会通告自己的窗口大小长期没有能力接收更多数据时发送方就会停下来等待。这样做的目的是不让任何一端因为处理速度不匹配而崩溃。业务系统里的队列恰恰经常破坏这种机制。生产者把任务丢进队列就算完事完全不去管消费者有没有能力接收任务也不会像 TCP 那样收到“接收方窗口已满”的信号。结果就是生产者和消费者之间形成了一根单向管道下游的问题被队列掩盖住直到把整条管道撑破。2.2 代码里怎么落背压有界队列加拒绝策略想在代码层面实现背压第一件事就是不要让队列无界。无界队列就像没有窗口控制的 TCP内存可以无限增长等 GC 顶不住或者系统 OOM事故就来了。用有界队列再用拒绝策略去做限流才算给了下游一个反馈窗口。拿 Java 里的ThreadPoolExecutor举例一个比较典型的配置是这样的ThreadPoolExecutor executor new ThreadPoolExecutor( 10, // 核心线程数 25, // 最大线程数 30L, TimeUnit.SECONDS, new ArrayBlockingQueue(500), // 有界队列容量 500 new ThreadPoolExecutor.CallerRunsPolicy() );这里的关键不是数字而是CallerRunsPolicy。阻塞队列满并且线程数达到上限时线程池不会简单地把任务丢掉而是让提交任务的那个线程自己去执行它。假设这个任务是从 Tomcat 线程池里提交进来的那这个线程就会停下手里原本要做的事转而去执行队列里余出的任务。这会让请求卡的这里线程等待于是 HTTP 连接池里的请求也会积压。这种阻塞会一级一级往上传递一直到最源头的调用方这其实就是背压。有人会问那直接用AbortPolicy把任务丢掉岂不是更省事省事是省事但丢任务意味着业务损失。如果任务有补偿机制倒还好没有补偿机制的话最可靠的方式就是让压力沿着链路传导回去让上游感知到系统的真实承受能力从而做出重试、降级或者退避的决策。2.3 背压的边界它不能解决所有排队问题需要注意的是背压控制的是“队列满了怎么办”但它没法解决“本来就不该进来的流量”。在网关层面成千上万的请求同时涌过来进程即使把压力传给上游也未必能让调用方降低速率更别说很多调用方根本不会处理这种反馈。所以背压通常只是系统的最后一道兜底入口处还得配合限流把超过能力的请求提前挡在外面。3. 限流与其等队列满不如在入口把水龙头拧小3.1 令牌桶和漏桶到底该怎么选限流最常见的两种算法是令牌桶和漏桶。漏桶的思路是不管水流多大底下漏水的速度始终固定超出的部分只能排队或者丢弃。令牌桶的思路则不同桶里以固定的速率生成令牌请求必须拿到令牌才能通过但桶里最多可以攒一定数量的令牌所以支持一定的突发流量。做一个简单的对比算法核心思路突发处理适合场景漏桶流出速率恒定输入多快都没用不支持突发均匀输出下游能力极固定必须严格匀速令牌桶按速率补充令牌有令牌就能过支持一定的突发窗口大多数对外 API允许短时间冲高很多系统用令牌桶是因为现实流量天然带突发性。比如秒杀开始后的前几秒请求量会瞬间冲到平时的几十倍但 MySQL 或下游服务往往能承受小周期的峰值。令牌桶让系统在突发窗口内放行一部分超额流量同时限制平均速率。这里给一个单机版令牌桶极简实现方便理解核心逻辑import time import threading class TokenBucket: def __init__(self, capacity, fill_rate): self.capacity capacity # 桶的最大容量决定突发上限 self.fill_rate fill_rate # 每秒补充的令牌数 self.tokens capacity self.lock threading.Lock() self.last_refill time.monotonic() def acquire(self, need1): with self.lock: now time.monotonic() # 先结算这段时间新增的令牌但不超过桶容量 self.tokens min( self.capacity, self.tokens (now - self.last_refill) * self.fill_rate ) self.last_refill now if self.tokens need: self.tokens - need return True return Falsefill_rate就是期望的每秒请求数capacity决定允许的突发请求数。容量设为 50意味着桶空的时候最多只能突然吃掉 50 个请求后面就必须等令牌慢慢补回来。这个参数直接影响限流的松紧程度绝不能随便填。3.2 限流参数不是拍脑袋要根据下游能力算限流限多少是一个容量评估问题。假设下游数据库稳定的写入能力是 600 QPS单次写操作平均耗时 150ms那需要的并发连接数为600 × 0.15 90。如果你的接口下游就是这套数据库入口限流就应该控制在 600 QPS 附近而处理线程池的最大并发不要低于 90否则即使限流到 600线程池自身也会成为新的瓶颈。很多人犯的错误是入口限流定了 2000消费者线程却只有 10。10 个处理线程就算全速运转假设每条任务耗时 20ms撑死也就是1000 / 20 50 QPS。入口放进来 2000消费者只能啃掉 50剩下的全积压在队列里。限流设得再好看消费者跟不上照样是爆炸。所以配置限流参数有一个基本顺序先测出消费者和下游的真实处理能力再倒推入口的 QPS 上限两者要留出合理的余量。余量不是越大越好太大的余量等于没限流太小的余量又会损失吞吐。3.3 限流之后请求往哪儿走请求被限流拦住后一般有三条路可以走。第一条是直接拒绝返回一个明确的错误码调用方收到后自行决定是否重试。这是最简单也最安全的方式但前提是业务允许失败。对外 API 通常配合 HTTP 429 状态码调用方见了就知道是限流而不是服务故障。第二条是排队等待。请求不放弃而是进入一个容量有限的等待队列等系统有能力时再继续处理。这其实就是削峰的雏形但等待队列必须有界且带超时控制否则它就会变成一个新的爆点。第三条是服务降级也就是用低性能但可用的替代方案来响应请求。比如实时算积分不了就先从缓存里读一个近似值写不了主库先写消息队列做异步补偿。降级不是欺骗用户而是用百分之八十的正确性换取整个系统在压力下的可用性。这三条路的取舍标准只有一个业务能不能接受延迟以及能不能接受一定比例的失败。能接受延迟的优先排队能接受失败的直接拒绝什么都接受不了的只能做强依赖链路规划和扩容而不是靠限流硬扛。4. 削峰把尖锐的峰填进水库让下游按自己的节奏干活4.1 削峰不是堵峰是延峰很多人误解了削峰以为削峰就是把流量堵住让上游少发请求。其实削峰的本质不是减少总流量而是把高峰期的流量在时间维度上拉平让系统在较长的时间段内以相对平稳的速率去处理这些请求。拿现实生活来说高铁站安检口前面都有蛇形排队通道。高峰期一下来了 1000 人检票口不会要求这 1000 人同时进去而是让他们在通道里排队保持每个检票口都能持续稳定地工作。人还是那 1000 人但高峰的强度被时间和空间消化了。这就是削峰。业务系统里的削峰最常见的做法就是把高峰期直接同步处理的请求转换成异步任务放进队列或者消息中间件由后台消费者按自己的节奏慢慢处理。用户的请求此时得到的是一个“已受理”的响应而不是“已完成”的响应。4.2 秒杀类场景是怎么做削峰的用一个常见的活动系统举例。某个积分发放接口平时 200QPS活动开始瞬间涌入 3000QPS但底层的数据库只能稳定支撑 600QPS而且业务可以接受延迟最多 10 秒才给出结果。按削峰思路流程可以这样设计API 入口加令牌桶限流平均速率限到 600 QPS容量允许突发放行 300 个请求。超出的请求进入一个容量 6000 的有界等待队列。3000QPS 中只会漏掉 600剩下的 2400 进入队列以 600 QPS 的速度被消费等待时间在 4 秒到 10 秒之间业务可接受。消费者线程池满负荷运行稳定按照 600 QPS 的速率去写数据库不再受瞬时流量影响。用户端收到“发放处理中”的响应真正的结果通过回调或轮询接口告知。这种设计下数据库看到的不再是吓人的 3000 峰值而是一条稳定的 600 速率直线。下游服务不会被打挂用户体验也是可接受的因为系统用 10 秒以内的延迟换回了整个链路的稳定性。4.3 削峰只能削“等得起”的业务削峰并不是万能药。如果一个请求要求用户在 200 毫秒内拿到最终结果比如在线订单支付、实时余额查询那这条路基本走不通。异步化等于把结果延迟到了用户不可接受的时间此时只能采用另一种思路提前扩容或者把热点数据前置预热。扩容可以简单理解成把检票口从 5 个临时加到 30 个让峰值期间的处理能力也临时提高。预热则是在活动开始前把热点商品、库存、价格等信息提前加载到缓存让高峰期的大多数请求在缓存层就可以返回根本不需要落到数据库。所以做削峰之前先想清楚一个问题发起请求的这个人能不能接受“晚一点再告诉你结果”。能接受就异步化不能接受就得在容量和时效性之间找一个平衡点。妄想用一条队列解决所有延迟敏感问题最后一定会被用户体验反噬。5. 实操记录一次压测把配置调到位的全过程5.1 背景和初始数据我接手过的一个积分发放服务就是典型的被队列撑爆的案例。服务平时 QPS 不高但每个月有两次运营活动活动开始那几分钟流量直线冲高。当时线上配置是线程池核心 5最大 10队列容量 20000。活动开始时队列积压飞快内存随之上涨用户端的请求大量超时最后只能临时重启服务来清空队列。我拿到问题后没有直接改配置而是先做了一次压测。压测模型设定瞬时 3000 QPS持续 60 秒后台任务单次平均执行耗时约 150ms底层数据库实测可承受大约 600 QPS 的稳定写入。先算一个总数。如果没有限流3000 QPS 的请求全部进入同步处理单任务耗时 150ms需要的并发线程数是3000 × 0.15 450。线上只有 10 个处理线程当然不可能扛住任务只能无限堆到队列里。5.2 三轮调整配置逐渐收敛第一轮我只是把线程池最大线程从 10 调大到 50队列还是 20000。压测结果比之前略好但依然不稳。50 个线程全速运行理论吞吐才50 / 0.15 333 QPS而入口仍然放进来 3000 QPS队列照样快速积压。内存没有爆是因为压测时间短但任务的等待时间已经拉到了几十秒超时率飙升。第二轮我在入口加入了令牌桶限流平均速率控制在 600 QPS同时把消费者线程数继续调大到 90因为600 × 0.15 90。线程池的队列容量从 20000 砍到 6000超出的请求直接进入等待队列或者在入口拒绝。这轮压测结果明显好转核心观察指标有三个入口放行的请求速率稳定在 600任务平均排队时间降到 4 到 5 秒没有出现线程池拒绝异常。第三轮我做了一个优化把数据库写入从单条提交改成批量提交每次凑够 20 条一起执行。这样每个消费者线程一次写 20 条有效写库次数变成原来的二十分之一线程数不需要 90 个那么多我最终压测定在 30 个同时留了余量给网络抖动和偶发慢 SQL。最终压测数据大概是这样一个对比配置项初始配置二次优化后最终配置入口令牌桶速率未限流600 QPS600 QPS突发容量无300300消费者线程数109030等待队列容量2000060006000单任务数据库写入单条单条批量 20 条压测结果队列积压超时稳定等待约5秒稳定等待约5秒资源占用更低5.3 配置顺序比配置本身更关键改配置的顺序不是随意的我强烈建议按照这个顺序来第一步先测下游和消费者的真实处理能力拿数据说话。第二步配置消费者线程数保证它能吞吐你预期的 QPS。第三步配置入口限流让进入系统的流量不超过消费者能力。第四步最后才设置队列容量队列只用来吸收短时间的流量抖动不负责扛长时间的积压。我见过太多人顺序反过来先把队列调得老大限流和线程池都没动结果还是一样被打爆。队列不是盾牌它只是缓冲带真正的防线是限流器和消费者自身的吞吐能力。6. 常见问题与排查技巧实录6.1 症状速查表线上遇到类似问题的时候先对照症状再决定从哪里入手排查现象可能原因排查方向队列持续积压CPU 不高消费者被外部依赖阻塞如数据库锁、远程调用慢看消费者线程的阻塞点和等待时间别急着扩线程队列积压线程池线程全忙消费者处理耗时过长吞吐不足计算实际 QPS 与线程数是否匹配队列满但还有空闲线程有界队列设置过小任务堆积但未触发扩容观察队列深度和最大线程数的关系大量任务被丢弃但日志不明显拒绝策略设置不合理缺少丢弃监控检查拒绝策略和丢弃计数指标入口限流了服务压力仍然大限流只挡了一部分热点数据穿透到了下游检查响应时间和缓存命中率判断队列问题我一直用的三个指标是生产 QPS、消费 QPS、队列深度。这三个指标放在一张监控图里看问题出现在哪一段通常一目了然。如果生产 QPS 远高于消费 QPS那就优先提升消费能力或调低限流如果两个速率接近但队列仍在积压那要从资源竞争和锁等待去排查。6.2 排查背压的三条有效路线第一条路线是主动验证下游能力。手动把处理线程数降为原来的三分之一让消费者速度慢下来观察上游限流器是否真正触达。如果降速之后入口限流还按原来的速率放行那背压链路就算没建立起来及时补上很重要。第二条路线是模拟消费者阻塞。用一个模拟任务去暂停消费者线程观察有界队列是否触发拒绝策略再看被拒绝的任务有没有进入补偿机制。这一步能发现很多“假背压”队列满了是满了但拒绝策略直接把任务丢了而且没有任何记录。第三条路线是检查等待队列和超时机制的配合。限流之后的排队等待必须带超时否则等待队列本身会成为新的爆点。线上排查时不要只看队列当前长度还要看队列里的任务最大等待时间。只要等待时间在可接受范围内队列长一点是可以容忍的一旦等待时间超限再长的队列也只是推迟灾难的发生。6.3 几个容易被忽视的小细节我还想提醒三个细节。第一限流器和线程池的指标要分开监控。入口限流值设置合理不代表线程池吃饱了可能线程都在等锁、等 IO看起来忙但其实没用。第二队列拒绝事件必须埋点统计。很多系统用了AbortPolicy却不记录丢弃次数等到业务反馈丢数据了才去翻日志这种排查成本很高。无论你用哪种拒绝策略都要把触发次数、触发时间、任务来源打上可观测的日志。第三扩容不能只靠调大线程数。线程数调大之后数据库连接池、下游连接数等配套资源也要跟着调大否则线程全卡在获取连接上整体吞吐反而下降。线程数提升之前先把连接池的上限算好。最后聊点实在的我在实际做这类系统的时候不会太纠结于把每一条队列配置调到最优因为流量模型总会变追求“最优”不现实。我给自己定的验收标准有三个第一入口限流能不能在流量超限后稳定工作第二消费者吞吐能不能匹配入口放行的速率第三队列积压的最坏情况下等待时间是否落在业务可接受的范围内。这三点在压测里验证通过我才会认为这套配置是可靠的。这套方法帮我扛过好几次流量高峰希望也能帮到正在为队列加班排查的你。