Всем привет! Меня зовут Алексей, и я работаю Java‑разработчиком с 2018 года. В статье расскажу, как столкнулся с проблемой обработки MultipartFile в многопоточном режиме. Почему эта проблема возникает и какие решения существуют.

Изначально стояла задача организовать фоновую обработку Excel-файлов: принимать файл, мгновенно возвращать клиенту HTTP-200 (без данных), а обработку содержимого выполнять асинхронно в отдельном потоке.

Вроде задачка тривиальная. Делаем контроллер:

@RestController
@RequestMapping
public class FileController {

    private final FileService fileService;

    @Autowired
    public FileController(FileService fileService) {
        this.fileService = fileService;
    }

    @PostMapping(value = "/upload-from-files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<Void> saveFromFiles(@RequestPart(value = "files", required = false) List<MultipartFile> files) {
        fileService.parseValuesFromFileToDTO(files);
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

Делаем сервис для обработки с использованием CompletableFuture, чтобы руками не создавать потоки.

@Service
public class FileService {
    static final Logger log = LoggerFactory.getLogger(FileService.class);
    public void parseValuesFromFileToDTO(List<MultipartFile> files) {
        CompletableFuture<Void> filesDtoFromFile = CompletableFuture.runAsync(() ->
        {
            for(MultipartFile file : files) {
                final Integer cellIndex = 0;
                final Integer sheetIndex = 0;
                final Integer limitValues = 100;
                try {
                    Set<String> emailsFromFile = parseCellValueByCellIndexAndSheetIndexWithLimitValues(cellIndex,
                            sheetIndex,
                            limitValues,
                            file.getBytes());
                    for (String email:emailsFromFile) {
                        log.info(email);
                    }
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }

        }, Executors.newSingleThreadExecutor()).exceptionally((e) -> {
            log.error("Ошибка парсинга значений из файла(ов)", e);
            return null;
        });
        filesDtoFromFile.thenRun(() -> log.info("Чтение данных из файла(ов) завершено"));
    }

    /***
     * Получение списка уникальных значений из файла из заданного листа и колонки по индексу с ограничением количества успешно считанных значений
     *
     * @param cellIndex  - индекс колонки файла.
     * @param sheetIndex - индекс листа файла.
     * @param limit      - ограничение по количеству значений итогового списка.
     *
     * @return Коллекция уникальных записей из файла.
     */
    public static Set<String> parseCellValueByCellIndexAndSheetIndexWithLimitValues(Integer cellIndex, Integer sheetIndex,
                                                                                    Integer limit, byte[] fileBytes) throws IOException {
        Set<String> values = new HashSet<>();

        try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(fileBytes))) {
            Sheet sheet = workbook.getSheetAt(sheetIndex);
            for (Row row : sheet) {
                Cell cell = row.getCell(cellIndex);
                if (!ObjectUtils.isEmpty(cell) && Objects.equals(cell.getCellType(), CellType.STRING)) {
                    String value = cell.getStringCellValue();
                    if (!ObjectUtils.isEmpty(value)) {
                        values.add(value.toLowerCase());
                    }
                    if (Objects.nonNull(limit) && values.size() >= limit) {
                        break;
                    }
                }
            }
        }
        return values;
    }
}

Тестирую на чтении данных из одного файла — всё ок.

2025-04-03T22:03:51.803+03:00  INFO 15940 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : test1_1@gmail.com
2025-04-03T22:03:51.804+03:00  INFO 15940 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : test1_5@gmail.com
2025-04-03T22:03:51.804+03:00  INFO 15940 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : test1_2@gmail.com
2025-04-03T22:03:51.804+03:00  INFO 15940 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : test1_4@gmail.com
2025-04-03T22:03:51.804+03:00  INFO 15940 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : test1_3@gmail.com
2025-04-03T22:03:51.804+03:00  INFO 15940 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : Чтение данных из файла(ов) завершено

Тестирую на чтении двух файлов — получаю ошибку:

2025-04-04T20:43:33.524+03:00  INFO 9020 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : test1_1@gmail.com
2025-04-04T20:43:33.524+03:00  INFO 9020 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : test1_5@gmail.com
2025-04-04T20:43:33.524+03:00  INFO 9020 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : test1_2@gmail.com
2025-04-04T20:43:33.524+03:00  INFO 9020 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : test1_4@gmail.com
2025-04-04T20:43:33.524+03:00  INFO 9020 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : test1_3@gmail.com
2025-04-04T20:43:33.525+03:00 ERROR 9020 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : Ошибка парсинга значений из файла(ов)

java.util.concurrent.CompletionException: java.lang.RuntimeException: java.nio.file.NoSuchFileException: C:\Users\Knd-a-aaosetskiy\AppData\Local\Temp\tomcat.8080.4482325861160796176\work\Tomcat\localhost\ROOT\upload_8740c826_186b_49e1_b8e7_a358cde48630_00000001.tmp
    at java.base/java.util.concurrent.CompletableFuture.wrapInCompletionException(CompletableFuture.java:323) ~[na:na]
    at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:359) ~[na:na]
    at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:364) ~[na:na]
    at java.base/java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1851) ~[na:na]
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) ~[na:na]
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) ~[na:na]
    at java.base/java.lang.Thread.run(Thread.java:1575) ~[na:na]
Caused by: java.lang.RuntimeException: java.nio.file.NoSuchFileException: C:\Users\Knd-a-aaosetskiy\AppData\Local\Temp\tomcat.8080.4482325861160796176\work\Tomcat\localhost\ROOT\upload_8740c826_186b_49e1_b8e7_a358cde48630_00000001.tmp
    at com.example.file_multithreading_problem.services.FileService.lambda$parseValuesFromFileToDTO$0(FileService.java:43) ~[classes/:na]
    at java.base/java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1848) ~[na:na]
    ... 3 common frames omitted
Caused by: java.nio.file.NoSuchFileException: C:\Users\Knd-a-aaosetskiy\AppData\Local\Temp\tomcat.8080.4482325861160796176\work\Tomcat\localhost\ROOT\upload_8740c826_186b_49e1_b8e7_a358cde48630_00000001.tmp
    at java.base/sun.nio.fs.WindowsException.translateToIOException(WindowsException.java:85) ~[na:na]
    at java.base/sun.nio.fs.WindowsException.rethrowAsIOException(WindowsException.java:103) ~[na:na]
    at java.base/sun.nio.fs.WindowsException.rethrowAsIOException(WindowsException.java:108) ~[na:na]
    at java.base/sun.nio.fs.WindowsFileSystemProvider.newByteChannel(WindowsFileSystemProvider.java:234) ~[na:na]
    at java.base/java.nio.file.Files.newByteChannel(Files.java:380) ~[na:na]
    at java.base/java.nio.file.Files.newByteChannel(Files.java:432) ~[na:na]
    at java.base/java.nio.file.spi.FileSystemProvider.newInputStream(FileSystemProvider.java:420) ~[na:na]
    at java.base/java.nio.file.Files.newInputStream(Files.java:160) ~[na:na]
    at org.apache.tomcat.util.http.fileupload.disk.DiskFileItem.getInputStream(DiskFileItem.java:196) ~[tomcat-embed-core-10.1.34.jar:10.1.34]
    at org.apache.catalina.core.ApplicationPart.getInputStream(ApplicationPart.java:97) ~[tomcat-embed-core-10.1.34.jar:10.1.34]
    at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile.getBytes(StandardMultipartHttpServletRequest.java:259) ~[spring-web-6.2.2.jar:6.2.2]
    at com.example.file_multithreading_problem.services.FileService.lambda$parseValuesFromFileToDTO$0(FileService.java:38) ~[classes/:na]
    ... 4 common frames omitted

2025-04-04T20:43:33.533+03:00  INFO 9020 --- [File Multithreading Problem] [pool-2-thread-1] c.e.f.services.FileService               : Чтение данных из файла(ов) завершено
Начало неожиданного поведения программы
Начало неожиданного поведения программы

Первое, что делаем — проверяем, а точно ли два файла пришли в сервис?

Выделена область, в которой видно, что в сервис передаются два файла
Выделена область, в которой видно, что в сервис передаются два файла

Из скрина видно, что точно файлы пришли. Тогда нужно понять, почему файл удаляется. Давайте посмотрим документацию на MultipartFile.

Перевод описания Mutipart файла из документации:

Представление загруженного файла, полученное в результате запроса, состоящего из нескольких частей.

Содержимое файла сохраняется либо в памяти, либо временно на диске. В любом случае пользователь несет ответственность за копирование содержимого файла в хранилище на уровне сеанса или постоянное хранилище по желанию. Временное хранилище будет очищено в конце обработки запроса.


Из документации становится понятно, что MutipartFile — это объект Spring, который привязан к запросу. А откуда у нас начинается обработка http запроса?

Правильно, из DispatcherServlet.

Давайте построим цепочку вызовов и посмотрим, где файл создается:

1. DispatcherServlet.doDispatch()
2. StandardServletMultipartResolver.resolveMultipart()
3. HttpServletRequest.getParts() (Servlet API)
4. org.apache.catalina.connector.Request.parseParts() (Tomcat)

Выделил место, где создаётся временный файл
Выделил место, где создаётся временный файл

Ок, мы поняли, что файл создаётся Томкатом как временный в момент создания запроса, но кто и когда его удаляет?

Ответ кроется в последних строчках DispatcherServlet.doDispatch().

В блоке finally идёт проверка на конкурентную обработку
В блоке finally идёт проверка на конкурентную обработку

Из кода видно, что если конкуретная обработка не начата, то при завершении обработки удаляем файл.

Под конкурентной обработкой понимается запрос не пустой и асинхронный.
Получается, если Spring не знает о том, что запрос асинхронный, то просто удаляет MultipartFile.

Когда нашёл причину неожиданного поведения программы
Когда нашёл причину неожиданного поведения программы

Решение номер 1: сообщить Spring, что запрос асинхронный

Отсюда есть следующий вариант решения: сообщить Spring, что запрос асинхронный:

Для этого выносим CompletableFuture в контроллер.

Пришлось вынести CompletableFuture в Controller, чтобы аннотация Async отработала
Пришлось вынести CompletableFuture в Controller, чтобы аннотация Async отработала

Ставим аннотацию Async над методом, который вызываем, чтобы Spring понял, что этот метод асинхронный.

Выделена аннотация @Async, которая указывает Spring на необходимость асинхронной обработки файлов.
Выделена аннотация @Async, которая указывает Spring на необходимость асинхронной обработки файлов.

И добавляем аннотацию @EnableAsyncк классу с аннотацией @SpringBootApplication.
После этого ошибка ушла. 

Решение номер 2: выгрузить файлы в массив байт до обработки в CompletableFuture

Когда второй вариант может быть полезен?

Если обработку файлов нужно встроить в сервис, который уже обрабатывает json в form-data, а теперь ещё часть данных из excel файлов берёт, и вы уверены, что не будет очень больших файлов и вы не получите OutOfMemoryError.

Итог:

  • Когда делаете обработку файла в многопоточном режиме, проверьте работу программы с несколькими файлами.

  • Если работаете с MultipartFile, то учитывайте что срок времени жизни данного объекта привязан к времени жизни запроса.

  • Если обрабатываете MultipartFile в многопоточном режиме, то либо сообщите Spring об этом или выгрузите файл в память до обработки файлов в многопоточном режиме.



Ссылки:
документация для MultiPart, ссылка на код

Комментарии (1)


  1. Farongy
    17.07.2025 07:01

    }, Executors.newSingleThreadExecutor())

    Не надо так делать

    Ставим аннотацию Async над методом, который вызываем, чтобы Spring понял, что этот метод асинхронный.

    И так тоже не надо.

    На каждый входящий запрос у вас будет создаваться долгоживущий и нагруженный поток. Когда их станет 400+, вашему сервису станет плохо.

    В целом всё это выглядит странно.

    Если парсинг простой, можно делать на лету. Если сложный, то вот это:

    file.getBytes()

    выжрет память и будет ООМ.