Разбираю миграцию трекера здоровья на TimescaleDB: как избежать потери данных, использовать непрерывные агрегаты и last() для рестатейментов. Читайте и внедряйте!
Вы когда-нибудь задумывались, что ваш трекер здоровья может молча терять данные, пока вы думаете, что всё работает? Я столкнулся с этим и перешёл на TimescaleDB. В этой статье разберу, как архитектура с неизменяемыми отчётами и непрерывными агрегатами решает проблему, которую не могли решить обычные апсерты. Вы узнаете, как избежать потери данных и сделать систему по-настоящему надёжной.
Раньше у меня не было сервера и приложения. Health Auto Export писал JSON-файлы в папки iCloud, а launchd-агент на MacBook раз в час парсил их и делал апсерт в SQLite-файл. Это была вся система. Но проблема была в том, что файлы iCloud могли быть метаданными-заглушками, которые агент не мог материализовать. Это приводило к ошибкам и потере данных. Кроме того, два автоматизма писали одни и те же данные в разные папки, и победитель определялся сортировкой по алфавиту, пока я не изменил логику на сравнение mtime. Но mtime в iCloud обновляется ненадёжно, и это стало корнем всех проблем.
Каждый час выполнялся запрос INSERT ... ON CONFLICT DO UPDATE, который просто перезаписывал старое значение новым. Это привело к двум потерям данных. Первая: iOS-обновление молча лишило Health Auto Export прав на чтение HealthKit для веса и мышечной массы, но экспорт продолжал работать, отправляя пустые массивы. Синхронизация писала status='ok' пять дней, а данные терялись. Вторая: дневной экспорт записал частичные данные за день, заменив полные, и я заметил это случайно.
Обе проблемы — один и тот же баг: апсерт уничтожает доказательства того, что значение изменилось. В схеме нельзя было отличить «никогда не сообщалось» от «сообщалось как пустое» от «сообщалось правильно, но потом перезаписано». Правило, которое я вывел: никогда не спрашивайте, прошла ли синхронизация, спрашивайте, актуальны ли данные.
Я потратил много времени на вопрос «апсерт или добавление?» и понял, что это неправильный вопрос. Правильный: «Храню ли я наблюдение о дне или отчёт, который пришёл в определённое время?» Это разные объекты. То, что я сжёг 9 августа, — факт о 9 августа. «Мои часы сказали мне 10 августа, что я сжёг 1834 ккал 9 августа» — факт о разговоре. Первое стабильно, второе случается многократно из разных источников с разными ответами.
Старая схема смешивала оба в одну строку, поэтому обе потери были невидимы. Если разделить их, модель записи перестаёт быть спорной: отчёты всегда добавляются. В моём коде нет ни одного UPDATE, касающегося данных о здоровье. Текущая истина — это то, что вы вычисляете, а не поддерживаете. Это разница между белой доской и журналом.
Вот четыре термина, которые я использую: Наблюдение — значение метрики за день; Отчёт — источник, который в конкретное время сообщает, каким он считает наблюдение; Наблюдаемый день — календарный день в фиксированном часовом поясе; Рестатеймент — более поздний отчёт, который расходится с более ранним о том же дне.
Именно поэтому у меня два счётчика строк, и оба верны: 73 210 отчётов и 69 613 уникальных наблюдений. Разрыв — это каждый случай, когда источник менял мнение. В старой схеме разрыв был равен нулю по построению.
Обратите внимание на время в определении отчёта: «в конкретное время». Этот временной штамп упорядочивает рестатейменты. В старой системе это было mtime файла, но iCloud его ненадёжно обновляет. Теперь это время получения HTTP-запроса — не приближение, а точный момент, когда источник сообщил данные.
Вот как выглядит базальная энергия за 9 августа в порядке поступления отчётов:
value reported_at (America/Chicago)
718.42 2026-08-09 <- день в процессе
1746.06 2026-08-09
1817.51 2026-08-09
1842.11 2026-08-10 <- день закончился
1834.03 2026-08-10 и число идёт ВНИЗ
2319.02 2026-08-10
2319.02 2026-08-11 <- стабилизировалось через два дняПереход от 1842.11 к 1834.03 — ключевой. День в процессе может только накапливаться, поэтому рост числа ничего не доказывает. А вот уменьшение после окончания дня — это Apple пересматривает историю. Почти все базы данных временных рядов предполагают, что такого не бывает: записал один раз, читай вечно, прошлое неизменяемо. Но мои данные о здоровье не читали маркетинговые материалы.
Что заменило ноутбук: Health Auto Export теперь отправляет данные в Vercel-функцию по расписанию, а запланированная задача забирает историю тренировок из Liftosaur. Всё попадает в TimescaleDB на Tiger Cloud. Никаких файлов, iCloud или Mac. Next.js-приложение читает данные и сообщает, достиг ли я нормы белка — это отдельная тема.
Это изменение удалило 150 строк кода и, главное, сделало время отчёта реальным измерением, а не выводом. Также выяснилось, что HealthKit может отдать все данные с 2016 года — 87 МБ JSON. Я запросил всё и получил десять лет данных о пульсе, шагах и весе. Обратная загрузка заняла 2,2 секунды и учетверила полезную историю проекта.
Всё, что дальше — это моделирование, которое можно реализовать на любой реляционной БД. Но здесь TimescaleDB незаменим.
Таблица отчётов — это гипертаблица, секционированная по observed_on, без первичного ключа и уникальных ограничений. Это не небрежность, а суть: уникальность сделала бы рестатеймент ошибкой, а это нормальный путь. Текущая истина — непрерывный агрегат:
CREATE MATERIALIZED VIEW observations_daily
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '1 day', observed_on) AS observed_on,
metric,
last(value, reported_at) AS value,
last(unit, reported_at) AS unit,
max(reported_at) AS last_reported_at,
count(*) AS report_count -- >1 означает, что были рестатейменты
FROM observations
GROUP BY 1, 2;Обратите внимание на last(value, reported_at) — это last-write-wins, те же семантики, что давал ON CONFLICT DO UPDATE, но выраженные как агрегат, а не как разрушительная запись. Каждый проигравший отчёт остаётся в таблице для запросов. report_count — это просто count(*), но каждая строка представления несёт информацию о том, были ли споры о значении.
Я задал три вопроса и получил три ответа, охватывающих два порядка величины:
Третий ответ — ключевой. 140 из 202 наблюдений за пять дней работы. Это направленный показатель, а не точная ставка. Он может только расти, потому что наблюдение, которое ещё не пересмотрено, может быть пересмотрено завтра. Любая цифра требует привязки к дате.
Раньше я не мог это измерить, потому что старая схема была спроектирована так, чтобы это было неизмеримо.
Знать, как часто данные пересматриваются, менее интересно, чем знать, что можно сделать, когда что-то идёт не так. Однажды вечером моё приложение для учёта еды и этот сайт разошлись на 868 калорий за один день, оба читая одну базу. Я написал отдельно о причине — это была ловушка TimescaleDB, которую я сначала диагностировал неправильно. Но здесь важно, что диагностика заняла один запрос. Оба отчёта лежали в журнале с временными штампами, и я увидел число, которое отдавала страница, число, которое пришло после того, как страница перестала смотреть, и пять часов между ними, объясняющие всё.
В старой схеме более поздняя запись перезаписала бы раннюю, не оставив следов. У меня было бы неверное число на странице и никакой возможности узнать, что произошло.
Если вы храните данные, которые могут быть пересмотрены — метрики, показания датчиков, любые наблюдения — пересмотрите свою модель записи. Не используйте апсерты для фактов, которые могут измениться. Вместо этого добавьте неизменяемые отчёты с временными штампами и вычисляйте текущее состояние через агрегаты. В TimescaleDB используйте last() в непрерывных агрегатах. Это займёт пару часов, но сэкономит вам дни отладки в будущем. Начните с малого: добавьте таблицу отчётов и материализованное представление, как показано выше.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →