Почему мы пока не используем базу данных
Мы оценили Turso/libSQL для нашего приложения рукописных заметок на iPad, всё измерили и выбрали плоские файлы снимков. Этой нагрузке не нужна база данных — каждый следующий шаг должен оплачивать только уже существующие проблемы.
Lulucat Notes — приложение для рукописных заметок на iPad. До прошлой недели в нём было одно полотно и не существовало понятия второй заметки. Мы собирались добавить библиотеку заметок — несколько документов, в каждом несколько страниц, — и первым архитектурным вопросом стало хранение данных.
База данных казалась очевидным ответом. Приложения для заметок хранят структурированные данные. Структурированные данные помещают в базы данных. Мы оценили Turso и его Swift SDK, запустили его в симуляторе iOS, измерили реальные данные штрихов, а затем решили им не пользоваться.
Вместо этого мы выбрали плоские файлы. Вот что мы выяснили и почему приняли такое решение.

Фото Gabriel Cox на Unsplash. Лицензия Unsplash.
Что приложение на самом деле делает с данными
У приложения для рукописного ввода узкий и предсказуемый способ доступа к данным. Чтение означает открыть страницу и сразу загрузить в память каждый её элемент — все штрихи и все изображения. Холст содержит всё; частичных запросов он не выполняет. Запись означает завершить штрих пера и добавить один элемент на страницу. В редких случаях пользователь стирает часть штриха, перемещает выделение или что-то удаляет, но это всё равно операции с одной страницей и одним элементом.
Конкурентного доступа нет. Один человек в каждый момент пишет на одной странице одного документа. Поиска между документами тоже нет — библиотеке заметок нужны только заголовок, временная метка, число страниц и миниатюра обложки каждого документа; читать содержимое страниц не требуется.
Базы данных созданы для запросов, индексов и координации конкурентного доступа. Наше приложение не использует ничего из этих трёх возможностей.
Оценка Turso
Мы оценили libsql-swift — официальный Swift SDK для движка libSQL от Turso.
SDK работает. Все 9 тестовых случаев проходят. Мы интегрировали его в копию приложения, собрали приложение для симулятора iOS, запустили его и создали локальную базу данных в sandbox приложения. В одной транзакции мы записали 100 штрихов по 3400 точек выборки каждый — 4,080,000 байт данных BLOB. На нашем рабочем Mac это заняло около 0.019s.
После выполнения PRAGMA wal_checkpoint(TRUNCATE) файл WAL уменьшился до нуля. Мы смогли скопировать один только основной файл .db в другое место, открыть его и прочитать все данные. Сам движок надёжен.
У SDK есть издержки. CLibsql.xcframework занимает 161 MB. После линковки размер Debug-сборки для симулятора вырос примерно с 1.9 MB до примерно 8.2 MB. API синхронный и блокирующий, обёрток для Swift Concurrency нет. Явного метода close() нет. Transaction.commit() не выбрасывает исключения — нижележащий C API возвращает void. README репозитория называет SDK «technical preview», а самый свежий commit был сделан примерно за год до нашей оценки, в июле 2025 года.
В экосистеме Turso есть пробел. Для новых проектов Turso теперь рекомендует свой новый движок «Turso Database» и протокол «Turso Sync». У Turso Sync есть клиентские SDK для TypeScript, Python, Go и Rust. Для Swift SDK нет. Старый режим Embedded Replica существует в libsql-swift, но его Swift-инициализатор не предоставляет параметр offline, необходимый полностью local-first мобильному приложению. Если сегодня выбрать libsql-swift, мы получим локальный форк SQLite, но не возможности синхронизации, которые делают Turso особенным.
Сколько база данных стоила бы нам сейчас
Даже если бы SDK был зрелым, нам всё равно пришлось бы платить за издержки, которые ничего не дают нашей нагрузке:
Управление дополнительными файлами WAL. Работающая база данных создаёт файлы-компаньоны -wal и -shm. Чтобы скопировать документ, нужно либо сначала выполнить checkpoint, либо атомарно скопировать все три файла. Экспорт пакета .lnote в Files или AirDrop теперь потребовал бы предварительного шага, которого пользователь не видит, а разработчик не может забыть.
Слой адаптации. Штрихи пришлось бы сериализовать в BLOB и десериализовать обратно. У элементов страницы есть естественный порядок в массиве, который холст рендерит напрямую; база данных добавила бы порядок строк и столбцы z-index. Нам пришлось бы написать слой перевода между двумя представлениями одних и тех же данных и поддерживать его при каждом изменении схемы.
Зависимость размером 161 MB. Для приложения, чья Debug-сборка меньше 2 MB, зависимость размером более 80× самого приложения — это заметная цена, особенно если она помечена как «technical preview» и не обновлялась год.
Эти издержки не гипотетические. Они начинаются в момент линковки зависимости. А платим мы за возможности — запросы, индексы и конкурентные записи, — которыми приложение не пользуется.
Решение, которое мы выпустили: пакеты файлов-снимков
Документ .lnote — это пакет-каталог:
Documents/Notes/<UUID>.lnote/
manifest.json # library cache: title, time, page count, cover
document.json # source of truth: document metadata + page order
pages/
<page-uuid>.content # one snapshot per page
assets/ # document-level shared resources
<asset-uuid>.jpg
thumbnails/
<page-uuid>.jpg # per-page thumbnail; first page doubles as cover
document.json — источник истины для структуры документа: его ID, заголовок, временные метки и упорядоченный список страниц, где для каждой страницы указаны размер холста, временные метки и число элементов. Файлы содержимого страниц хранят массив элементов в том же квантованном целочисленном формате, который приложение уже использует, — координаты и радиусы с точностью 0.1 пункта, давление в тысячных и временные метки в относительных миллисекундах.
Библиотека заметок читает только manifest.json и миниатюры обложек. Она никогда не разбирает document.json или содержимое страниц. При открытии страницы загружается один файл .content. Это единственное чтение файла, которое затрагивает данные штрихов.
Страницы решают проблему write amplification
В приложении для рукописного ввода уже есть понятие страницы — это единица, в которой мыслят пользователи и между которой они перелистывают. Если сделать страницу единицей сохранения, автосохранение будет переписывать только изменившиеся страницы.
Одна исписанная страница — скажем, 1,000–2,000 штрихов — занимает примерно 3–5 MB в нашем квантованном формате. Одна запись из 21 штриха с 3400 точками выборки после квантования занимает около 55 KB. Запись одного снимка страницы во flash-память на современном оборудовании занимает 10–20 ms. При debounce в 0.5s сохранения незаметны пользователю.
Стоимость сохранения растёт вместе с объёмом письма на текущей странице, а не с общим числом страниц документа. Блокнот на 200 страниц сохраняется ровно так же быстро, как блокнот на 2 страницы, потому что переписывается только изменённая страница.
Каждая запись использует атомарные операции с файлами — запись во временный файл, затем переименование, — поэтому сбой в середине сохранения не создаст обрезанную страницу. При переходе приложения в background все изменённые страницы немедленно сбрасываются, как и раньше, когда был один холст.
Согласованность без транзакций
У файловых пакетов нет транзакций, но есть чёткие правила владения, которые выполняют ту же задачу:
Сначала ресурсы, потом ссылки. Когда пользователь вставляет изображение, файл asset сразу записывается в assets/. Снимок страницы, ссылающийся на asset по ID, записывается позже отложенным автосохранением. В любой момент страница не ссылается на asset, которого нет на диске.
Источник истины побеждает. document.json и каталог pages/ — источник истины. manifest.json — кэш. Если они расходятся, следующее сохранение приводит кэш в соответствие с источником. Миниатюры производны и могут быть пересозданы в любой момент.
Сироты лучше висячих ссылок. Худший результат сбоя — осиротевший asset: файл в assets/, на который не ссылается ни одна страница. Сироты очищаются при закрытии документа. Обратная ситуация — страница с ссылкой на отсутствующий файл — невозможна, потому что assets записываются до снимка страницы, который на них ссылается.
Эти правила проще понимать, чем WAL checkpointing и изоляцию транзакций, и они точно соответствуют модели доступа приложения: один процесс, одна страница.
Путь обновления уже описан
Выбор плоских файлов сейчас не означает, что мы выбрали их навсегда. Структура пакета спроектирована так, чтобы обновление движка хранения меняло содержимое пакета, но не сам пакет.
Уровень 1: снимок + журнал добавления. Если write amplification когда-нибудь станет заметной — например, непрерывная запись на странице с тысячами штрихов вызовет ощутимую задержку сохранения, — каждый файл страницы разделится на снимок и журнал добавления. Новые элементы будут добавляться как фреймы [length][CRC][type][payload]. При воспроизведении журнала фрейм с несовпадающим CRC будет отброшен, что обеспечит безопасность после сбоя. Когда журнал превысит порог или страница будет закрыта, он сольётся обратно со снимком. Это примерно ~200 LOC без внешних зависимостей.
Поскольку снимки на уровне страниц уже устраняют межстраничное write amplification, этот уровень может долго не понадобиться. Переписывание страницы размером 5 MB каждые 0.5s с большим запасом укладывается в бюджет записи flash-памяти.
Уровень 2: база данных SQLite. Если приложению когда-нибудь понадобятся полнотекстовый поиск по заметкам, синхронизация отдельных элементов или индексация между документами, SQLite станет правильным инструментом. Вероятным движком тогда будет GRDB — зрелая Swift-обёртка, компилируемая из исходников и почти не увеличивающая размер бинарного файла. К libsql-swift мы вернёмся только если экосистема Turso — особенно Turso Sync для Swift — станет реальной потребностью продукта.
Миграция механическая: массив элементов каждой страницы отображается в таблицу strokes / images, с одним неизменяемым BLOB на каждый штрих (16 байт на точку выборки в двоичном формате little-endian). Бенчмарк 0.019s для 100 штрихов подтверждает жизнеспособность подхода. Перед выпуском миграция проверит, совпадают ли количества элементов и assets в старом пакете и новой базе данных, а также проходят ли проверки восстановления после сбоя, ограничений WAL и фонового flush.
Когда платить
Это решение не является оценкой баз данных. SQLite умеет работать с конкурентными записями, сложными запросами и восстановлением после сбоев в общем состоянии — сейчас приложению ничего из этого не нужно. Платить за возможности до появления проблем, которые они решают, — значит нести чистый убыток.
Издержки базы данных — зависимость, управление WAL, слой адаптации и размер бинарного файла — начинаются в момент линковки библиотеки. Преимущества начинаются, когда приложению нужно выполнять запросы, поддерживать индексы или координировать конкурентные записи. На этом этапе у него нет ни одной из этих трёх задач.
Каждый шаг развития нашего хранения данных будет оплачивать только уже возникшие проблемы. Снимки на уровне страниц оплачивают сегодняшнюю проблему: сохранение многостраничных документов без переписывания всего файла. Если write amplification станет измеримой, журнал добавления оплатит эту проблему. Если поиск или синхронизация станут потребностью продукта, база данных оплатит эту проблему.
Структура пакета, manifest и схема документа не привязаны к конкретному движку хранения. Стоимость перехода мала, потому что границы проведены в правильных местах. Когда действительно понадобится база данных, мы примем её для конкретной, уже измеренной проблемы — не для гипотетической.