Часовые пояса: конвенция для абсолютного времени и производных дат #63
Notifications
Due Date
No due date set.
Blocks
#5 Этап 3: заказы и каталог
ddmitry/clickstream-data-platform
#48 docs(generator): путеводитель по генератору
ddmitry/clickstream-data-platform
Reference: ddmitry/clickstream-data-platform#63
Reference in New Issue
Block a user
Пояса в ClickHouse — частые грабли, и стенд сейчас стоит на неявном умолчании:
колонки времени объявлены без пояса, а сходится всё лишь потому, что пояс
сервера —
UTC. Тикет просит принять конвенцию и записать её, покаdds.*ивитрины не написаны: правило понадобится именно там — воронки, удержание,
«покупки по дням».
Что измерено на стенде
7 августа 2026 года, ClickHouse 26.3.17.56,
ods.eventпосле #43. Настоящаястрока модельного дня, колонка
UTCEventTime DateTimeбез пояса:UTCsession_timezone='Europe/Samara'2026-05-31 21:24:092026-06-01 01:24:09toDate(UTCEventTime)2026-05-312026-05-31toDate(UTCEventTime, 'Europe/Samara')2026-06-012026-06-01EventDateиз выгрузки2026-06-012026-06-01У клиента с чужим поясом глаз и
GROUP BYрасходятся: в строке виднопервое июня, а группируется она в тридцать первое мая. Документация ClickHouse
это объясняет: у колонки без объявленного пояса на запись действует пояс
сервера и только он,
session_timezoneна запись не влияет, а на чтение —влияет (сверено через Context7 7 августа 2026 года).
Расхождение не редкость:
toDate(UTCEventTime) != EventDateу 1713 событий из50 626 модельного дня — 3,4%. Так задуман мир: пояс счётчика UTC+4,
EventDateживёт в нём,
UTCEventTimeабсолютна (спека генератора, раздел 9).Развилка
Решение принимается до правок, веером и с записью отклонённого — скилл
brainstorm-with-docs. Что видно сейчас:UTC, поясов в типах нет;DateTime('UTC'),DateTime64(3, 'UTC').Dateне трогается — у него пояса нет вовсе;времени — только с явно названным поясом, голый
toDateпоDateTimeврепозитории не пишется;
UTCEventTime, назван дляполноты веера.
Учебная ценность у второго и третьего одна и та же, и она не оборонительная:
явный пояс в DDL — не сторож, а названное намерение. Менти читает
DateTime('UTC')и спрашивает, зачем пояс написан; вопрос этот ровно тот, радикоторого мир сделан с поясом счётчика +4.
Что войдёт после решения
колонками; отклонённые варианты — там же или в ADR.
sql/ddl/10-stg-tables.sql,sql/ddl/20-ods-tables.sql.sql/ddl/30-ods-views.sql. Это не DDL, авыражение матвью, и правка там другого рода — третий аргумент
'UTC'уparseDateTimeOrNull. Без неё тип колонки чинит только чтение: суффиксZмаска сверяет как букву и выбрасывает, поэтому число, которое ляжет в
колонку, сегодня определяет пояс сервера. Замерено 8 августа 2026 года:
под
session_timezone='Europe/Samara'та же строка даёт 1780262649 безаргумента и 1780277049 с
'UTC'.(
world.COUNTER_TIMEZONE_MINUTES = 240), аtoDateтребует имя IANA(
Europe/Samara). Сегодня они эквивалентны — Самара часы не переводит, — норазъедутся молча, если однажды поменять одно.
Критерии приёмки
make check-clickhouseзелёный и не медленнее прежнего.
одна названа заранее и обязана измениться: у клиента с чужим поясом
колонка показана не
2026-06-01 01:24:09, а2026-05-31 21:24:09.Это и есть поставляемое — глаз и
GROUP BYперестают спорить.Границы
Расхождение глаза и
GROUP BYучит лабой: Superset и Grafana рендерят всвоём поясе, и это готовая сцена урока, а не приёмка.
Dateостаётся без пояса — у типа его нет.Сначала прочитать
toDate(UTCEventTime)не равенEventDateу ночных событий.COUNTER_TIMEZONE_MINUTES.Проверка
make clean && make up && make check-clickhouseSELECTиз «что измерено», до и после правкиХвост с грилинга, чтобы не потерялся до этапа дашбордов.
Пояс показа — развилка, а не недоделка. У дашборда Grafana умолчание
browser, то есть часы читателя. Сутки мира самарские (UTC+4), читательскорее московский (UTC+3) — час разницы сдвигает часовые срезы относительно
модельных суток тихо и правдоподобно. Сверено по документации Grafana
8 августа 2026 года.
Два варианта, и оба связные:
browser— но только если та же работа пишет лабу, котораясдвиг вскрывает («сравни пиковый час на дашборде с пиковым часом из SQL»).
Подстроенная ловушка учит, просто оставленная — врёт: менти сидит в Москве
и зацепки, что магазин самарский, у него нет;
Europe/Samaraявной настройкой дашборда.В
docs/architecture/storage.mdэтого нет намеренно: зона того документа —сторона ClickHouse, а не показ. Решать вместе с дашбордами.
Реализация легла в ветку
docs/63-timezone-convention, три коммита поверх записанной конвенции.Сделано
UTCEventTime—DateTime('UTC');_load_tsиkafka_timestamp—DateTime64(3, 'UTC')в STG и ODS.system.columnsподтверждает по всем колонкам времени обоих слоёв.parseDateTimeOrNullполучил третьим аргументом'UTC'— три вызова в двух матвью.Europe/Samaraстоит комментарием кCOUNTER_TIMEZONE_MINUTESвworld.py. Константу с тестом сходимости сначала завели, потом срезали: константу не читал ни один модуль, единственным её читателем был тест про неё же.Замер повторён
Событие
WatchID = 384218330540,EventDate=2026-06-01. Стенд поднят с нуля уже по конвенции.session_timezone='Europe/Samara'2026-05-31 23:37:002026-05-31 23:37:00toDate(UTCEventTime)2026-05-312026-05-31toDate(…, 'Europe/Samara')2026-06-012026-06-01EventDate2026-06-012026-06-01Названная заранее клетка изменилась: глаз и
GROUP BYбольше не спорят. Родной клиент и HTTP отвечают одинаково.Путь записи от сессии тоже отвязан: под
session_timezone='Europe/Samara'строка2026-06-04T20:58:56Zдаёт1780592336без имени пояса и1780606736с ним.Проверки
make lint,make typecheck,make test(407),make clean && make up && make check-clickhouse— 9 из 9 за 7 секунд,make smoke— 20,make check-services— 7. Таблица ошибок разбора пуста.Два холодных ревью
Линия дефектов нашла три неверных утверждения и один мёртвый замер — все приняты и исправлены. Крупнейшее: «тип колонки не решает, какое число ляжет» было сказано шире, чем верно. При вставке строки решает именно он —
CAST('2026-06-01 01:24:09' AS DateTime('UTC'))даёт1780277049, то же вDateTime('Europe/Samara')—1780262649. Формулировка сужена до нашего случая, где разбор отдаёт готовое число.Отдельная находка: матвью приёма оставляет
now64(3)и_timestamp_msбез имени пояса, поэтому в схемеhits_raw_mvдве колонки голые. На данные это не влияет — значение абсолютно. Назвать пояс у виртуальной колонки Kafka безCASTнельзя, а такойCAST— украшение. Поэтому правило в конвенции привязано к местам, где линза что-то решает: объявление хранимой колонки и выражение, считающее дату.Линия уместности дала три реза, два приняты: пересказ механики в ADR 0005 схлопнут до фразы со ссылкой, учебный комментарий срезан с семи строк до пяти. Рез повторённого замера отклонён — соседний пункт ледгера измеряет состояние до правки, новый после, и это критерий приёмки.
Хвост для того, кто будет писать витрины. В доке этого нет намеренно — там осталась одна фраза с симптомом, механика сюда.
EventDateимеет типDate, а у него места под пояс нет вовсе: лежит номер дня, чьи это сутки — не записано. Пока день сравнивают с днём, это неважно, и нарезка партиций поEventDateбезопасна ровно поэтому: ей нужен ярлык, а не момент. Как только по дню считают время, ловушек оказывается две, и симптом у них общий — часы суток уезжают за границы 0–23.Первая: голое приведение дня в момент.
toDateTime(EventDate)берёт полночь по поясу сессии, а тот по умолчанию серверный. На стартовом мире часы от начала суток идут −4 … 19, отрицательных 15 843 события (те же 3,95%, что и ночное расхождение). Момент, который назовёт голое приведение, зависит от того, кто спрашивает:toDateTime(EventDate)toDateTime(EventDate, 'Europe/Samara')178027200017802576001780257600178025760017802864001780257600Вторая:
dateDiffчерез разные пояса. Назови пояс в приведении правильно — и он всё равно соврёт: 4 … 27. Минимальный случай, оба аргумента суть один и тот же момент, epoch1780257600:Числа одинаковые, ярлыки разные. Документация ClickHouse (сверено через Context7 8 августа 2026 года): функция считает пересечения границ по стенным часам и берёт пояс у каждого аргумента свой; необязательный четвёртый аргумент назначает один пояс на оба.
Правильные формы, замерено на стартовом мире:
0 … 23,9997;dateDiff('hour', toDateTime(EventDate, 'Europe/Samara'), UTCEventTime, 'Europe/Samara')—0 … 23.