大模型推理服务的吞吐量上不去,绝大多数时候不是模型本身的问题,而是调度策略把它拖住了。我见过不少团队在单卡上跑一个7B模型,显存明明还剩一大截,QPS却死死卡在个位数,调大batch size也没用——因为请求是一个一个来、一个一个走的,GPU大部分时间在等。这就是Continuous Batching要解决的问题。
这篇内容围绕Continuous Batching(连续批处理)展开,讲清楚它为什么能把推理吞吐量拉高几倍甚至十几倍、底层调度的核心逻辑是什么、自己动手实现时哪些细节最容易被忽略、以及上线后哪些指标才是真正该盯的。无论你是刚接触推理优化、只听说过vLLM这类框架名字的新手,还是已经在做推理服务、想搞明白调度器内部怎么转的工程师,都能从下面这些拆解里拿到可以直接复现的东西。全文以"一个请求在调度器里到底经历了什么"为主线,把原理、实现、踩坑和调优串起来讲。
1. 先搞清楚静态批处理为什么注定浪费算力
要理解Continuous Batching的价值,得先看清它要取代的东西有多低效。很多人第一次做推理服务,用的都是最直觉的方式:攒一批请求,凑够N条,一起送进模型,等这一批全部生成完,再收下一批。这个做法在教科书里叫静态批处理(Static Batching),在早期推理框架里非常普遍。
1.1 一个被"最短请求"拖死的批次
静态批处理最大的结构性缺陷在于:一个batch的完成时间,由batch里最长的那个请求决定。假设一批里同时进来了四条请求,输出长度分别是20、50、200、800个token。GPU必须等到那条800 token的请求吐完最后一个token,整批才算结束,才能释放资源接收新请求。前面三条早就在几十、几百步之前就结束了,但它们占用的显存槽位一直空转,不能还给调度器。
这个浪费有多严重,算一笔账就明白了。假设单步解码(也就是生成一个token)耗时固定为10毫秒,那么:
| 请求 | 输出长度 | 实际需要时间 | 被强制占用时间 | 空转占比 |
|---|---|---|---|---|
| A | 20 token | 200 ms | 8000 ms | 97.5% |
| B | 50 token | 500 ms | 8000 ms | 93.75% |
| C | 200 token | 2000 ms | 8000 ms | 75% |
| D | 800 token | 8000 ms | 8000 ms | 0% |
四条请求,GPU真正干活的时间只有200+500+2000+8000=10700毫秒的等价工作量,但总占用时长是4×8000=32000毫秒。也就是说,算力利用率不到34%。如果batch更大、长度分布更离散,这个数字还会继续往下掉。真实业务里的输出长度分布恰恰非常离散——有人问一句"今天几号",输出5个token;有人让模型写一篇长文,输出两三千token。把这两类请求塞进同一个静态batch,短的必然陪跑。
1.2 显存槽位和计算槽位是两回事
理解这个问题还需要区分两个概念,很多资料混着讲,容易把人绕晕。
- 显存槽位(KV Cache slot):每个请求在prefill阶段要为所有输入token算好Key和Value并缓存起来,解码阶段每生成一个token,还要往缓存里追加一份。这部分显存是按"槽位"预分配的,一个请求占一个槽位,请求不结束,槽位不释放。
- 计算槽位(compute slot):GPU的并行计算单元在每个解码步能同时处理多少条序列,取决于batch size和显存带宽。
静态批处理的问题在于,它把这两个槽位强行绑死了:一个请求结束前,既占着显存槽位,又占着计算槽位。可实际上请求A在第20步就没事干了,它完全可以把计算槽位让出来给别人用,只保留自己已经生成的KV Cache(虽然它其实也不需要保留了)。Continuous Batching的核心洞察就在这里——把"是否还在计算"和"是否还占着显存"解耦,让每个解码步都能重新决定谁能上GPU。
1.3 请求到达的泊松特性放大了浪费
还有一个常被忽视的维度:请求到达不是均匀的。真实线上流量往往呈现明显的突发性,早高峰和低谷的QPS能差一个数量级。静态批处理在这种场景下会陷入两难:批次攒小了,GPU喂不饱;批次攒大了,排队延迟爆炸,而且用户等半天等来的还是一个被长请求拖住的批次。
我在一个模拟项目里做过压测,同一批流量走静态批处理,P99延迟是同吞吐下Continuous Batching的3到4倍,原因就是尾部请求总是被前面的批次卡住。所以静态批处理不只是"吞吐低",它的延迟分布也更难看,只是很多人只盯着平均值没发现。
2. Continuous Batching的调度内核到底在做什么
抛开各种框架的实现差异,Continuous Batching的调度逻辑可以浓缩成一句话:每一个解码步(decode step)都是一次全新的组批机会。这就是它名字里"continuous"的含义——批次不是固定的,而是连续流动的。
2.1 从迭代级调度看请求的完整生命周期
我把一个请求在调度器里的路径拆成五个阶段,按这个顺序走一遍,比看任何架构图都清楚:
- 入队(Enqueue):请求到达后被放进waiting队列,携带自身的输入token、采样参数、目标输出长度上限等元信息。
- 预填充(Prefill):调度器在某个解码步决定接纳它,把它和同批的其他新请求一起做一次前向,算出全部输入token的KV Cache并写入显存池。这一步是计算密集型的,token数量大,耗时明显高于后续单步解码。
- 解码(Decode):进入running队列,每个解码步只输入上一个生成的token,输出下一个token,同时把新的KV追加进缓存。
- 逐出(Preempt):如果显存池不够了,调度器会把某些请求踢回waiting队列,释放其KV空间,等有空间了再重新prefill。这一步是Continuous Batching实现里最容易被写得很难看的部分,后面单独讲。
- 完成(Finish):命中EOS(结束符)或达到最大长度,释放槽位,把结果返回。
关键在于第2、3、4步之间没有"批次边界"这个概念。每一步开始前,调度器都重新扫一遍running队列和waiting队列,算一笔账:现有running请求的KV占用多少、还剩多少显存块、能塞进几个新请求、塞进来的prefill开销会不会把这一步的延迟拉爆。算完再决定"这一步跑谁"。
2.2 KV Cache分页:让槽位可以像内存一样碎片化利用
如果KV Cache还是按"每个请求预分配最大长度的连续显存"来管,Continuous Batching根本跑不起来——因为没人知道一个请求最终会生成多长,按最大值预分配等于提前把显存锁死,动态调度也就失去了意义。
真正的解法借鉴了操作系统的虚拟内存分页思想,业界一般叫PagedAttention。核心做法是把KV Cache切成固定大小的块(block),比如每个block装16个token的KV。一个请求的KV不再需要连续存放,而是维护一张"逻辑块到物理块"的映射表,物理块从全局块池里按需分配。
这个设计带来三个直接好处:
- 按需增长:请求生成到第17个token时,才去申请第二个block,显存占用跟着实际长度走,不再有预分配浪费。业界实测这种做法的显存浪费率能从60%以上压到4%以下。
- 块可复用:系统提示词这类多个请求共享的前缀,其KV块可以被多个请求的映射表同时指向,只存一份。对于带长系统提示的场景,这一项能省下相当可观的显存和prefill计算。
- 碎片可控:块大小固定,物理块池的分配和回收就是简单的位图或空闲链表操作,不会出现"有空间但凑不出连续大块"的经典碎片问题。
提示:block size不是越大越好。块太大,一个短请求也要占满一整块,尾部浪费明显;块太小,映射表变长,调度器每次要遍历的条目变多,CPU侧开销上升。常见的取值在8到32之间,需要按你的平均输出长度和并发量实测对比。
2.3 Chunked Prefill:把长输入的大计算拆开摊平
纯Continuous Batching还有个隐患:prefill是"要么全做,要么不做"的。一个两万token的长输入请求被接纳时,它的一次prefill就要吃掉大量算力,导致同一步里其他正在解码的请求全部被拖慢,P99延迟出现尖刺。用户体感就是"我聊得好好的,突然卡了一下"。
Chunked Prefill(分块预填充)就是补这个洞的。它把一个长prefill按token数切成若干chunk,每个解码步只处理一个chunk,剩下的留到后续步骤继续。这样每个解码步的总计算量更平稳,解码请求不会被长输入突然挤占。
代价是长请求自己的首token延迟(TTFT)会变长,因为它的prefill被摊到了多步。所以这里有个明确的调参取舍:你要的是TTFT低,还是ITL(相邻token间隔)稳。线上一般配置一个单步token预算上限,比如每步最多处理2048个token,新请求的prefill按剩余预算切块进入,超出的部分顺延。这个预算值直接决定了延迟曲线的形状,值得花时间压测。
2.4 调度器每一步要做的三个决策
抽象一下,调度器每个step其实就在做三件事,把这三件事的判定顺序理清楚,自己写调度器就不会乱:
| 决策 | 判定依据 | 常见策略 |
|---|---|---|
| 这一步要不要跑新请求的prefill | 剩余token预算、显存块余量、waiting队列是否非空 | 优先保解码,预算有余再插prefill |
| 这一步要不要逐出running请求 | 显存块池是否耗尽、是否存在可回收空间 | 逐出KV占用最大或最久未推进的 |
| 每步实际batch大小 | 上述两者的结果、硬件并行度上限 | 动态,不设死上限,靠预算约束 |
这三件事的判定顺序很关键。我的做法是先算显存能不能容纳现有running集合,再算token预算够不够插prefill,最后才决定逐出。顺序反了会出现"刚逐出一个请求,下一步发现其实不用逐出"的抖动,白白多了一次重新prefill的开销。
3. 自己动手搭一个能跑的最小调度器
知道原理和写出来能跑,中间隔着一堆具体问题。这一节按"从零到能压测"的路径走,给出可落地的结构设计和关键代码片段。语言用Python描述逻辑,实际生产环境的调度器多用C++或Rust写以压低CPU开销,但逻辑是同一套。
3.1 数据结构设计:三个队列加一个块池
先把核心数据结构定下来,这是整个实现的地基。
from dataclasses import dataclass, field from typing import List, Optional @dataclass class Sequence: seq_id: int prompt_ids: List[int] output_ids: List[int] = field(default_factory=list) # 逻辑块 -> 物理块 的映射 block_table: List[int] = field(default_factory=list) # prefill 是否完成 prefilled: bool = False # 本轮 prefill 已处理到第几个 token prefill_cursor: int = 0 max_new_tokens: int = 256 finished: bool = False class BlockPool: def __init__(self, num_blocks: int, block_size: int = 16): self.block_size = block_size self.free_blocks = list(range(num_blocks)) self.used = set() def allocate(self, n: int) -> Optional[List[int]]: if len(self.free_blocks) < n: return None got = self.free_blocks[:n] self.free_blocks = self.free_blocks[n:] self.used.update(got) return got def release(self, blocks: List[int]): for b in blocks: if b in self.used: self.used.remove(b) self.free_blocks.append(b)三个队列分别是waiting(待prefill)、running(解码中)、finished(已完成待回收)。请求进入waiting后,只有在调度器判定"显存够 + token预算够"时才会被搬进running。这里我特意把prefill_cursor放进Sequence里,是为了支持前面说的chunked prefill——它不是一次性把prompt喂完,而是记住进度,分步推进。
有个细节值得强调:block_table必须是逻辑到物理的映射,而不是物理块编号的列表。因为你后面要支持前缀共享,多个Sequence的block_table里会指向同一个物理块,回收时靠引用计数判断能不能真正释放。如果偷懒直接存物理块列表,共享和回收都会出错。
3.2 单步调度循环的骨架写法
调度主循环是这套东西的心脏,写清楚它的顺序,比堆功能重要得多。
def schedule_step(running, waiting, block_pool, token_budget): # 第一步:先看 running 集合能不能装下,装不下就逐出 need = sum(estimate_new_blocks(seq) for seq in running) while need > len(block_pool.free_blocks): victim = pick_victim(running) # 选一个逐出 release_seq(victim, block_pool) running.remove(victim) waiting.insert(0, victim) # 放回队首,优先重入 need = sum(estimate_new_blocks(s) for s in running) # 第二步:在剩余预算内插入新的 prefill budget_left = token_budget prefill_batch = [] while waiting and budget_left > 0: seq = waiting[0] chunks = min(len(seq.prompt_ids) - seq.prefill_cursor, budget_left) blocks_needed = blocks_for_tokens(seq.prefill_cursor + chunks, block_pool.block_size) if block_pool.allocate(blocks_needed - len(seq.block_table)) is None: break # 显存不够,这一步不插了 seq.prefill_cursor += chunks budget_left -= chunks if seq.prefill_cursor >= len(seq.prompt_ids): seq.prefilled = True running.append(waiting.pop(0)) prefill_batch.append(seq) decode_batch = [s for s in running if s.prefilled] return prefill_batch, decode_batch这段骨架里有三个地方最容易写错,逐个说。
第一处是逐出的选择策略。pick_victim选谁很有讲究。选错会导致频繁重入、反复prefill。我一般的策略是:优先逐出KV块占用最多的请求(腾出的空间最大),但如果所有请求的占用都差不多,就选"最近刚被逐出过"的,避免同一个请求来回被踢。更稳的做法是给每个请求记一个"已等待步数"的优先级,逐出时选等待最久的、或者按LIFO逐出(后进先出),因为后进的请求往往输出还没开始,重算代价小。
第二处是逐出后的重入位置。看代码里waiting.insert(0, victim),放队首不是随手写的。被逐出的请求已经浪费了一次prefill,如果还排到队尾重新等,用户感知的延迟会非常难看。放队首让它尽快重入,虽然会牺牲一点公平性,但对P99延迟的改善非常明显。
第三处是分配失败时的处理。代码里显存不够就break,这一步不再插新请求。但这里要小心一个陷阱:如果waiting头部那个请求特别长,一直分配不到足够的块,它会永久堵住队首,后面的短请求全被饿死。这是个经典的队头阻塞问题,解决方式是允许跳过队首、尝试队列中其他能装下的请求,或者对超长请求做长度上限校验后拒掉。
3.3 逐出与重入的账:它到底有多贵
很多人讨论Continuous Batching时把逐出当成免费的,实际上它有明确代价,算清楚才能定策略。
被逐出一个请求,意味着它已经生成的KV全部作废,重新进来时要从头prefill整个prompt加上已经生成的输出。假设一个请求prefill了1000个token,生成到第300个token时被逐出,那么重入时prefill的量变成1300个token。按前面10ms/token的粗算,这一下就多花十几秒的等价算力(实际prefill是并行的,耗时会小很多,但token量级的浪费是实打实的)。
更麻烦的是抖动。逐出频繁发生时,系统会出现周期性的吞吐塌陷:逐出N个—重新prefill—再逐出N个。我在一个模拟项目里测过,当显存余量低于总量的15%时,逐出频率呈指数上升,吞吐反而比不加逐出策略还低。
围绕这个,实际调参有三条经验:一是留足余量,别把块池吃到满,留20%到25%的空闲块作为缓冲;二是尽量用前缀共享降低重算量,系统提示词那部分被缓存住了,重入时这部分KV可以直接复用,不用重算;三是给逐出降温,连续若干步都在逐出就说明显存是真的不够,此时应该考虑降低并发上限或者把请求排到别的实例,而不是硬撑。
3.4 采样与分词这些"外围"环节的坑
调度器跑通之后,问题往往出在调度器外面。我在模拟项目X里遇到的几个坑,都跟外围环节有关。
采样的随机性来源要统一。Continuous Batching要求一批里的每个序列用各自的采样参数(temperature、top_p、seed),但很多框架的采样kernel是按整批一个参数写的。如果你直接把它用在异构批次上,会出现"这批请求全部按第一个请求的参数采样"的诡异现象,而日志上看不出报错。排查方式就是固定一个seed,构造两条需求明显不同的请求,看输出分布是否一致。
分词器的批处理路径和调度器的批处理路径要对齐。分词通常在主进程做,调度在推理进程做,两边对"一条请求"的边界理解必须一致,尤其是遇到超长prompt被截断时,截断点要明确在分词侧还是调度侧。我踩过一次,因为截断在两侧重复发生,导致模型生成的回答开头总是莫名其妙地重复半句话。
流式输出和批次完成不同步。用户那边是streaming的,一个token一个token往外推;调度器这边是"批次整体前向一次,返回一批token"。要保证每个请求的token按序、及时推给对应的流式连接,中间的缓冲队列设计不能偷懒,否则会出现"早完成的请求最后才吐出来"的错乱。
4. 上线前必须压的几组指标与调参手感
代码能跑不代表能用。这一节讲我在压测阶段的观察顺序,以及那些"文档里不写、但不调就出事"的参数。
4.1 别只看吞吐,四个指标要一起看
推理服务的质量不是一个QPS能概括的。我压测时固定看这四个,缺一个就会判断错。
| 指标 | 含义 | 关注点 |
|---|---|---|
| TTFT | 首token延迟,从请求到达到吐出第一个token | 受prefill排队、chunked prefill的块大小影响最大 |
| ITL / TPOT | 相邻token间隔 | 受单步batch大小、逐出抖动影响最大,是用户"卡不卡"的直接体感 |
| 吞吐 tokens/s | 单位时间生成的token总数 | 综合指标,要和上面两个一起看,单看会骗人 |
| 有效并发 | 同时在跑的请求数 | 用来判断显存余量和并发上限的合理区间 |
这四者的关系是相互拉扯的。batch size调大,吞吐上去,但ITL变差;chunked prefill的块调大,TTFT改善,但ITL变差;块池余量留多,抖动少、ITL稳,但吞吐上不去。所以不存在"最优参数",只有"针对你的SLA的最优参数"。我的做法是先给TTFT和ITL定一个硬上限(比如TTFT不超过800ms、ITL不超过100ms),然后在这两个约束下把吞吐往死里调。
4.2 长度分布决定你的参数该往哪调
一个反直觉的结论:平均输出长度对你的调参几乎没指导意义,P95和P99的长度才有。因为决定批次完成时间和显存峰值的是尾部请求。
我压测时会把实际的输出长度分布画出来,重点看P99。如果P99长度是平均值的10倍以上,说明你的流量里有长尾请求,那么块池余量要给得更足、逐出策略要更保守(宁可排队也别逐出)、chunked prefill的预算要给得稍微宽一点让长请求尽快过去。如果分布很集中,就可以把余量压紧、追求更高吞吐。
还有一个实操建议:对超长输出设置硬上限。不加限制的话,个别请求生成几千token会长时间占着槽位,把整个池子拖慢。设max_new_tokens上限并配合提前截断,是成本最低的稳定性保障。截断时返回一个明确的结束原因,让调用方能处理。
4.3 逐出阈值没设计好,会触发雪崩
前面提过逐出抖动,这里把现象和处置讲透。
现象是:吞吐曲线原本平稳,加载到某个并发后突然掉到原来的六成,同时ITL的P99飙高,日志里逐出计数暴涨。此时如果你继续加压,情况不会更糟但也不会好转,系统卡在一个低效的平衡态。
根因是块池余量低于缓冲线后,每个step都在"逐出—释放—分配—再逐出"之间循环,真正用于计算的block占比下降,等效batch变小,吞吐自然掉。
处置顺序我一般这样走:第一步先看逐出率(逐出次数/总请求数),超过5%就说明余量不够,把空闲块缓冲从15%提到25%试试;第二步确认是否有个别超长请求长期占位,如果是,给它加长度上限或优先级降级;第三步如果还不行,说明并发上限设高了,该在入口层做限流而不是让调度器硬扛。
注意:不要把"逐出次数多"直接等同于"显存不够",也有可能是你的block size设得太小导致块数虚高,或者是前缀共享没生效。先排除这两个,再动显存配置。
4.4 前缀共享的收益要用真实流量验证
前缀共享听起来很美,但收益高度依赖流量特征。带统一系统提示的场景(比如所有请求前面都挂一段一千多token的角色设定),收益非常显著,能省下大比例的prefill计算和显存。但如果每条请求的prompt都完全独立,前缀共享几乎是零收益,反而增加了映射表管理的开销。
验证方法很简单:统计真实流量里前N个token的重复率。做法是把最近的请求prompt按前缀截断到比如128token,做一次频次统计,看最高频的那段前缀覆盖了多少比例的请求。覆盖率超过50%就值得开前缀共享,而且要把这段前缀放在显存里长期驻留,别被回收掉。
5. 几个绕不开的实现难点与我的处理办法
这一节是把前面分散提到的坑集中讲清楚,都是我在模拟项目里真实撞过、并且找到稳定解法的。
5.1 prefill和decode混批时的数值一致性
一个批次里同时有prefill的请求和decode的请求,是Continuous Batching的常态,也是很多实现翻车的地方。原因是这两类请求的计算模式不同:prefill是矩阵乘,吃的是大块token;decode是逐token生成,每个序列只有一个token。强行拼成一个batch,需要构造特殊的attention mask(通常叫打包序列或用变长的cu_seqlens),让每个序列只能看到自己的部分。
这里的坑在于mask写错不会报错,只会让结果变差。你可能会发现输出还是通顺的,但质量微妙地不对,比如重复、跑题。排查方式是构造一个极端case:把一条100token的请求和一条5token的请求混在一批,然后分别单批跑一遍,对比两者的输出logits。如果对不上,就是mask的问题。这个对比测试我建议固化成回归用例,每次改调度逻辑都跑一遍。
5.2 优先级与公平性:别让短请求饿死
线上流量通常混杂着不同优先级的请求,而且用户对"简单问题"的响应期待更高。纯FCFS + 优先逐出长请求的策略,容易让长请求一直排队。反过来,如果为了照顾长请求而放松余量,短请求又开始排队。
我的处理是按输出长度做预估分级,但并不真的预测长度(预测不准反而添乱),而是用一个静态的"段位":prompt短的、且max_new_tokens设得小的请求,进快车道;其余进普通道。快车道有独立的token预算配额(比如每步预留30%的预算给快车道),保证这类请求的TTFT有下限保障。这个改动实现起来不复杂,但对体感延迟的改善立竿见影。
提示:快车道的配额不能给太高,否则普通请求的prefill一直排不上,又会引发另一头的饥饿。经验值是20%到35%之间。
5.3 多卡并行下的调度边界
单卡调好了,上多卡(张量并行)之后调度会变复杂。核心区别在于,prefill的chunk切分要在所有卡上保持同步——不能这张卡切2048、那张卡切1024,那样每步的通信量和计算量都对不齐,反而更慢。
另外一个容易被忽略的点是逐出的粒度。在多卡设置下,一个请求的KV是分布在多张卡上的,逐出必须全卡同步释放,否则会出现"这张卡上已经释放了、那张卡还占着"的状态不一致。实现时要在调度器里维护一个全局的逐出决策,然后广播给各卡的执行端,而不是各卡自己判断。
我在模拟的跨平台系统里就吃过这个亏:单卡跑得好好的调度逻辑,铺到四卡上之后,隔一段时间就会出现一次明显的卡顿,排查下来是某张卡的块池先耗尽,触发了局部逐出,其他卡跟着等。改成全局统一决策后,卡顿消失。
5.4 调度器本身的开销不能无视
调度逻辑跑在CPU上,每步都要扫队列、算块数、维护映射表。当batch很大、步频很高(比如每步10ms甚至更短)时,调度开销占比会上升,甚至成为瓶颈。极端情况下,GPU那边等CPU算调度,利用率掉下来。
控制手段有几个:一是控制块表的规模,block size别设太小;二是队列扫描做增量化,别每步全量遍历,维护一个"可调度的候选集合";三是把调度器写成编译型语言,Python虽然好写,但在高频路径上确实吃亏。我在一个Demo里做过对比,同样的逻辑,从Python移到C++后,高频小batch场景下的吞吐提升接近两成,主要就是省下来的调度开销。
6. 落地时的取舍清单
最后把这一路下来我认为最值得记住的几个判断列出来,都是需要你自己在真实流量上验证的。
batch size不要设成固定值。Continuous Batching的整个思路就是让批次动态变化,如果你非要加一个硬上限,就把它设成一个"绝不会成为瓶颈"的值。真正的约束应该是token预算和显存余量,而不是一个拍脑袋的数字。
显存余量比峰值吞吐更重要。留20%到25%的空闲块,看着像是浪费,实际是在买稳定性。余量不足带来的逐出抖动,会让你损失远超这部分余量能带来的吞吐。
chunked prefill的预算和你的SLA强绑定。TTFT和ITL是一对矛盾,预算大小就是在两者之间挪移。先定SLA,再倒推预算,别反过来。
长度上限不是限制,是保护。对输出长度设死上限,配合明确的截断返回,能避免个别请求把整个池子拖进低效状态。这是成本最低的一项稳定性措施。
回归用例要覆盖混批场景。prefill和decode混批、逐出重入、前缀共享命中,这三个场景的数值一致性都要固化下来定期跑。出了问题,这几个用例能在几分钟内帮你定位到是不是调度逻辑改坏了。
我在实际项目里最大的体会是:Continuous Batching的框架选型其实不难,难的是把自己流量特征摸清楚,然后耐心地把那几个参数压到合适的位置。同一套代码,参数调对了和调错了,吞吐能差两三倍,而且差的那版看起来一切正常,就是慢。压测阶段多看延迟分位数、少看平均值,能少走很多弯路。