Перейти к содержанию

Время и часовые пояса в базе

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

Используем PostgreSQL 17.11 и неизменённые моменты учебных курсов. Снимок catalog-db-advanced/lesson-26 находится в архиве продолжения. SQL не выполнялся. Исходные значения явно заданы в UTC, а изменения настроек сессии внутри демонстрации ограничены транзакцией и откатываются.

Один момент и разные записи

У Markdown и PostgreSQL публикация задана как 2025-02-01 10:00+00. Та же точка времени может быть записана с другим смещением:

SELECT TIMESTAMPTZ '2025-02-01 10:00+00'
     = TIMESTAMPTZ '2025-02-01 13:00+03' AS same_moment;

Ожидается true. Часы различаются на три, смещение различается согласованно, а событие одно. Тип timestamptz представляет момент; он не сохраняет для каждой строки исходное название региона или буквальную форму введённой даты. Правила типов времени раскрыты в документации PostgreSQL.

Если событие сортируется по этому полю, читатель в другом часовом поясе не должен получить иной порядок свежести только из-за формата. Равные моменты остаются равными, поэтому в ленте по-прежнему нужен второй ключ id. Зона отображения не заменяет устойчивый порядок одинаковых публикаций.

В нашей схеме draft имеет отсутствующий момент. Это не полночь какого-то дня и не «нулевая дата», которую можно бездумно сравнивать с реальными событиями. Представление отсутствия уже закреплено ограничением курса, а публичная выборка явно отбирает status. Время и состояние должны читаться совместно.

Представление в локальных часах

Для фиксированного смещения можно получить локальное представление без изменения хранимого момента:

SELECT id, published_at,
       published_at AT TIME ZONE INTERVAL '+03:00' AS local_clock
FROM catalog.courses
WHERE id IN (3, 5)
ORDER BY id;

Ожидаемое локальное время — 13:00 первого февраля для обоих курсов. Результат AT TIME ZONE в таком направлении является локальным timestamp без часового пояса. Он пригоден для представления часов, но сам по себе уже не сообщает, какой регион или смещение вы выбрали. Поэтому его нельзя сохранять вместо исходного момента без нового договора.

Оператор работает и в обратном направлении: локальное время вместе с выбранной зоной превращается в момент. Направление зависит от типа левой части. Это причина явно читать тип, а не полагаться на одинаковое написание функции. Преобразования описаны в руководстве date/time functions.

В отдельном фрагменте SET LOCAL TIME ZONE меняет представление timestamptz текущего блока. После ROLLBACK прежняя настройка возвращается. Такая демонстрация не переносит событие на три часа вперёд: меняется вывод. Если клиент получил уже отформатированную строку без смещения, он потерял часть контекста, и это ошибка формата передачи.

Смещение и регион

Фиксированное смещение подходит математическому преобразованию, но не описывает историю и будущие правила региона. Именованная зона имеет правила, включая возможные сезонные переходы. Для местного расписания нужно хранить не только «плюс три», когда задача требует именно местных часов определённого города.

Рассмотрим отдельное значение расписания без создания новой таблицы:

WITH schedule(local_time, zone) AS (
  VALUES (TIMESTAMP '2025-02-01 13:00', 'Europe/Minsk')
)
SELECT local_time, zone, local_time AT TIME ZONE zone AS starts_at
FROM schedule;

В выбранном учебном случае ожидается соответствующий момент 10:00 UTC при правилах этой зоны для указанной даты. Это преобразование использует данные зон конкретной установки. Оно не означает, что любой регион всегда имеет постоянное смещение или что текущий offset из системного представления доказывает offset любой исторической даты.

Для будущего повторяющегося вебинара предметный договор ещё шире: «каждый вторник в 13:00 местного времени» отличается от «каждые семь суток после первого момента». При изменении сезонного смещения локальные часы и длительность между моментами могут вести себя по-разному. Такое расписание требует явной модели повторов, а не только одного timestamptz.

Неоднозначные локальные часы

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

PostgreSQL имеет собственные правила интерпретации таких дат, однако пользовательский сценарий не обязан молча принимать всё, что технически преобразовалось. Редактор ожидает понятного расписания. Если зона или политика меняется, сохраняемые намерение и рассчитанный момент должны пересматриваться осознанно.

Ввод смещения в литерале timestamp без time zone не превращает этот тип в момент автоматически. Поэтому в листингах явно используются TIMESTAMP для местных часов и TIMESTAMPTZ для события. Скрытая зависимость от выбранного типа особенно опасна, когда строка выглядит убедительно благодаря знаку +03, но приложение на самом деле его не использует так, как задумано.

Поле формы и момент публикации

Представим, что редактор вводит в форме «1 февраля, 13:00». Без часового пояса это ещё не однозначный момент. Интерфейс должен знать, относится ли значение к зоне редактора, принятой зоне сайта или явному выбору рядом с полем. Сервер не должен незаметно принимать собственную настройку сессии за ответ на этот пользовательский вопрос.

Если приложение получает готовый момент с явным смещением, его можно хранить как timestamptz и отображать в нужной зоне. Если задача — расписание «каждый понедельник в 13:00 по местному времени», одного сохранённого UTC-момента недостаточно для описания повторения. Понадобятся календарное правило и идентификатор зоны; следующий момент вычисляется по ним, а не простым копированием первого значения.

Наш фиксированный пример показывает конкретный день и смещение, поэтому ожидаемое сравнение моментов определено. Для другой даты в зоне с переходами правила могут различаться. Значение сегодняшнего UTC offset не является универсальным смещением на год вперёд или назад. Именно поэтому в коде нужно различать именованную зону и фиксированный интервал.

Отчёт «публикации за местный день» также требует согласовать границы. Сначала выбирается местная календарная дата и зона отчёта, затем получают два соответствующих момента для SQL-фильтра. Полуоткрытый интервал не включает следующий день дважды. Если просто применить UTC-день к пользователю в другой зоне, запрос может быть технически корректным и при этом отвечать на другой вопрос интерфейса.

Граница дня в запросе

Для отчёта публикаций первого февраля в UTC лучше задать полуоткрытый интервал:

SELECT id, slug
FROM catalog.courses
WHERE published_at >= TIMESTAMPTZ '2025-02-01 00:00+00'
  AND published_at <  TIMESTAMPTZ '2025-02-02 00:00+00'
ORDER BY published_at, id;

Ожидаются Markdown и PostgreSQL. Верхняя граница исключена, поэтому не нужно угадывать последнюю долю секунды дня. Если отчёт относится к местному дню читателя, сначала рассчитывают соответствующие границы моментов по его зоне. Просто заменить UTC-подпись на название региона после выполнения SQL было бы неверно.

Приведение published_at к date зависит от контекста отображения. Оно также меняет форму выражения, что имеет значение при выборе индекса. Для устойчивого договора отчёта полезно сохранять сравнение исходного моментного поля с явно рассчитанными границами, а не надеяться на одинаковую настройку всех сессий.

Снимок возвращает UTC после временных настроек. При будущем ручном чтении сравните равенство двух записей момента, локальные часы и выбранные границы дня. Теперь публикация, представление читателя и локальное расписание имеют отдельный смысл, позволяя приложению работать со временем без скрытого смешения события и красивой строки даты.

Оглавление курса.