
Ошибки во времени дорого стоят. Срываются автоматические рассылки, отчёты «пляшут» после перевода часов, пользователи видят разные даты одного и того же заказа. Всё это бьёт по деньгам и доверию.
Цель — сделать так, чтобы:
Коротко, что работает на практике годами:
2026-03-27T12:34:56Z, 2026-03-27T12:34:56+03:00.Пример в Node.js без лишних библиотек (формирование и разбор ISO‑8601):
// node >= 18
function toIsoUtc(date) {
// date — объект Date в любом поясе, приводим к UTC ISO
return new Date(date).toISOString(); // всегда Z
}
function parseIsoToDate(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) throw new Error('Некорректная дата');
return d; // внутренно используем как UTC мгновение
}
console.log(toIsoUtc('2026-03-27T15:00:00+03:00')); // 2026-03-27T12:00:00.000Z
console.log(parseIsoToDate('2026-03-27T12:00:00Z').toISOString());
Пример в Go (перевод локального в UTC и обратно):
package main
import (
"fmt"
"time"
)
func main() {
loc, _ := time.LoadLocation("America/New_York")
// Локальная бизнес-встреча 2026-11-01 09:00 в Нью-Йорке (день перехода на зимнее время)
local := time.Date(2026, 11, 1, 9, 0, 0, 0, loc)
utc := local.UTC()
fmt.Println("UTC:", utc.Format(time.RFC3339))
// Покажем это же мгновение пользователю в Берлине
berlin, _ := time.LoadLocation("Europe/Berlin")
fmt.Println("Berlin:", utc.In(berlin).Format(time.RFC3339))
}
-- Всегда храните события в timestamptz
CREATE TABLE orders (
id uuid PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
paid_at timestamptz,
customer_tz text -- например, 'Europe/Moscow', если нужно хранить prefered TZ
);
-- Получить локальную дату клиента для группировок
SELECT (created_at AT TIME ZONE 'Europe/Moscow')::date AS local_date, count(*)
FROM orders
GROUP BY local_date
ORDER BY local_date;
-- Отформатировать для отчёта человекочитаемо
SELECT to_char(created_at AT TIME ZONE 'America/New_York', 'YYYY-MM-DD HH24:MI:SS') AS ny_time
FROM orders
LIMIT 5;
AT TIME ZONE — ключевой оператор. Помните:
created_at::date = '2026-03-27' — опасно, дата зависит от timezone сеанса. Лучше явно: (created_at AT TIME ZONE 'Europe/Moscow')::date = '2026-03-27'.SELECT date_trunc('hour', created_at AT TIME ZONE 'Europe/Moscow') AS local_hour,
count(*)
FROM orders
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01'
GROUP BY local_hour
ORDER BY local_hour;
Главное правило — полуинтервалы [from, to). Пример: отчёт за день в Москве.
WITH bounds AS (
SELECT
(timestamp '2026-03-27 00:00:00' AT TIME ZONE 'Europe/Moscow') AS from_utc,
(timestamp '2026-03-28 00:00:00' AT TIME ZONE 'Europe/Moscow') AS to_utc
)
SELECT count(*) AS orders_cnt
FROM orders o, bounds b
WHERE o.created_at >= b.from_utc AND o.created_at < b.to_utc;
Такой подход стабилен при любых миллисекундах и не даёт пересечений при сшивке дней подряд.
Генерация рядов по часам для графиков (с заполнением нулей):
WITH tz AS (
SELECT 'Europe/Moscow'::text AS z
), frame AS (
SELECT
(timestamp '2026-03-01 00:00:00' AT TIME ZONE (SELECT z FROM tz)) AS from_utc,
(timestamp '2026-03-02 00:00:00' AT TIME ZONE (SELECT z FROM tz)) AS to_utc
), grid AS (
SELECT generate_series(from_utc, to_utc, interval '1 hour') AS bucket_utc FROM frame
)
SELECT g.bucket_utc AT TIME ZONE (SELECT z FROM tz) AS local_hour,
coalesce(count(o.*), 0) AS cnt
FROM grid g
LEFT JOIN orders o
ON o.created_at >= g.bucket_utc
AND o.created_at < g.bucket_utc + interval '1 hour'
GROUP BY local_hour
ORDER BY local_hour;
Задача: «каждый день в 09
по поясу клиента» с учётом переходов на летнее/зимнее время.Схема хранения:
Функция PostgreSQL для расчёта следующего запуска:
CREATE OR REPLACE FUNCTION next_daily_run(local_t time, tzid text, now_utc timestamptz DEFAULT now())
RETURNS timestamptz
LANGUAGE plpgsql AS $$
DECLARE
-- Текущее локальное время в заданном поясе
now_local timestamp;
base_date date;
candidate_local timestamp;
candidate_utc timestamptz;
BEGIN
-- Преобразуем текущее мгновение в локальное представление
now_local := now_utc AT TIME ZONE tzid;
base_date := now_local::date;
-- Кандидат: сегодня в нужное локальное время
candidate_local := make_timestamp(extract(year from now_local)::int,
extract(month from now_local)::int,
extract(day from now_local)::int,
extract(hour from local_t)::int,
extract(minute from local_t)::int,
extract(second from local_t))::timestamp;
-- Переводим локальное в UTC (это и учтёт DST)
candidate_utc := candidate_local AT TIME ZONE tzid;
IF candidate_utc <= now_utc THEN
-- Берём следующий день
candidate_local := (base_date + 1)::timestamp + (local_t - time '00:00');
candidate_utc := candidate_local AT TIME ZONE tzid;
END IF;
RETURN candidate_utc;
END $$;
Использование:
-- Пример: 09:00 America/New_York, текущее now() учитывается
SELECT next_daily_run(time '09:00', 'America/New_York');
Важно: в дни перевода часов может не существовать «02
» или оно может встречаться дважды. Оператор AT TIME ZONE корректно разрешает такие случаи: несуществующее локальное время смещается вперёд до ближайшего валидного, а «дублирующееся» интерпретируется однозначно через выбранное правило тайм‑базы PostgreSQL.После запуска задания делайте:
UPDATE schedules
SET next_run = next_daily_run(local_time, tzid, now())
WHERE id = $1;
Аналог на Go (если хочется считать в приложении):
package main
import (
"fmt"
"time"
)
func NextDailyRun(localHH, localMM int, tz string, now time.Time) (time.Time, error) {
loc, err := time.LoadLocation(tz)
if err != nil { return time.Time{}, err }
nowLocal := now.In(loc)
candidate := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day(), localHH, localMM, 0, 0, loc)
if !candidate.After(nowLocal) {
candidate = candidate.Add(24 * time.Hour)
// Добавление суток корректно пройдёт через DST в Go
candidate = time.Date(candidate.Year(), candidate.Month(), candidate.Day(), localHH, localMM, 0, 0, loc)
}
return candidate.UTC(), nil
}
func main() {
t, _ := NextDailyRun(9, 0, "America/New_York", time.Now().UTC())
fmt.Println(t.Format(time.RFC3339))
}
Сценарий: у вас столбец events.occurred_at типа timestamp без пояса, в нём локальное время Москвы. Нужно перейти на timestamptz в UTC.
ALTER TABLE events ADD COLUMN occurred_at_utc timestamptz;
-- Заполняем новое поле для новых вставок/изменений
CREATE OR REPLACE FUNCTION events_dualwrite() RETURNS trigger AS $$
BEGIN
IF NEW.occurred_at IS NOT NULL THEN
NEW.occurred_at_utc := (NEW.occurred_at AT TIME ZONE 'Europe/Moscow');
END IF;
RETURN NEW;
END; $$ LANGUAGE plpgsql;
CREATE TRIGGER tr_events_dualwrite
BEFORE INSERT OR UPDATE OF occurred_at ON events
FOR EACH ROW EXECUTE FUNCTION events_dualwrite();
-- Пример батча на 10k строк
UPDATE events
SET occurred_at_utc = (occurred_at AT TIME ZONE 'Europe/Moscow')
WHERE occurred_at_utc IS NULL
ORDER BY id
LIMIT 10000;
CREATE INDEX CONCURRENTLY idx_events_occurred_at_utc ON events (occurred_at_utc);
Переключите приложение на использование нового поля (feature‑flag), проверьте отчёты/метрики.
После стабилизации — удалите старый столбец и триггер, переименуйте новое поле:
DROP TRIGGER tr_events_dualwrite ON events;
ALTER TABLE events DROP COLUMN occurred_at;
ALTER TABLE events RENAME COLUMN occurred_at_utc TO occurred_at;
Ни одной секунды простоя и согласованное поведение в коде на всём пути.
Итог: единая модель обращения со временем снимает класс целых классов инцидентов — и сразу даёт выигрыш в отчётах, интеграциях и предсказуемости расписаний. Это та редкая дисциплина, которая одновременно упрощает код и снижает риски для бизнеса.