
1. 先把 flush 在 FileOutputStream 上到底做了什么事说清楚1.1 它其实是个空方法什么都没做很多人对FileOutputStream调flush()的心理预期是把数据推出去但如果你翻开 JDK 源码FileOutputStream压根就没有重写flush()它继承的是OutputStream里的实现方法体是空的// java.io.OutputStream public void flush() throws IOException { }是的空的。一行都没有。所以你写fos.flush()的时候编译器不报错、IDE 不提示、运行时也不抛异常但它真的什么都没干。这不是 bug是设计使然——FileOutputStream底层就没有用户态缓冲区每次write都是一次真实的系统调用把字节交给内核。我见过不少同事在这上面卡半天调试器断点打在flush()那一行单步进去看到空方法体愣了两秒然后骂了一句。更有意思的是如果FileOutputStream外面套了一层BufferedOutputStream有些人会想我内层 flush 一下先让数据下去外层慢慢攒结果内层flush()是空操作数据全卡在BufferedOutputStream那个 8KB 的byte[]里文件大小纹丝不动。注意flush()的语义是把这一层缓冲区里的数据推给下一层。如果这一层没有缓冲区那它就没什么可推的如果下一层还有缓冲区推过去也没用。理解这句话标题里 80% 的不生效就已经破案了。1.2 一次 write 到落盘中间其实有四层缓冲要判断 flush 到底生效到什么程度得先建立一张全链路图。从你的write()调用到数据真正躺在盘片上中间穿过了四层层级谁在负责缓冲怎么才能把数据推到下一层掉电会不会丢应用层包装流缓冲BufferedOutputStream、OutputStreamWriter内部的StreamEncoder、GZIPOutputStream的Deflater调用最外层的flush()或close()会丢JDK 原生 fd 写入FileOutputStream自身无缓冲直接触发write(2)系统调用不涉及内核页缓存 page cache操作系统脏页由回写线程周期性刷fsync(2)/fdatasync(2)/sync(2)/ 打开时带O_SYNC会丢存储设备写缓存机械盘/SSD 固件里的小缓存收到SYNC CACHE命令或 FUA 标志无掉电保护的设备仍可能丢看清楚这张表标题里那句flush 操作有时不生效就有了解释空间——因为生效这个词本身是含糊的。你是希望同机器上的另一个进程能立刻读到还是希望进程被 kill -9 后数据还在还是希望机器突然断电后数据还在三种诉求对应的动作完全不同而FileOutputStream.flush()对这三种诉求的贡献都是零。我一般会先问提问的人一句你说的不生效具体是怎么观察到的这一问往往对方自己就发现问题在哪了。1.3 不生效的三种典型语义对应三套完全不同的解法第一种语义是可见性写完之后另一个进程读不到、tail -f看不到。这种情况在单机上几乎不可能发生因为write(2)返回时数据已经进了 page cache同一台机器上任何进程读同一个文件都会命中同一份页缓存。真正读不到基本都是因为数据压根没到内核——卡在应用层缓冲区了。第二种语义是进程存活JVM 被kill -9、或者 OOM Killer 干掉了重启后发现文件末尾缺了一截。这就是 page cache 的问题write返回不等于落盘脏页还没到回写周期进程一死数据就跟着内存一起没了。这种情况必须fsync。第三种语义是设备掉电机房断电、容器所在宿主机宕机。理论上fsync之后数据应该安全但如果磁盘自己的写缓存没有掉电保护且驱动没有下发SYNC CACHE仍有可能丢最后一批数据。这个层级已经超出 Java 能控制的范围了属于硬件和文件系统挂载参数的范畴。三件事混在一起谈就会得出flush 有时生效有时不生效这种玄学结论。实际上它一次都没生效过只是你观察的维度变了。2. 四种真实世界里的失效场景逐个拆开看2.1 包装流只 flush 了内层外层还在憋着这是出现频率最高的一种没有之一。典型代码长这样File file new File(/tmp/demo.log); FileOutputStream fos new FileOutputStream(file, true); BufferedOutputStream bos new BufferedOutputStream(fos, 8192); bos.write(hello\n.getBytes(StandardCharsets.UTF_8)); fos.flush(); // 以为这样就把数据推下去了 System.out.println(file.length()); // 打印 0 bos.flush(); // 这才是关键 System.out.println(file.length()); // 打印 6跑一遍第一行打印0第二行打印6。原因前面说了fos.flush()是空方法bos里那 6 个字节根本没动。比BufferedOutputStream更隐蔽的是OutputStreamWriter/FileWriter。FileWriter继承自OutputStreamWriter内部持有一个StreamEncoder默认缓冲区是 8192 字节。你用new FileWriter(f)写了一点东西看着像写进文件了其实全在StreamEncoder的ByteBuffer里必须flush或close才会编码成字节并下发。再往上一层PrintWriter、ObjectOutputStream、DataOutputStream都有各自的缓冲。一个链条套三层是常有的事PrintWriter pw new PrintWriter( new BufferedWriter( new OutputStreamWriter( new FileOutputStream(file, true), StandardCharsets.UTF_8)));这种写法不是炫技是真实项目里为了控制编码和控制换行符搞出来的。此时你只flush最里面那一层等于什么都没做。规则很简单flush 永远从最外层调一层都不能漏。2.2 flush 做了它该做的但它本来就不负责落盘第二种场景是人没问题预期错了。看下面这段try (FileOutputStream fos new FileOutputStream(path, true)) { fos.write(payload); fos.flush(); // 空操作但即使有作用也只是到内核 } // 进程正常退出数据在 page cache 里还没落盘try-with-resources结束时close()会关闭 fd但关闭文件描述符并不会触发 fsync。数据安安静静躺在 page cache 里等着内核回写线程来处理。Linux 上回写有两组参数控制/proc/sys/vm/dirty_writeback_centisecs决定回写线程多久醒一次默认 500也就是 5 秒/proc/sys/vm/dirty_expire_centisecs决定脏页多久算过期默认 300030 秒。也就是说一批数据正常情况下几秒到几十秒内会被刷下去但这个时间窗口内断电就是丢。很多人在本地开发时从来没遇到过因为写完就看文件、机器也没断过电。一上生产环境容器被调度走、节点被重启、OOM 被杀日志尾部就少了一段。然后回头查代码看到flush()调了就断定flush 不生效。真正要落盘只有一条路FileDescriptor.sync()或者FileChannel.force()。这两个最终都会走到fsync(2)try (FileOutputStream fos new FileOutputStream(path, true)) { fos.write(payload); fos.getFD().sync(); // 真正的 fsync }sync()会抛SyncFailedException说明底层fsync返回了错误通常是磁盘满了或者 IO 出错这个异常不能吞。2.3 PrintWriter 的 autoFlush 只认 println不认 print这个坑我踩过一次印象很深。当时写一个按行输出的小工具用了PrintWriter pw new PrintWriter(new FileWriter(file), true); // autoFlush true pw.print(no newline here); // 这里没有 printlnautoFlush 不触发PrintWriter的autoFlush语义在 Javadoc 里写得很清楚只有在调用println、printf、format这几类方法时才会触发 flush普通的print不会。更坑的是autoFlush触发的 flush 只推到PrintWriter这一层底下FileWriter的StreamEncoder还是要靠flush才能把字节交给FileOutputStream。不过PrintWriter.flush()内部会级联调用out.flush()所以这条链路是通的只要触发了就没问题。类似的还有ObjectOutputStream。它内部有一个BlockDataOutputStream缓冲 1024 字节还有一层HandleTable做对象引用记录。如果你在两个 JVM 之间传对象写完一个对象没 flush对面的readObject就会一直阻塞等数据。这种情况必须显式flush()必要时还要reset()清掉句柄表否则同一个对象被复用引用对方可能反序列化出意料之外的结果。还有一个更隐蔽的GZIPOutputStream。它的flush()默认行为是把底层outflush 一下但Deflater内部攒着的压缩数据不会输出因为默认构造的syncFlush是false需要累积到一个完整的 deflate block 才吐出来。如果你做的是流式压缩传输发送方flush之后等着接收方解压接收方会一直卡着没数据。正确做法是用带syncFlush参数的构造函数GZIPOutputStream gz new GZIPOutputStream(new FileOutputStream(file), true); // 或者在网络流场景 GZIPOutputStream gz new GZIPOutputStream(socket.getOutputStream(), true);打开syncFlush后flush()会用Deflater.SYNC_FLUSH把当前可压缩的数据强行收尾成一个块吐出来接收方就能立刻解压出已经发送的部分。代价是压缩率会下降因为每个 SYNC_FLUSH 都会插入一个空的存储块。这个取舍要看场景实时性优先就开纯文件归档就别开。2.4 跨进程、跨机器、跨文件系统的可见性差异单机单文件系统上页缓存是共享的write返回后其他进程就能读到。但一旦跨出这个边界情况就变了。NFS 是最典型的例子。NFSv3 有所谓的 close-to-open 一致性客户端 A 写完不close客户端 B 打开文件读可能读到旧内容。因为 A 的写入还缓存在客户端本地的页缓存里没有提交到服务端。这种情况下flush()更没用你需要的是close()或者fsync()NFS 上fsync会触发 COMMIT 操作把数据推给服务端。容器场景也有类似情况。如果两个容器共享一个 volume但挂载方式不同比如一个用 bind mount一个是 volume理论上还是同一个内核页缓存可见性没问题。但如果其中一个用了O_DIRECT打开文件就会绕过页缓存直接读磁盘这时候对方写的数据还在缓存里没落盘O_DIRECT的读者是看不到的。Java 本身不直接暴露O_DIRECT但某些数据库、某些 native 库会用到。还有一种容易忽略的如果你写的是/dev/shm下的文件tmpfsfsync在 tmpfs 上是空操作返回成功但什么都没做。数据就在内存里可见性没问题但机器一重启全没了。用 tmpfs 存中间文件很常见但要清楚它的边界。3. 一次完整的排查实操从现象到定位到修复3.1 第一步永远是确定现象不是翻代码我现在的习惯是遇到数据没写进去先不看代码先做三件事确认文件路径对不对、确认文件大小变化、确认系统调用有没有发生。路径这件事听起来很傻但我真的遇到过大小写不一致导致写到另一个文件的情况。快速验证# 看进程打开了哪些文件 ls -l /proc/pid/fd/ # 看某个 fd 当前的读写偏移 cat /proc/pid/fdinfo/fdfdinfo里的pos字段是当前文件偏移量如果它是 0说明这个 fd 上确实没写过东西如果它一直在涨而文件大小不涨那问题就在别处比如写的是另一个路径或者是稀疏文件。这个信息比打断点直观得多。第二步看系统调用。只要怀疑写没写下去strace是最高效的工具strace -f -p pid -e tracewrite,writev,fsync,fdatasync,openat,close -s 200如果write系统调用压根没出现那 100% 是应用层缓冲区没刷如果write出现了但fsync没出现那就是落盘时机的问题如果write返回-1 EIO那是磁盘层面的错误得去看dmesg。再说一个我常用的土办法用一个独立的小脚本每秒stat一次文件大小把变化过程记录下来while true; do printf %s %s\n $(date %H:%M:%S) $(stat -c %s /tmp/demo.log) sleep 0.5 done跑起来之后再看业务日志两条时间线一对齐什么时候数据真正进了文件一目了然。3.2 复现把偶发变成必现偶发问题最怕的就是复现不了。我的做法是把变量一个个固定住构造一个最小可复现程序。下面这个例子模拟写入后 kill -9的场景public class CrashWriter { public static void main(String[] args) throws Exception { String path args[0]; boolean doSync args.length 1 sync.equals(args[1]); FileOutputStream fos new FileOutputStream(path, true); for (int i 0; i 100000; i) { String line line- i \n; fos.write(line.getBytes(StandardCharsets.UTF_8)); if (doSync i % 10 0) { fos.getFD().sync(); } } } }一种跑法不带sync一种带。跑到一半kill -9然后数文件行数。不带sync的那个最后几十上百行会消失带sync的最多丢 9 行因为每 10 行才 sync 一次。这个实验做完基本就能说服所有人flush在这件事上毫无贡献sync才是答案。同时它也直观地告诉你同步的代价——每 10 行 sync 一次行数丢得少但吞吐掉下来了每 1000 行 sync 一次吞吐上去了但可能丢 1000 行。这个平衡点要根据业务对数据完整性的要求来定没有标准答案。顺带说一个观察手段/proc/meminfo里的Dirty字段能直接看到当前系统有多少脏页等着回写。写一批数据后立刻看一眼Dirty涨了说明数据确实进了页缓存但还没落盘。这个数字比任何日志都有说服力。3.3 修复方案三层配套才能真的稳定位清楚之后修复不能只改一处要按层补齐。第一层把包装流的 flush 链理顺。要么统一用try-with-resources让close()兜底要么在最外层显式 flush。我倾向于前者因为close()一定能触发 flush而且异常路径也覆盖了try (BufferedWriter writer Files.newBufferedWriter(path, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { writer.write(payload); writer.flush(); // 想立刻可见就先 flush } // 离开作用域自动 close第二层落盘时机按业务需要加sync。不是所有写入都要 sync那样性能扛不住。我的判断标准是这批数据如果丢了业务会不会出问题日志丢了能接受就不 sync账务流水丢了就是事故就必须 sync。第三层考虑清楚批量策略。逐条 sync 是最蠢的做法fsync一次通常要几毫秒到几十毫秒QPS 直接被打到个位数。合理的方式是攒批public class SyncBatchWriter implements Closeable { private final FileOutputStream fos; private final int batchSize; private int pending 0; public SyncBatchWriter(File file, int batchSize) throws IOException { this.fos new FileOutputStream(file, true); this.batchSize batchSize; } public void write(byte[] data) throws IOException { fos.write(data); if (pending batchSize) { fos.getFD().sync(); pending 0; } } Override public void close() throws IOException { try { if (pending 0) { fos.getFD().sync(); } } finally { fos.close(); } } }batchSize怎么定我的经验是先测出单次fsync的耗时再用1000ms / fsync耗时估算每秒最多能做多少次 sync。比如一次 5ms那理论上限是 200 次/秒。如果你的写入速率是 2000 条/秒那batchSize至少得是 10 才能跟上再留点余量取 20 到 50 比较稳妥。注意close()里先sync再close顺序不能反。close之后 fd 就失效了再调sync会抛异常。而且close本身不会触发 fsync别指望它。3.4 性能代价实测与替代方案fsync有多贵取决于存储介质。机械盘上一次fsync常常要 5 到 20 毫秒因为要等磁头真正写完并刷新缓存SATA SSD 走SYNC CACHE大概 0.5 到 2 毫秒NVMe 盘在 0.1 毫秒量级启用了掉电保护的企业级盘更快。这个数字直接影响你的吞吐上限。我在一套日志采集的场景里做过对比不加 sync 时单线程能写到 40 万条/秒改成每条 sync 直接掉到 80 条/秒差了三个数量级。最后采用的是每秒 sync 一次 关闭时兜底 sync的策略吞吐保住 30 万条/秒最坏情况丢 1 秒数据业务方接受了这个折中。如果确实需要高频落盘又扛不住 fsync有几个思路可以试换成预分配的大文件配合FileChannel的write 定期force减少元数据更新。用fdatasync而不是完整fsync。Java 里FileChannel.force(false)在 Linux 上对应fdatasync只同步数据不同步元数据比如 mtime能省一点。但注意如果文件大小变了fdatasync也需要同步这部分元数据省不了太多。把写入改成追加式的日志结构避免随机写导致的额外元数据开销。关键数据不要只依赖落盘加一层内存队列 独立的强一致存储把持久化的责任交出去。4. 症状对照表与六条踩坑经验4.1 常见症状速查症状最可能的原因快速验证手段处理方式文件大小为 0tail没内容包装流没 flush/closestrace看不到write调最外层 flush 或补 close同机其他进程读不到同上数据没进内核/proc/pid/fdinfo看pos同上进程崩溃后末尾数据缺失数据在 page cache 没落盘对比Dirty字段变化fd.sync()或force(true)断电后数据缺失磁盘写缓存未刷新查阅设备是否带掉电保护换设备或调整挂载参数跨机器读不到NFS/网络文件系统缓存在写入端close后再读close()或fsync()触发 COMMIT流式压缩对端解不出Deflater缓冲未 SYNC_FLUSH对端持续阻塞无数据用syncFlushtrue构造PrintWriter输出不完整print不触发 autoFlush换成println试试显式flush()4.2 我在实际项目里攒下的六条经验第一条先别怀疑 flush怀疑代码路径。我经手的问题里八成以上的flush 不生效最后查出来是写到了另一个路径、另一个文件、或者是被logback的异步 appender 吞了。花五分钟确认路径和文件大小比在代码里翻半小时有用。第二条try-with-resources能救命但它不等于落盘。它能保证closeclose能保证 flush 链走完但它保证不了fsync。这两件事必须分开记。第三条FileWriter尽量别直接用。它的编码依赖平台默认值缓冲区大小也不可控。老老实实用Files.newBufferedWriter(path, StandardCharsets.UTF_8)编码明确还能配合OpenOption控制行为。第四条日志框架的immediateFlush是个双刃剑。很多日志框架默认开着立即 flush看起来日志一条不少实际上每次写日志都伴随一次系统调用高并发下这就是瓶颈。把它关掉、改成按大小或时间刷再配合定期sync性能能涨一大截。但要接受崩溃时可能丢最后几条日志这个事实这本来就不是日志框架该负责的事。第五条不要在循环里逐条 sync。这个错误新手犯得最多。哪怕你只是写个小脚本把 sync 提到循环外面速度也能差几十倍。养成批量攒、统一下发的习惯。第六条测试环境一定要模拟真实故障。本地跑通不代表线上稳。我现在的做法是每个涉及持久化的模块都写一个kill -9 测试一个进程狂写另一个脚本随机时刻kill -9然后校验数据完整性。这个测试写起来不到二十行但能提前发现绝大多数落盘逻辑的坑。5. Fork 一下思路什么时候该换 FileChannel 或者 mmap5.1 FileChannel.force 的参数不是随便填的FileChannel.force(boolean metaData)两个取值在 Linux 上的语义不一样force(false)对应fdatasync(2)只保证文件数据落盘元数据可能不刷force(true)对应fsync(2)数据和元数据都刷。什么时候需要元数据如果你的写入改变了文件大小绝大多数追加写都会改变那么文件长度属于元数据的一部分fdatasync也得把它刷下去否则重启后文件长度不对数据就成了孤儿字节。所以只有当你在一个预分配好、长度不变的区域内做覆盖写时force(false)才真的能省事。try (FileChannel ch FileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) { ByteBuffer buf ByteBuffer.wrap(payload); while (buf.hasRemaining()) { ch.write(buf); // 注意 write 可能只写一部分 } ch.force(true); }上面那个while (buf.hasRemaining())不是多余的。FileChannel.write不保证一次写完所有字节返回值是实际写入量剩下的要靠循环补。直接ch.write(buf)一行了事的写法在数据量大或者磁盘繁忙时是会丢尾巴的。这一点和FileOutputStream.write不同——后者接受byte[]参数时方法签名声明了会写完全部或者抛异常语义更强。5.2 mmap 的 force 也没有想象中那么硬内存映射文件看着很爽写起来像操作数组一样没有系统调用开销。但它的持久化语义非常模糊。MappedByteBuffer.force()在 Linux 上走的是msync(MS_SYNC)只保证把脏页写回页缓存并等待 IO 完成但它并不能保证数据真的到了盘片上——某些实现下它的行为和fsync不完全等价。更要命的是munmap的时候如果还有脏页操作系统会在后台慢慢刷进程退出那一刻并不会阻塞等待。所以你会看到我 force 了但断电后数据还是丢了这种情况。真要强持久化mmap 不是个好选择。另外一个坑是MappedByteBuffer的释放。JDK 里没有提供公开的unmap方法映射的文件在 GC 回收DirectByteBuffer之前不会解除映射。在 Windows 上这会导致文件删不掉、改不了名。想主动释放只能靠反射调用sun.misc.Unsafe.invokeCleaner而这个 API 在新版本 JDK 里被限制得越来越严。所以除非是大文件随机读、或者进程间共享内存这类场景我一般不推荐用 mmap 做写入。普通的追加写场景FileOutputStream 定期sync或者FileChannelforce都是更稳妥的选择。简单、语义清晰、不容易踩坑。花哨的东西往往意味着你得对底层有更深的了解才能驾驭。5.3 一个容易被忽略的方向直接写原始 fd最后提一个偏门但很有用的手段。如果你的场景对性能极度敏感可以绕开 Java 的流包装直接用FileDescriptor加FileOutputStream(fd)构造。这种方式下没有额外的缓冲层每次write都是直接系统调用配合fd.sync()就能精确控制落盘时机。但代价是你得自己管理缓冲区。没有BufferedOutputStream帮你合并小写入每条日志一个write系统调用上下文切换的开销反而更大。所以这个方案只适合写入块本来就很大的场景比如每次写 64KB 以上的数据块。我的经验是先把测量做起来用strace -c统计一下write的调用次数和耗时占比。如果write次数极少、单次数据量大那说明缓冲策略是对的如果write次数是写入条数的好几倍那说明缓冲区根本没起作用得回头检查包装流是不是被漏掉了。这个数据比任何猜测都靠得住。