«Never say never» или Работаем с таймзонами правильно

в 19:22, , рубрики: datetime, made of steel, python, timezone, Блог компании Mail.Ru Group, боль, Программирование, страдание

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

TL;DR: Работа с таймзонами — это боль и унижение. Никогда не работайте с таймзонами!

Итак, все кругом твердят вам, что при получении времени от пользователя нужно сразу же переводить его в UTC, работать со временем нужно только в UTC и хранить время тоже нужно строго в UTC. Совет, на первый взгляд, выглядит разумным, и следование ему делает вашу жизнь проще… Если только ваша программа не предполагает сложной работы с датами. Записать в базу данных дату и время регистрации пользователя на сайте? Сохранить время отправки сообщения или дату создания заказа в интернет-магазине? Вывести сообщение в лог с указанием даты-времени? Используйте UTC и всё будет в порядке, можете даже не читать эту статью дальше. Любое текущее время можно совершенно спокойно конвертировать в UTC и забыть о проблемах. Но что, если мы хотим работать с временем в будущем? Или в прошлом? Например, если мы пишем сервис календаря, или сервис для отложенной отправки сообщений?

UTC не панацея

Поясню на примере. Допустим, мы создали тот же сервис отложенных сообщений. Зайдя на наш сайт пользователь может создать себе напоминание на любое время (разумеется, в будущем) по почте или СМС. Сайт наш предельно прост: задаём дату, время, вводим текст напоминания и канал связи (адрес email или номер телефона), полученные от пользователя данные складываем в базу и потом периодически делаем по ней выборки и отправляем сообщения. Всё, профит и уважение благодарных людей!

Нет, не всё. Следуя совету всегда везде хранить всё в UTC, мы преобразовали полученную от пользователя дату и время в UTC и положили их в базу данных. Пусть пользователь из Москвы зашёл на наш сайт 2 марта 2014 года и создал напоминание на 09:00 утра 3 ноября 2014 года. Соответственно, в базу мы положили значение «2014-11-03 05:00:00», ведь в тот день, 2 марта 2014 года, смещение для таймзоны «Europe/Moscow» для 3 ноября 2014 года составляло «UTC+4».

Понимаете, к чему я клоню?

Да, 21 июля 2014 года Государственная дума Российской Федерации приняла законопроект об отмене летнего времени. Согласно этому закону, с 26 октября 2014 года, смещение для таймзоны Europe/Moscow стало «UTC+3» вместо «UTC+4» (а ещё переход на летнее время отменили, но речь сейчас не об этом). Соответственно, если мы отправим уведомление пользователю 3 ноября в 5:00 утра по UTC, он получит его в 8:00 утра по Москве, и я уверен, что пользователь будет недоумевать, ведь он просил, чтобы уведомление пришло ему ровно в девять утра.

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

С другой стороны, если вы будете хранить в базе данных локальное время пользователя и его таймзону, работать с такими данными будет практически невозможно. Вернёмся к нашему примеру сервиса уведомлений: два пользователя создали по уведомлению. Первый пользователь из Москвы, попросил прислать ему СМС 15 декабря 2014 года в 15:00 (пишем в базу его локальное время «2014-12-15 15:00:00» и его часовой пояс «Europe/Moscow»). Второй пользователь из Нью-Йорка, попросил прислать ему письмо на электронную почту 15 декабря 2015 года в 7:00PM (пишем в базу его локальное время «2014-12-15 19:00:00» и его часовой пояс «America/New_York»). Пока всё хорошо: у нас записано локальное время, в которое пользователь хотел бы получить своё уведомление, и он его получит строго в это время, даже если правительство одной из этих стран изменит один из этих часовых поясов (смещение, правила перехода на летнее время, всё, что угодно).

Проблемы начинаются, когда вы будете писать скрипт, выбирающий из базы уведомления для отправки. Если бы все даты были записаны в UTC, всё было бы просто, — каждую минуту выбираем сообщения для отправки:

SELECT * FROM reminders WHERE remind_time < NOW();

При условии, что «SELECT NOW();» возвращает время в UTC. Но мы записали в базу локальное время пользователя и его часовой пояс, что же делать? Страдать :-) Ведь «NOW()» по UTC — это "+3" часа в Москве (и сообщение уже опоздало) и "-5" часов в Нью-Йорке (сообщение ещё рано отправлять).

Нет, конечно можно придумать много способов выборки из базы тех уведомлений, которые пора отправлять, но все они на более-менее нагруженном сервисе приведут к проблемам с производительностью, да и вообще мы же хотим сделать всё правильно, без «костылей», да?

Какие есть варианты? Их много, однако я вижу только один более-менее приемлимый вариант: хранить в базе три значения: время в UTC (для выборки по этому полю), локальное время пользователя и его часовой пояс (таймзону). Да, у нас будут храниться избыточные данные, однако я не знаю ни одного нагруженного сервиса, который не прибегал бы к денормализации данных. В реальном мире это нормально. Какие плюсы мы получаем? В случае изменений часовых поясов, мы можем пройтись по записям для изменившихся таймзон специальным скриптом, и обновить время в UTC, если оно поменялось в результате обновления часового пояса. По моему скромному мнению, это хороший компромисс.

Всё ещё хуже, чем кажется

Вроде всё, да? Нет, мы только начали :-) Правительство может не только менять конфигурацию часовых поясов, но и добавлять новые и выкидывать таймзоны. Так, например, жители Российского города Чита с 26 октября 2014 года перешли на новый часовой пояс «Asia/Chita» (раньше такого часового пояса не существовало) вместо употреблявшегося до этого «Asia/Yakutsk». Разница с UTC у прежнего часового пояса («Asia/Yakutsk») составляет "+09:00", а у нового часового пояса («Asia/Chita») эта разница составляет "+08:00". Проблема заключается в том, что мы храним в базе только время и часовой пояс пользователя, но не его географическое положение. И для записей с часовым поясом «Asia/Yakutsk» мы никак не можем знать, из Читы ли наш пользователь, или из Якутстка, и мы никак не можем достоверно определить время отправки сообщения пользователю. Шах и мат! Не забываем страдать, друзья.

Если у вас есть возможность узнать географическое положение пользователя и при следующем его заходе на сайт определить, что он находится в регионе со сменившейся таймзоной (Чита для случая выше), можно спросить у него правильный часовой пояс. И предложить обновить таймзону для всех его событий (с пересчётом времени в UTC для каждого события), но здесь тоже могут возникнуть подводные камни и нюансы, выходящие за рамки данной статьи. Кстати, отчасти по этой причине мы в настройках Календаря mail.ru просим пользователя выбрать его географическое местоположение (город), а не часовой пояс, как это делают остальные сервисы :-) И даже несмотря на это, скажу честно, периодически бывают проблемы.

С хранением времени в прошлом тоже не всё так просто. Если это прошлое — относительно недавнее (скажем, речь идёт о двадцать первом веке), то проблем с хранением времени в UTC быть не должно (хотя гарантий вам, конечно, никто не даст). Если же речь идёт о двадцатом веке или (о, ужас) более давних временах, проблемы гарантированы. Начнём с того, что для многих периодов истории прошлого века, информация о переводе часов постоянно меняется по сей день. Так, например, в обновлении базы данных часовых поясов tzdata версии 2014g от 30 августа 2014 года для ряда часовых поясов СССР были внесены изменения на несколько секунд или минут для дат до 1926 года. Просто кто-то заметил несоответствие и уведомил об этом составителей tzdata. Или вот ещё пример из более близких нам времён: в обновлении tzdata версии 2014a от 9 марта 2014 года изменилась информация о дате перехода Украины с Московского времени на Восточноевропейское: этот переход произошёл не первого января 1992 года (как было записано в этой базе), а первого июля 1990 года.

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

Как же всё-таки правильно хранить время?

Итак, как же всё-таки правильно хранить время в базе данных? Лучше, конечно, этого не делать, однако если очень нужно, то вот мои личные рекомендации (буду рад услышать критику или предложения):

  1. Если вам нужно хранить время только что произошедшего события, текущее время, по факту определённого действия, храните его в UTC. Это могут быть записи в логах, время регистрации пользователя, совершения заказа или отправки письма.
  2. Если время не привязано к пользователю или его часовому поясу, храните его в UTC. Это может быть, например, время следующего солнечного затмения.
  3. Если вам нужно хранить время в прошлом или в будущем, сохраняйте локальное время пользователя, а рядом сохраняйте его таймзону. А ещё лучше, так, чтобы наверняка, сохраняйте географическое положение пользователя. Если нужно делать выборки по этому времени, сохраняйте рядом время в UTC, и обновляйте это время при изменении информации о часовом поясе.
  4. Если вам нужно совершенно точно знать время для любой даты для заданного географического положения (например, для астрономических расчётов) — храните точные координаты пользователя, но не его часовой пояс. Впрочем, если перед вами стоит такая задача, то вы и так знаете, как делать правильно.

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

Работаем со временем

С хранением времени, вроде, разобрались. Однако часто можно услышать так же совет «всегда работайте с временем в UTC». Подразумевается, что как только вы получили время от пользователя, его нужно сразу же перевести в UTC и работать только с временем в UTC. Звучит логично, не правда ли?

Неправда. По крайней мере, не во всех случаях, и вот вам конкретный пример.

Вернёмся к нашему примеру с сервисом отложенных сообщений. Всё хорошо, сервис разбивается, пользователи довольны, но просят добавить функционал повторяющихся уведомлений. А повторы бывают не только простые («каждый день», «через день», «каждый месяц»), но и достаточно сложные («каждую неделю по вторникам», «каждый месяц в последнюю пятницу месяца» и т.д.). Чтобы не писать свой велосипед для этих повторов, изучим уже готовые решения. Существует такое понятие, как «повторяющиеся события». Существует специальный формат описания правил повторения, который, конечно, учитывает не все возможные варианты (например, нельзя задать «два дня через два»), однако большую часть случаев он покрывает. Примеры применения этого формата можно увидеть в описании поля RRULE спецификации iCalendar и в документации объекта rrule модуля python-dateutil для Python.

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

Один из вариантов повторяющихся событий — повтор по дням недели. Мы можем описать событие, которое повторяется, например, в 12:00 каждую неделю по вторникам и пятницам. Вот как это может выглядеть на практике, в реальном коде:

>>> import datetime
>>> from dateutil import rrule
>>> list(rrule.rrule(rrule.WEEKLY, count=4, byweekday=(rrule.TU, rrule.FR),
                     dtstart=datetime.datetime(2014, 11, 3, 12, 0)))
[datetime.datetime(2014, 11, 4, 12, 0),
 datetime.datetime(2014, 11, 7, 12, 0),
 datetime.datetime(2014, 11, 11, 12, 0),
 datetime.datetime(2014, 11, 14, 12, 0)]

Казалось бы, всё хорошо. Теперь давайте представим себе, что пользователь из Москвы создал повторяющееся событие, которое происходит в час ночи. Как только мы получили от него время «2014-11-03 01:00:00» мы, согласно рекомендациям умных людей, сразу же переводим его в UTC (процесс перевода нас сейчас не интересует, нам следует знать, что фактически мы отнимаем три часа от полученного времени), и получаем следующее время в UTC: datetime.datetime(2014, 11, 2, 23, 0). Пока всё хорошо. Давайте получим повторы для полученного времени:

>>> list(rrule.rrule(rrule.WEEKLY, count=4, byweekday=(rrule.TU, rrule.FR),
                     dtstart=datetime.datetime(2014, 11, 2, 23, 0)))
[datetime.datetime(2014, 11, 4, 23, 0),
 datetime.datetime(2014, 11, 7, 23, 0),
 datetime.datetime(2014, 11, 11, 23, 0),
 datetime.datetime(2014, 11, 14, 23, 0)]

Кажется, что-то пошло не так. Если мы переведём полученные значения в локальное время пользователя (прибавим к каждому три часа), мы увидим, что повторы сдвинулись и событие повторяется всё так же в час ночи, но уже по средам и субботам. И это не ошибка модуля python-dateutil, код отработал корректно. Это наша ошибка, в этом конкретном случае нам нужно было работать с локальным временем пользователя.

Кстати, многие сервисы календарей имеют этот баг, например, программа iCal в OS X, в определённых случаях, считает повторы совершенно неправильно.

Не забывайте страдать

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

А работа с таймзонами — это боль и страдание, да. Если есть хоть малейшая возможность не работать с ними — воспользуйтесь ей, не пожалеете. Напоследок приведу пару примеров некорректной работы реальных программ:

Python 2.7.6

➜ date
воскресенье,  9 ноября 2014 г. 22:44:32 (MSK)
➜ python -c "import datetime; print datetime.datetime.now()"
2014-11-09 22:44:33.310904
➜ python -c "import datetime; print datetime.datetime.utcnow()"
2014-11-09 19:44:34.405287

Вроде всё хорошо. Смотрим дальше:

➜ date +%z
+0300
➜ python -c "import time; print time.timezone/3600"
-4

WAT? Нет, вроде это не баг, а фича, однако никому от этого не легче. Какой вообще смысл в коде, который в любой момент может сломаться (и ломается!)?

Firefox 33.0.3

new Date(2015, 0, 6) 
"Tue Jan 06 2015 00:00:00 GMT+0300 (Russia TZ 2 Standard Time)" 

new Date(2015, 0, 7) 
"Tue Jan 06 2015 23:00:00 GMT+0300 (Russia TZ 2 Standard Time)" 

new Date(2015, 0, 8) 
"Thu Jan 08 2015 00:00:00 GMT+0400 (Russia TZ 2 Daylight Time)"

WAT? Нет, я понимаю, что этот вопрос уже много раз поднимался, но жить от этого не легче.

В общем, что могу сказать, не забывайте страдать :-)

А как вы работаете с датами, временем и таймзонами?

Владимир Рудных,
Технический руководитель Календаря Mail.Ru.

Автор: Dreadatour

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js