一、前言
为什么突然想起写这个话题呢?
这里先抛出两个议题:
- 写文件应该怎么写,什么是顺序读写和随机读写?
- 更换了SSD硬盘,随机读写也比顺序读写慢吗?
在很多初入IT门的人看来,甚是很少关注自己的程序是如何写磁盘的,往往大家认为CPU的处理能力和内存的大小对系统的性能影响更大。其实程序员平常对于文件的读写,大部分是进行小批量小文件的操作,对于读写文件成为系统性能瓶颈的场景见得过少。自从接手了一个系统,性能甚是低下,高峰期直接卡死。运维也发现磁盘IO极高,更换了更贵的SSD磁盘,仍然无济于事。几番排查,竟然是其中一个保存附件的功能导致。几番思考,故决定写一篇博文,作为分享也作为自己的总结。
本文从硬盘的原理(机械硬盘和固态硬盘)和操作系统写硬盘的流程来分析,应该如何写硬盘,最后附上相关程序代码(先Java,C++和Python后续奉上)。
二、机械硬盘结构和原理
如果拆开硬盘,结构大抵如上图。其中几个主要的部件:磁盘、磁头、主轴。
磁盘是真正存储数据的介质。一个硬盘一般有多个磁盘,磁盘有上下两个盘面,一般来说上下两个盘面都可以存数据。
一个盘面又分为多个磁道。磁道是以主轴为中心的环形,一个盘面会很多个磁道,磁道上布满了存储数据的磁介质,如上图。
为了更好的利用存储介质,磁道又划分为多个扇区。扇区是磁盘的最小存储单元,大小一般为512b。
好了,上面机械硬盘的结构铺垫完了,开始重点部分:机械硬盘读写数据的流程是怎样的?如下:
- 磁道移动到对应的磁道,这是由马达控制的机械动作,一般为10ms左右(取决于磁头位置和目标磁道的距离),这叫做寻道时间;
- 等待对应的扇区旋转到磁头位置(磁头是不动的),按现在主流磁盘转速7200转/分钟,旋转一周需要8.33ms,这叫等待时间;
- 对应扇区在磁头旋转而过,数据就被读写完成了,一般一个磁道又63个扇区,一个扇区掠过磁头的时间为 8.33ms/63=0.13ms,我们叫它传输时间。
由上面,可以推出数据的 存取时间 = 寻道时间(t1) + 等待时间(t2) + 传输时间(t3)。
所有要读写硬盘时间更快,我们要t1,t2,t3尽量小。其中t2和t3是由硬盘的转速决定的,不是程序决定的,要快只能买更快(听说15000转/分钟 是天花板了)。
我们能干预的是寻道时间t1。如果我们存储的数据位置,经过精心设计,让它分布在连续的扇区,这样磁头只需要移动一次到目标磁道,然后可以顺着磁盘的旋转,一次就能读写完成,这种方式就是顺序读写。
相反,如果数据的存储位置是杂乱无章的,那么磁头需要在对应的磁道反复移动,反复等待扇区旋转到磁头位置,那么就会耗费更多的时间,这就叫做随机读写。
一般测试得到的结果,顺序读写的速度是随机读写的100多倍。由此得到的结论是,要更快的读写磁盘,尽量使用顺序读写,比如闻名的套吞吐量消息系统kafka,采用的就是这个机制。
- 固态硬盘结构和原理
上面分析了机械硬盘的原理,我们思考下,固态硬盘不像机械硬盘,没有磁头,也不需要等待磁盘旋转,是不是就没有了顺序读写和随机读写的速度差异了呢?
上图是固态硬盘实际图,原理图可以简化如下:
固态硬盘的读写虽然没有了机械硬盘的机械结构,但仍然有寻址操作和数据传输。其中寻址依赖于主控芯片,数据传输依赖于主控芯片和缓存。如果文件在闪存中是分散存储的,则需要主控芯片频繁发送地址指令,过多的指令会消耗时钟周期,如果地址指令过多,比如影响书写效率。
故顺序读写对于固态硬盘,仍比随机读写要快。一般而言,顺序读写比随机读写仍然快10倍左右(不同品牌的固态硬盘差异巨大)。
- 随机读写实现(Java)
Java由于历史渊源,读写文件有很多种方式,如下多组有不同的使用场景,可以拿走使用。
- 使用FileWriter和FileReader
/**
* 使用FileWriter写文本文件
* @param fileName 文件名
* @param content 内容
* @param append 是否追加形式写文件
*/
public static void writeText(String fileName, String content, boolean append) {
try {
FileWriter writer = new FileWriter(fileName, append);
writer.write(content);
writer.close();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 使用FileReader读文本,一次读一个字符,然后组装String(效率低不建议用)
* @param fileName 文件名
* @return String
*/
public static String readText(String fileName){
StringBuilder content = new StringBuilder();
try {
File file = new File(fileName);
FileReader reader = new FileReader(file);
char[] ch = new char[1];
while (reader.read(ch) != -1) {
content.append(ch);
}
reader.close();
}catch (IOException e){
e.printStackTrace();
}
return content.toString();
}
- 带缓存方式BufferedWriter和BufferedReader
/**
* 使用缓冲FileWriter方式写文件
* @param fileName 文件名
* @param content 内容
* @param append 是否追加形式写文件
*/
public static void writeTextByBuffer(String fileName, String content, boolean append) {
try {
BufferedWriter writer = new BufferedWriter(new FileWriter(fileName, append));
writer.write(content);
writer.close();
}catch (Exception e) {
e.printStackTrace();
}
}
/**
* 使用缓冲FileReader读取文本文件,常用于读面向行的格式化文件
* @param fileName 文件名
* @return String
*/
public static String readTextByBuffer(String fileName) {
StringBuilder content = new StringBuilder();
try {
BufferedReader reader = new BufferedReader(new FileReader(fileName));
// 一次读入一行,直到读入null为文件结束
String line;
while ( (line = reader.readLine()) != null) {
content.append(line);
content.append("\n");
}
}catch (Exception e) {
e.printStackTrace();
}
return content.toString();
}
- 流方式FileOutputStream和FileInputStream
/**
* 以文件流的方式写文件,常用于读二进制文件,如图片、声音、影像等文件。
* @param fileName 文件名
* @param content 内容
*/
public static void writeFile(String fileName, byte[] content){
try {
OutputStream out = new FileOutputStream(fileName);
out.write(content);
out.close();
}catch (Exception e) {
e.printStackTrace();
}
}
/**
* 以文件流的方式读取文件,常用于读二进制文件,如图片、声音、影像等文件。
* @param fileName 文件名
*/
public static void readFile(String fileName) {
try {
InputStream in = new FileInputStream(fileName);
// 一次读多个字节
int buffSize = 100;
byte[] buff = new byte[buffSize];
while (in.read(buff) != -1) {
System.out.println(new String(buff));
//防止读不满buff,结尾处遗留了上次的内容
Arrays.fill(buff, (byte)0);
}
in.close();
}catch (Exception e) {
e.printStackTrace();
}
}
- 顺序读写(Java)
顺序读写和随机读写一个很大的不同点是,顺序读写需要维护一个文件索引信息文件。顺序写首先分配一个固定大小的文件(比如下面例子的500M),然后每次写入都紧跟着上次写入的结尾处,并把每次写入的开始位置和结束位置记录到文件索引,然后保存下来。读的时候根据索引信息读出上次写入的信息。
索引信息file_mapping格式如下,第一位是每次写入的开始位置,第二位是结束位置+1(也就是下次写入的开始位置)。
0,54
54,108
108,162
162,216
216,270
如下是实现代码:
/**
* 顺序写文件例子
* @throws Exception
*/
public static void writeFileOfSequence() throws Exception {
// 文件名
final String fileName = "file.txt";
// 文件大小
final long fileSize = 1024 * 1024 * 500;
// 文件内容索引
final String fileMapping = "file_mapping.txt";
RandomAccessFile randomAccessFile = new RandomAccessFile(fileName, "rw");
FileChannel fileChannel = randomAccessFile.getChannel();
// 开启一片内存映射,映射大小为文件大小
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileSize);
// 当前写入的文件位置
int currentPosition = 0;
// 写入后,下一个文件位置
int nextPosition;
final String content = "[abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ]";
Map<Integer, Integer> positionMap = new LinkedHashMap<>();
System.out.println(String.format("writeFileOfSequence start: %s", new Date()));
do {
// 按位置写入内容
mappedByteBuffer.position(currentPosition);
mappedByteBuffer.put(content.getBytes());
nextPosition = mappedByteBuffer.position();
//String log = String.format("index: %d, content: %s", currentPosition, content);
//System.out.println(log);
// 记录位置映射(记录当前位置和下一个位置,nextPosition - currentPosition即为内容长度)
positionMap.put(currentPosition, nextPosition);
currentPosition = nextPosition;
}while (currentPosition + content.getBytes().length <= fileSize);
mappedByteBuffer.force();
fileChannel.close();
randomAccessFile.close();
System.out.println(String.format("writeFileOfSequence end: %s", new Date()));
// 把映射信息记录到一个文件(kafka也是这样的)
StringBuilder mappingInfo = new StringBuilder();
for(Map.Entry<Integer, Integer> item: positionMap.entrySet()){
String line = String.format("%d,%d\n", item.getKey(), item.getValue());
mappingInfo.append(line);
}
writeText(fileMapping, mappingInfo.toString(), false);
}
/**
* 顺序读文件例子
* @throws Exception
*/
public static void readFileOfSequence() throws Exception{
// 文件名
final String fileName = "file.txt";
// 文件大小
final long fileSize = 1024 * 1024 * 500;
// 文件内容索引
final String fileMapping = "file_mapping.txt";
// 读入位置映射信息
Map<Integer, Integer> positionMap = new LinkedHashMap<>();
BufferedReader reader = new BufferedReader(new FileReader(fileMapping));
String line;
while ( (line = reader.readLine()) != null) {
String[] lineSplit = line.split(",");
if (lineSplit.length == 2){
positionMap.put(Integer.parseInt(lineSplit[0]), Integer.parseInt(lineSplit[1]));
}
}
System.out.println(String.format("readFileOfSequence start: %s", new Date()));
RandomAccessFile randomAccessFile = new RandomAccessFile(fileName, "rw");
FileChannel fileChannel = randomAccessFile.getChannel();
// 开启一片内存映射,映射大小为文件大小
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileSize);
// 按文件内容索引读取内容
for (Map.Entry<Integer, Integer> item: positionMap.entrySet()){
int length = item.getValue() - item.getKey();
byte[] buff = new byte[length];
mappedByteBuffer.get(buff, 0, length);
//String log = String.format("index: %d, content: %s", item.getKey(), new String(buff));
//System.out.println(log);
}
System.out.println(String.format("readFileOfSequence end: %s", new Date()));
}
- 随机读写和顺序读写对比(Java)
先分别按随机和顺序两种方式个写一个500M的文件。
随机读写主程序:
public class NormalWrite {
public static void main(String [] args) throws Exception{
final String fileName = "file-normal.txt";
FileWriter writer = new FileWriter(fileName, true);
final String content = "[abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ]";
System.out.println(String.format("NormalWrite start: %s", new Date()));
for(int i=0; i < 10000000; i++) {
writer.write(content);
}
writer.close();
System.out.println(String.format("NormalWrite end: %s", new Date()));
}
}
public class NormalRead {
public static void main(String [] args) throws Exception{
final String fileName = "file-normal.txt";
System.out.println(String.format("NormalRead start: %s", new Date()));
InputStream in = new FileInputStream(fileName);
// 一次读多个字节
int buffSize = 54;
byte[] buff = new byte[buffSize];
while (in.read(buff) != -1) {
//System.out.println(new String(buff));
//防止读不满buff,结尾处遗留了上次的内容
Arrays.fill(buff, (byte)0);
}
in.close();
System.out.println(String.format("NormalRead end: %s", new Date()));
}
}
顺序读写主程序:
public class SequenceWrite {
public static void main(String [] args) throws Exception{
writeFileOfSequence();
}
}
public class SequenceRead {
public static void main(String [] args) throws Exception{
readFileOfSequence();
}
}
随机写入后读出的时间是:5s
顺序写入后读出的时间是:x毫秒(毫秒没有打印)