第3章:写入瓶颈的成因
你想想看,一个异步缓冲区写数据慢,到底卡在哪里?
我经常被问到这个问题。说实话,大部分人都觉得“慢就是慢呗,还能有什么花样”。但做性能优化这么多年,我发现瓶颈就那么几类。搞清楚了,你写代码时就能提前避开,而不是上线后再抓耳挠腮。
这一章,我带你逐个拆解。搞懂了这些根因,你才谈得上优化。
3.1 磁盘IO瓶颈:写操作的“背锅侠”
磁盘IO,算是写入瓶颈里最好定位的一个了。指标太明显,你一看监控面板就知道了。
典型的场景是这样的:你有个业务系统,每秒要写几千条日志或埋点数据。刚开始没问题,等数据量上来,写入速度往下掉,最后直接打满磁盘队列。我见过一个团队,线上日志落盘慢,整个应用的延迟从10ms飙到800ms。查了半天,发现是机械盘的随机写惹的祸。
具体来说,磁盘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 说明磁盘几乎满载
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% 都在锁竞争和线程调度上打转。
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 忙不过来。
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 |
掌握了这些根因,接下来我们才能谈“怎么优化”。不然你以为调了个参数就能解决所有问题?不可能的。先诊断,再动手,这是我在无数事故中学到的教训。