MAATRIX / Блог / Забыли про часовой пояс и отчёты сместились на сутки

Забыли про часовой пояс и отчёты сместились на сутки

MAATRIX

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

Конкретный сценарий: сервер в UTC, бизнес — нет

Возьмём типичный случай. VPS арендован в дата-центре — неважно, в UK, США или где угодно ещё — и по умолчанию (и по общепринятой практике) настроен на UTC:

$ timedatectl
               Local time: чт 2026-08-27 14:32:10 UTC
           Universal time: чт 2026-08-27 14:32:10 UTC
                 RTC time: чт 2026-08-27 14:32:08
                Time zone: UTC (UTC, +0000)

Бизнес — интернет-магазин, оператор которого сидит в Москве (UTC+3). Разработчик пишет скрипт ежедневного отчёта «продажи за вчерашний день» и цепляет его в cron на 05 00 * * * — по мнению разработчика, отчёт формируется в 3:05 по Москве, сразу после полуночи, когда вчерашний день уже точно закрыт. Логика в голове разработчика была правильной, но выполняется она на сервере, где локальное время — не московское, а UTC.

Скрипт выбирает данные так:

SELECT *
FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL '1 day'
  AND created_at < CURRENT_DATE;

Если колонка created_at хранится как TIMESTAMP без часового пояса и была записана «как есть» из времени сервера (то есть в UTC), а CURRENT_DATE вычисляется тоже в UTC (сессия PostgreSQL берёт timezone из настроек сервера или подключения), то «вчерашний день» здесь — это вчерашний день по UTC, а не по Москве. Разница в три часа означает, что заказы, сделанные в Москве после 21:00 (что соответствует ещё «сегодня» по UTC до 21:00, но уже относится к московским суткам, которые сдвинуты), попадают не в тот отчёт, а заказы с 00:00 до 03:00 по московскому времени — которые с точки зрения бизнеса уже относятся к новым суткам — по факту всё ещё сидят в «вчерашнем» окне UTC.

Итог: три часа заказов в начале и в конце московских суток систематически утекают в соседний отчёт. Цифры не нулевые и не выглядят как сбой — просто чуть занижены в одном отчёте и завышены в соседнем. При случайном взгляде на дашборд это неотличимо от обычного разброса продаж по дням недели.

Почему это не вызывает тревоги: тихий сбой без ошибки выполнения

Ключевая причина, по которой такие баги живут годами, — это отсутствие технического сбоя. Скрипт:

  • завершается с кодом выхода 0;
  • не пишет ничего в stderr;
  • укладывается в разумное время выполнения;
  • возвращает непустой, правдоподобный набор строк.

Мониторинг вроде healthchecks.io или систем алертинга на cron-задачи в такой ситуации молчит по делу — он и должен проверять факт выполнения, а не смысловую корректность данных. Разбирали похожий класс проблем в статье про тихий сбой cron по ночам — там cron вообще не срабатывал, здесь ситуация тоньше: задача срабатывает исправно, просто с неверной границей времени.

Смещение по времени — это ошибка данных, а не ошибка кода в узком смысле. Автоматические тесты, если они вообще есть для отчётов, обычно проверяют структуру ответа («есть ли поле total», «не пустой ли список»), а не смысловую границу «эти строки реально относятся к нужным календарным суткам». Человек, который читает готовый отчёт, видит правдоподобное число и не имеет причин заподозрить смещение — тем более что оно может быть частичным (не все данные сдвинуты, а только пограничные несколько часов).

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Почему одни бизнесы страдают сразу, а другие — годами не замечают

Величина эффекта прямо пропорциональна разнице часовых поясов между сервером и бизнес-аудиторией отчёта:

Смещение (сервер UTC vs локальное время бизнеса)Заметность проблемы
UTC+0 / UTC+1 (Лондон, Западная Европа)Почти незаметно — сдвиг в пределах часа, легко спутать с погрешностью или летним временем
UTC+3 (Москва)Заметный, но не катастрофичный сдвиг — 2–4 часа данных «гуляют» между сутками
UTC+5:30, UTC+8 (Индия, Китай)Существенный сдвиг, половина рабочего дня может попасть не туда
UTC+10, UTC+12 (Владивосток, Окленд)Смещение способно перекрыть почти половину суток — фактически «вчера» и «сегодня» в отчёте перепутаны местами на большом отрезке дня

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

Правильная архитектура: UTC внутри, локальное время только на выводе

Единственный по-настоящему устойчивый подход — жёсткое разделение уровней:

  1. Хранение и внутренняя обработка — всегда в UTC. Каждая временная метка, попадающая в базу данных, лог, очередь сообщений или межсервисный вызов, хранится в UTC. Никаких «серверное время», никаких «время как удобно разработчику» — один эталон на весь стек.
  2. Преобразование в локальный часовой пояс — только в момент финального отображения для конкретного человека или конкретной аудитории отчёта, и нигде раньше.

На практике это означает конкретные технические решения:

В PostgreSQL — использовать TIMESTAMPTZ, а не голый TIMESTAMP:

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    amount NUMERIC(12,2) NOT NULL
);

TIMESTAMPTZ физически хранит момент времени в UTC независимо от timezone сессии, а конвертация в нужный пояс делается явно и только там, где формируется отчёт для человека:

SELECT *
FROM orders
WHERE created_at >= (CURRENT_DATE AT TIME ZONE 'Europe/Moscow')
  AND created_at <  (CURRENT_DATE + INTERVAL '1 day' AT TIME ZONE 'Europe/Moscow');

Здесь граница суток вычисляется явно в Europe/Moscow, а не подразумевается через timezone сервера. Разбирали похожие грабли с настройками часовых поясов подробнее в статье про настройку timezone на сервере.

В MySQL — аналогично: хранить DATETIME в UTC (или использовать TIMESTAMP, который MySQL сам конвертирует по системной time_zone, но именно поэтому им нужно управлять аккуратно и явно), а границу суток для отчёта вычислять с явным сдвигом:

SELECT *
FROM orders
WHERE created_at >= CONVERT_TZ('2026-08-27 00:00:00', 'Europe/Moscow', 'UTC')
  AND created_at <  CONVERT_TZ('2026-08-28 00:00:00', 'Europe/Moscow', 'UTC');

Явное указание часового пояса в коде — на каждом шаге

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

Python. Не использовать «наивные» объекты времени без явной зоны:

# Плохо — наивное время, неявно зависит от TZ системы
from datetime import datetime
now = datetime.now()

# Хорошо — явный UTC на хранение
from datetime import datetime, timezone
now_utc = datetime.now(timezone.utc)

# Хорошо — явная конвертация в локальную зону только для отчёта
from zoneinfo import ZoneInfo
report_boundary = now_utc.astimezone(ZoneInfo("Europe/Moscow"))

Модуль zoneinfo (стандартная библиотека с Python 3.9+) избавляет от необходимости тащить pytz и явно требует указать зону — случайно забыть её сложнее, чем при работе с datetime.now() без аргументов.

JavaScript/Node.js. new Date() без обвязки сам по себе хранит момент в UTC (внутреннее представление — миллисекунды с эпохи), но методы вроде .getHours() или .toLocaleString() без явной зоны используют часовой пояс окружения — а он может быть переопределён переменной TZ в контейнере и отличаться от того, что разработчик видел у себя на ноутбуке:

// Плохо — неявная локальная зона окружения
const hour = new Date().getHours();

// Хорошо — явная зона через Intl API
const formatter = new Intl.DateTimeFormat('ru-RU', {
  timeZone: 'Europe/Moscow',
  hour: '2-digit',
  hour12: false
});
const hourMoscow = formatter.format(new Date());

Cron и bash. Явно фиксировать зону выполнения задачи, а не полагаться на системную:

# В crontab — задать TZ прямо в переменной окружения задания
TZ=Europe/Moscow
5 0 * * * /usr/local/bin/generate_daily_report.sh

# Внутри самого скрипта — не доверять голому `date`, указывать зону явно
report_date_utc_start=$(TZ=Europe/Moscow date -d "yesterday 00:00" -u +"%Y-%m-%d %H:%M:%S")

Docker. Контейнер по умолчанию использует UTC, если явно не пробросить TZ, а разные образы (Alpine без tzdata, Debian с tzdata) ведут себя по-разному — стоит явно фиксировать зону в docker-compose.yml там, где она нужна для конкретного процесса, а не полагаться на совпадение с хостом:

services:
  report-worker:
    image: myorg/report-worker:2026.08
    environment:
      - TZ=UTC

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

Как найти проблему, если она уже есть

Полагаться на визуальный осмотр отчёта («вроде похоже на правду») не работает — рассинхронизация в пределах нескольких часов практически никогда не видна невооружённым глазом на агрегированных цифрах. Единственный надёжный способ — целевая проверка граничного случая:

  1. Взять конкретную транзакцию (заказ, событие, лог-запись) рядом с полуночью по локальному времени бизнеса — например, запись, сделанная в 23:50 и в 00:10 по нужной зоне в один и тот же переход суток.
  2. Прогнать генератор отчёта за оба граничных дня и убедиться, что запись в 23:50 попала в отчёт «этого» дня, а запись в 00:10 — уже в отчёт «следующего».
  3. Повторить для утренней границы, если в системе есть смещение и там (например, отчёт формируется по расписанию, привязанному к UTC-полуночи, а не к локальной).
  4. Сделать эту проверку частью регулярного набора тестов для отчётов, а не разовым ручным действием — граничный день имеет свойство появляться каждый месяц, а не один раз.

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

import pytest
from zoneinfo import ZoneInfo
from datetime import datetime, timezone

def test_boundary_record_assigned_to_correct_local_day():
    # 23:50 по Москве 27 августа = 20:50 UTC того же дня
    ts = datetime(2026, 8, 27, 20, 50, tzinfo=timezone.utc)
    local = ts.astimezone(ZoneInfo("Europe/Moscow"))
    assert local.date().isoformat() == "2026-08-27"

    # 00:10 по Москве 28 августа = 21:10 UTC 27 августа
    ts2 = datetime(2026, 8, 27, 21, 10, tzinfo=timezone.utc)
    local2 = ts2.astimezone(ZoneInfo("Europe/Moscow"))
    assert local2.date().isoformat() == "2026-08-28"

Если такой тест уже встроен в CI и запускается автоматически, при настройке cron-задач для отчётов или при переезде сервера в другой дата-центр (а значит — потенциальной смене системной зоны) проблема ловится до того, как отчёт с неверными данными окажется в почте у руководителя.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Достаточно ли выставить timedatectl set-timezone Europe/Moscow на сервере, чтобы всё исправить?

Нет, и это отдельная опасность. Смена системной зоны сервера чинит одни места и одновременно ломает другие — например, логи systemd и записи, которые уже сохранены в предположении UTC, начнут интерпретироваться иначе, а сторонние сервисы, ожидающие UTC (мониторинг, бэкапы, TLS-сертификаты с проверкой времени), могут повести себя непредсказуемо. Общепринятая практика — держать сервер в UTC и решать вопрос локального времени только на уровне приложения.

Как понять, что колонка в базе хранит именно UTC, а не локальное время сервера?

Проверить, как именно она заполнялась в коде: если это now() в PostgreSQL при типе TIMESTAMPTZ — гарантированно UTC внутри. Если это TIMESTAMP (без TZ) и заполнялась функцией вроде datetime.now() без явной зоны — заранее не известно, нужно смотреть на код записи и, в спорных случаях, на реальные значения рядом с известными по времени событиями.

Одинаково ли эта проблема проявляется для бизнеса в UK, США и России?

Механизм один и тот же, но выраженность разная. Для аудитории в британском часовом поясе (UTC или UTC+1 летом) сдвиг почти незаметен; для российской аудитории (UTC+3 и восточнее) — уже заметен на глаз; для американских часовых поясов (UTC-5…UTC-8) искажение идёт в другую сторону календарной границы, но по величине сопоставимо с российским случаем.

Нужно ли что-то менять, если у бизнеса вся аудитория тоже физически в UTC?

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

Может ли переход на летнее/зимнее время создать похожий эффект даже без смены дата-центра?

Да — это тот же класс ошибок. Даже без переезда сервера смена локального смещения (например, у бизнеса в зоне с переходом на летнее время, если зона указана как фиксированный оффсет +03:00, а не именованная Europe/London) сдвигает границу суток на час дважды в год. Использование именованных зон (ZoneInfo, IANA tz database) вместо жёстких оффсетов защищает от этого автоматически.

Обсудить статью, задать вопрос или начать новую тему

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

Перейти в сообщество →