第3章:写入瓶颈的成因

你想想看,一个异步缓冲区写数据慢,到底卡在哪里?

我经常被问到这个问题。说实话,大部分人都觉得“慢就是慢呗,还能有什么花样”。但做性能优化这么多年,我发现瓶颈就那么几类。搞清楚了,你写代码时就能提前避开,而不是上线后再抓耳挠腮。

这一章,我带你逐个拆解。搞懂了这些根因,你才谈得上优化。

核心观点:写入瓶颈不是单一问题,而是 IO、网络、锁、CPU 调度四个维度交织的结果。不先诊断类型,优化就是瞎折腾。
异步缓冲区写入瓶颈成因总览 写入瓶颈四大成因 磁盘IO瓶颈 网络延迟 锁竞争 上下文切换 · 随机写 vs 顺序写 · IOPS / 吞吐量限制 · 磁盘队列深度 · TCP 握手/拥塞 · 带宽与 RTT · 网络小包问题 · 锁粒度过大 · 锁争抢回退 · CAS / AQS 开销 · CPU 核间调度 · 协程/线程切换 · 缓存失效 诊断口诀 磁盘看IOPS和延迟曲线 网络看吞吐和RTT波动 锁看线程排队时间和自旋次数

3.1 磁盘IO瓶颈:写操作的“背锅侠”

磁盘IO,算是写入瓶颈里最好定位的一个了。指标太明显,你一看监控面板就知道了。

典型的场景是这样的:你有个业务系统,每秒要写几千条日志或埋点数据。刚开始没问题,等数据量上来,写入速度往下掉,最后直接打满磁盘队列。我见过一个团队,线上日志落盘慢,整个应用的延迟从10ms飙到800ms。查了半天,发现是机械盘的随机写惹的祸。

我的建议: 先分清你的磁盘是随机写还是顺序写。顺序写的吞吐能到几百 MB/s,随机写可能连 1 MB/s 都不到。差别就在这里。

具体来说,磁盘IO瓶颈有这几个典型表现:

  • IOPS打满: 每秒的读写请求数到了硬件上限。机械盘通常 100-200 IOPS,SSD 能到几万,但遇到高并发写入照样崩。
  • 磁盘队列深度飙高: 队列深度超过 2-3 意味着请求在排队。你可以用 iostat -x 1 看 avgqu-sz 和 %util。
  • fsync 成了性能杀手: 很多存储引擎每写一笔就调一次 fsync,把写缓冲强制刷盘。我见过一个案例,关掉不必要的 fsync,性能直接翻倍。
# 用 iostat 诊断磁盘瓶颈
$ iostat -x 1
Device    r/s    w/s    rkB/s    wkB/s  avgqu-sz  await  %util
sda      12.0  340.0   480.0  15200.0      8.5   24.3   98.2

# avgqu-sz 8.5 说明队列很长
# %util 98.2 说明磁盘几乎满载
注意: 不要只看 %util。SSD 的并发能力很强,%util 到 100% 可能还没到瓶颈。关键是看 avgqu-sz 和 await 是否持续升高。

3.2 网络延迟:分布式写入的隐形杀手

一旦你的异步缓冲区跨了网络,比如写到远端数据库或消息队列,网络延迟就成了绕不开的坎。

说白了,网络延迟就是“从发出请求到收到响应”的耗时。这里面有几个东西影响最大:

因素 典型值 对写入的影响
TCP 握手 1-3 个 RTT 短连接下每次写入都有建连开销
带宽限制 1 Gbps / 10 Gbps 数据量超过带宽时直接排队丢包
网络抖动 0.1ms ~ 200ms 导致写入延迟不稳定
小包问题 MTU 1500 字节 每笔写数据量小,但网络开销不降

我在项目中遇到过最典型的问题:一个日志采集器,每 10ms 就往 Kafka 写一条 200 字节的数据。本地网络延迟才 0.5ms,但一压测就发现吞吐上不去。为什么?因为 TCP 的 Nagle 算法会合并小包,但也要等一段时间。后来我把 TCP_NODELAY 打开,关闭小包合并,性能立刻起来了。

经验之谈: 网络瓶颈往往藏在“看起来不大”的地方。带宽、小包、挥手延迟,每一项单独看都不是大事,合在一起就是灾难。

3.3 锁竞争:多线程写入的“拥堵区”

锁竞争这个东西,说实话,是最让人头疼的。因为磁盘和网络的瓶颈,你还能通过监控看出来。锁的问题,经常是代码写死了,你从外面看不出异常,但性能就是上不去。

为什么会这样?

你想想看,多个线程要往同一个缓冲区写数据,总得有个东西来保护队列不崩吧。最常见的就是加锁。但锁一加,写入就变成了串行操作——一个线程在写,其他线程只能排队等着。

// 一个典型的锁竞争场景
private final ReentrantLock lock = new ReentrantLock();
private final List<byte[]> buffer = new ArrayList<>();

public void write(byte[] data) {
    lock.lock();  // 所有线程都在这里等
    try {
        buffer.add(data);
        if (buffer.size() >= BATCH_SIZE) {
            flushToDisk(buffer);
            buffer.clear();
        }
    } finally {
        lock.unlock();
    }
}

这个代码看起来没问题,对吧?但一旦并发量高到一定程度,锁内部的排队和自旋就开始消耗 CPU 了。我记得有一次,我帮一个团队调优他们的写入组件。监控上看,CPU 用了 80%,但实际 IO 只占 20%,剩下 60% 都在锁竞争和线程调度上打转。

解决办法: 一是减少临界区,把“加数据”和“刷盘”分开锁。二是用分段锁或 CAS 替换大锁。三是考虑无锁数据结构,比如 Disruptor 的 RingBuffer。

3.4 上下文切换:看不见的 CPU 浪费

最后一个成因,也是最容易被忽略的——上下文切换。

上下文切换,说白了就是 CPU 从一个线程切到另一个线程。每次切换都要保存当前线程的状态,加载新线程的上下文。这个过程本身就要几十纳秒到几微秒。听起来不多,但一秒钟切几万次,你就知道厉害了。

我在项目里见过一个极端情况:一个写入程序,开了 200 个线程。每个线程都在不停竞争同一把锁。结果怎么着?CPU 跑到了 100%,但有效工作时间不到 10%。剩下的 90% 都在切来切去。

# 用 pidstat 看上下文切换
$ pidstat -w -t 1
Linux 5.4.0
10:30:01    UID      TGID     TID    cswch/s   nvcswch/s  Command
10:30:01   1000         -    1234      32000        1500   my_writer

# cswch/s: 自愿上下文切换(通常是等锁或IO)
# nvcswch/s: 非自愿切换(时间片用完)
# 每秒 3.2 万次自愿切换,明显是锁竞争太激烈了

诊断上下文切换,我建议你重点盯着两个指标:

  • cswch/s(自愿上下文切换): 线程主动让出 CPU,比如等锁、等 IO。值越高,说明锁竞争或 IO 等待越严重。
  • nvcswch/s(非自愿上下文切换): 线程时间片被强制打断。值高了说明线程太多,CPU 忙不过来。
避坑指南: 我曾经以为线程多就是并行高。后来发现,线程数量超过 CPU 核心数的 2-3 倍后,上下文切换的代价就超过并行收益了。尤其是 I/O 密集型任务,建议线程数控制在 2 * CPU核心数 / (1 - 阻塞系数)。

小结

到这,四大成因就讲完了。我给你一张速查表:

瓶颈类型 关键指标 定位工具 常见优化方向
磁盘IO avgqu-sz, await, %util iostat, blktrace 顺序写 / 合并写 / SSD
网络延迟 带宽, RTT, 丢包率 ping, tcpdump, netstat 连接池 / 批量发送 / TCP_NODELAY
锁竞争 线程等待时间, 自旋次数 perf, jstack, async-profiler 分段锁 / 无锁结构 / CAS
上下文切换 cswch/s, nvcswch/s pidstat, vmstat 合理线程数 / 协程 / 绑定 CPU

掌握了这些根因,接下来我们才能谈“怎么优化”。不然你以为调了个参数就能解决所有问题?不可能的。先诊断,再动手,这是我在无数事故中学到的教训。