DanLevy.net

Ваши временные метки — это ложь

Чему меня научил билет на поезд о хранении времени в базах данных

Я бронировал билет на поезд из Нью-Йорка в Чикаго, когда меня осенило, почему типы временных меток в Postgres такие запутанные. На билете было указано:

Три разных способа говорить о времени — и каждый нужно хранить в базе данных по-своему.

Вопрос, который никто не задаёт первым

И TIMESTAMP, и TIMESTAMPTZ в Postgres занимают ровно 8 байт с одинаковой точностью до микросекунд. Так зачем вообще два типа?

Потому что «который час?» зависит исключительно от того, что вы пытаетесь сообщить.

Когда я сажусь на поезд в Нью-Йорке, мне нужно знать, что он отправляется в 8:00 по восточному времени. Это число на вокзальных часах, которое я должен увидеть. Когда друг забирает меня в Чикаго, ей нужно знать, что я прибываю в 19:30 по центральному времени — это число на её часах. А если я пытаюсь понять, хватит ли мне времени почитать книгу, мне нужно знать, что поездка занимает одиннадцать с половиной часов.

Один и тот же поезд. Одна и та же поездка. Три совершенно разных представления о времени.

Что на самом деле делает TIMESTAMPTZ

Вот в чём фокус с TIMESTAMPTZ — и он не такой, как думает большинство. Он не хранит часовой пояс. Название обманчиво.

Он берёт любое переданное время, конвертирует его в UTC перед сохранением, а затем при чтении конвертирует обратно в часовой пояс вашей сессии. Часть «TZ» относится не к хранению, а к поддержке конвертации.

Допустим, вы храните отправление того поезда. Кто-то в Токио запрашивает вашу базу и видит время отправления в JST. Кто-то в Лондоне — в GMT. Все смотрят на один и тот же абсолютный момент, просто выраженный в их настроенном часовом поясе. Это идеально для записи событий: «когда прошёл этот платёж?» или «когда произошёл этот API-запрос?»

А что насчёт билета на поезд? Вы не хотите, чтобы время отправления менялось только потому, что кто-то запрашивает базу из другого часового пояса. Поезд отправляется в 8:00 по восточному времени — и точка. Это не абсолютный момент во времени — это обещание о том, что покажут часы на Гранд-Сентрал.

Храните то, что вам на самом деле нужно

Для той поездки нужно хранить разные вещи для разных целей:

Теперь ваше приложение может делать то же, что билет на поезд: показывать «Отправление 8:00 EST», конвертируя абсолютный момент в часовой пояс отправления, показывать «Прибытие 19:30 CST», конвертируя в часовой пункт назначения, и показывать «В пути: 11ч 30м» напрямую из интервала.

Человек, бронирующий билет из Токио, увидит те же местные часы на каждой станции. Это то, что ему нужно знать.

Почему ваше приложение для отслеживания рейсов ошиблось

Замечали, как некоторые приложения для отслеживания рейсов показывают ваш часовой пояс во время полёта? Вы над Атлантикой, а оно говорит: «Текущее время: 16:32 GMT». Кому какое дело? Вы не в Гринвиче, вы на высоте 11 000 метров где-то над океаном.

Что вы на самом деле хотите видеть:

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

Видите? Две операции с интервалами (NOW() - actual_departure и estimated_arrival - NOW()), одна конвертация часового пояса в конкретное место (AT TIME ZONE destination_timezone). Ваш текущий часовой пояс здесь ни при чём.

Когда местное время — это то, что вам на самом деле нужно

Отелям нет дела до абсолютных моментов. Им важны показания часов в их локации.

«Регистрация после 15:00» не означает «регистрация через 15 часов после полуночи UTC». Это означает «когда часы в нашем холле покажут 15:00, вы можете зарегистрироваться». Если ваши серверы в Вирджинии, а отель в Париже, вы всё равно хотите, чтобы это правило срабатывало в 15:00 по парижскому времени.

Тип TIME (без даты и часового пояса) представляет именно это: «показания на часах». В паре с текстовым полем часового пояса («Europe/Paris») вы можете применять правила местного времени независимо от того, где живут ваши серверы. Но вам также понадобятся колонки TIMESTAMPTZ для записи конкретных моментов регистрации и выезда гостей — это абсолютные моменты, которые ваш бэкенд должен отслеживать.

Проблема календаря

У меня стоит повторяющееся напоминание на 9:00: «Просмотр ежедневных приоритетов». Я хочу, чтобы оно срабатывало в 9:00 где бы я ни был. Если я в поездке, оно всё равно должно срабатывать в 9:00 по местному времени.

Но у меня также есть событие в календаре: «Командной стендап в 10:00 EST». Мой коллега в Берлине должен видеть «16:00 CET» для того же события. Одна и та же встреча, разное отображаемое время, потому что это абсолютный момент, к которому мы все присоединяемся.

Два разных типа событий, две разных стратегии хранения. Встреча получает TIMESTAMPTZ. Напоминание получает TIME плюс моя текущая настройка часового пояса. Не пытайтесь впихнуть оба в одно поле.

Вещи, которые ломаются в продакшене

Даже с правильными типами точность может подвести. Postgres хранит микросекунды: 10:00:00.123456. Объект Date в JavaScript использует миллисекунды: 10:00:00.123.

Поэтому этот запрос может таинственно вернуть ноль строк:

SELECT * FROM orders WHERE created_at = '2026-01-15 10:00:00.123';

В базе хранится 10:00:00.123456, а ваш код передаёт 10:00:00.123. В зависимости от того, как ваш драйвер это обрабатывает, они могут не совпасть.

Не используйте точное равенство для временных меток. Используйте запросы по диапазону или — что лучше — вообще не ищите записи по времени создания. Используйте правильное уникальное ограничение или ключ идемпотентности.

Практические правила

По умолчанию выбирайте TIMESTAMPTZ. Если сомневаетесь, используйте TIMESTAMPTZ. Он автоматически обрабатывает мультирегиональные развёртывания, переход на летнее время и будущие изменения часовых поясов. Он занимает столько же места, что и TIMESTAMP, так что штрафа нет.

Храните контекст отдельно. Если нужно показывать «Отправление 8:00 EST» вместе с фактическим моментом, храните и TIMESTAMPTZ, и origin_timezone в отдельных колонках. Не пытайтесь закодировать всё в одно поле.

Думайте об интервалах. Многие требования, связанные со временем, на самом деле касаются длительности, а не моментов. «Как долго это находится в ожидании?» «Когда это истечёт?» Используйте операции с INTERVAL, а не конвертацию часовых поясов.

Запускайте всё в UTC. Ваши серверы должны быть настроены на UTC. Сессии базы данных должны по умолчанию использовать UTC. Конвертируйте в местные часовые пояса только при отображении пользователям — и только когда знаете, какой пояс имеет значение.

Требуйте от клиентов информацию о часовом поясе. Если клиент присылает 2026-01-15T10:00:00 без смещения, отклоняйте. Требуйте формат ISO-8601 либо с Z, либо с явным смещением вроде -05:00. Не гадайте.

Принудительные настройки по умолчанию

Если TIMESTAMPTZ — ваш стандарт (а так и должно быть), подумайте о принудительном применении на уровне базы данных. Триггер, отвергающий колонки TIMESTAMP WITHOUT TIME ZONE, звучит радикально, но поймать «забыл добавить TZ» при создании схемы лучше, чем отлаживать это через полгода, когда кто-то создаст новую таблицу и забудет.

Чему меня научил тот билет на поезд

Время в базах данных сложно не потому, что временные метки запутаны. Оно сложно, потому что мы обычно храним несколько задач в одном поле или не думаем о том, что на самом деле пытаемся показать пользователям.

Тот билет на поезд был прав: время отправления в часовом поясе отправления, время прибытия в часовом поясе назначения и длительность как отдельная сущность. Три разных куска информации, каждый значим по-своему.

Ваша база данных может делать то же самое. Храните абсолютные моменты как TIMESTAMPTZ. Храните контекст отображения (часовые пояса, локации) в отдельных колонках. Используйте типы INTERVAL для длительностей. Пусть Postgres делает конвертации, когда они нужны, но явно указывайте, какой часовой пояс важен для какой цели.

В большинстве случаев это означает TIMESTAMPTZ и UTC повсюду, с конвертацией в местные часовые пояса только на этапе отображения. Но когда вам нужно местное время или повторяющиеся расписания, типы TIMESTAMP или TIME существуют именно для этого.

Ключ в том, чтобы понимать, на какой вопрос вы пытаетесь ответить: «Когда это произошло?» vs. «В котором часу мне быть там?» vs. «Сколько это займёт времени?» Это разные вопросы о времени, и для них часто нужны разные стратегии хранения.

Подумайте, что ваши пользователи должны видеть. Затем храните данные, которые позволят показать им именно это.

Ресурсы