Cross-Cleaner: стриминг JSON частями вместо загрузки всей базы очистки в память

—

от автора

Стриминговый разбор JSON в Cross-Cleaner на serde/serde_json: практическая реализация

Что уже есть в коде

Cross-Cleaner уже проделал большую часть работы по снижению памяти — но не на уровне парсинга, а на уровне представления данных:

  • Arc<str> вместо String для category, program, class, sub_category, files_to_remove, directories_to_remove — с дедупликацией через string_interner (intern_arc);

  • SharedPath — путь хранится как Arc<[Arc<str>]> из интернированных сегментов, что даёт разделение общих префиксов (AppData\Local\...) между десятками тысяч записей;

  • CleanerIndex и RegistryIndex — лёгкие проекции для сканирования категорий, где «неизвестные поля игнорируются serde и не аллоцируются».

Это уже сильно снижает установившийся размер базы в памяти. Но пик потребления при десериализации всё ещё определяется тем, как именно вызывается serde_json. Если база грузится через serde_json::from_reader::<_, Vec<CleanerData>>, то в момент разбора в памяти одновременно живут: буфер чтения, строящийся Vec, и промежуточные String внутри кастомного Deserialize for CleanerData (структура Helper содержит String, а не Arc<str>). Именно Helper — главный источник временных аллокаций.

Где именно теряется память при текущем Deserialize

Кастомный impl<'de> Deserialize<'de> for CleanerData делает:

rust

#[derive(Deserialize)]struct Helper {    path: String,    category: String,    program: String,    class: String,    sub_category: String,    files_to_remove: Vec<String>,    directories_to_remove: Vec<String>,    // ...}let h = Helper::deserialize(deserializer)?;// затем всё это переинтернивается в Arc<str>

То есть на каждую запись сначала аллоцируются String, а уже потом они конвертируются в Arc<str> через intern_arc. Пиковая память на запись ≈ размер всех её строк в виде String + размер интернированных Arc<str> + накладные расходы Vec. Для одной записи это немного, но при потоковом разборе важно, что Helper освобождается сразу после конвертации — а при from_reader в Vec<CleanerData> это происходит для каждой записи последовательно, так что пик по временным строкам не суммируется. Хорошая новость: сам по себе этот Deserialize уже не «загружает всё в строки разом».

Проблема остаётся в итоговом Vec<CleanerData>: все записи живут одновременно. Для базы очистки это, вероятно, приемлемо, если она нужна целиком для сканирования. Но если задача — например, построить только CleanerIndex или прогнать категорию, держать полный Vec<CleanerData> не нужно.

Что даёт StreamDeserializer в этой кодовой базе

serde_json::StreamDeserializer уместен в двух сценариях.

Сценарий 1: построение индекса без полной базы

Если база — JSON-массив CleanerData, а нужен только CleanerIndex (для сканирования категорий), можно не десериализовать CleanerData вовсе. CleanerIndex уже игнорирует лишние поля, и serde_json не будет их аллоцировать. При потоковом обходе элементов массива в памяти живёт один CleanerIndex за раз:

rust

use std::fs::File;use std::io::BufReader;fn build_index(path: &str) -> Result<Vec<CleanerIndex>, Box<dyn std::error::Error>> {    let file = File::open(path)?;    let reader = BufReader::new(file);    let mut index = Vec::new();    for item in iter_json_array::<CleanerIndex, _>(reader) {        index.push(item?); // в памяти — только CleanerIndex, без Helper и без полных данных    }    Ok(index)}

Ключевой момент: CleanerIndex использует deserialize_shared_opt/deserialize_shared_str_default, то есть сразу интернирует в Arc<str>. Временные String живут только внутри одного вызова Deserialize и освобождаются до перехода к следующему элементу. По сравнению с Vec<CleanerData>, где каждая запись тащит files_to_remove, directories_to_remove и flags, это радикально меньше.

Сценарий 2: обработка базы по одной записи без накопления

Если CleanerData нужны, но не все сразу (например, фильтрация по ОС, по категории, немедленная регистрация в планировщике), потоковый обход позволяет не держать Vec:

rust

fn for_each_rule<F>(path: &str, mut f: F) -> Result<(), Box<dyn std::error::Error>>where    F: FnMut(CleanerData) -> Result<(), Box<dyn std::error::Error>>,{    let file = File::open(path)?;    let reader = BufReader::new(file);    for item in iter_json_array::<CleanerData, _>(reader) {        f(item?)?; // item освобождается после вызова    }    Ok(())}

Здесь Helper внутри Deserialize for CleanerData создаётся и уничтожается на каждой записи, а итоговый CleanerData живёт ровно один вызов f.

Обёртка iter_json_array под топ-левел массив

StreamDeserializer не умеет итерировать элементы массива напрямую, поэтому нужна тонкая обёртка. Она не держит данные — только читает байты и пропускает [, ,, ]. Минимальная версия:

rust

use serde::de::DeserializeOwned;use serde_json::Deserializer;use std::io::{self, Read};fn skip_ws<R: Read>(reader: &mut R) -> io::Result<Option<u8>> {    loop {        let mut b = [0u8; 1];        match reader.read(&mut b)? {            0 => return Ok(None),            _ if b[0].is_ascii_whitespace() => continue,            _ => return Ok(Some(b[0])),        }    }}fn deserialize_one<T: DeserializeOwned, R: Read>(reader: &mut R) -> io::Result<T> {    Deserializer::from_reader(reader)        .into_iter::<T>()        .next()        .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "premature EOF"))?        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))}pub fn iter_json_array<T, R>(mut reader: R) -> impl Iterator<Item = io::Result<T>>where    T: DeserializeOwned,    R: Read,{    let mut started = false;    let mut done = false;    std::iter::from_fn(move || {        if done {            return None;        }        if !started {            started = true;            match skip_ws(&mut reader) {                Ok(Some(b'[')) => {}                Ok(_) => {                    done = true;                    return Some(Err(io::Error::new(                        io::ErrorKind::InvalidData,                        "expected `[`",                    )));                }                Err(e) => {                    done = true;                    return Some(Err(e));                }            }            match skip_ws(&mut reader) {                Ok(Some(b']')) => {                    done = true;                    return None;                }                Ok(Some(first)) => {                    // «вернуть» первый байт в поток невозможно, поэтому                    // используем Cursor с цепочкой                    let chained = std::io::Cursor::new([first]).chain(reader);                    let mut chained = chained;                    return Some(deserialize_one(&mut chained));                }                Ok(None) => {                    done = true;                    return Some(Err(io::Error::new(                        io::ErrorKind::UnexpectedEof,                        "unterminated array",                    )));                }                Err(e) => {                    done = true;                    return Some(Err(e));                }            }        }        match skip_ws(&mut reader) {            Ok(Some(b',')) => Some(deserialize_one(&mut reader)),            Ok(Some(b']')) => {                done = true;                None            }            Ok(_) => {                done = true;                Some(Err(io::Error::new(                    io::ErrorKind::InvalidData,                    "expected `,` or `]`",                )))            }            Err(e) => {                done = true;                Some(Err(e))            }        }    })}

Важная деталь: deserialize_one создаёт StreamDeserializer на одном элементе и сразу его отбрасывает, поэтому внутренние буферы serde_json не накапливаются между элементами. Обёртка Cursor::new([first]).chain(reader) нужна только для первого элемента, чтобы не потерять уже прочитанный байт.

Почему это сочетается с текущим дизайном

Текущий код уже готов к потоковому использованию:

  1. CleanerIndex и RegistryIndex — готовые лёгкие типы. Их Deserialize использует deserialize_shared_opt, то есть сразу интернирует. Временные String живут в пределах одного элемента.

  2. deserialize_vec_arc — принимает Vec<String>, интернирует и возвращает Vec<Arc<str>>. Временные String для files_to_remove/directories_to_remove освобождаются сразу после интернирования, не дожидаясь конца базы.

  3. SharedPath::deserialize — читает String, разбивает на сегменты и интернирует каждый. Временная строка пути живёт только на время одного Deserialize.

  4. CleanerData::Deserialize через Helper — единственное место, где временные String живут чуть дольше, но всё равно в пределах одной записи.

То есть переход от from_reader::<_, Vec<CleanerData>> к iter_json_array::<CleanerData, _> не требует менять ни один Deserialize — он уже написан так, что временные аллокации локальны для записи.

Ограничения и подводные камни

  • StreamDeserializer и BufReader. serde_json не буферизует ввод. Без BufReader каждый read — это syscall, и потоковый разбор станет медленнее полного. BufReader обязателен.

  • Формат базы. Если база — не топ-левел массив, а, например, объект {"rules": [...]}, обёртку нужно адаптировать под префикс. Альтернатива — перевести базу на JSON Lines, тогда iter_json_array не нужен вовсе: BufReader::lines() + serde_json::from_str на строку.

  • CustomCleaner не сериализуется. Он определён в коде, а не в JSON, поэтому к нему потоковый разбор неприменим — там Vec<CustomCleaner> собирается из зарегистрированных функций.

  • CleanerDataRegistry (Windows) — обычный derive(Deserialize), без кастомного Helper. Его тоже можно гонять через iter_json_array, если база реестра — массив.

  • Порядок и агрегация. Если после разбора нужна сортировка или глобальная дедупликация по всей базе, потоковый обход сам по себе не поможет — придётся либо накапливать индекс (что и делает CleanerIndex), либо использовать внешнюю сортировку.

Ожидаемый эффект

Для базы очистки Cross-Cleaner переход на потоковый разбор даёт:

  • пик памяти при загрузке определяется одной записью (CleanerData + временный Helper), а не всей базой;

  • при построении индекса (CleanerIndex) в памяти остаются только SharedPath + три Arc<str> на запись — без files_to_remove, directories_to_remove, flags и без Helper;

  • возможность обрабатывать базы, превышающие доступную RAM, если обработка не требует держать все записи одновременно;

  • сохранение всех существующих оптимизаций (string_interner, SharedPath, Arc<str>) без изменений в Deserialize.

Заключение

В Cross-Cleaner снижение памяти уже достигнуто на уровне представления: интернирование строк и сегментированные пути. Следующий шаг — на уровне способа чтения: заменить serde_json::from_reader::<_, Vec<CleanerData>> на iter_json_array поверх StreamDeserializer (или на JSON Lines). Существующие Deserialize-реализации уже устроены так, что временные String локальны для одной записи, поэтому потоковый обход не требует их переписывать — достаточно обёртки, которая пропускает разделители массива и создаёт StreamDeserializer на каждый элемент отдельно.

ссылка на оригинал статьи https://habr.com/ru/articles/1099174/