Публикация была переведена автоматически. Исходный язык: Русский
Не про архитектуру "в целом" и не про модные фреймворки, а про очень приземлённую, но адски болезненную вещь: как на самом деле выглядит жизнь, когда у тебя миллионы событий от пользователей, которые постоянно оффлайн, часы на устройствах гуляют, серверы живут в разных дата-центрах, а продуктовая команда требует "одну честную ленту действий пользователя"- и чтобы всегда "как будто всё происходило ровно по порядку'.
Как мы несколько лет пытались договориться о времени и почему я теперь вздрагиваю от слова “timestamp”
Когда говорят "мобильная разработка + сервер", обычно вспоминают push-и, авторизацию, кэширование, ну и оффлайн-режим. Почти никто не рассказывает, что настоящий ад начинается, когда вам вдруг по-настоящему важен порядок событий во времени.
Не 'где-то там на диаграмме последовательностей", а так, чтобы конкретному живому человеку открылась лента из серии:
```
- 10:31 -> отменил заказ
- 10:32 -> создал новый
- 10:33 -> деньги вернулись на бонусный счёт
```
и чтобы это всегда было правдой. Не "иногда", не "в проде в среднем норм", а именно всегда. На любом устройстве, при любом интернете, при любой фазе луны и кривизне часиков у пользователя.
Спойлер: не получилось. И не потому, что мы глупые. А потому что сама постановка "одной правды о времени" во вселенной из мобильников и серверов - это, мягко говоря, оптимистичная фантазия.
Как всё начиналось: "ну просто возьмём created_at"
Первая версия была очень наивной.
Есть событийная лента: пользователь что-то делает в приложении, мы шлём на сервер события. На сервере лежит Postgres, есть таблица events, в ней колонка created_at TIMESTAMPTZ и индекс по ней. Клиент показывает события, сортируя по этому полю.
Что может пойти не так?
Всё.
Мы очень быстро поймали первые абсурдные кейсы:
- Пользователь улетел в страну с другим часовым поясом, телефон не синхронизируется, время на часах "залипло" где-то посередине.
- Пользователь неделю сидит оффлайн, кликает кнопочки, потом прилетает домой и всё это одним залпом улетает на сервер.
- Андроид-телефон живёт в режиме "экономия батареи", и время там внезапно откатывается назад, потому что какой-то хитрый китайский vendors-кий софт решил "поддержать".
И ты видишь в базе:
user_id | event_type | created_at
--------+-------------+------------------------
42 | order_new | 2025-03-01 10:33:02+03
42 | order_cancel| 2025-02-28 09:12:11+03то есть отмена заказа по времени раньше, чем его создание.
С этого момента все обсуждения в команде стали походить на шаманские танцы вокруг часов.
Логика была такая:
клиент присылает событие без своего времени (или с ним, но только для логов), а истинным временем считаем now() на сервере в момент записи в базу.
Это ненадолго создало ощущение порядка. Пользователь что-то сделал - запрос дошёл, сервер поставил метку, записал. Всё красиво.
Пока не вспомнили, что:
- мобильные клиенты могут отправлять повторные запросы (ретраи при плохом интернете);
- часть событий прилетает через один API-шлюз, часть через другой (разные дата-центры, очереди, балансировщики);
- очереди иногда "захлёбываются" и отдают событие спустя секунды, а то и минуты.
В какой-то момент у нас появился такой сценарий:
- Клиент A отправляет событие Е1, оно идёт через очередь Q1.
- Клиент A отправляет событие Е2, но оно идёт уже напрямую, без очереди.
- Сервер получает сначала Е2, только потом из очереди вываливается Е1.
На таймлайне:
2025-03-10 10:00:01 -> событие Е2
2025-03-10 10:00:05 -> событие Е1А по смыслу наоборот.
Мы попытались "починить" это, добавив на сервере дополнительный seq_id per user -автоинкремент, который гарантирует порядок при записи. Но это сработало только у того конкретного сервиса, куда события попадали напрямую. Везде, где были очереди, ретраи и асинхронщина - там порядок к нам относился с лёгким презрением.
Вторая волна просветления: ладно, сервер - штука асинхронная, значит надо доверять клиентскому времени. Мол, кто как не устройство знает, когда пользователь нажал кнопку?
Мы сделали то, что делают почти все:
- клиент отправляет client_timestamp (миллисекунды с epoch);
- сервер пишет его в отдельное поле client_at;
- сортируем по нему.
На демо всё ок.
Но дальше полезла статистика:
- разница между client_at и created_at у одного и того же пользователя гуляет в диапазоне от –7 до +9 минут;
- у другого пользователя один и тот же телефон умудрился "перепрыгнуть" на пару часов назад и потом вперёд;
- часть событий приходила с одинаковыми client_at (даже с учётом миллисекунд!) - потому что на некоторых устройствах таймер там один, на всех потоках.
В какой-то момент я поймал себя на том, что всерьёз читаю форумы по NTP для Android-телефонов и обсуждения "почему часы на телефоне иногда живут своей жизнью".
Мы пытались:
- оценивать drift устройства и на сервере фактически строить поправку client_at + delta(user_id);
- сбрасывать этот drift при каждом успешном запросе;
- хранить историю "поправок" и выводить доверительный интервал.
Звучит умно, на практике - грязь, эвристики и краевые кейсы на краевых кейсах.
На третьем круге ада кто-то (кажется, один из backend-разработчиков, который в какой-то прошлой жизни трогал распределённые системы) сказал:
Да забейте вы на timestamps, давайте делать логические часы.
Мы вдохновились идеями из Lamport clocks и версий документов. Появилась структура:
{
"user_id": 42,
"event_type": "order_cancel",
"client_ts": 1710064982000,
"logical_seq": 17
}Где logical_seq инкрементировался на клиенте при каждом действии пользователя. Сервер проверяет:
- если входящий logical_seq == last_seq + 1 - всё хорошо;
- если > last_seq + 1 -- значит, потеряли события (клиент не смог отправить);
- если <= last_seq - значит, это ретрай или дубль.
Казалось, вот оно.
Лента внутри одного клиента стала более последовательной, мы начали находить пропавшие события и дубль-запросы.
Но жизнь, как обычно, сложнее.
- Пользователь логинится на втором устройстве - и у него логическая последовательность начинается сначала, с нуля.
- Пользователь переустанавливает приложение - история logical_seq обнуляется.
- В редких случаях кеш с last_seq на сервере терялся (да, мы и это умудрились поймать, когда в одной из микрослужб поменяли схему кеширования).
Пришлось дописывать связку (user_id, device_id) и тащить device_id во все цепочки. Это, конечно, помогло, но мы внезапно оказались в мире, где есть много таймлайнов, а не один. Пользователь заходил в свой аккаунт с двух-трёх телефонов и планшета, и мы должны были как-то сложить это в одну картинку.
На одном из ревью у нас случился очень характерный диалог.
Продукт-менеджер открывает приложение, видит:
- на телефоне:
- 10:31: возвращены бонусы
- 10:32: отменён заказ
- в админке:
- 10:31: отменён заказ
- 10:33: возвращены бонусы
и задаёт простой человеческий вопрос:
А где правда?
И вот тут внезапно выяснилось, что "правды", в том виде, в каком человек её ожидает, нет. Есть:
- физическое время на устройстве;
- физическое время на одном из серверов;
- логическая последовательность событий на конкретном девайсе;
- порядок, в котором события доехали до ядра;
- порядок, в котором мы их отрендерили.
В идеальном мире все эти вещи совпали бы. В реальном - они только приблизительно одинаковые и периодически рассыпаются.
Мы попытались честно определить приоритеты:
- Деньги и юридически значимые действия - приоритизируем серверное время записи транзакции.
- То, что видит пользователь в истории в приложении - приоритизируем "правдоподобие" последовательности, местами жертвуя точностью.
- Внутренние логи разработчиков - храним оба времени и логическую последовательность, не пытаясь их склеить окончательно.
Самое неприятное - объяснять это людям. И пользователям ("в истории время может немного отличаться"), и менеджерам ("нет, мы не можем обещать, что порядок всегда будет идеальным").
Если смотреть со стороны, кажется, что это чисто server-side проблема. На самом деле нет.
С мобильной стороны:
- Нужно жить оффлайн и аккуратно буферизовать события, чтобы не хрустнули при первом же reconnection.
- Нужно очень аккуратно трогать локальную БД: если вы переписываете записи, когда "уточнилось" время - вы внезапно ломаете историю в кэше и разъезжаетесь с сервером.
- Нужно учитывать, что OS может убить приложение в любой момент, посреди пачки подтверждённых действий.
С серверной:
- Надо уметь принимать грязные, несовершенные данные и всё равно собирать из них более-менее согласованный таймлайн.
- Надо объяснить бизнесу, что строгий total order для всего на свете обойдётся либо в невозможные задержки, либо просто невозможен.
- Надо проектировать API так, чтобы они не делали вид, что проблемы нет. Для нас, например, было жизненно важно передавать и client_ts, и server_ts, и логические последовательности, а не пытаться спрятать всё в один created_at.
За несколько лет этой возни с временем у меня сильно поменялось отношение к фразе "ну там просто поле времени добавим".
Если бы меня сейчас попросили коротко сформулировать вывод, он был бы примерно таким:
- Не существует единственного "правильного" времени события.Есть разные точки зрения: устройства, сервера, очереди, человека. И они неизбежно расходятся.
- Любая попытка "идеального порядка" превращается в бесконечный костыль.Можно сделать правдоподобный порядок для конкретных сценариев (например, для одного устройства, для одной ветки бизнес-логики), но как только вы начинаете склеивать всё в одну вселенную, грабли автоматически прилагаются.
- Лучшее, что можно сделать - честно выбрать, какая "правда" важнее в каждом контексте.Для денег - одно. Для UI-истории - другое. Для отладки - третье. И не стесняться иметь несколько разных представлений времени одновременно.
- Мобильные и серверные разработчики должны обсуждать время вместе.Пока клиент живёт в своей иллюзии "я знаю, когда пользователь нажал кнопку", а сервер в своей "я знаю, когда это попало в базу", они неизбежно в какой-то момент устроят пользователю сюрреализм в истории действий.
Самое, наверное, честное признание:
большая часть этих вещей стала понятна не из книжек, а из того самого опыта "тысячи часов и несколько раз всё переписать".
Сначала ты строишь простую модель и веришь, что она "почти всегда" будет работать.
Потом приходят реальные пользователи, реальный интернет, реальные телефоны и реальный хаос.
Ты начинаешь наращивать костыли. Потом понимаешь, что костыли уже толще, чем сама модель.
Выбрасываешь половину, оставляешь только то, что действительно даёт ценность. Остальное- честно признаёшь неразрешимым в общем случае.
И в какой-то момент ловишь себя на мысли, что самое сложное в мобильном приложении у тебя - не анимации, не push-уведомления и даже не авторизация, а банальное слово "когда".
Не про архитектуру "в целом" и не про модные фреймворки, а про очень приземлённую, но адски болезненную вещь: как на самом деле выглядит жизнь, когда у тебя миллионы событий от пользователей, которые постоянно оффлайн, часы на устройствах гуляют, серверы живут в разных дата-центрах, а продуктовая команда требует "одну честную ленту действий пользователя"- и чтобы всегда "как будто всё происходило ровно по порядку'.
Как мы несколько лет пытались договориться о времени и почему я теперь вздрагиваю от слова “timestamp”
Когда говорят "мобильная разработка + сервер", обычно вспоминают push-и, авторизацию, кэширование, ну и оффлайн-режим. Почти никто не рассказывает, что настоящий ад начинается, когда вам вдруг по-настоящему важен порядок событий во времени.
Не 'где-то там на диаграмме последовательностей", а так, чтобы конкретному живому человеку открылась лента из серии:
```
- 10:31 -> отменил заказ
- 10:32 -> создал новый
- 10:33 -> деньги вернулись на бонусный счёт
```
и чтобы это всегда было правдой. Не "иногда", не "в проде в среднем норм", а именно всегда. На любом устройстве, при любом интернете, при любой фазе луны и кривизне часиков у пользователя.
Спойлер: не получилось. И не потому, что мы глупые. А потому что сама постановка "одной правды о времени" во вселенной из мобильников и серверов - это, мягко говоря, оптимистичная фантазия.
Как всё начиналось: "ну просто возьмём created_at"
Первая версия была очень наивной.
Есть событийная лента: пользователь что-то делает в приложении, мы шлём на сервер события. На сервере лежит Postgres, есть таблица events, в ней колонка created_at TIMESTAMPTZ и индекс по ней. Клиент показывает события, сортируя по этому полю.
Что может пойти не так?
Всё.
Мы очень быстро поймали первые абсурдные кейсы:
- Пользователь улетел в страну с другим часовым поясом, телефон не синхронизируется, время на часах "залипло" где-то посередине.
- Пользователь неделю сидит оффлайн, кликает кнопочки, потом прилетает домой и всё это одним залпом улетает на сервер.
- Андроид-телефон живёт в режиме "экономия батареи", и время там внезапно откатывается назад, потому что какой-то хитрый китайский vendors-кий софт решил "поддержать".
И ты видишь в базе:
user_id | event_type | created_at
--------+-------------+------------------------
42 | order_new | 2025-03-01 10:33:02+03
42 | order_cancel| 2025-02-28 09:12:11+03то есть отмена заказа по времени раньше, чем его создание.
С этого момента все обсуждения в команде стали походить на шаманские танцы вокруг часов.
Логика была такая:
клиент присылает событие без своего времени (или с ним, но только для логов), а истинным временем считаем now() на сервере в момент записи в базу.
Это ненадолго создало ощущение порядка. Пользователь что-то сделал - запрос дошёл, сервер поставил метку, записал. Всё красиво.
Пока не вспомнили, что:
- мобильные клиенты могут отправлять повторные запросы (ретраи при плохом интернете);
- часть событий прилетает через один API-шлюз, часть через другой (разные дата-центры, очереди, балансировщики);
- очереди иногда "захлёбываются" и отдают событие спустя секунды, а то и минуты.
В какой-то момент у нас появился такой сценарий:
- Клиент A отправляет событие Е1, оно идёт через очередь Q1.
- Клиент A отправляет событие Е2, но оно идёт уже напрямую, без очереди.
- Сервер получает сначала Е2, только потом из очереди вываливается Е1.
На таймлайне:
2025-03-10 10:00:01 -> событие Е2
2025-03-10 10:00:05 -> событие Е1А по смыслу наоборот.
Мы попытались "починить" это, добавив на сервере дополнительный seq_id per user -автоинкремент, который гарантирует порядок при записи. Но это сработало только у того конкретного сервиса, куда события попадали напрямую. Везде, где были очереди, ретраи и асинхронщина - там порядок к нам относился с лёгким презрением.
Вторая волна просветления: ладно, сервер - штука асинхронная, значит надо доверять клиентскому времени. Мол, кто как не устройство знает, когда пользователь нажал кнопку?
Мы сделали то, что делают почти все:
- клиент отправляет client_timestamp (миллисекунды с epoch);
- сервер пишет его в отдельное поле client_at;
- сортируем по нему.
На демо всё ок.
Но дальше полезла статистика:
- разница между client_at и created_at у одного и того же пользователя гуляет в диапазоне от –7 до +9 минут;
- у другого пользователя один и тот же телефон умудрился "перепрыгнуть" на пару часов назад и потом вперёд;
- часть событий приходила с одинаковыми client_at (даже с учётом миллисекунд!) - потому что на некоторых устройствах таймер там один, на всех потоках.
В какой-то момент я поймал себя на том, что всерьёз читаю форумы по NTP для Android-телефонов и обсуждения "почему часы на телефоне иногда живут своей жизнью".
Мы пытались:
- оценивать drift устройства и на сервере фактически строить поправку client_at + delta(user_id);
- сбрасывать этот drift при каждом успешном запросе;
- хранить историю "поправок" и выводить доверительный интервал.
Звучит умно, на практике - грязь, эвристики и краевые кейсы на краевых кейсах.
На третьем круге ада кто-то (кажется, один из backend-разработчиков, который в какой-то прошлой жизни трогал распределённые системы) сказал:
Да забейте вы на timestamps, давайте делать логические часы.
Мы вдохновились идеями из Lamport clocks и версий документов. Появилась структура:
{
"user_id": 42,
"event_type": "order_cancel",
"client_ts": 1710064982000,
"logical_seq": 17
}Где logical_seq инкрементировался на клиенте при каждом действии пользователя. Сервер проверяет:
- если входящий logical_seq == last_seq + 1 - всё хорошо;
- если > last_seq + 1 -- значит, потеряли события (клиент не смог отправить);
- если <= last_seq - значит, это ретрай или дубль.
Казалось, вот оно.
Лента внутри одного клиента стала более последовательной, мы начали находить пропавшие события и дубль-запросы.
Но жизнь, как обычно, сложнее.
- Пользователь логинится на втором устройстве - и у него логическая последовательность начинается сначала, с нуля.
- Пользователь переустанавливает приложение - история logical_seq обнуляется.
- В редких случаях кеш с last_seq на сервере терялся (да, мы и это умудрились поймать, когда в одной из микрослужб поменяли схему кеширования).
Пришлось дописывать связку (user_id, device_id) и тащить device_id во все цепочки. Это, конечно, помогло, но мы внезапно оказались в мире, где есть много таймлайнов, а не один. Пользователь заходил в свой аккаунт с двух-трёх телефонов и планшета, и мы должны были как-то сложить это в одну картинку.
На одном из ревью у нас случился очень характерный диалог.
Продукт-менеджер открывает приложение, видит:
- на телефоне:
- 10:31: возвращены бонусы
- 10:32: отменён заказ
- в админке:
- 10:31: отменён заказ
- 10:33: возвращены бонусы
и задаёт простой человеческий вопрос:
А где правда?
И вот тут внезапно выяснилось, что "правды", в том виде, в каком человек её ожидает, нет. Есть:
- физическое время на устройстве;
- физическое время на одном из серверов;
- логическая последовательность событий на конкретном девайсе;
- порядок, в котором события доехали до ядра;
- порядок, в котором мы их отрендерили.
В идеальном мире все эти вещи совпали бы. В реальном - они только приблизительно одинаковые и периодически рассыпаются.
Мы попытались честно определить приоритеты:
- Деньги и юридически значимые действия - приоритизируем серверное время записи транзакции.
- То, что видит пользователь в истории в приложении - приоритизируем "правдоподобие" последовательности, местами жертвуя точностью.
- Внутренние логи разработчиков - храним оба времени и логическую последовательность, не пытаясь их склеить окончательно.
Самое неприятное - объяснять это людям. И пользователям ("в истории время может немного отличаться"), и менеджерам ("нет, мы не можем обещать, что порядок всегда будет идеальным").
Если смотреть со стороны, кажется, что это чисто server-side проблема. На самом деле нет.
С мобильной стороны:
- Нужно жить оффлайн и аккуратно буферизовать события, чтобы не хрустнули при первом же reconnection.
- Нужно очень аккуратно трогать локальную БД: если вы переписываете записи, когда "уточнилось" время - вы внезапно ломаете историю в кэше и разъезжаетесь с сервером.
- Нужно учитывать, что OS может убить приложение в любой момент, посреди пачки подтверждённых действий.
С серверной:
- Надо уметь принимать грязные, несовершенные данные и всё равно собирать из них более-менее согласованный таймлайн.
- Надо объяснить бизнесу, что строгий total order для всего на свете обойдётся либо в невозможные задержки, либо просто невозможен.
- Надо проектировать API так, чтобы они не делали вид, что проблемы нет. Для нас, например, было жизненно важно передавать и client_ts, и server_ts, и логические последовательности, а не пытаться спрятать всё в один created_at.
За несколько лет этой возни с временем у меня сильно поменялось отношение к фразе "ну там просто поле времени добавим".
Если бы меня сейчас попросили коротко сформулировать вывод, он был бы примерно таким:
- Не существует единственного "правильного" времени события.Есть разные точки зрения: устройства, сервера, очереди, человека. И они неизбежно расходятся.
- Любая попытка "идеального порядка" превращается в бесконечный костыль.Можно сделать правдоподобный порядок для конкретных сценариев (например, для одного устройства, для одной ветки бизнес-логики), но как только вы начинаете склеивать всё в одну вселенную, грабли автоматически прилагаются.
- Лучшее, что можно сделать - честно выбрать, какая "правда" важнее в каждом контексте.Для денег - одно. Для UI-истории - другое. Для отладки - третье. И не стесняться иметь несколько разных представлений времени одновременно.
- Мобильные и серверные разработчики должны обсуждать время вместе.Пока клиент живёт в своей иллюзии "я знаю, когда пользователь нажал кнопку", а сервер в своей "я знаю, когда это попало в базу", они неизбежно в какой-то момент устроят пользователю сюрреализм в истории действий.
Самое, наверное, честное признание:
большая часть этих вещей стала понятна не из книжек, а из того самого опыта "тысячи часов и несколько раз всё переписать".
Сначала ты строишь простую модель и веришь, что она "почти всегда" будет работать.
Потом приходят реальные пользователи, реальный интернет, реальные телефоны и реальный хаос.
Ты начинаешь наращивать костыли. Потом понимаешь, что костыли уже толще, чем сама модель.
Выбрасываешь половину, оставляешь только то, что действительно даёт ценность. Остальное- честно признаёшь неразрешимым в общем случае.
И в какой-то момент ловишь себя на мысли, что самое сложное в мобильном приложении у тебя - не анимации, не push-уведомления и даже не авторизация, а банальное слово "когда".