锁竞争不高却吞吐掉得厉害,先查线程切换还是下游等待?
锁竞争指标不高,不等于并发链路就健康。吞吐下降时,更有价值的是把线程切换、队列波动、连接池等待、下游阻塞和伪并发边界放进同一段证据里看清楚,再判断问题是线程在空耗 CPU,还是业务线程长期卡在等待型瓶颈上。
线上吞吐掉下来时,很多团队第一反应会先看两类指标:
- CPU
- 锁竞争
这当然没错。但特别容易把人带偏的一类现场是:
- 吞吐明显下降
- 任务耗时在拉长
- 队列开始有点抖
- 但锁竞争指标并不高
- 甚至看起来几乎没有严重锁争用
这时很容易出现一句很有迷惑性的判断:
锁也没怎么抢,那并发层应该没什么问题吧?
这句话经常只对一半。
因为 吞吐下降并不一定靠“高锁竞争”来表现。真实线上里,更常见的两条主线其实是:
- 线程没有在争同一把大锁,但一直在切来切去,做了很多无效调度
- 线程看起来也不忙争锁,却大量卡在下游、连接池、网络、Future、队列或别的等待点上
也就是说,这类问题真正要分清的,通常不是“有没有锁”,而是:
线程到底是在被频繁切换耗掉了吞吐,还是根本没有在做有效工作,而是在等下游、等连接、等任务结果?
想先抓住现场重点,可以先记住一句更直接的话:
先看线程多数时间落在
RUNNABLE、WAITING还是BLOCKED;再把上下文切换、线程池 active / queue、连接池 pending 和下游 RT 对齐到同一段时间窗里,基本就能看出是调度空耗在吃吞吐,还是等待型瓶颈在把线程挂住。
如果锁竞争不高但吞吐掉了
| 你现在看到的现象 | 更适合先看哪条线 | 为什么 |
|---|---|---|
| 锁竞争不高,但吞吐、线程池、连接池、下游等待一起变差 | 继续看本文 | 这正是“锁没怎么抢,但线程和下游一起把吞吐拖下去”的现场 |
| 你已经确认主要是分布式锁热点 key、粒度、续约问题 | 先看《分布式锁没报错但吞吐变差,应该先查续约还是锁粒度?》 | 那篇更适合确认锁热点、粒度和续约是不是主因 |
| 你还没判断这个场景要不要用分布式锁 | 先看《分布式锁什么时候该用,什么时候不该用》 | 先分清是不是互斥问题 |
| 你当前最严重的是重复消费、重复副作用没有收住 | 先看《幂等校验明明通过了,为什么消息还是会重复消费?》 | 那篇更适合先把重复副作用这条线收住 |
| 你当前最严重的是 MQ backlog 压不下去、团队想直接扩消费者 | 先看《消息越堆越多,为什么常常不是消费者数量不够,而是下游变慢了?》 | 那篇更适合先确认是不是下游变慢把消费侧拖住了 |
先确认这是不是你眼前那种吞吐问题
如果你现在看到的是锁竞争并不高,但吞吐、线程池、连接池和下游 RT 一起变差,这篇就值得先看。
我不是想讨论分布式锁该不该上,也不打算展开幂等和 backlog 那几条线。
我更想先把一个常见误判掰正:
锁没怎么抢,不等于并发层就没问题;线程也可能一直在切、一直在等,最后把吞吐空耗掉。
先把这条判断立住,后面才不会过早得出“不是锁问题所以先别看并发链”的结论。
先做一轮快速判断
- 先看线程状态分布,分清线程主要是在
RUNNABLE、WAITING还是BLOCKED。 - 再看
pidstat -w、vmstat有没有明显切换升高。 - 然后把线程池 active / queue、连接池 pending、下游 RT 放进同一时间窗对齐。
- 如果线程主要停在连接获取、RPC、
Future.get()、数据库调用上,优先怀疑等待型瓶颈。 - 只有当线程真的在 CPU 前高频切换、任务很碎、Runnable 线程过多时,再把主线收敛到调度空耗。
一、为什么“锁竞争不高”会制造错觉
因为很多人默认把吞吐下降理解成:
- 大锁冲突
- synchronized 打满
- ReentrantLock 抢不到
这当然是吞吐变差的一种经典原因,但远远不是唯一原因。
真实工程里,更常见的几类吞吐下降其实长这样:
1. 线程并没有被大锁卡住,但频繁被调度
例如:
- 线程数开得太多
- CPU 核数不够承接当前 runnable 线程规模
- 任务很碎、很短、切换非常频繁
- 上下文切换成本开始吃掉有效工作时间
这时你看到的不是高锁竞争,而是:
- runnable 线程不少
- CPU 未必完全打满
- 吞吐却提不上去
2. 线程不在争锁,而是在等资源
例如:
- 等数据库连接
- 等慢 SQL
- 等下游 RPC
- 等 Redis
- 等网络建连
- 等 Future 汇总结果
这时线程池看起来有活跃线程,但它们没有在推进工作,而是在等待型瓶颈上耗时间。
3. 任务模型本身是伪并发
例如:
- 线程很多,但热点 key、热点租户、热点分区把任务串行化
- 线程池很大,但真正有效并发度极低
- 任务外面没锁,里面却依赖同一条下游链路或同一连接资源
这时锁竞争指标当然不高,但吞吐照样会掉。
所以“锁竞争不高”最多只能说明:
- 没有明显的大锁争用证据
它不能说明:
- 调度没有空耗
- 线程没有等待
- 并发模型真的高效
二、第一步别先选边:先分清线程是在跑、在切,还是在等
如果你现在就碰到了这种现场,第一刀不要先切“线程切换”还是“下游等待”,而是先把线程行为分型。
至少先回答这三个问题:
- 线程主要处在 RUNNABLE、WAITING、TIMED_WAITING,还是 BLOCKED?
- CPU 时间主要花在线程执行上,还是上下文切换和等待上?
- 高峰窗口里,线程池活跃数、队列、连接池、下游 RT 有没有一起变化?
更建议一起看哪些证据
top -Hppidstat -t -wvmstatjstack- 线程池 active / queue / reject
- 连接池 active / pending
- 下游 RT / timeout / QPS
这几组证据放在一起,比单看“锁竞争率”有解释力得多。
三、更像线程切换空耗时,常见会有什么信号
如果问题更偏线程切换,通常会看到下面这些特征。
1. 上下文切换明显升高
这是最直接的信号之一。
例如:
pidstat -w看到自愿或非自愿上下文切换明显抬高vmstat里 cs 指标异常活跃- CPU 不一定 100%,但调度很忙
2. 线程数明显偏多,Runnable 线程堆在 CPU 前面
这类场景经常发生在:
- CPU 核数有限
- 线程池开得太激进
- 短任务被拆得过碎
- 多个线程池同时高活跃
这时即使没有大锁,线程之间也会因为抢 CPU 时间片而频繁切换。
3. jstack 看起来没有明显阻塞点,但热点线程位置分散
如果你多次抓栈发现:
- 没有特别明显的大量 WAITING 在同一资源上
- 线程都在跑各种短逻辑
- 热点位置来回跳
那就要考虑调度空耗和线程模型问题,而不是先去怀疑下游。
4. 吞吐下降伴随 CPU 使用效率变差
这类问题最典型的感受是:
- CPU 不是完全没空
- 但吞吐没有跟着 active 线程数上涨
- 甚至线程越多,系统越“热闹”但越不出活
这时候就很像线程在忙着切换,而不是忙着完成任务。
四、更像下游等待时,常见会有什么信号
另一条更高频的主线,是线程都没有被大锁卡住,但被等待型瓶颈拖住了。
1. 线程大多停在等待、网络、连接池、Future 或数据库调用上
这在 jstack 里通常很明显。
常见停留点包括:
- 连接池获取连接
- JDBC 调用
- Socket read
- HTTP client 调用
- Redis client 调用
Future.get()/CompletableFuture.join()- 队列 take / poll
2. 连接池、下游 RT、慢 SQL、超时率一起抬头
这是最有解释力的一组信号。
例如:
- 锁竞争不高
- CPU 也没特别炸
- 但连接池 pending 上升
- 下游 RT 变长
- 线程池 active 居高不下
这时吞吐下降的根因通常不在锁,也不在调度,而在线程迟迟释放不出来。
3. queue 不长,但单任务耗时在变长
这种现场非常典型。
任务一进线程池就能拿到线程,但拿到之后:
- 在数据库里等
- 在下游里等
- 在 Future 汇总结果时等
这时整体吞吐还是会掉,只是队列和锁都不一定很刺眼。
4. 线程状态更像“有活跃数,但没有效推进”
这句话很关键。
吞吐下降不是因为线程不工作,而是因为:
- 线程看起来都在忙
- 实际却在忙着等别人
五、一个特别常见的误区:把“没有锁争用”误判成“并发层没问题”
并发问题从来不只等于锁问题。
更准确地说,并发层至少还包括:
- 线程数和 CPU 核数的关系
- 任务粒度
- 线程池隔离和共享
- 上下文切换成本
- 队列行为
- Future / 回调依赖关系
- 下游等待传播
所以如果你只拿“锁竞争不高”来给并发层开绿灯,很容易漏掉两类高频问题:
1. 线程模型不经济
例如:
- CPU 密集任务开了大量线程
- 短任务拆得太碎
- 多个线程池彼此争 CPU
- 线程池参数不匹配任务类型
2. 等待型并发问题
例如:
- 业务线程都在等下游
- 汇总线程都在等子任务
- 连接池和线程池互相放大等待
- 本地没锁,但外部链路把并发压扁了
六、一个更稳的排查顺序
如果你在线上碰到“锁竞争不高,但吞吐明显掉”的问题,我更建议按下面顺序走。
第 1 步:先看线程状态分布
先回答:
- BLOCKED 多不多
- WAITING / TIMED_WAITING 多不多
- RUNNABLE 多不多
如果 BLOCKED 并不突出,就先别把主线放在锁上。
第 2 步:再看上下文切换
重点看:
- 线程切换是否显著升高
- Runnable 线程规模是否过大
- 线程数是否远超 CPU 适配范围
如果切换很高,说明吞吐下降可能和调度空耗有关。
第 3 步:把线程池、连接池、下游 RT 放进同一窗口
看是否存在这类联动:
- 线程池 active 高位
- queue 不一定长
- 连接池等待变长
- 下游 RT 抬升
- SQL 或 Redis RT 抖动
如果这些一起变差,更像等待型瓶颈。
第 4 步:看是否存在伪并发边界
例如:
- 热点 key
- 单租户热点
- 分区串行化
- Future 汇总等待慢分支
- 某个下游单连接 / 单资源限制
这一步能解释很多“锁不高但吞吐就是起不来”的现场。
第 5 步:最后才决定优化方向
- 如果更像切换空耗,优先回到线程数、任务粒度、线程池模型
- 如果更像下游等待,优先回到连接池、数据库、缓存、RPC、Future 汇总链路
七、一个典型例子:为什么锁不高,吞吐还是从每秒 2000 掉到 800
假设某个异步消费服务出现下面的现象:
- 吞吐从 2000/s 掉到 800/s
- 锁竞争监控几乎没明显异常
- 线程池 active 很高
- queue 只轻微抖动
第一眼很多人会先排除并发层问题。
继续往下看:
pidstat -w发现上下文切换比平时高了一截,但不是唯一异常jstack发现大量 worker 线程停在数据库连接获取和 HTTP 下游调用上- 连接池 pending 明显抬升
- 某次发布后,每个任务新增了两次下游回查
- 线程虽然没被锁住,但大量时间都花在等连接和等下游
这时链路就很清楚:
- 吞吐下降不是因为本地大锁
- 也不完全是因为线程切换空耗
- 真正主因是等待型瓶颈把线程长期占住
- 上下文切换只是线程规模和等待传播下的伴生现象
这个例子特别能说明:
“先查线程切换还是下游等待”不是拍脑袋选边,而是要靠线程状态、切换数据和下游证据一起定。
八、关键误判:这类问题最容易在哪些地方走偏
误判 1:锁竞争不高,就直接排除并发问题
并发问题远不只锁竞争,还包括调度、线程模型、Future 依赖和伪并发边界。
误判 2:上下文切换高,就默认全是线程数太多
切换高可能是结果,不一定是起点。
如果线程是被下游等待拖住,线程规模被动膨胀后,切换也会跟着高。
误判 3:只看 CPU,不看线程状态
CPU 只能告诉你忙不忙,不能告诉你线程到底在跑什么、等什么。
误判 4:只看线程池,不看连接池和下游
很多吞吐下降问题真正的起点在线程池外。
误判 5:把“线程都活跃”理解成“系统有高效并发”
活跃线程高,不等于有效推进高。很多时候只是很多线程一起卡住。
九、FAQ:这类问题里最常被问到的几个问题
1. 锁竞争不高,是不是说明先不用看并发层?
不是。
更该看线程状态、上下文切换、队列行为和下游等待,而不是只用锁竞争一项做结论。
2. 怎么快速判断更像线程切换还是下游等待?
最实用的方法是同时看:
pidstat -w/vmstat的切换信号jstack的线程停留点- 连接池和下游 RT 是否同步变差
3. queue 不长但吞吐掉了,说明什么?
常见说明两件事:
- 任务拿到线程后执行链变慢了
- 真正瓶颈不在池内排队,而在执行中等待或伪并发边界
4. 这种问题要不要先扩线程池?
通常不建议先扩。
如果根因在下游等待或连接池,扩线程池只会把更多并发压向同一个等待点。
如果证据已经把你带到这些岔口
如果你已经确认问题更像等待型协作放大,下一步就按当前最强证据继续收:
- 更像锁热点、持锁时间或 TTL 边缘问题:看《分布式锁没报错但吞吐变差,应该先查续约还是锁粒度?》
- 更像重复消费、ack 重投、外部副作用重复打出:看《幂等校验明明通过了,为什么消息还是会重复消费?》
- 更像 backlog 已经形成,并开始向上游回流放大:看《消息越堆越多,为什么常常不是消费者数量不够,而是下游变慢了?》
- 其实还没做完分布式锁适用性判断:先回《分布式锁什么时候该用,什么时候不该用》
十、按现有证据分岔到下一篇
如果你已经接受一个前提:吞吐下降不一定要靠高锁竞争来证明,那后面的文章也别按专题名翻,直接按你手里最硬的证据选。
- queue 不长,但任务拿到线程后就是跑不快:看《线程池队列不长但任务还是慢,常见瓶颈在哪里?》
- 已经怀疑线程池参数、任务类型和线程数不匹配:看《线程池打满以后,应该先查队列、拒绝策略还是慢任务?》和《线程池参数怎么按 CPU 密集和 IO 密集任务分别定?》
- 证据开始指向连接池、数据库或下游等待:看《连接池等待时间变长时,如何判断是数据库慢还是应用拿着不放?》和《接口慢但 CPU、GC、数据库都不高,常见隐藏等待点有哪些?》
- backlog 已经往异步链路和上游传导:看《异步任务越堆越多,问题常常不在异步本身》
十一、最后总结:别急着给并发层开绿灯,先看线程为什么没把活做完
锁竞争不高却吞吐掉得厉害,最容易让人误判的地方就是:
- 没看到明显大锁
- 就顺手把并发层排除掉
但真实现场更常见的是另外两种情况:
- 线程在频繁切换中把 CPU 时间片空耗掉了
- 线程没有被锁住,却长期卡在连接池、下游、Future、网络或数据库等待上
所以更实用的顺序不是先争论“到底算不算锁问题”,而是:
先看线程多数时间是在跑、在切,还是在等;再把上下文切换、线程池、连接池、下游 RT 和任务模型摆到同一段证据里,最后决定该回头收线程模型,还是先治理等待型瓶颈。
顺序一旦立住,很多原本会被一句“锁竞争不高”带偏的吞吐问题,就能更快收敛到真正的慢点上。