Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save inilim/279b0e50d2ccda5ff153155e16d8fc92 to your computer and use it in GitHub Desktop.

Select an option

Save inilim/279b0e50d2ccda5ff153155e16d8fc92 to your computer and use it in GitHub Desktop.
[Статья] PHP, SQLite и Fiber: Неочевидный трюк для экономии памяти

В PHP 8.1 появились файберы (Fibers), а в SQLite 3.38.0 — встроенная поддержка json_tree. Что будет, если объединить эти две технологии с кастомной функцией PDO? Получится весьма экзотический, но эффективный способ обработки данных с минимальным оверхедом по памяти.

В чем проблема?

Стандартный json_decode в PHP работает отлично, пока вы не сталкиваетесь с мегабайтными файлами.

Существуют стримовые парсеры, но они часто медленнее и сложнее в использовании. Наш подход — это "золотая середина": проще стриминга, но экономнее json_decode.

Суть метода

Идея состоит в том, чтобы передать "тяжелую" работу по обходу структуры данных внутрь SQLite (который написан на C и очень эффективен), а данные в PHP получать порционно через механизм Fiber::suspend().

Вот как это выглядит в коде:

<?php

/**
 * 1. Экономия памяти: Если JSON весит 5MB, этот метод займет около 12-15MB.
 *    Стандартный "json_decode" часто требует x10 от размера файла.
 * 2. SQLite "Shadow RAM": В PHP 8.4 отслеживание памяти, потребляемой SQLite,
 *    может работать иначе и не всегда учитываться в memory_limit скрипта напрямую. (Win)
 */
function example_parse(string $json): \Generator
{
    // Используем in-memory базу данных
    $pdo = new \PDO('sqlite::memory:');
    
    // Регистрируем PHP-функцию, которую вызовет SQLite
    // Это ключевой момент: функция приостанавливает выполнение запроса
    $pdo->sqliteCreateFunction('FETCH', static function ($key, $value, $type, $fullkey) {
        \Fiber::suspend([$key, $value, $type, $fullkey]);
        return null; // Возвращаем null, чтобы условие WHERE не выполнялось и не формировался result set
    });

    $fiber = new \Fiber(static function (\PDO $pdo, string $json) {
        // Запрос не должен формировать выдачу (SELECT 1 ...),
        // это упрощает работу движка SQLite.
        // Мы "перехватываем" данные прямо во время фильтрации.
        $stmt = $pdo->prepare('
            SELECT 1 
            FROM json_tree(:json)
            WHERE [key] IS NOT NULL 
            AND FETCH([key], [value], [type], [fullkey]) = 1
        ');

        $stmt->execute(['json' => $json]);
    });

    // Запускаем файбер и начинаем "вытягивать" данные
    $value = $fiber->start($pdo, $json);

    while (!$fiber->isTerminated()) {
        yield $value;
        $value = $fiber->resume();
    }
}


// Пример использования
$json = file_get_contents('big.json');


foreach(example_parse($json) as $node){
    [$key, $value, $type, $fullkey] = $node;
    // Обрабатываем каждый узел по отдельности
    // echo "$fullkey: $value\n";
}

Как это работает под капотом

  1.  SQLite как итератор: Функция json_tree в SQLite рекурсивно обходит JSON.
  2.  sqliteCreateFunction + FETCH: Мы создаем мост. Когда SQLite находит узел, он вызывает нашу PHP-функцию FETCH.
  3.  Fiber pause: Внутри FETCH мы не возвращаем результат сразу. Мы говорим скрипту: "Стоп! Верни управление внешнему циклу с текущими данными" (Fiber::suspend).
  4.  Yield: Внешний цикл получает данные через генератор, обрабатывает их и командует Fiber::resume(), заставляя SQLite продолжить работу с того места, где он остановился.

Важный нюанс: Обратите внимание на SELECT 1 ... AND FETCH(...) = 1. Мы специально пишем запрос так, чтобы SQLite оптимизация понимала что выборки нет. Мы используем его движок WHERE клаузы как итератор.

Еще экономия: файловая база вместо in-memory

В примере выше мы использовали sqlite::memory:, но сам JSON всё ещё находится в переменной $json в памяти PHP, можно пойти дальше:

function example_parse_file(string $jsonFilePath): \Generator
{
    // Создаем временную файловую базу SQLite
    $file = \tmpfile();
    $tempDb = \stream_get_meta_data($file)['uri']; // eg: /tmp/phpFx0513a
    $pdo = new \PDO("sqlite:$tempDb");
    
    $pdo->sqliteCreateFunction('FETCH', static function ($key, $value, $type, $fullkey) {
        \Fiber::suspend([$key, $value, $type, $fullkey]);
        return null;
    });

    // Создаем таблицу и загружаем JSON прямо из файла
    $pdo->exec('CREATE TABLE data (json_content TEXT)');
    // readfile() может не быть.
    $stmt = $pdo->prepare('INSERT INTO data (json_content) VALUES (readfile(:path))');
    $stmt->execute(['path' => $jsonFilePath]);
    $stmt = null;

    $fiber = new \Fiber(static function (\PDO $pdo) {
        $stmt = $pdo->prepare('
            SELECT 1 
            FROM data, json_tree(data.json_content)
            WHERE [key] IS NOT NULL 
            AND FETCH([key], [value], [type], [fullkey]) = 1
        ');
        $stmt->execute();
    });

    $value = $fiber->start($pdo);

    while (!$fiber->isTerminated()) {
        yield $value;
        $value = $fiber->resume();
    }
    
    // Очистка
    $pdo = null;
    \fclose($file);
    @\unlink($tempDb);
}

Почему это еще экономнее:

  • JSON-строка не загружается в память PHP через file_get_contents().
  • SQLite читает файл напрямую и парсит его внутри своего процесса.

Это не только про JSON: Паттерн "SQL-Итератор"

JSON здесь — лишь наглядный пример. Сила подхода в том, что вы получаете прямой доступ к потоку данных внутри SQL-запроса с возможностью управлять им из PHP.

PS: Из меня очень плохой писатель, поэтому оформление статьи при помощи LLM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment