前阵子在帮朋友排查一个数据导出服务的性能问题,程序是用fprintf往文件里写记录,单次批次数据量大概几百KB,整体吞吐就是上不去。朋友的第一反应是调大setvbuf的缓冲区,我让他先翻翻代码里是不是在每个批次末尾都调用了fflush和fsync。结果整个代码逻辑虽然写的是标准IO,实际行为却已经退化成了系统调用级别的落盘频率。排查完那一刻我意识到,很多人对“标准IO”和“系统调用”这两层关系是模糊的——日常写着fopen/fread,出问题后又不知道该往底层的read/write怎么切。
今天这篇就系统聊聊这条路:从标准IO走向系统调用,到底意味着什么、什么时候该走、怎么走才不踩坑,以及我在实际项目里验证过的性能规律和避坑经验。内容面向C/C++后端开发者、Linux服务端程序员,以及所有正在调试IO性能的人。
1. 为什么会有两套世界:标准IO与系统调用的本质差异
1.1 两层IO栈:C库与内核的分工
标准IO指的是C标准库提供的fopen、fread、fwrite、fprintf、fflush、fclose这一整套流操作接口。它们返回值操作的是FILE *,一个由libc维护的结构体。系统调用指的是操作系统内核暴露的接口,对应到文件读写就是open、read、write、close、lseek、fsync,操作对象是文件描述符fd。
关键区别在于:标准IO并不是另一个独立的IO通道,而是建立在系统调用之上的一层封装。fread真正读数据,最终还是会调用read;fwrite最终还是会调用write。中间隔的这层,主要就是“缓冲”。你可以把标准IO理解成内核前面加了一个用户态蓄水池,系统调用则是直接拧开水管往桶里倒水。两者不是竞争关系,而是上下游关系。
搞懂这层关系,很多问题就很好解释了:为什么fprintf之后另一进程立刻读文件可能读不到?因为数据还在当前进程的FILE缓冲区里,没有进入内核。为什么write之后也可能读不到?因为数据可能还在内核的页缓存里,没刷到磁盘。缓冲可以出现在两个地方,标准IO的层是用户态的,内核页缓存那层是内核态的。
1.2 标准IO的存在意义:三件事
标准IO的价值主要体现在三个点。
第一是格式化。fprintf的%d、%s、%.3f这些格式化能力是write不具备的。你可以自己写一个数字转字符串的辅助函数,再调用write,但纯手写格式化逻辑又容易出错又浪费时间,标准库把这件事做得很成熟。这是很多人留在标准IO的最大原因,而且这个理由非常正当。
第二是减少系统调用次数。每调用一次read或write,进程就要从用户态切换到内核态一次,这个切换有真实开销。对于多次小规模读写场景,比如逐行写日志,如果每行都直接write,系统调用次数就是行数。标准IO通过缓冲区把成百上千次小写入合并成一次大write,用内存拷贝的代价换掉了昂贵的上下文切换。
第三是跨平台。C标准库在不同操作系统上维护了统一的接口,fopen的代码在Linux、Windows、macOS上都能编译运行,而open在Windows下叫_open,语义还有不少差异。如果项目需要跨平台,标准IO是默认选择。
1.3 系统调用为什么“贵”
系统调用的开销来自几个方面。最核心的是用户态到内核态的切换:CPU模式切换、寄存器保存恢复、参数拷贝、内核路径上还要做权限校验、文件描述符表查找、文件系统锁处理等。这些加起来,一次简单的read或write通常要花费几百纳秒到几微秒,普通函数调用只要几个纳秒。相差两个数量级。
还有个容易被忽略的点是页面缓存锁竞争。多线程同时write同一个文件,内核里的地址空间锁、页缓存锁会成为严重瓶颈,系统调用次数越多,锁竞争越明显。这就是为什么有些程序从标准IO切到底层write后性能反而更差——因为调用次数暴增的同时触发了锁竞争。所以“走向系统调用”绝不等于“所有地方都用write”,而是要知道在什么粒度上使用它。
1.4 一个常见的误解:写完了不等于落盘了
很多人以为write返回成功就是数据写到磁盘了。这里必须澄清:write成功通常只代表数据从用户态缓冲区复制到了内核页缓存,磁盘设备的刷新由内核根据策略决定。断电、宕机时页缓存里的数据就可能丢失。要确保落盘,需要调用fsync或fdatasync。
同样的,fclose会调用fflush把用户态缓冲区的数据交给内核,但也不触发磁盘刷新。也就是说,标准IO和系统调用在“数据真正持久化”这一层是平级的,都需要fsync兜底。这是理解IO栈时最容易走偏的地方。
2. 核心API细节:从fwrite到write的逐个对照
2.1 标准IO核心函数与缓冲模式
标准IO为每个打开的文件流维护三样东西:文件描述符、缓冲区指针、缓冲模式。缓冲模式分为三类:
- 全缓冲(
_IOFBF):缓冲区满时才真正调用write。普通磁盘文件的默认模式。 - 行缓冲(
_IOLBF):写入换行符时就调用write。终端交互模式下stdout的默认模式。 - 无缓冲(
_IONBF):每次写入都直接调用write。stderr永远是这模式。
关于默认模式有个经典陷阱:stdout在终端运行时是行缓冲,但重定向到文件时却变成全缓冲。所以你的程序如果靠printf输出日志,带着终端跑没问题,重定向到文件后用tail -f看日志就会发现刷新不及时,根本原因就是缓冲模式变了。
如果你想自己控制缓冲区,用setvbuf:
#include <stdio.h> int main(void) { FILE *fp = fopen("data.log", "w"); if (!fp) return 1; char buf[8192]; setvbuf(fp, buf, _IOFBF, sizeof(buf)); // 从此刻起,fp的写入会先进buf,满了才write fprintf(fp, "hello\n"); // 此时文件里可能是空的,数据还在buf里 fclose(fp); // fclose会做fflush return 0; }注意setvbuf必须在第一次读写之前调用,而且传入的缓冲区数组必须在该文件流存活期间一直有效,否则后果比你想的严重得多。我自己就见过有人传入一个局部数组,函数返回后缓冲区栈地址释放,后续所有写入全部变脏数据。
另一个核心函数是fflush(fp),它把缓冲区内数据立刻交给内核。注意它只保证数据到了页缓存,不保证落盘。如果需要落盘,还得fsync(fileno(fp))。
2.2 系统调用核心API与返回值语义
系统调用一侧的核心函数就几个,但每个都有严格的返回语义,这是它们比标准IO“难用”的地方。
read(fd, buf, count)返回实际读到的字节数。返回0表示读到EOF,返回-1表示出错,需要查errno。读取阻塞式文件描述符时,如果信号中断了本次调用,read返回-1且errno为EINTR,这是极其常见的场景,正确处理方式是循环重试。
write(fd, buf, count)返回实际写入的字节数。它可能部分写入,比如写了3000字节就返回了3000,剩下1000字节需要你自己处理。常规文件上概率低,但管道、socket上很常见。很多人第一次手写write就栽在这上面:
#include <unistd.h> #include <errno.h> #include <stdio.h> static ssize_t robust_write(int fd, const void *buf, size_t count) { size_t done = 0; while (done < count) { ssize_t n = write(fd, (const char *)buf + done, count - done); if (n < 0) { if (errno == EINTR) continue; // 被信号打断,重试 return n; } if (n == 0) break; // 不常见,但保险起见 done += (size_t)n; } return (ssize_t)done; }自己封装一个这样的robust_write是走向系统调用后的第一课。标准IO内部其实也做了类似的循环处理,只是全被隐藏了。
open和close的坑也不少。open的flags参数很有讲究,后面实操部分细说,这里先提一个最常见的坑:O_TRUNC表示截断文件,O_APPEND表示追加写入。追加模式下,每次write的偏移都会被强制置到文件末尾,这能避免多线程写同一文件时互相覆盖,但每次写入也会带来额外的锁开销。
2.3 连接两个世界的桥:fileno/fdopen/fflush
既然系统调用和标准IO是上下游关系,实际项目中经常需要在这两层之间穿梭。C库提供了两座桥。
第一座桥是fileno(FILE *fp),从文件流拿到底层描述符。常见用途是在fprintf之后补一个fsync:
FILE *fp = fopen("data.db", "w+"); fprintf(fp, "record1\n"); fflush(fp); // 先清空用户态缓冲 fsync(fileno(fp)); // 再刷内核页缓存第二座桥是fdopen(int fd, const char *mode),把一个已打开的文件描述符包装成FILE *。这在接受外部传入的socket或文件描述符时非常有用,比如网络服务里接收连接后,想把socket交给标准IO做格式化输出:
int client_fd = accept(listen_fd, ...); FILE *client = fdopen(client_fd, "w+"); fprintf(client, "HTTP/1.1 200 OK\r\n\r\n"); fclose(client); // 注意:fclose会同时关闭底层fdfclose对fdopen包装出来的流也一样会见底下的fd关掉。如果你希望关闭文件流时保留fd,不能用fclose,要用fflush后自行调用close,但这样会丢失FILE内部的资源管理。这里没有一个完美的纯标准接口,涉及具体的libc扩展,比如__fclose这类内部函数,一般情况下不要用。更干净的做法是在设计阶段就算清楚谁负责关闭。
混合使用两层API时还有一条铁律:从标准IO切到底层调用前,先fflush;从底层调用切回标准IO前,如果不是顺序自然衔接,最好先lseek或重新定位。否则标准IO内部缓存的状态和文件实际偏移会不一致,轻则读到脏数据,重则覆盖写错位置。
2.4 各API的适用场景对照表
为了方便快速选型,我整理了一张在实际工作中反复用到的对照表:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 文本格式化输出,如日志 | fprintf+ 全缓冲 | 格式化能力强,批量化写 |
| 对每行日志的实时可见性要求高 | 行缓冲或每行后fflush | 兼顾格式化和可观测性 |
| 二进制大文件顺序读写 | read/write搭配较大缓冲 | 可控性强,调用次数少 |
| 需要精确控制每次落盘时机 | write+fsync/fdatasync | 标准IO缓冲时机不可控 |
| 管道、socket上的消息完整性 | 底层write/read | 返回语义精确,好做协议 |
| 多线程写同一文件 | open用O_APPEND+write | 避免竞争覆盖 |
| 随机访问结构化文件 | open+pread/pwrite或mmap | 避免lseek状态竞争 |
3. 实操路线:从标准IO切换到系统调用
3.1 场景A:批量数据导出的正确缓冲姿势
先从一个最常见的场景说起:程序要导出几万行文本记录,每行一条,格式固定。粗暴的做法是每行fprintf一次,然后每1000行fflush一次。但这仍然会让标准IO内部多次调用write,只不过将写盘交给了内核。
换一种思路,先把全部数据格式化到内存缓冲区里,再一次write。这种方式避免了对FILE *的反复操作,同时把系统调用次数控制到个位数:
#include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { const int rows = 50000; size_t cap = 2 * 1024 * 1024; char *buf = malloc(cap); if (!buf) return 1; size_t len = 0; for (int i = 0; i < rows; i++) { int n = snprintf(buf + len, cap - len, "row-%d|value=%d\n", i, i * 7); if (n >= 0 && (size_t)n < cap - len) { len += (size_t)n; } else { fprintf(stderr, "buffer too small, flush and continue\n"); break; } } FILE *fp = fopen("export.txt", "w"); if (fp) { fwrite(buf, 1, len, fp); fclose(fp); } free(buf); return 0; }这种做法的收益很明显:snprintf只做格式化无系统调用,最后一次性进入内核。实测导出千万行数据时,比逐行fprintf加周期性fflush快了好几倍,而且代码逻辑更简单。要注意内存缓冲的长度估算,数据量大时可以先预估行均长度,再加一个安全值,或者用动态增长的字符缓冲结构。
3.2 场景B:大文件顺序复制的两种写法
大文件复制是一个经典对比案例。第一种写法用标准IO,fread/fwrite配一个大缓冲,比如256KB。第二种直接用read/write,也是在用户态开一个同样大小的缓冲。
直接上代码:
#include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <errno.h> int copy_syscall(const char *src, const char *dst) { int fd_in = open(src, O_RDONLY); if (fd_in < 0) return -1; int fd_out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd_out < 0) { close(fd_in); return -1; } size_t bufsz = 256 * 1024; char *buf = (char *)malloc(bufsz); if (!buf) { close(fd_in); close(fd_out); return -1; } ssize_t n; while ((n = read(fd_in, buf, bufsz)) > 0) { ssize_t m = 0; while (m < n) { ssize_t w = write(fd_out, buf + m, (size_t)n - (size_t)m); if (w < 0) { if (errno == EINTR) continue; free(buf); close(fd_in); close(fd_out); return -1; } m += w; } } free(buf); close(fd_in); close(fd_out); return 0; }标准IO版本的差别就一句话的事:
FILE *in = fopen(src, "rb"); FILE *out = fopen(dst, "wb"); char *buf = (char *)malloc(256 * 1024); size_t n; while ((n = fread(buf, 1, 256 * 1024, in)) > 0) { fwrite(buf, 1, n, out); } fclose(in); fclose(out); free(buf);代码量差距很大。IO调度上,全缓冲的fread/fwrite在每次缓冲满或读空时也会调用一次系统调用,所以和直接read/write相比,只要缓冲大小一致、系统调用次数基本一致,性能不会有明显差异。如果你只看吞吐,标准IO并没有明显劣势。
但为什么还要走向系统调用?两个理由。第一是标准IO会做内部锁。fread/fwrite内部有锁保护,单线程不明显,多线程共享同一个FILE *时锁竞争十分严重。第二是错误处理的精细度。read/write能精确控制每个阶段的行为,比如部分写入、中断恢复。如果你的逻辑对这两点有要求,就该切到底层。
3.3 场景C:日志场景如何控制“落盘现场”
另有一个我在生产环境反复遇到的场景:服务日志。这里“落盘现场”指两个层面,一是数据从进程缓冲进入内核页缓存,二是数据从页缓存进入磁盘。
标准IO时代我推荐的模式是:
static FILE *log_fp; static pthread_mutex_t log_lock; void log_write(const char *line) { pthread_mutex_lock(&log_lock); fprintf(log_fp, "%s\n", line); fflush(log_fp); // 保证进入内核,不保证落盘 pthread_mutex_unlock(&log_lock); }这个模式解决了跨进程可见性问题,另一个进程tail -f能实时看到日志,因为每次写后都fflush了。代价是每次日志都触发一次write,但量级可以接受。
如果要求每条日志都真正落盘,比如审计系统、交易流水,那就需要在fflush之后追加fsync:
int fd = fileno(log_fp); fflush(log_fp); fsync(fd);这样做的成本很高,因为fsync会强迫内核等待设备写完成,单次延迟毫秒级甚至更高。按每秒50条日志算,理论上就有几十毫秒的延迟,实际压测中通常只能支撑较低吞吐。真相是:每条都fsync,性能天花板远低于你的想象。架构上的合理姿势是攒批:比如1秒内攒一次,然后批量write一次,fsync一次。业务的“精确落盘”需求必须和性能做交易,这是不能两全的。
3.4 延伸:mmap和O_DIRECT的对比
走向系统调用之后,你还会碰到两个经常被提起的底层方案:mmap和O_DIRECT。
mmap把文件区域映射到进程地址空间,访问映射区域就像访问内存。省去了一次用户态到内核态的数据拷贝,对随机访问特别友好。但它不是银弹:顺序大吞吐场景下,mmap缺页带来的页错误开销、以及内核回写时序不可控,往往让吞吐不如大块read/write。使用mmap后如果进程崩溃,脏数据回写交由内核决定,真正要把数据落到磁盘仍需msync。
O_DIRECT则是绕开页缓存,让数据直接往返于用户缓冲区与存储设备。看起来“最接近磁盘”,实际限制也多:缓冲区地址、文件偏移、读写长度通常要求对齐到512字节或4KB,不满足会返回EINVAL。绝大多数场景下,O_DIRECT性能反而不如普通write加页缓存,因为内核无法帮你做块合并、缓存命中这些优化。数据库这类自己管理缓冲池、要精确控制内存占用和落盘时机的系统才真正适合O_DIRECT,普通服务没必要直接上。
所以“从标准IO走向系统调用”并不是一路走到O_DIRECT就赢了。合理的路线图应该是:先确保标准IO的缓冲策略用对,再根据需要引入底层read/write,只有极少数特殊场景才需要mmap或O_DIRECT。
4. 性能实测与参数选择逻辑
4.1 实测设置
为了把上面的理论落到数据上,我在一台普通Linux服务器上做了一组对比测试。环境是Linux 5.15,SSD磁盘,文件系统ext4,gcc版本11.4,O2优化。测试任务是生成一个1GB的文件,分别采用以下方式:
- 方式A:
fwrite默认全缓冲,单次写4KB。 - 方式B:
fwrite全缓冲,但缓冲区调到1MB,单次写1MB。 - 方式C:每个4KB都直接
write,即无用户态缓冲。 - 方式D:每次写256KB,用
write。
每组都跑三遍取中位数,结果大致如下:
| 方案 | 系统调用次数 | 耗时 | 吞吐约值 |
|---|---|---|---|
| A:fwrite默认全缓冲 | 约26万次 | 约5秒 | 200MB/s |
| B:fwrite,1MB缓冲 | 约1000次 | 约1.6秒 | 620MB/s |
| C:write每次4KB | 约26万次 | 约8秒 | 125MB/s |
| D:write每次256KB | 约4000次 | 约1.7秒 | 590MB/s |
不同硬件上的绝对数字会有差异,但相对规律是一致的。测试结果很清楚:缓冲大小比“用标准IO还是系统调用”更影响吞吐,两者本质拼的是系统调用次数。方式B和方式D吞吐接近,方式C最慢,方式A也不快。如果再用1字节去write,那吞吐会崩到个位数MB/s,纯粹是上下文切换开销加锁开销把性能吃掉了。
4.2 结果解读
这个测试给了三个重要结论。
第一,系统调用次数和性能强相关,每次write都有实打实的固定开销。把1GB数据拆成4KB调用,要26万次系统调用,拆成1MB只要1000次,开销差了两个数量级,吞吐自然天差地别。
第二,缓冲大小的选择要结合硬件行为。不要以为越大越好。1MB和4MB在多数磁盘场景差别不大,但缓冲区过大会增加内存压力,1GB文件的复制如果贸然分配512MB缓冲并不合理。一个可以起步的经验值是256KB,后面用fstat拿到st_blksize再调大一圈都不错。
第三,标准IO并不慢,慢的是“假标准IO”。什么叫假标准IO?就是明明用的是fwrite,但每行都fflush一次,或者直接没搞清楚缓冲模式,最后行为变成了每行一次系统调用。真到了这种状态,fprintf的那点格式化优势早就被调用开销抵消了。
4.3 如何预估你的场景
性能问题不能靠猜,我给一个可以动手复盘的路径。
先统计你的IO特点:一次会话平均多少字节?总条数是多少?当前用了标准IO的哪种缓冲模式?在当前模式下,单位时间会触发多少次系统调用?最直接的工具是strace:
strace -c -e trace=read,write ./your_program拿到的结果会统计read/write的调用次数、耗时、出错次数。如果write次数级接近你的业务条目数,说明标准IO的缓冲形同虚设。举个例子,程序写了100万行日志,strace结果显示write调用约100万次,那就说明每行都在调write。这时候优化目标就很简单:把系统调用次数压下来。你可以调大缓冲、攒批、或者干脆改成整块写入。
估算延迟也有一个粗糙的公式:总耗时 ≈ 系统调用次数 × 单次调用开销 + 实际磁盘写入时间。单次调用开销在Linux普通文件上大致在1微秒量级,磁盘时间取决于数据量和设备。按这个公式,把100万次调用降到1万次,光调用开销就能省下接近1秒,更不用说缓解了锁竞争。
5. 常见问题与排查技巧实录
5.1 问题速查表
我把实际踩过或帮别人排查过的典型问题整理成了一张速查表:
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 程序脱机运行后日志刷新严重延迟 | stdout重定向到文件后变全缓冲 | setvbuf设为行缓冲或按需fflush |
fprintf后另一个进程读文件为空 | 数据还在用户态缓冲区 | 调用fflush(fp) |
调了fflush还是丢数据 | 数据在内核页缓存没落盘 | 追加fsync/fdatasync |
write返回小于请求字节数 | 管道/socket常见,文件偶尔 | 循环写入直到全部完成 |
read/write返回-1且errno是EINTR | 系统调用被信号中断 | 判断EINTR后重试 |
混用FILE*和fd时读到陈旧数据 | 标准IO内部缓冲没有同步 | 切换层之前先fflush或fseek |
| 多线程写同一文件出现交叉碎片 | 每个write不是原子的交叉 | 用O_APPEND或自行加锁 |
| 程序退出后文件内容还是空的 | 没做fclose或flush就异常退出 | 检查信号处理,补fflush |
O_DIRECT打开文件失败/读写返回EINVAL | 对齐要求未满足 | 缓冲区/偏移/长度对齐到512或4K |
| 日志写得很慢,每条延迟几十毫秒 | 每条都有fsync | 攒批再刷,降低fsync频率 |
5.2 几个容易忽略的隐藏坑
速查表之外,还有几个更容易被忽略的问题,值得单独拿出来讲。
第一个坑是ungetc和底层读混用。标准IO允许你通过ungetc把一个字符塞回缓冲区。如果你在这之后直接用fileno加read去读,read读到的偏移和FILE内部的灵魂位置已经不一致了,读出来的数据会是乱的。这是两层API混用时最隐蔽的问题之一,处理方式是读回前先fflush甚至fseek到已知位置。
第二个坑是fork之后的标准IO缓冲区。如果程序先写了一些日志到FILE缓冲区,然后fork,子进程退出时会把这部分缓冲数据再写一遍,导致日志重复或文件内容错乱。原因很简单:fork会把父进程的用户态缓冲区完整复制到子进程,子进程退出时的fclose/atexit刷新会把这部分数据写到同一个文件尾巴上。解决办法是在fork前先fflush所有FILE*,或者子进程直接_exit不走标准刷新路径。
第三个坑是关于文件偏移的:read/write自动推进文件偏移,而pread/pwrite不改变偏移。多线程并发读写时,如果用普通的read/write,所有线程共享同一个偏移,必须先加锁才能保证一致。pread/pwrite不需要锁就能并发操作不同区域,这是高并发随机读写时非常干净的选择。性能上它们和普通read/write基本一致,优势全在语义层面。
第四个坑是fclose失败被忽略。很多人写完就fclose(fp)不管返回值。写日志场景可能没事,但如果是负责数据落盘的核心代码,fclose的返回值必须检查——它可能触发最后一次fflush,如果磁盘满,这个fflush会在fclose内部失败,你不检查就等于丢了一截数据而不自知。
5.3 调试工具心得
排查IO问题时,我常用这几样工具,也推荐给读者。
strace是最直接的武器。可以跟踪单个进程的所有系统调用,看它是不是按你预期的方式做IO。strace -c做统计汇总,先看数量级对不对;strace -e trace=write -s 64可以看write的内容和大小,判断缓冲区是否真的满到触发了写入。
lsof可以看进程打开的每个文件的缓冲状态。FD列能看到u表示文件已打开。有些libc扩展还能显示缓冲区大小。
iotop观察磁盘实时读写和进程IO等待。如果程序“计算时间”很低但是iowait极高,说明瓶颈在真正落盘,而不是用户态缓冲。
还有一个很容易被忽视的点:dmesg里可能有内核打印的块层报错或文件系统错误,比如磁盘坏道、文件系统中断,这类故障在应用层只会表现为write返回EIO,不查dmesg很难定位根因。
6. 我的选择标准与最后几句经验
聊到这里,我把自己的选择标准收拢一下。
默认情况下,我建议先用标准IO,但用对方式:选好缓冲大小、理解缓冲模式、只在必要处fflush。这能覆盖80%的业务需求,代码简洁,跨平台性好。只有当项目出现以下信号时,才真正有必要走向系统调用:需要精确控制系统调用次数和落盘时机、多线程并发访问同一文件、需要处理EINTR/部分写入等细粒度错误、或者需要对非阻塞IO和事件驱动做配合。这时候你切到read/write是合理的,但注意保持足够的缓冲,不要退化成1字节一次调用的灾难现场。
从标准IO走向系统调用,不是一条“谁替代谁”的路——标准IO的好用是扎实的,但它的好用在缓冲策略正确的前提下才成立。我经手的大多数“IO性能差”问题,罪魁祸首都不是标准IO本身,而是缓冲没用好:要么无脑fflush,要么缓冲区设太小,要么fsync频率失控。真正把缓冲逻辑捋顺了,性能往往原地翻倍;这时再决定要不要下沉到系统调用,心里才有数。
最后再分享一个小技巧:不要在代码里把FILE *的缓冲策略和业务逻辑绑死。给IO层留两个开关——一个是缓冲模式的运行时配置,一个是是否启用fsync的开关。这样你在测试环境能方便地压测出“有fsync”和“无fsync”的差距,上线时可以根据业务对持久化的真实要求做权衡。这个设计只花十分钟,但能让你在日后每次性能排查中都少走弯路。