Сказка о потерянном времени

Disclaimer

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

TL;DR

  1. Всегда храните время в формате, позволяющем однозначно идентифицировать момент наступления события.
  2. Никогда не используйте тип TIMESTAMP в MySQL (если знаете случаи, когда он необходим или хотя бы уместен — делитесь в комментариях).
  3. Помните о том, что Unix Timestamp не безгрешен, а диапазона “00-59” при представлении времени не всегда достаточно для выполнения п. 1.
  4. Помните о разных определениях часовых поясов и используйте их уместно.

Краткая предыстория

Когда-то на собеседованиях я задавал один и тот же вопрос в немного разных вариациях: если на компьютерах, скажем, в Минске (UTC+3) и Лондоне (на момент публикации там UTC+1) одновременно выполнить функцию time() (возвращающую UNIX timestamp, целое число), какова будет разность полученных значений (если от минского значения отнять лондонское; считаем, что часы и там, и там в момент выполнения шли верно)?

Ответ, который я чаще всего слышал — 7200. В этой статье я хотел бы рассказать, почему я хочу услышать что-то другое.

Как идентифицировать момент времени или затягиваем пояса

Казалось бы, очевидный момент. Посмотреть в календарь и на часы. Например, эти строки пишутся 14 сентября 2017 года в 14:46:02. Допустим, этот факт надо где-то зафиксировать. Достаточно ли будет записать что-то вроде “2017-09-14 14:46:02”? Думаю, не надо объяснять, что эта строка в упомянутой выше паре городов, Минске и Лондоне, будет интерпретирована по разному: ведь когда я в Минске написал эти строки, часы жителей Лондона показывали 12:46:02, а 14:46:02 там будет только через два часа. Понятное дело, казалось бы: надо добавить часовой пояс!

Что такое time zone? Согласно википедии, это участок земной поверхности, на котором в соответствии с некоторым законом установлено определённое официальное время (standard time). Таким образом, Минск находится в часовом поясе UTC+3, а Лондон — UTC (UTC±0). При этом некоторые территории используют так называемое летнее время (daylight saving time) – во время действия летнего времени смещение относительно UTC изменяется: сейчас в Лондоне действует летнее время, поэтому там выше было сказано «UTC+1» (он же BST, British Summer Time).

Кроме того, существует tz database — база данных часовых поясов, собираемая и поддерживаемая под эгидой IANA (Internet Assigned Numbers Authority — «Администрация адресного пространства Интернет»). Там часовые пояса привязываются к условному центру территории, которая находится в одном часовом поясе и использует одни правила перехода на летнее время. Например, Europe/Minsk, Europe/London. При этом часовой пояс из tz database будет «знать» всю историю переводов стрелок часов.

Теперь давайте посмотрим на то, как использовать всё это.

Как мы уже решили, для того, чтобы однозначно идентифицировать момент времени к локальному времени, которое мы видим на часах, нужно добавить часовой пояс. Например в случае с лондонским временем – какой именно? UTC? UTC+1? BST? Europe/London? Давайте разберемся на примере: попытаемся описать 01:00 29 октября 2016 в Лондоне. Это время наиболее показательно, потому что оно в Лондоне наступило дважды (такое случается в любой стране, которая с летнего возвращается на зимнее, официальное время).

Итак, рассмотрим 2 ситуации:

  1. 2016-10-29 01:00:00, наступившее после 2016-10-29 00:59:59
  2. 2016-10-29 01:00:00, наступившее после 2016-10-29 01:59:59 в результате перевода стрелок на час назад

Интуиция подсказывает, что «добавка» должна быть разной, если само время выглядит одинаково: нужно же их различать как-то! Таким образом, Europe/London отпадает сразу – и правильный ответ приходит сам собой: первое — все еще летнее (daylight saving или summer) время, UTC+1 или BST; второе — уже официальное (standard) для Великобритании, UTC.

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

  1. 2016-10-29 01:00:00 UTC+1 (или 2016-10-29 00:00:00 UTC)
  2. 2016-10-29 01:00:00 UTC

Итак, для преобразования местного времени в однозначный формат, нам нужно смещение. Теперь давайте разберемся, как выполнить обратное преобразование. Есть у нас, скажем, значение “2016-10-29T00:00:00UTC+00:00”, которое нужно показать пользователю, представившемуся жителем Лондона. Какое смещение применить? Никакого не применять? Сделать +1? Вот тут-то нам и поможет tz database: если поискать по имени Europe/London, то она поможет узнать, какое смещение было там в указанный момент времени:

<?php
// Создаем объект, хранящий однозначное описание момента времени
$time = new DateTimeImmutable('2016-10-29T00:00:00+00:00');

$londonTimeZone = new DateTimeZone('Europe/London');

// Готовим объект к тому, что вывод будет в лондонском часовом поясе
$londonTime = $time->setTimezone($londonTimeZone);

var_dump($londonTime->format('Y-m-d H:i:s (P)'));

string(28) "2016-10-29 01:00:00 (+01:00)"

Обратите внимание, следующий пример не сработает:

<?php

$notLondonTime = new DateTimeImmutable('2016-10-29T00:00:00+00:00', new DateTimeZone('Europe/London'));

var_dump($notLondonTime->format('Y-m-d H:i:s (P)'));

string(28) "2016-10-29 00:00:00 (+00:00)"

Это поведение ожидаемо. Вот что говорит документация:

public DateTime::__construct ([ string $time = "now" [, DateTimeZone $timezone = NULL ]] )

Значение аргумента $timezone равно как и текущая временная зона не будут учитываться, если в качестве аргумента $time передается метка времени UNIX (например @946684800) или время, в котором временная зона уже содержится (например 2010-01-28T15:00:00+02:00).

Интересно будет также взглянуть на объекты $londonTime и $notLondonTime вместе:

<?php
$time = new DateTimeImmutable('2016-10-29T00:00:00UTC+00:00');
$londonTimeZone = new DateTimeZone('Europe/London');
$londonTime = $time->setTimezone($londonTimeZone);

$notLondonTime = new DateTimeImmutable('2016-10-29T00:00:00UTC+00:00', new DateTimeZone('Europe/London'));

var_dump($londonTime, $notLondonTime);
object(DateTimeImmutable)#3 (3) {
  ["date"]=>
  string(26) "2016-10-29 01:00:00.000000"
  ["timezone_type"]=>
  int(3)
  ["timezone"]=>
  string(13) "Europe/London"
}
object(DateTimeImmutable)#4 (3) {
  ["date"]=>
  string(26) "2016-10-29 00:00:00.000000"
  ["timezone_type"]=>
  int(1)
  ["timezone"]=>
  string(6) "+00:00"
}

Обратите внимание на значение свойств timezone_type и timezone. PHP тоже видит разницу в том, просто ли передано смещение или указан часовой пояс из Tz Database и нужно учитывать переводы часов.

Итого: записывайте время с текущим смещением, а когда нужно отобразить его человеку, используйте Tz Database.

Небольшая заметка на правах постскриптума: смещение относительно UTC не всегда равно целому числу часов. Например, Иран и Индия живут со смещением +3:30 (не считая перехода на летнее время) и +5:30 соответственно, а Непал — и вовсе +5:45 (и это не единственные «нецелые» пояса).

Unix Timestamp

Тут мы плавно подходим к тому, что же такое Unix Timestamp (и что возвращает функция time()): это количество секунд, которое прошло с 1970-01-01 00:00:00 UTC. То есть просто один из способов представления универсального координированного времени — в виде целого числа.

Возвращаясь к вопросу в начале статьи: отличалось ли количество секунд, прошедшее с полуночи 1 января 1970 по UTC в Лондоне и Минске в один и тот же момент времени? Конечно, нет. Unix Timestamp описывает момент времени независимо от места, где его наблюдают.

Что для этого всего есть в MySQL?

Как всем, наверное, известно, в MySQL есть 2 типа, позволяющие работать с датой и временем (INT с записанным туда Unix Timestamp в расчет не берем): TIMESTAMP и DATETIME. Предлагаю кратко взглянуть на то, чем они отличаются и в чем состоят наиболее важные на мой взгляд особенности работы с ними.

С точки зрения формата взаимодействия с полями этих типов, они не отличаются вообще ничем: и то, и то принимает и возвращает значения в формате YYYY-MM-DD HH:MM:SS (или, в терминах date()/DateTime::format(), хорошо знакомый Y-m-d H:i:s).

Разница очевидна, если понимать, как они устроены под капотом:

  • TIMESTAMP хранится как Unix Timestamp (это 32-битное целое число), поэтому может содержать значения с ‘1970-01-01 00:00:01’ UTC по ‘2038-01-19 03:14:07’ UTC (остальные приводятся к 0, который возвращается как 0000-00-00 00:00:00, или вызывают ошибку в зависимости от sql_mode)
  • DATETIME может хранить значения в диапазоне с ‘1000-01-01 00:00:00’ по ‘9999-12-31 23:59:59’ (там используется число бóльшей разрядности)

Соответственно, и места занимают разное количество:

Тип < 5.6.4 ≥ 5.6.4
TIMESTAMP 4 B 4 B + 0–3 B
DATETIME 8 B 5 B + 0–3 B

Начиная с версии 5.6.4 в MySQL появилась возможность добавления до 6 (включительно) знаков «после запятой» к типам, касающимся времени, и указанные выше 0–3 байта отводятся под опциональную дробную часть. Если ее нету, то и этой «добавки» нету, чем больше знаков нужно, тем больше места будет занимать значение. Кроме того, заодно был переработан формат хранения значений DATETIME, в результате чего они стали занимать не 8, а 5 байт — соответственно, и аргумент о том, что DATETIME занимает больше места, стал менее весомым (хотя по моему скромному мнению, даже когда DATETIME был вдвое тяжелее, состоятельность аргументов в пользу использования TIMESTAMP была в большинстве случаев крайне сомнительной).

Я тут вожу вокруг да около, но давате начистоту о том, что не так с TIMESTAMP. Казалось бы, отличный способ однозначно сохранить текущий момент в базе данных — но, как водится, есть нюанс. MySQL преобразует передаваемые ему значения перед записью в поле типа TIMESTAMP в соответствии со значением параметра time_zone. Он может быть задан тремя способами:

  • глобально в конфиге: параметр называется default_time_zone
  • глобально в рантайме: SET GLOBAL time_zone = timezone;
  • для конкретной сессии: SET time_zone = timezone;

Чтобы провернуть любой из последних двух пунктов, нужно загрузить в MySQL информацию о часовых поясах. Часовой пояс по умолчанию – SYSTEM. Это значит, что MySQL читает его из переменной system_time_zone, которую, в свою очередь определяет как смещение относительно UTC согласно часовому поясу, установленному в системе на момент запуска (т.е. если, к примеру, на сервере “Europe/Minsk”, то MySQL будет использовать “+03:00”).

Допустим есть приложение, которое приучено всегда работать с UTC, и передает данные о дате и времени в MySQL в UTC; MySQL сконфигурирован по умолчанию, сервер настроен на использование UTC. При таком раскладе MySQL не будет производить никаких преобразований перед записью: просто Y-m-d H:i:s преобразует в Unix Timestamp. В какой-то момент администратору сервера надоело смотреть на этот UTC во всех логах, кронах и всем вот этом, и он, не подозревая о каких-то побочных эффектах поменял часовой пояс на свой локальный, например, Europe/Minsk. После этого MySQL начинает думать, что ему передается время в UTC+3, и перед записью отнимает 3 часа. Всё, с этого момента можно считать, что в базе не время, а бессмысленная последовательность чисел: что-то было изменено при вставке, что-то — нет, но было оно изменено или нет, чтобы открутить обратно, уже невозможно.


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


Кроме того, если на сервере стоит, скажем, Europe/London, в котором время может быть летним и стандартным, то часовой пояс в MySQL будет зависеть от времени старта MySQL-сервера! Справедливости ради, с Минском тоже не всё так гладко, как кажется: часовые пояса меняются; вероятно даже чаще, чем может показаться. Посмотрите здесь историю изменений для Минска: найдете ли вы хоть одно десятилетие после 1979 года без изменений?

Вывод, который я сделал для себя: TIMESTAMP небезопасен и его следует избегать. Как? Забить на экономию на спичках (от 1 до 4 байт в зависимости от версии MySQL на каждое значение) и использовать DATETIME, при этом заставляя приложение всегда писать в базу UTC (т.е. setTimezone('UTC') перед записью и записать format('Y-m-d H:i:s') — таким образом смещение явно не хранится, но приложение делает его всегда нулевым и при работе со временем из базы подразумевается, что это UTC). Это надежнее, чем TIMESTAMP и удобнее, чем INT с Unix Timestamp (хотя на вкус и цвет, как известно, все фломастеры разные). Главное – всегда храните время в формате, позволяющем однозначно идентифицировать момент времени!

Атомное и астрономическое время

Несмотря на то, что сутки принято считать равными 86400 секундам, это не совсем так. Земля со временем замедляет вращение вокруг своей оси, что увеличивает реальную длительность суток; сутки (оборот планеты вокруг своей оси) занимают всё больше и больше; и хоть эта величина изменяется относительно незначительно, с годами разница нарастает и становится ощутимой. В связи с этим от астрономического определения секунды как 1/86400 времени обращения Земли вокруг своей оси перешли к другому способу установить эталон:

Секунда есть время, равное 9 192 631 770 периодам излучения, соответствующего переходу между двумя сверхтонкими уровнями основного состояния атома цезия-133.

Итак, сутки становтся длиннее, то есть со временем часы начинают пробивать полночь раньше, чем планета достигает определенного положения. Да, на какие-то там тысячные доли секунды, но за несколько лет набегает целая секунда, что уже серьезно. Для коррекции этого эффекта была введена «секунда координации» или «високосная секунда» (leap second). Итак, когда разница между солнечным временем и UTC, отсчитываемым атомными часами, приближается к значению в 0.6 с, 30 июня или 31 декабря к концу суток добавляется одна секунда: после 23:59:59 идет 23:59:60. То есть 2016-12-31T23:59:60Z — это не ошибка и не бред сумасшедшего, а реальное время.

Самое интересное во всем этом с точки зрения этой статьи то, что Unix Timestamp эту високосную секунду не учитывает, и двум секундам в UTC (той самой високосной и первой в день, следующий за днем координации) соответствует один и тот же Unix Timestamp. Как следствие, PHP, хранящий под капотом в DateTime и DateTimeImmutable Unix Timestamp тоже високосную секунду «проглотит»:

<?php
var_dump(
  (new DateTimeImmutable('2016-12-31T23:59:59Z'))->getTimestamp(),
  (new DateTimeImmutable('2016-12-31T23:59:60Z'))->getTimestamp(),
  (new DateTimeImmutable('2017-01-01T00:00:00Z'))->getTimestamp(),
  (new DateTimeImmutable('2017-01-01T00:00:01Z'))->getTimestamp()
);
int(1483228799)
int(1483228800)
int(1483228800)
int(1483228801)
<?php
var_dump(
  (new DateTimeImmutable('2016-12-31T23:59:59Z'))->modify("+1 second")
);
class DateTimeImmutable#2 (3) {
  public $date =>
  string(26) "2017-01-01 00:00:00.000000"
  public $timezone_type =>
  int(2)
  public $timezone =>
  string(1) "Z"
}

В целом, о Unix Timestamp (и функции time()) надо помнить следующее:

  1. time() не всегда скажет правду о том, сколько времени прошло между вызовами: при наступлении високосной секунды echo time(); sleep(1); time(); вполне может вернуть одно и то же дважды (в случае с бенчмарком можно увидеть небывалый прирост производительности!);
  2. Вообще говоря, нельзя рассчитывать на то, что time() возвращает монотонно возрастающие значения: синхронизация времени по NTP при определенных обстоятельствах может, так сказать, отбросить в прошлое (в том числе из-за появления високосной секунды, но ведь часы могут сбиться и по другим причинам).

На этом, пожалуй, всё. Постарайтесь не терять время попусту :-)

Александр Курило
Системный архитектор
comments powered by Disqus