Обработка огромных объёмов данных в современных приложениях является одной из самых сложных и интересных задач. Особенно если речь идёт о файлах, содержащих миллиарды строк, которые архивируют значимый массив информации. В данной статье расскажем, как на платформе Bun и с помощью TypeScript можно эффективно обработать файл с миллиардами строк размером 13.8 ГБ менее чем за 10 секунд, рассмотрим возникающие при этом проблемы и пути их решения. Изначально основные сложности связаны с ограничениями памяти, многопоточностью в однопоточной среде и характером ввода-вывода.
Работа с большими файлами требует грамотного подхода к чтению данных, чтобы избежать перегрузки оперативной памяти и не столкнуться с ошибками во время загрузки. Первая попытка загрузить весь файл целиком в память часто оказывается неудачной. На практике оказывается, что хотя операционная система и физически располагает достаточным объёмом RAM, платформа Bun накладывает жёсткие ограничения на размер объектов Buffer, которые в большинстве случаев не могут превышать 4 ГБ из-за технических особенностей реализации с использованием 32-битных чисел. В итоге попытка вызова readFileSync с огромным файлом приводит к ошибке ENOMEM – недостаток памяти. Чтобы решить эту проблему, необходимо отказаться от загрузки всего файла целиком и перейти к пакетной обработке данных.
Разбиение файла на небольшие чанки позволяет выделять память под manageable куски и последовательно обрабатывать их. В Bun для работы с файлами доступно несколько API: readFileSync, Bun.file с .text(), методы открывания и чтения с использованием fd (file descriptor), а также поточное чтение с readline. Для работы с большими объемами лучше всего подходят именно чтение чанками через открытый файловый дескриптор и потоковые методы.
Выбор размера чанка – это также важный момент. Эксперименты показали, что слишком большие буферы (например, 4 ГБ) не дают прироста в скорости и даже замедляют процесс из-за затрат на выделение и инициализацию памяти. С другой стороны, слишком маленькие размеры увеличивают количество системных вызовов и оверхед при обработке. Оптимальным размером оказался буфер в диапазоне 128 КБ, что совпадает с размерами страниц памяти и параметрами readahead в современных ОС. Это даёт почти мгновенное чтение файла без падений и стабильную работу процесса.
Однако прочитывать файл кусками недостаточно. Поскольку данные распределены по строкам, необходимо гарантировать, что чанк не заканчивается посередине строки. Иначе потеряются данные или возникнут ошибки при парсинге. Для этой цели внедрена функция, которая при разбиении файла под каждый поток определяет границы чанка, пробегая назад от отметки, заданной по длине. Она ищет символ перевода строки '\n' и устанавливает границу чанка именно в конце строки.
Таким образом гарантируется, что каждый поток получит на обработку полный набор строк без пересечений и потерь. Для максимальной скорости обработки важно не только эффективно читать данные, но и задействовать весь доступный ресурс процессора. Bun — однопоточная платформа, что ограничивает использование нескольких ядер напрямую. Чтобы обойти это, возникает идея использования воркеров — отдельных процессов или потоков, которые параллельно обрабатывают выделенные части файла. Каждый воркер читает свой чанок, парсит строки и аккумулирует статистику локально.
В конце результаты всех воркеров собираются и агрегируются в глобальный статистический отчёт. Создание ровного числа чанков, равного количеству CPU ядер, и распределение работы между воркерами позволяет добиться масштабируемости и снизить время обработки в несколько раз. Такой подход является аналогом технологий многопоточной обработки в других языках и фреймворках, но реализован с учётом особенностей Bun и JavaScript. Следующий узкий горлышко — парсинг строк из их текстового представления. В некоторых инструментах для построчного чтения используется встроенное преобразование в UTF-8 строку, что стоит больших затрат процессорного времени при обработке миллиардов записей.
Исходя из этого, решение было найдено в отказе от дорогих преобразований с использованием строк в пользу прямого парсинга байтов. Анализ каждого байта из прочитанного буфера позволяет существенно ускорить распознавание данных. В файле ожидаются ограниченный набор символов — латинские буквы, цифры, точка с запятой, знак минуса, цифры с плавающей точкой и символ перевода строки. Зная это, можно последовательно пройтись по байтам, определить позицию разделителя (';'), отделить имя станции и температуру, а затем численно собрать число из ASCII-кодов символов без промежуточных конвертаций в строки. Такой байтовый парсинг, реализованный через проверку кодов символов и построение численных значений вручную, позволяет избежать лишнего времени на преобразование и аллокацию строк, что при работе с миллиардами строк даёт выигрыш в десятки процентов по времени.
Кроме того, для каждого узла информации аккумулируется статистика: минимум, максимум, сумма и количество измерений, что в конце позволяет вычислить среднее значение температуры для каждой станции. Хранение этой информации в Map или хеш-таблице с быстрым доступом позволяет вести подсчёт за один проход файла. В итоге, комплексно применяя все перечисленные техники — правильный выбор размера буфера, аккуратное разбиение файла с сохранением целостности строк, использование многопоточности через воркеры и оптимизированный байтовый парсинг — удалось ускорить процесс обработки файла с миллиардами строк до 9.22 секунд. Этот результат впечатляет для языка на базе JavaScript, обычно не ассоциируемого с высокопроизводительной обработкой больших данных.
Он показывает перспективы и возможности платформы Bun и TypeScript в нетипичных задачах, выходящих за пределы стандартного веб-разработчика. Важным выводом из проделанной работы является осознание ограничений платформы и необходимость глубокого понимания внутренней работы инструментов и операционной системы. В частности, о лимите буфера на 4 ГБ, поведении планировщика и файловой системы, обработке памяти и необходимости работы с низкоуровневыми байтовыми массивами. Также работа с воркерами в Bun показала, что хотя подход возможен, он требует дополнительной обвязки и разделения кода, что немного усложняет разработку по сравнению с языками, изначально заточенными под параллелизм, такими как Go или Rust. Тем не менее его использование значительно расширяет возможности в однопоточном окружении JavaScript.