问题排查 - 存储性能问题排查
= = 虽然我们产线是做大数据与云计算相关的项目,但我们一直都比较穷,而且每次客户看起来也穷,每次给的研发服务器性能较低,一整个集群的性能可能干不过我的 13 寸 macbook ,于是出了不少奇怪的问题
2022 年互联网股票暴跌,我们集团也不例外,降本增效这玩意儿也没落下。好吧,降本,部署的 k8s 基础设施与集团的自研产品对接,于是,期间可能是对 k8s 底层不够熟悉,又或者是服务器或者网络设备性能低,踩了不少坑
本文分享对接集团期间,碰到的两个存储性能问题
一、IO 刷盘超时问题
1.1 问题现象
集群配置 8C16G,虚拟机做出来的三个节点,SATA 盘,这样的集群条件下,java 业务这边,有一个业务会做一件数据同步的功能:
- 服务 a 请求服务 b ,服务器 b 会去获取 pvc 目录中的一个 zip 文件,并将其二进制流传输给服务 a
- 服务 a 获取到文件的二进制流时,会流式解压流中的 zip 子文件(5GB 以上)到一个非 pvc 目录
- 服务 a 流式解压完一个文件后,会将此文件分片传输给服务 b,服务 b 会将文件写到某个 pvc 目录
也就是这样的一个数据同步的过程,报错,第三步,服务 b 在写文件到 pvc 目录写到最后一片,进行刷磁盘时会报错:java.io.IOException: Resource temporarily unavailable
比较致命的是:
1、java 服务 b 报错,导致业务功能失败
2、刷盘期间,整个集群的 MySQL 此时也会被影响阻塞不可用
1.2 解决方案
1.2.1 业务层方案 - 均摊每次 IO 的缓冲刷盘
服务 b 写文件是用 NIO 方案写的,将其改为 BIO 写后,1.1 的第一个问题可以解决,但 1.1 的第二个问题无法解决
因为无论是 NIO 还是 BIO,都存在一个问题,刷盘慢。NIO 最后的刷盘函数是 channel.force(true)
;BIO 最后的刷盘操作是 stream.flush()
这两步会耗时非常久,或许问题根本在于如何解决最后刷盘慢
于是针对这两个问题,每次分片写到磁盘时,就 force/flush 一次,发现最后 channel.force(true)
时不再报错,此时的效果相当于均摊刷磁盘的压力到每一次业务写,同时整个集群的 MySQL 也不会被影响了
至此,1.1 的两个问题得到解决
1.2.2 物理设备的方案 - 提升物理设备的性能
k8s 底座存储用的是 samba 协议,对 samba 服务端下面的 rbd 镜像镜像 io 读写测试,发现只能支撑 12MB/s 的最高写入吞吐,正常情况下 rbd 并发顺序的速度应该可以达到 N*100MB/s,因此不会出现这种情况,这里存储底座研发给出的建议是:提高磁盘物理设备的性能
后面我们换了更好的集群,其中一个节点是 32C128G,磁盘也是 SATA 但性能比之前的环境好很多,我没有存储层研发的专业测试手段,对于服务的 IO 性能的测试我主要通过 MySQL 的 QPS 和 TPS 进行测试
对 MySQL 建立数据库 benchmark,准备数据 prepare,客户端安装 benchmark,并跑命令 prepare 与 run,脚本详细见附录(io 性能测试 - mysql 脚本)
最后发现新的环境的 MySQL QPS & TPS 比旧环境好 9 倍 ~ 10 倍,不过此时发现 java 应用层的 io 写性能其实平均下来并没有比旧的环境好多少,估计也就性能高了 10%,脚本详细见附录(io 写性能测试 - java 脚本)
最后经过一系列的问题排查后,发现是另一个问题(详细见章节二),解决完对应的问题后,验证本小节的方案,即 提高磁盘物理设备的性能 确实可以提高应用层的刷盘时间,不过还是需要结合 1.2.1 的均摊刷盘去做,因为往往客户是不会给好的设备的,需要自己从代码层面解决
二、好的物理设备下 IO 写慢问题
2.1 问题现象
针对 1.2.1 我们换了一个物理设备性能更好的集群,但测试(附录 io 写性能测试 - java 脚本)结果发现并没有好多少?
物理设备性能升上来了,但应用层表现不尽人意?
2.2 问题排查
针对这个问题,我们专门拉上了存储层的研发一起讨论,从文件的写的过程中分析,最后排查到其实一个文件写的过程中在目前集群存在多余的节点转发
理论上可以:node2 -→ node2 io 写,现在变成了 node2 -→ master -→ node2 io 写
复现手段:进入到慢的 pvc 目录,模拟其写的过程,也就是 mount 目录到指定的存储节点 ip,并对其进行 io 写
对于 node2 -→ node2 io 写
// 先 mount node2 的 ip
mount.cifs //【这里写node2的ip】/user2/pvc-9f120b42-a1eb-4f0b-bc8c-9f607fef1857 /mnt -o username=user2,vers=1.0,port=32403,noserverino
// 压测每次写 6MB,写到 5G
strace -r dd if=/dev/zero of=/mnt/nio_test/test.img30 bs=6M count=854
// 得到写性能 145MB/S
对于 node2 -→ master -→ node2 io 写
mount.cifs //【这里写master的ip】/user2/pvc-9f120b42-a1eb-4f0b-bc8c-9f607fef1857 /mnt -o username=user2,vers=1.0,port=32403,noserverino
// 压测每次写 6MB,写到 5G
strace -r dd if=/dev/zero of=/mnt/nio_test/test.img31 bs=6M count=854
// 得到写性能 9.6MB/S
可以发现写的时候转了一道就变慢了?
同时观察网卡的速度,基本是被写满的,稳定在 10MB/S 左右,因此推测:集群的网卡/交换机网口性能较低,最后运维发现确实交换机是百兆扣,换了一个千兆口后问题解决
最后新环境的 io 写性能是旧环境的 8 倍左右,符合预期,与 MySQL 的压测性能对比来看出入较小
三、总结
虽然这个过程中运维或者其他研发都持消极态度,认为新环境的性能问题无解,但我始终相信问题只要深入,只要花时间一定能解的,最后排查到是交换机的问题,大家也都挺开心
这种物理设备的问题也是头一次排查,之前做的都是应用层问题的排查,两个问题的最大体验就是做好一个项目,物理设备的协调挺重要的,网卡、网口、磁盘、内存、交换机,都得注意
附录
io 性能测试 - mysql 脚本
sysbench \
--test='/usr/local/Cellar/sysbench/1.0.20_3/share/sysbench/tests/include/oltp_legacy/oltp.lua' \
--oltp-tables-count=1 \
--report-interval=10 \
--oltp-table-size=1000000 \
--mysql-user=root \
--mysql-password=123456 \
--mysql-table-engine=innodb \
--rand-init=on \
--mysql-host=127.0.0.1 \
--mysql-port=13336 \
--mysql-db=benchmark \
--time=300 \
--max-requests=0 \
--oltp_skip_trx=on \
--oltp_auto_inc=off \
--oltp_secondary=on \
--threads=50 \
prepare
io 写性能测试 - java 脚本
java -jar io.jar "nio"
或者 java -jar io.jar "bio"
注意里面的写目录需要改为需要的测试的 PVC 目录
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.RandomUtil;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class Application {
public static void main(String[] args) throws IOException {
String type = args[0];
if ("nio".equals(type)) {
nio();
}
if ("bio".equals(type)) {
bio();
}
}
private static void bio() throws IOException {
// open stream
String p = "/opt/sss/nio_test/" + RandomUtil.randomString(16);
File f = FileUtil.file(p);
FileUtil.del(f);
FileUtil.touch(f);
OutputStream stream = new FileOutputStream(f);
System.out.println("path ==> " + p);
// write
write5GBBio(stream);
// close stream
long l = System.currentTimeMillis();
System.out.println("stream start close...");
try {
stream.close();
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("stream close time(ms): " + (System.currentTimeMillis() - l));
}
}
private static void nio() throws IOException {
// open channel
String p = "/opt/sss/nio_test/" + RandomUtil.randomString(16);
File f = FileUtil.file(p);
FileUtil.del(f);
FileUtil.touch(f);
Path path = Paths.get(p);
FileChannel channel = FileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.READ);
System.out.println("path ==> " + p);
// write
write5GBNio(channel);
// close channel
long l = System.currentTimeMillis();
System.out.println("channel start force...");
try {
channel.force(true);
channel.close();
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("channel force time(ms): " + (System.currentTimeMillis() - l));
}
}
private static void write5GBNio(FileChannel channel) throws IOException {
// 4MB File byte Array
int size = (64 * 10 * 10 * 6) * 6;
String[] bytes = new String[size * 2];
for (int i = 0; i < size; i = i + 2) {
bytes[i] = (i + "-" + UUID.randomUUID());
bytes[i + 1] = "\n";
}
// 6M write to local,5000 MB ,about 835 times
List<Long> times = new ArrayList<>();
for (int i = 0; i < 835; i++) {
long l1 = System.currentTimeMillis();
byte[] data = toByteArray(bytes);
channel.write(ByteBuffer.wrap(data));
channel.force(true);
long l2 = System.currentTimeMillis();
times.add((l2 - l1));
System.out.println((i + 1) + ", write file byte len " + data.length + ", time(ms): " + (l2 - l1));
}
Double avg = times.stream().mapToLong(Long::longValue).average().getAsDouble();
System.out.println("write file byte avg time(ms): " + avg);
}
private static void write5GBBio(OutputStream stream) throws IOException {
// 4MB File byte Array
int size = (64 * 10 * 10 * 6) * 6;
String[] bytes = new String[size * 2];
for (int i = 0; i < size; i = i + 2) {
bytes[i] = (i + "-" + UUID.randomUUID());
bytes[i + 1] = "\n";
}
// 6M write to local,5000 MB ,about 835 times
List<Long> times = new ArrayList<>();
for (int i = 0; i < 835; i++) {
long l1 = System.currentTimeMillis();
byte[] data = toByteArray(bytes);
stream.write(data);
stream.flush();
long l2 = System.currentTimeMillis();
times.add((l2 - l1));
System.out.println((i + 1) + ", write file byte len " + data.length + ", time(ms): " + (l2 - l1));
}
Double avg = times.stream().mapToLong(Long::longValue).average().getAsDouble();
System.out.println("write file byte avg time(ms): " + avg);
}
public static byte[] toByteArray(Object obj) {
byte[] bytes = null;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try {
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(obj);
oos.flush();
bytes = bos.toByteArray();
oos.close();
bos.close();
} catch (IOException ex) {
ex.printStackTrace();
}
return bytes;
}
}