一、前言
为什么突然想起写这个话题呢?
这里先抛出两个议题:
(1)写文件应该怎么写,什么是顺序读写和随机读写?
(2)更换了SSD硬盘,随机读写也比顺序读写慢吗?
在很多初入IT门的人看来,甚是很少关注自己的程序是如何写磁盘的,往往大家认为CPU的处理能力和内存的大小对系统的性能影响更大。其实程序员平常对于文件的读写,大部分是进行小批量小文件的操作,对于读写文件成为系统性能瓶颈的场景见得过少。自从接手了一个系统,性能甚是低下,高峰期直接卡死。运维也发现磁盘IO极高,更换了更贵的SSD磁盘,仍然无济于事。几番排查,竟然是其中一个保存附件的功能导致。几番思考,故决定写一篇博文,作为分享也作为自己的总结。
本文从硬盘的原理(机械硬盘和固态硬盘)和操作系统写硬盘的流程来分析,应该如何写硬盘,最后附上相关程序代码(先Java,C++和Python后续奉上)。
二、机械硬盘结构和原理
如果拆开硬盘,结构大抵如上图。其中几个主要的部件:磁盘、磁头、主轴。
磁盘是真正存储数据的介质。一个硬盘一般有多个磁盘,磁盘有上下两个盘面,一般来说上下两个盘面都可以存数据。
一个盘面又分为多个磁道。磁道是以主轴为中心的环形,一个盘面会很多个磁道,磁道上布满了存储数据的磁介质,如上图。
为了更好的利用存储介质,磁道又划分为多个扇区。扇区是磁盘的最小存储单元,大小一般为512b。
好了,上面机械硬盘的结构铺垫完了,开始重点部分:机械硬盘读写数据的流程是怎样的?如下:
(1)磁道移动到对应的磁道,这是由马达控制的机械动作,一般为10ms左右(取决于磁头位置和目标磁道的距离),这叫做寻道时间;
(2)等待对应的扇区旋转到磁头位置(磁头是不动的),按现在主流磁盘转速7200转/分钟,旋转一周需要8.33ms,这叫等待时间;
(3)对应扇区在磁头旋转而过,数据就被读写完成了,一般一个磁道又63个扇区,一个扇区掠过磁头的时间为 8.33ms/63=0.13ms,我们叫它传输时间。
由上面,可以推出数据的存取时间 = 寻道时间(t1) + 等待时间(t2) + 传输时间(t3)。
所有要读写硬盘时间更快,我们要t1,t2,t3尽量小。其中t2和t3是由硬盘的转速决定的,不是程序决定的,要快只能买更快(听说15000转/分钟 是天花板了)。
我们能干预的是寻道时间t1。如果我们存储的数据位置,经过精心设计,让它分布在连续的扇区,这样磁头只需要移动一次到目标磁道,然后可以顺着磁盘的旋转,一次就能读写完成,这种方式就是顺序读写。
相反,如果数据的存储位置是杂乱无章的,那么磁头需要在对应的磁道反复移动,反复等待扇区旋转到磁头位置,那么就会耗费更多的时间,这就叫做随机读写。
一般测试得到的结果,顺序读写的速度是随机读写的100多倍。由此得到的结论是,要更快的读写磁盘,尽量使用顺序读写,比如闻名的套吞吐量消息系统kafka,采用的就是这个机制。
三、固态硬盘结构和原理
上面分析了机械硬盘的原理,我们思考下,固态硬盘不像机械硬盘,没有磁头,也不需要等待磁盘旋转,是不是就没有了顺序读写和随机读写的速度差异了呢?
上图是固态硬盘实际图,原理图可以简化如下:
固态硬盘的读写虽然没有了机械硬盘的机械结构,但仍然有寻址操作和数据传输。其中寻址依赖于主控芯片,数据传输依赖于主控芯片和缓存。如果文件在闪存中是分散存储的,则需要主控芯片频繁发送地址指令,过多的指令会消耗时钟周期,如果地址指令过多,比如影响书写效率。
故顺序读写对于固态硬盘,仍比随机读写要快。一般而言,顺序读写比随机读写仍然快10倍左右(不同品牌的固态硬盘差异巨大)。
四、随机读写实现(Java)
Java由于历史渊源,读写文件有很多种方式,如下多组有不同的使用场景,可以拿走使用。
(1)使用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();}(2)带缓存方式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();}(3)流方式FileOutputStream和FileInputStream
/**以文件流的方式写文件,常用于读二进制文件,如图片、声音、影像等文件。文件名内容 */public static void writeFile(String fileName, byte[] content){ try { OutputStream out = new FileOutputStream(fileName); out.write(content); out.close(); }catch (Exception e) { e.printStackTrace(); }}/**以文件流的方式读取文件,常用于读二进制文件,如图片、声音、影像等文件。文件名 */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,5454,108108,162162,216216,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毫秒(毫秒没有打印)