服务一上量,最先出问题的往往不是模型而是并发:队列堆积、超时雪崩、限流误伤,全靠拍脑袋加机器解决不了。这篇不聊协议也不聊客户端,只把并发控制这件事拆开——控制什么、用哪些算法、边界在哪、出问题按什么顺序查,让你在设计接口和排队层时知道自己在做哪种取舍。数字口径一律以站内页面为准。
服务一上量,最先炸的往往不是模型本身,而是并发:队列堆成山、超时连锁、限流误伤正常用户。加机器能顶一阵,但顶不住设计上的漏洞。这篇不聊协议、不聊客户端选型,只把并发控制拆开看:你到底在控什么、几种算法各自适合什么流量形状、排队和拒绝的分界线在哪、重试怎么才不反噬自己、最后怎么用指标验证设计生效。你在设计排队层或接口层时,照着这份清单对一遍,基本能知道自己在做哪种取舍。
需要说明的是,本文不写死任何具体阈值。不同业务的承压能力差得远,凡是涉及延迟基线、配额上限、错误率拐点这类数字,都到站内对应页面看当时的真实口径再定。照抄别人的数字,通常会在你这条链路上翻车。
先分清你到底在控制什么
很多人一上来就说「我要做限流」,但并发控制至少有四层对象,混着谈必然打架:连接数、在飞请求数、下游处理槽位、后端账号配额。这四层各自的上限来源完全不同,有的是你自己进程扛不住,有的是下游数据库只能吃这么多,有的是上游服务商给你的硬配额。先定位瓶颈在哪一层,再决定限哪一层。
| 控制对象 | 上限通常来自 | 限它的目的 | 限错了会怎样 |
|---|---|---|---|
| 连接数 | 本机 fd、内存、握手成本 | 防止资源被连接吃光 | 限太死会让健康请求排队握手 |
| 在飞请求数 | 下游并发处理能力 | 保护脆弱下游不被打穿 | 下游其实能扩时压低了吞吐 |
| 处理槽位 | 工作线程、协程池容量 | 让队列和延迟可控 | 槽位和队列都不设上限等于没控 |
| 账号配额 | 上游硬性限制 | 避免整批请求被整体拒绝 | 只控这一层会被瞬时突发击穿 |
- 动作一:画一张「调用链—瓶颈点—可限点」的表,把每次外部调用的上下游标清楚
- 动作二:给每个可限点标注上限来源,是进程资源、下游容量还是外部配额
- 动作三:只对瓶颈层设硬限制,其余层保留观测位但先不拦截
- 什么时候不适用:下游是弹性扩容且没有硬配额时,早限流反而压低吞吐,这时该控的是成本和超时
几种核心算法适合什么形状的流量
- 计数窗口
- 按固定时间片统计请求数,实现最省事,但窗口边界容易放过瞬时峰值,适合量级粗放的内部任务
- 漏桶
- 无论来多少都以恒定速率放行,削峰最干净,适合保护一次只能吃固定量的脆弱下游
- 令牌桶
- 按速率发令牌,桶里攒的量决定能突发多少,适合用户侧交互这种允许短时突发的场景
- 信号量槽位
- 控制的是「同时有多少个在跑」,和速率限制不是一回事,必须另外给队列长度设上限
最容易搞混的是速率限制和并发槽位。速率限制管的是单位时间放进来多少,槽位管的是此刻有多少个在跑。一个下单接口可以每秒放进来很多请求,但如果它同时只能有固定数量的请求在跑,那就得靠槽位。只做速率限制不给槽位,长任务会把并发全占住,短任务只能干等。
- 突发可容忍且下游有余量:选令牌桶
- 下游一次只能吃固定量:选槽位加队列,队列长度单独设上限
- 只用内存实现、精度要求低:用计数窗口近似即可
- 要求严格匀速:选漏桶,但注意它会牺牲峰值吞吐
- 判断标准:先问下游能接受多大突发,再问你能接受多长排队,两个答案基本决定算法选型
排队、拒绝与背压:排队不是免费的
队列只能削峰,不能扩容。队列越长,尾延迟越高,很多超时事故的根因就是把排队时间排除在超时预算之外:请求在队列里已经躺了很久,一出队还没执行就超时了。正确的做法是在入队那一刻就把预计排队时长计入整体超时预算,超了就当场拒绝,别让它白占一个槽位。
- 把超时预算拆开分成排队、执行、重试三段,各自留出上限,加起来不能超过调用方愿意等的时间
- 设置两个水位一个水位开始告警和降级,另一个水位开始直接拒绝,两个值分开调,别用一个阈值兼顾两件事
- 拒绝要显式满了就返回可重试的明确信号并带上建议等待时长,比让请求在队列里静默过期对调用方友好得多
- 背压往上游传并发到顶时把「忙」的状态透出,让上游降速或分批,而不是让它无限重试再打进来一轮
重试、优先级和公平性怎么不掉坑
重试会放大并发,这一点在设计阶段最容易被忽略。下游只是打了个嗝,客户端集体重试,直接把下游打穿,这就是自己打自己的雪崩。所以重试必须带预算:整体失败率高到一定程度就停止重试,把节奏交给上层降级;重试间隔要加随机抖动,避免所有客户端同一时刻再冲一次。
共享槽位必须分池。交互请求和批量任务混在一个池里,批量的长任务会把交互的延迟拖垮,用户侧感受最直接。分池的粒度按限什么来定:按用户限是防单个用户刷爆,按 Key 限是保护后端配额,按租户限是防止大客户挤占小客户。哪种更合适,取决于超限的那一刻你更怕误伤谁。
- 给重试设置总预算和最大次数,两者取先到者
- 每次重试间隔加随机抖动,别用固定间隔
- 交互池和批量池分开,配额互不挤占
- 内部单一调用方、任务无优先级差异时,分池带来的复杂度大于收益,可以不分
- 公平性判断标准:哪个群体被误伤时业务损失最大,就把配额优先分给它的对手方
怎么观测和验证你的设计真的生效
没有观测的限流等于蒙眼开车。至少要同时看四个指标:在飞请求数、队列等待时长、拒绝率、重试次数。只看拒绝率会误判,拒绝率低可能是限得太松根本没拦住,也可能是因为上游自己先崩了;四个指标放一起看,才能分清是限太死还是太松。
| 现象 | 大概率原因 | 下一步动作 |
|---|---|---|
| 队列等待时长涨、在飞数不高 | 槽位不够或下游变慢 | 先查下游延迟,再谈要不要加槽位 |
| 拒绝率突然抬高、重试次数同步涨 | 阈值偏紧或下游抖动 | 先看下游健康状况,别急着放宽阈值 |
| 在飞数顶住上限、延迟平稳 | 限流正常生效 | 这个位置就是当前安全水位 |
| 重试次数远高于拒绝率 | 客户端没做预算和抖动 | 从调用方侧收重试,而不是改服务端 |
验证方法很简单:压测时逐档抬高并发,记录尾延迟和错误率的拐点,拐点之前的位置才是安全水位。上线顺序建议先只观测不拦截,拿到真实分布再收紧阈值,避免第一天就误伤正常流量。延迟基线不要凭感觉设,到站内测速页面 /tools/speed-test 量一遍你这条链路的真实数字,再据此定阈值。
限流的目标不是把请求挡住,而是让系统在压力下依然给出可预期的行为。下一步把你现在这条接口链路的瓶颈层和可限点列成表,再到站内页面核对上游配额和实时价格口径,把阈值建立在真实数字上,而不是拍脑袋。→
常见问题
并发控制和限流是一回事吗?
不完全是。限流通常指按速率放行,属于并发控制的一种手段;并发控制还包括在飞请求数、槽位占用、队列深度和重试预算。只做速率限制而不控在飞数,下游变慢时依然会堆积。先把瓶颈分层,再决定用哪一类手段。
队列设多长合适?
没有通用答案,取决于你能接受的最大等待时长和下游处理速度。做法是先定端到端超时预算,把网络、排队、处理三段分开,排队能分到多少时间就对应多长队列。队列长了尾延迟必然上升,需要用真实流量校准。
被限流后重试该怎么做?
重试要带抖动和次数上限,并且整体受一个重试预算约束,避免重试把并发再推高一倍。优先按服务端返回的可重试信号退避,不要固定间隔硬撞。同时把重试次数记进日志,否则你看到的流量永远比真实请求少。