Skip to content

1000 个线程同时写一个文件怎么设计

问题

面试官追问:如果有 1000 个线程需要同时写入同一个文件,你如何设计?这不是一个典型的八股题,但恰恰是考察并发 + IO 综合理解的高频追问。

核心思路:把"并发写"转为"有序写"

直接让 1000 个线程各自 FileOutputStream.write() 同一个文件,会带来两个问题:

  1. 文件锁竞争 — 操作系统在文件层有锁,大量线程抢锁会导致上下文切换暴涨
  2. 磁盘 IO 混乱 — 随机写入导致寻道时间(HDD)或写入放大(SSD),总吞吐反而下降

所以核心思路是把"1000 个线程同时写"变为"单线程或少量线程有序写"。下面从简单到深入,给出三层方案。

方案一:生产者-消费者模式

1000 个工作线程把数据写入一个线程安全的阻塞队列,一个单独的 IO 线程从队列中消费并写入文件。

java
public class ConcurrentFileWriter {
    private final BlockingQueue<String> queue = new ArrayBlockingQueue<>(10000);
    private final volatile boolean running = true;

    public void startWriter(Path file) {
        Thread writer = new Thread(() -> {
            try (BufferedWriter bw = Files.newBufferedWriter(file,
                    StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
                while (running) {
                    String line = queue.poll(1, TimeUnit.SECONDS);
                    if (line != null) {
                        bw.write(line);
                        bw.newLine();
                        bw.flush(); // 攒批 flush 可优化
                    }
                }
            } catch (IOException | InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "file-writer");
        writer.start();
    }

    public void write(String data) throws InterruptedException {
        queue.put(data); // 阻塞直到入队成功
    }

    public void shutdown() {
        running = false;
    }
}

优点:实现简单,解耦生产与消费。 缺点:单线程写文件,吞吐上限受限于磁盘带宽和队列大小。

方案二:每个线程写独立文件,最后合并

每个线程写自己的临时文件,所有线程完成后,主线程按顺序合并。

java
public class MergeFileWriter {
    private static final int THREAD_COUNT = 1000;
    private final Path[] tempFiles = new Path[THREAD_COUNT];

    public void write(int threadId, String data) throws IOException {
        Path temp = tempFiles[threadId];
        if (temp == null) {
            temp = Files.createTempFile("shard-" + threadId, ".log");
            tempFiles[threadId] = temp;
        }
        Files.writeString(temp, data + System.lineSeparator(),
                StandardOpenOption.APPEND);
    }

    public void merge(Path output) throws IOException {
        try (var out = new FileOutputStream(output.toFile())) {
            for (Path temp : tempFiles) {
                Files.copy(temp, out);
                Files.delete(temp);
            }
        }
    }
}

优点:零锁竞争,每个线程只写自己的文件,完全并行。 缺点:中间文件占用磁盘空间,合并阶段是串行瓶颈,大文件合并时 IO 压力大。

方案三:RandomAccessFile + 偏移预分配

预先分配好大文件空间,每个线程只负责写入自己固定的偏移区间,互不重叠。

java
public class OffsetAllocator {
    private final long chunkSize;
    private final AtomicLong nextOffset = new AtomicLong(0);

    public OffsetAllocator(long chunkSize) {
        this.chunkSize = chunkSize;
    }

    public long allocate() {
        return nextOffset.getAndAdd(chunkSize);
    }
}

// 使用
RandomAccessFile raf = new RandomAccessFile("output.log", "rw");
long offset = allocator.allocate();
raf.seek(offset);
raf.write(data.getBytes(StandardCharsets.UTF_8));

优点:无锁写入,极端场景下吞吐最高。 缺点:需要预先知道或估算文件大小;写入顺序和原始顺序未必一致,需要额外元数据。

分析:面试官到底想考什么?

这道题表面在问"用什么锁",实际考的是对 IO 瓶颈的理解深度

文件 IO 的瓶颈不在锁,在磁盘带宽。即使 1000 个线程并发写,磁盘的物理 IOPS 和写入带宽是硬上限。在 HDD 上,1000 个线程的随机写入会加剧寻道时间;在 SSD 上,并发写入会触发写入放大(Write Amplification),总吞吐反而比单线程低。

正确的优化方向

  • 攒批(Batching):攒够 4KB 或 64KB 再写,减少系统调用次数
  • 顺序写入(Sequential Write):SSD 顺序写入可达 5–7 GB/s,随机写入只有 1/10
  • 缓冲区对齐(Buffer Alignment):对齐到文件系统的块大小(通常 4KB)

P7/P8 深度:进阶方案

1. mmap(内存映射文件)

将文件映射到虚拟内存,多个线程在不同偏移位写入,底层由操作系统处理脏页回写。

java
FileChannel channel = FileChannel.open(path, 
        StandardOpenOption.READ, StandardOpenOption.WRITE, 
        StandardOpenOption.CREATE);
MappedByteBuffer buffer = channel.map(
        FileChannel.MapMode.READ_WRITE, 0, fileSize);

// 每个线程写入自己的偏移
buffer.position(offset);
buffer.put(data);

mv 减少了一次用户态到内核态的数据拷贝,性能远优于 RandomAccessFile。但需要预先分配文件大小,且不适合动态增长的文件。

2. 日志场景的专用方案:Disruptor

Log4j2 的异步 Appender 底层用 LMAX Disruptor,基于无锁环形缓冲区(RingBuffer),支持每秒百万级日志写入。核心设计:

  • 预分配 RingBuffer 槽位,避免 GC
  • CAS 替代锁,Cache-Friendly 数组结构
  • 消费者生产者并行,通过序列号(Sequence)协调

3. 带版本号的 WAL 写入

如果写入需要保证顺序(如 Write-Ahead Log),每个线程分配单调递增的序列号,写入时按序列号排序,回放时恢复正确顺序:

java
record LogEntry(long sequence, byte[] data) {}

// 写入线程
long seq = sequenceGenerator.incrementAndGet();
queue.put(new LogEntry(seq, data));

// 消费线程按 sequence 排序后写入
// 使用 PriorityBlockingQueue 或 TreeMap 缓存乱序到达的条目

总结

回答这道题的关键不是背一个固定方案,而是展示你对IO 模型、调度策略、硬件特性的综合理解:

  1. 先给出生产者-消费者方案作为 baseline
  2. 指出瓶颈在磁盘带宽而非锁
  3. 提到攒批写入、顺序 IO、缓冲区对齐等优化方向
  4. 如果面试官继续追问,抛出 mmapDisruptor 方案,展示深度

最终使用的方案取决于业务场景:日志收集用 Disruptor,配置更新用 mmap,临时数据聚合用生产者-消费者。没有银弹,但理解底层的 IO 原理后,任何场景都能找到合适的方案。


参考:Linux man page mmap(2) · LMAX Disruptor 文档 · Log4j2 异步日志设计 · Brian Goetz《Java 并发编程实战》

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。