Подробные статьи о технологиях, определяющих будущее.

Встраиваемые графовые базы данных за пределами SQLite

Почему графовые базы данных становятся встраиваемыми, что в этом даёт Rust и когда графовая модель действительно лучше реляционной для вашей задачи.

Крошечный куб из светящихся связанных узлов внутри ржавой механической оболочки

SQLite есть повсюду. Он в вашем телефоне, браузере, умном телевизоре и, скорее всего, в машине. Он решил фундаментальную задачу: дал приложениям полноценную SQL-базу данных без отдельного сервера. И сделал это настолько хорошо, что «встраиваемая база данных» и «SQLite» стали почти синонимами. Но реляционная модель SQLite подходит не всегда. Если ваши данные в первую очередь описывают связи, например социальные графы, графы зависимостей, сети знаний или задачи маршрутизации, то загонять их в таблицы с внешними ключами means запросы, которые либо чудовищно сложны, либо мучительно медленны.

Новая волна встраиваемых графовых баз данных пытается сделать для графовых данных то же, что SQLite сделал для реляционных: дать быструю, не требующую зависимостей, работающую внутри процесса базу, которая говорит на правильном языке запросов для связанных данных. Несколько таких систем написаны на Rust, и это оказалось отличным решением для этой задачи. Разберёмся, почему графовые базы становятся встраиваемыми, что экосистема Rust привносит в эту историю и когда стоит всерьёз рассматривать такую базу.

Слепая зона реляционной модели

Реляционные базы хорошо справляются с большинством паттернов данных. Связь «один ко многим»? Внешний ключ. «Многие ко многим»? Таблица связей. Простые выборки, агрегации, фильтры: SQL создан именно для этого. Проблемы начинаются, когда ваши запросы зависят от путей, глубины и связности.

Возьмём резолвер зависимостей. У вас есть пакеты, каждый из которых зависит от других пакетов, и у каждой зависимости есть ограничения по версиям. Нужно ответить на вопросы: «Если я установлю пакет X, какое полное транзитивное дерево зависимостей у него получится? Есть ли циклические зависимости? Возникают ли конфликтующие требования к версиям на любой глубине?» В SQL для этого нужны рекурсивные CTE (общие табличные выражения). Они многословны, плохо оптимизируются и становятся экспоненциально медленнее по мере роста глубины графа.

-- Finding all transitive dependencies in SQL
-- This works, but gets ugly fast
WITH RECURSIVE deps AS (
-- Base case: direct dependencies
SELECT dependency_id, 1 as depth
FROM package_dependencies
WHERE package_id = 'my-package'
UNION ALL
-- Recursive case: dependencies of dependencies
SELECT pd.dependency_id, d.depth + 1
FROM package_dependencies pd
JOIN deps d ON pd.package_id = d.dependency_id
WHERE d.depth < 50  -- safety limit to prevent infinite loops
)
SELECT DISTINCT dependency_id, MIN(depth) as min_depth
FROM deps
GROUP BY dependency_id
ORDER BY min_depth;
-- Now try detecting circular dependencies.
-- Or finding the shortest path between two packages.
-- Or querying all packages within 3 hops that match a version constraint.
-- Each query gets progressively more painful.

В графовой базе это нативный паттерн запроса. Вы не боретесь с моделью данных, а работаете вместе с ней. Обход рёбер, следование по путям, поиск циклов: всё это операции первого класса, а не рекурсия, прикрученная сбоку.

Почему важна встраиваемость

Neo4j уже больше десяти лет остаётся доминирующей графовой базой, и она действительно отличная. Но это сервер. Вы запускаете её как отдельный процесс, подключаетесь по сетевому протоколу, управляете памятью JVM и тащите на себе её операционные накладные расходы. Для продакшен-приложения с выделенной командой эксплуатации это нормально. Для CLI-утилиты, десктопного приложения, системы сборки или встраиваемого устройства это абсурд.

Идея SQLite здесь работает так же: многим сценариям нужны графовые запросы без накладных расходов на сервер. Инструмент анализа кода, который строит граф вызовов. Игровой движок, который хранит связи между сущностями. Локальное приложение для заметок с двусторонними ссылками. Анализатор сетевой топологии. Всем им нужны графовые запросы, и никто не хочет заставлять пользователей ставить и настраивать сервер баз данных.

Встраиваемые графовые базы работают внутри вашего процесса, хранят данные в локальных файлах и предоставляют библиотечный API вместо сетевого протокола. Ваше приложение линкуется с ними так же, как линковалось бы с SQLite. Никакого сервера, портов, аутентификации и сложностей с развёртыванием.

Что Rust привносит в графовые базы данных

Rust стал языком выбора для непропорционально большого числа новых проектов баз данных, и причины тут выходят за рамки привычного аргумента «безопасность памяти без сборщика мусора».

  • Предсказуемая задержка. Обходы графа чувствительны к задержкам. Каждый переход в обходе — это обращение к памяти, а глубокие обходы делают их миллионы. Пауза GC посреди обхода на 10 шагов съедает ваш хвост задержек. Модель владения в Rust даёт детерминированное управление памятью без пауз GC, что критично для стабильной производительности запросов.
  • Безопасная конкурентность. Графовые базы очень выигрывают от параллельного обхода. Исследование нескольких путей одновременно может превратить запрос на 100 мс в запрос на 10 мс. Система типов Rust исключает гонки данных на этапе компиляции, так что можно агрессивно распараллеливать работу, не опасаясь повредить данные графа.
  • Маленький бинарник, без рантайма. Для встраиваемой базы размер деплоя имеет значение. Графовая база на Rust собирается в одну нативную библиотеку без зависимостей от рантайма. Сравните с решением на JVM, которому нужен рантайм в 200 МБ, или с решением на Go, которое тащит сборщик мусора, о котором вы не просили.
  • C FFI. Возможность Rust экспонировать C-совместимый API означает, что базой можно пользоваться практически из любого языка. Пишете на Rust, вызываете из Python, JavaScript, Go, Swift или из чего угодно, что понимает C.

Модель property graph

Большинство встраиваемых графовых баз используют модель property graph. Её стоит понять, если вы раньше не работали с графовыми базами. Модель opирается на три примитива:

  • Узлы — сущности с меткой и свойствами «ключ-значение». Их можно представить как строки таблицы, но без фиксированной схемы.
  • Рёбра — направленные связи между узлами, у которых тоже есть метка и свойства. Метка описывает тип связи (DEPENDS_ON, AUTHORED_BY, LINKS_TO).
  • Обходы — запросы, которые идут по рёбрам от узла к узлу, при необходимости фильтруя по свойствам, агрегируя результаты или находя пути.
// Conceptual API for an embeddable graph database
let db = GraphDB::open("my_graph.db")?;
// Create nodes
let alice = db.create_node("Person", props! {
"name" => "Alice",
"role" => "engineer"
})?;
let project = db.create_node("Project", props! {
"name" => "Auth Service",
"language" => "Rust"
})?;
// Create edges
db.create_edge(alice, project, "WORKS_ON", props! {
"since" => "2025-01"
})?;
// Traverse: find all projects worked on by Alice's teammates
let results = db.traverse()
.from(alice)
.follow("WORKS_ON")        // Alice -> projects
.reverse("WORKS_ON")       // projects <- other people
.follow("WORKS_ON")        // other people -> their projects
.filter(|node| node.label() == "Project")
.collect()?;

Property graph гибче реляционных схем для быстро меняющихся данных. Не нужно заранее описывать схему или делать миграции, когда появляется новый тип связи. Просто создаёте рёбра с новыми метками. Эта схемная гибкость палка о двух концах: вы теряете гарантии, которые даёт чётко описанная схема. Но для приложений, где структура графа эволюционирует (базы знаний, социальные сети, отслеживание зависимостей), это прагматичный компромисс.

Когда действительно стоит брать графовую базу

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

Хорошо подходят: разрешение зависимостей, контроль доступа (кто через какие группы может получить доступ к чему), обнаружение мошенничества (поиск связей между сущностями), рекомендательные системы (пользователи, которым понравился X, также любят Y), анализ сетевой топологии, графы знаний и инструменты анализа кода. Общая черта: ваши запросы естественно выражают пути и связность.

Плохо подходят: простые CRUD-приложения, временные ряды, аналитика и агрегации, а также всё, где запросы в основном сводятся к «найти записи, подходящие под условие X». Если вы пишете SELECT * FROM users WHERE country = 'US' ORDER BY created_at, реляционная база — правильный инструмент. Не берите графовую базу только потому, что она звучит интересно. Берите её, если ваши запросы действительно имеют форму графа.

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

Движки хранения и компромиссы

Под капотом встраиваемые графовые базы сталкиваются с интересными решениями по движку хранения. Самые распространённые подходы:

  • Хранение списков смежности. Каждый узел хранит список исходящих рёбер. Быстро для локальных обходов (поиск соседей узла), но медленно для глобальных запросов (найти все рёбра с меткой X). Большинство встраиваемых графовых баз используют именно это, потому что локальные обходы — самая частая операция.
  • Хранение списка рёбер. Рёбра лежат в отдельной отсортированной структуре, индексированной по источнику, цели или метке. Лучше для глобальных запросов и массовых операций, но добавляет косвенность при локальных обходах.
  • Гибридные подходы. Некоторые базы используют списки смежности для обходов и поддерживают вторичные индексы по меткам рёбер или свойствам узлов для фильтрованных запросов. Это самый гибкий вариант, но он занимает больше места и делает записи дороже.

Многие графовые базы на Rust строятся поверх готовых встраиваемых хранилищ «ключ-значение», таких как RocksDB или sled. Это прагматичный выбор: персистентность, восстановление после сбоев и компакция достаются вам бесплатно, проверенными временем. Графовый слой отображает узлы и рёбра на операции с ключами и значениями. Минус в том, что потолок производительности ограничен характеристиками хранилища «ключ-значение», а графовые оптимизации (например, хранение рёбер узла рядом на диске для кэш-дружественных обходов) реализовать сложнее.

Языки запросов: открытый вопрос

SQL — универсальный язык для реляционных баз. У графовых баз такого консенсуса нет. Cypher (из Neo4j), Gremlin (из Apache TinkerPop), SPARQL (для RDF-графов) и новый стандарт GQL борются за внимание разработчиков.

Большинство встраиваемых графовых баз обходят этот вопрос, предлагая вместо языка запросов builder-API на языке хоста. Обходы собираются цепочками методов, что эргономично в типизированных языках и избавляет от сложности парсинга и оптимизации языка запросов. Цена в том, что запросы не переносимы между базами: переход с одной встраиваемой графовой базы на другую означает переписывание кода запросов.

GQL (Graph Query Language) — стандарт ISO, который должен объединить запросы к графам. Он во многом заимствует из Cypher и постепенно набирает популярность. Если вы выбираете встраиваемую графовую базу сегодня, стоит проверить, есть ли поддержка GQL в планах: стандартизированные языки запросов в long run обычно побеждают, даже если проприетарные поначалу выглядят полированнее.

Практические соображения

Если вы оцениваете встраиваемую графовую базу для своего проекта, вот что на практике действительно важно:

  1. Измеряйте на своей нагрузке. Бенчмарки графовых баз очень обманчивы. База, которая отлично справляется с мелкими широкими обходами (друзья друзей в соцсети), может буксовать на глубоких узких (разрешение цепочек зависимостей). Прототипируйте на своих реальных паттернах запросов.
  2. Проверьте надёжность при сбоях. Встраиваемые базы живут и умирают по своим гарантиям долговечности. Использует ли база write-ahead log? Защищена ли она от сбоев? Может ли восстановиться после отключения питания без потери данных? «Повреждение при неожиданном выключении» — недопустимый ответ для продакшена.
  3. Посмотрите на модель памяти. Некоторые встраиваемые графовые базы отображают весь граф в память через mmap. Это отлично работает, пока граф не превышает доступную RAM. Другие используют подход с приоритетом диска и кэшированием. Знайте, какую модель использует выбранная база, и помещается ли в неё ваш граф.
  4. Оцените качество биндингов. Если база написана на Rust, но вы используете её из Python, то Python-биндинги важнее внутренностей на Rust. Проверьте, поддерживаются ли биндинги, документированы ли они и быстры ли — или это запоздалая мысль.
  5. Подумайте о миграциях. Отсутствие схемы не означает отсутствие изменений. Когда модель графа эволюционирует, как вы будете работать с существующими данными? Некоторые базы поддерживают скрипты миграций или версионированные схемы. Другие полностью оставляют это на ваше усмотрение.

Сфера встраиваемых графовых баз пока молода по сравнению с реляционным миром. SQLite доработали больше чем за два десятилетия. Большинству встраиваемых графовых баз меньше пяти лет. Это значит больше шероховатостей, меньше ресурсов сообщества и больше рисков. Но ключевое преимущество, графовые запросы без накладных расходов на сервер, по-прежнему в силе. Для подходящей задачи встраиваемая графовая база может заменить сотни строк рекурсивного SQL несколькими строками кода обхода, работать при этом быстрее и сделать вашу модель данных действительно соответствующей решаемой задаче.