Работай с датой и временем правильно!


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

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

Часовой пояс и смещение

Начнём с того, что большая часть людей не видит никакой разницы между терминами “административный часовой пояс” и “смещение часового пояса”. Из этого базового непонимания произрастает невероятно большое количество проблем: код становится неоправданно сложным, запутанным и ненадёжным.

  • Административный часовой пояс (time zone). Например, Europe/Moscow или Sweden/Stockholm. Участок земной поверхности, на котором в соответствии с некоторым законом установлено определённое официальное время. В некоторых странах есть установленный местным правительством регламент переключения с летнего времени на зимнее и обратно – так называемый DST (Daylight Saving Time). В России переключения с летнего времени на зимнее и обратно длились до 2014 года. И никто не гарантирует, что завтра правительство какой-либо страны или региона не решит перевести свои часы вперед или назад. Более того, некоторые страны живут с разницей, которая не кратна часу: в Непале (Asia/Kathmandu) люди благополучно живут со смещением по времени UTC+05:45. Все эти турбулентности с часами в разных странах старательно логируются в единую базу данных – IANA Time Zones, которая доезжает до конечных пользователей, например, с регулярными обновлениями ОС.
  • Смещение часового пояса (UTC offset, time zone offset). Например, -05:00 или +03:00. Фиксированная разница во времени между временем в конкретном административном часовом поясе и всемирным координированным временем (UTC, Coordinated Universal Time). Оно показывает, на сколько часов и минут местное время отличается от времени на нулевом меридиане (Гринвичский меридиан). Смещение – это абсолютная величина, которая не зависит от административного часового пояса (есть только обратная зависимость).

Дополнительно есть понятие географического часового пояса (theoretical/standard time zone). Условная полоса на земной поверхности шириной примерно в 360°/24=15°, которой соответствует определённое смещение времени относительно среднего меридиана, кратное 1 часу. Пояса западнее среднего меридиана имеют отрицательное смещение, восточнее – положительное. Иногда смещения административных поясов совпадают с теоретическими 15-градусными зонами, но политические границы почти всегда нарушают эту идеальную модель. Именно поэтому в программном коде мы работаем с IANA-идентификаторами и смещениями, а не с географическими поясами.

Теперь приземлим эту теорию на практику.

В Java, начиная с 8-й версии, появился детально проработанный набор классов по работе с датой и временем – пакет java.time. Он настолько хорошо сделан, что я считаю его идеальной реализацией стандарта ISO-8601. Поэтому в дальнейших примерах буду использовать его как универсальный референс. Для других языков программирования есть аналогичные инструменты, но, как правило, выполненные в виде отдельных библиотек или пакетов. Однако не думаю, что это помешает найти нужное сходство.

  • ZoneRegion – административный часовой пояс. Пример: ZoneId.of("Europe/Moscow").
  • ZoneOffset – смещение часового пояса. Пример: ZoneOffset.of("+05:00").

Форматы представления времени

Исходя из разницы между часовым поясом и смещением, выделяют следующие типы данных для представления момента времени:

  • Instant. Например, 2026-06-21T02:25:10.456Z. Абсолютный момент времени, UTC Time. Точка на временной шкале, идеальная математическая модель представления времени. Хранит абсолютное значение относительно UTC (нулевого меридиана - Z), следовательно, оно не зависит от календаря, часовых поясов, смещений и политических решений. Используйте это представление времени везде, где только можно, до тех пор, пока не появится острая необходимость работы с часовыми поясами и смещениями.
  • ZonedDateTime. Например, 2026-06-21T07:25:10.456+05:00[Asia/Yekaterinburg]. Время в конкретном административном часовом поясе. Момент времени жестко привязан к региональным правилам. К примеру, в 1999 году в России еще были переходы на летнее время, поэтому для следующей даты установлено смещение +06:00 (летнее время): 1999-06-21T08:25:10.456+06:00[Asia/Yekaterinburg]. Постарайтесь использовать это представление только в случае крайней необходимости или перед показом данных пользователю, используя настройки на его устройстве.
  • OffsetDateTime. Например, 2026-06-21T07:25:10.456+05:00. Абсолютное время с указанием смещения относительно UTC. Не имеет привязки к региональным правилам, поэтому по характеристикам близок к Instant, но позволяет работать с календарными полями: год, месяц, день, час и т.д. Постарайтесь использовать это представление только в случае, когда нужно работать с разными частями даты и времени.

Все эти типы приводимы друг в друга.

Instant.parse("2026-06-21T02:25:10.456Z").atZone(ZoneId.of("Europe/Moscow"))
// => 2026-06-21T05:25:10.456+03:00[Europe/Moscow]

Instant.parse("2026-06-21T02:25:10.456Z").atOffset(ZoneOffset.of("+05:00"))
// => 2026-06-21T07:25:10.456+05:00

ZonedDateTime.parse("2026-06-21T05:25:10.456+03:00[Europe/Moscow]").toInstant()
// => 2026-06-21T02:25:10.456Z

ZonedDateTime.parse("2026-06-21T05:25:10.456+03:00[Europe/Moscow]").toOffsetDateTime()
// => 2026-06-21T05:25:10.456+03:00

OffsetDateTime.parse("2026-06-21T05:25:10.456+03:00").toInstant()
// => 2026-06-21T02:25:10.456Z

OffsetDateTime.parse("2026-06-21T05:25:10.456+03:00").atZoneSameInstant(ZoneId.of("Europe/Moscow"))
// => 2026-06-21T05:25:10.456+03:00[Europe/Moscow]

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

  • LocalDateTime. Например, 2026-06-21T05:25:10.456. Только дата и время. Предоставляет возможность работать с календарными полями. Для подавляющего большинства – абсолютно бесполезный тип данных. Его использование без понимания выше обозначенного контекста – прямой путь к багам. Можно использовать только в том случае, если такое представление должно быть жестко зафиксировано в данных, например, дата и время рождения/смерти, вписанное в юридический документ (свидетельство о рождении/смерти).
  • LocalDate. Например, 2026-06-21. Только дата. Предоставляет возможность работать с календарными полями. В отличие от предыдущего варианта этот тип находит больше применений, т.к. чаще всего он интуитивно используется в логически правильных местах. Следует использовать только при необходимости фиксации представления данных, например, дата рождения в свидетельстве о рождении или дата выдачи паспорта.
  • LocalTime. Например, 05:25:10.456. Только время. Предоставляет возможность работать с частями времени. Применяется крайне редко и, как правило, в описательных целях. Например, режим работы магазина, слоты времени в календаре.
  • OffsetTime. Например, 05:25:10.456+05:00. Только время и его смещение относительно UTC. Применяется крайне редко и, как правило, в описательных целях или служит дополнением к локальной дате. Иначе говоря, указание фиксированного времени события в определенном смещении, когда дата уже известна из контекста. Например, ежедневный стрим в 20:00+03:00; или указание времени при записи на прием к врачу: сначала выбираем дату, затем опционально время посещения.

Трудно представить, сколько раз я сталкивался с ситуацией, когда необоснованное использование LocalDateTime приводило к потере истинного момента времени, на основе которого должны были приниматься решения. Особенно остро этот вопрос стоит при работе с базами данных и передачи временных меток по сети.

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

  • Year2026
  • YearMonth2026-06
  • Month – перечисление
  • MonthDay06-21

Конечно, это не всё, но основное – база, которую необходимо понимать каждому техническому специалисту.

Формат и точность хранения

К этому моменту вы уже чётко понимаете все особенности представления даты и времени, можете обоснованно выбрать подходящий тип данных. Теперь осталось определиться, с форматом и точностью хранения.

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

В подавляющем большинстве случаев для хранения и передачи момента времени лучше использовать UTC-формат, т.е. вышерассмотренный тип Instant. В SQL-базах этот тип часто называется TIMESTAMP, в других есть аналогичный тип, и в действительности он не хранит информацию о часовом поясе или смещении: физически хранится только UTC Time.

Тогда откуда при работе с базой берётся информация о часовом поясе или смещении? На самом деле тут нет никакой магии:

  • из DDL колонки или таблицы;
  • из настроек экземпляра БД;
  • из настроек сервера;
  • из настроек соединения;
  • передаётся в самом запросе.

При работе с TIMESTAMP нужно явно определить на базе каких настроек будет происходить конвертация при передаче, чтении и записи.

Тут кто-то может поспорить: в PostgreSQL можно указать TIMESTAMP WITH TIME ZONE. Да, можно, только это определяет способ представления и конвертации, а не хранения.

  • TIMESTAMP WITH TIME ZONE – говорит БД: это абсолютное время, храни в UTC, но при показе учитывай настройки сессии.
  • TIMESTAMP WITHOUT TIME ZONE – говорит БД: это просто показания календаря и часов, не трогай их, я сам буду приводить время к нужному виду.

Других смыслов там нет, и не ищите его.

Таким образом, проводя аналогию с приведённой выше программной моделью, TIMESTAMP WITH TIME ZONE идентичен Instant; а TIMESTAMP WITHOUT TIME ZONELocalDateTime, возлагая на код приложения всю ответственность по работе с часовыми поясами и смещениями.

Дополнительно следует учесть точность хранения времени (time precision), т.к. в некоторых ситуациях это может сыграть важную роль. Например, при сборе телеметрии с высокочастотных источников (датчиков). Если хранить информацию с точностью до миллисекунд, возможно нарушение порядка следования показаний, что может привести к ложным срабатываниям сигнализации (алармов) или неправильной работе автоматики.

На практике, как правило, достаточно микросекундной точности, и, например, TIMESTAMP в PostgreSQL по умолчанию имеет именно такую точность. Однако следует помнить, что в программной модели Instant имеет наносекундную точность, что теоретически может приводить к разнице во времени до и после сохранения в БД.

Но не всегда настройки по умолчанию в БД такие удобные. Например, в первых версиях ClickHouse был только тип DateTime, который предлагал секундную точность, поэтому для работы с бо́льшей точностью приходилось использовать long (Unix Time в миллисекундах) и встроенные функции по работе с датой и временем. И лишь позже был добавлен DateTime64, в котором стало возможно указать нужную точность, вплоть до наносекунд.

А что делать с другими специфическими типами данных БД вроде date или time? Их применимость точно такая же, как и с рассмотренными типами программной модели: используйте их в том случае, если такое представление должно быть жёстко зафиксировано в данных. Например, “дата рождения”, “дата выдачи паспорта”, “время открытия магазина”, “время регулярного события” и т.д. Подобное использование даты и времени только опосредованно соотносится с моментом времени. Момент рождения – это Instant, дата рождения – это “строка” в свидетельстве о рождении; снятие магазина с сигнализации в начале рабочего дня – это Instant, время открытия магазина – это “строка” в его описании на сайте. Никогда не смешивайте эти понятия!

Хранение часового пояса или смещения

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

Часовая зона или смещения нужны лишь для на этапе анализа или отображения данных. Как правило, эти сведения известны или зафиксированы на уровне настроек системы. К примеру, из базы данных достаём UTC-время 2026-06-21T02:25:10.456Z, в этом же виде (или в виде числа – Unix Time) передаём его по сети, а перед показом пользователю преобразуем к его локальному времени: 2026-06-21T05:25:10.456+03:00[Europe/Moscow]. Другой пользователь из Челябинска увидит это время как 2026-06-21T07:25:10.456+05:00[Asia/Yekaterinburg].

Если, несмотря на все мои доводы, вам всё-таки нужно хранить исходное представление времени, включая часовой пояс и смещение, то придётся придумывать своё собственное решение. Самый простой вариант – рядом с TIMESTAMP хранить его исходное текстовое представление (text), например так: 2026-06-21T05:25:10.456+03:00[Europe/Moscow].

Честно говоря, я никогда не сталкивался с подобной необходимостью, но могу представить только два возможных случая, когда это может быть нужно:

  • Аудит и юридические требования. Возможно, в некоторых финансовых или юридических системах может потребоваться доказать, что операция была совершена именно в определённый момент времени с привязкой к часовому поясу, указанному в документе. Хотя Instant как раз решает именно эту проблему.
  • Сбор данных в науке. Возможно, нужна связь времени снятия показания с географическим местоположением.

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

Выборка за последние N дней

Пример часто встречающихся требований, характерный для журналов и отчётов:

Сделать выборку данных за последние N дней.

Первый вопрос, который нужно задать в этом случае:

За последние N дней или N суток?

Вероятней всего, выборка должна быть за “последние N суток”, но, не видя разницы, большинство продолжает настаивать на днях.

Если разница не ясна, то стоит задать, как минимум, следующие вопросы:

  • Как определить часовой пояс? Рассчитывать на то, что на серверах системы настроен нужный часовой пояс точно не стоит. Также не стоит рассчитывать, что она одна и та же на всех узлах. Даже если вы очень сильно доверяете своим специалистам и инфраструктуре. Потребитель данных может находиться в одной локации, но запрашивать данные для другой. Например, будучи в Москве, пользователь формирует дневной отчет о работе филиала в Челябинске. В таком случае, даже если в ответ вы услышите что-то вроде: “Мы работаем только в Москве!” – не верьте этому и добавьте настройки часовой зоны в конфигурацию приложения. Пусть даже с настройкой по умолчанию Europe/Moscow.
  • В какое время начинается день? Для кого-то это начало суток – 00:00:00.000, а для кого-то – начало рабочего дня, например, 09:00:00.000. А если начало рабочего дня, то, наверное, некрасиво её хардкодить и желательно брать из конфигурации или какой-нибудь таблицы в БД. А что делать, например, если в выборку попадают сотрудники с разным распорядком дня? В разных филиалах? В разных часовых поясах?
  • Как анализировать окончание периода? Часто текущий момент времени – это окончание периода выборки. Предположим, что сейчас 2026-06-21T07:25:10.456+05:00. В выборку должны попадать все данные до этого момента времени? Или все данные до начала текущих суток – 2026-06-21T00:00:00.000+05:00? Или до начала рабочего дня – 2026-06-21T09:00:00.000+05:00? Или до окончания рабочего дня – 2026-06-21T18:00:00.000+05:00? Или до начала следующих суток – 2026-06-22T00:00:00.000+05:00? Или до начала следующего рабочего дня – 2026-06-22T09:00:00.000+05:00?
  • Учитываются ли праздники и выходные? Вполне возможно, что в требованиях имелось в виду “за последние N рабочих дней”. Если так, то задача становится на порядок сложней, т.к. выборка данных должна производиться не только с учётом часового пояса и распорядка дня, но и с учётом производственного календаря. К счастью, такие специфические требования встречаются не во всех областях и задачах, но, как минимум, в бухгалтерском и налоговом учёте.

Это лишь часть вопросов, которые должны возникать в голове архитектора и разработчика, когда он слышит слова период и день. А уж если кто-то всё-таки заикнулся про часовой пояс, то тут вообще должен сработать механизм самозащиты, и нужно всеми силами постараться упростить задачу и свести дни к суткам.

Хорошо, а что значит “за последние N суток”? Это значит, что начало периода определяется как разница текущего момента времени (Instant) и N*24*60*60 секунд. Всё. И тут нам не важен часовой пояс, начало или конец (рабочего) дня, распорядок (рабочего) дня или производственный календарь. Могу сказать, что под такой класс задач попадает большинство, включая различного рода журналы и отчёты.

Вычисление начала периода

Предположим, нужно выбрать данные за последние N суток. Как определить границы выборки? Логика подсказывает такое:

startDay = now - N days
endDay = now 

Логика верная, поэтому многие, не задумываясь, пишут примерно такой код:

var endDay = now().minusDays(N);
var startDay = now();

Проблема в том, что в выборку может попасть N+1 день. Например, на стыке суток, что часто наблюдается в задачах, выполняемых по расписанию. Предположим, первый вызов now() был в момент 2026-06-21T23:59:59.999, а второй в 2026-06-22T00:00:00.000. Тогда при N=1 вышеуказанный код сформирует период с 2026-06-20 по 2026-06-22, а не с 2026-06-20 по 2026-06-21, как, скорей всего, предполагалось. Подобные эффекты могут иметь разные последствия, в том числе послужить причиной порчи данных.

Если код имеет зависимость от какого-то момента времени, то нужно где-то зафиксировать этот момент: либо в начале алгоритма, либо в принимать аргументах методов.

var baseline = now();
var endDay = baseline.minusDays(N);
var startDay = baseline;

Обработка окончания периода

Тут всё очень просто. Очень часто видел, что время окончания периода задаётся в стиле 23:59:59.999, подразумевая, что это время включается в результат выборки.

WHERE ... created_at <= '2026-06-21T23:59:59.999Z' ...

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

WHERE ... created_at < '2026-06-22T00:00:00Z' ...

Измерение продолжительности

Давайте честно, так делали если не все, то очень многие, кто пытался измерить время выполнения какого-то участка кода.

startTime = now();
doSomething();
endTime = now();
duration = endTime - startTime;

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

Это классическая ловушка с Wall Clock. Неявно предполагается, что часы на рабочем узле всегда двигаются только вперед. На самом деле время на часах регулярно прыгает назад, вперед, замедляется и ускоряется. Причины могут быть разные:

  • Административные действия. Изменение серверного времени администратором.
  • Автоматическая синхронизация часов на узлах в сети. Например, с использованием NTP и аналогичных протоколов. В одной из разрабатываемых мной систем был разработан собственный механизм синхронизации часов между узлами приложения.
  • Паузы на уровне runtime или системы виртуализации. Сборка мусора в JVM или .NET, планировщик ресурсов виртуальных машин кластера – они могут поставить вычисления на паузу. Когда процесс продолжит свою работу, может пройти достаточно много времени, что приведёт к неожиданному и, возможно, несправедливому прерыванию вычислений по таймауту.

Человек с опытом наверняка знает про указанные проблемы, поэтому будет использовать Monotonic Clock. Монотонные часы гарантированно идут только вперед, не могут быть сброшены или переведены назад. Реализация таких часов есть на уровне каждой ОС, следовательно, и в языках программирования.

  • Linux/macOS: clock_gettime(CLOCK_MONOTONIC)
  • Windows: QueryPerformanceCounter
  • Java: System.nanoTime()
  • C#: Stopwatch
  • Python: time.perf_counter()

Таким образом, вышеприведенный код на Java нужно переделать в такой:

long startTime = System.nanoTime();
doSomething();
long endTime = System.nanoTime();
long duration = endTime - startTime;

Но это не финал, если продолжительность нужно измерять с особой точностью. Monotonic Clock избавляет от многих проблем, но не от всех. В частности, в продолжительность выполнения всё еще будет включаться время простоя рабочего потока, в том числе по причине GC Pauses или возросшей нагрузки на сервер. Следовательно, вероятность получить неоправданно большое значение всё ещё остается. Что делать?

Единственным спасением может стать измерение CPU Usage – сколько времени код выполнялся на CPU. Разница этого времени даст более стабильный результат, но и он подвержен искажению, т.к. в CPU Usage включаются не только затраты на вычисления, но и издержки кэш промахов на уровне CPU, что ведёт к необходимости чаще обращаться к медленной памяти (RAM). Поэтому один и тот же код, работающий в ненагруженной и нагруженной системе будет иметь разную продолжительность по CPU Usage.

  • Linux/macOS: time.clock_gettime(CLOCK_THREAD_CPUTIME_ID)
  • Windows: GetThreadTimes()
  • Java: ThreadMXBean.getCurrentThreadCpuTime()
  • C#: Thread.GetCurrentThread().TotalProcessorTime
  • Python: time.process_time()

По итогу можно сделать только такой вывод: в идеале измерять нужно не только общую задержку, но и CPU Usage. Это позволит дифференцировать, например, такие ситуации:

  • Wall Time = 500 ms, CPU Time = 480 ms – нужно оптимизировать код или это просто CPU-bound-нагрузка.
  • Wall Time = 500 ms, CPU Time = 50 ms – поток долго простаивал (пиковая нагрузка, много потоков, работа GC, блокировки, ожидание I/O).

Оптимизация CPU-bound-кода если не простая, то очень понятная задача, а вот второй случай обычно гораздо более частый и сложный как в понимании, так и в исправлении.

Время регистрации событий

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

В особо изощренных случаях, где возникает необходимость упорядочивания действий при разрешении конфликтов данных, приходится рассматривать векторные часы (Vector Clocks, Lamport Timestamps) или сложные алгоритмы синхронизации и поиска консенсуса (например, Raft, Paxos, Zab).



Понравилась статья?

Посмею напомнить, что у меня есть Telegram-канал Архитектоника в ИТ, где я публикую материал на похожие темы примерно раз в неделю. Подписчики меня мотивируют, но ещё больше мотивируют живые дискуссии, ведь именно в них рождается истина. Поэтому подписывайтесь на канал и будем оставаться на связи! ;-)

Статьи из той же категории: