PostgreSQL: три источника времени в одной таблице
Стек: Gin + GORM v2 + pgx/v5 в режиме simple protocol, PostgreSQL, PgBouncer. Кейс: при оформлении заказа товар резервируется на 30 минут, дальше резерв снимается, а товар удаляется из корзины.
За снятие отвечает фоновая джоба. Раз в 5 минут она ищет истёкшие резервы и завершает их.
В поддержку прилетает тикет:
Пользователь оформляет заказ, фронт показывает таймер «резерв истечёт через 30 минут», но через пять минут из корзины пропадает товар. Воспроизводится у всех клиентов.
Разбираемся в таймзонах
Во времени легко запутаться, потому что все говорят «время», а имеют в виду разное. Поэтому сначала договоримся о словах, потом пойдём в код. Для начала рассмотрим схему, которая объясняет больше, чем определения. Одно и то же событие в трёх носителях:
одно и то же событие
│
┌─────────────────────┼────────────────────────┐
▼ ▼ ▼
PostgreSQL PostgreSQL Go
timestamp timestamptz time.Time
дата и время момент времени, момент и зона,
без пояса: внутри всегда UTC в которой показывать
'2026-09-17 13:06'
13:06 ПО МОСКВЕ 13:06+03 и 10:06+00 одно значение
ИЛИ ПО UTC? это один момент. печатается как
НИКТО НЕ ЗНАЕТ. Пояс не хранится: 16:06 (+03)
Знает только тот, при выводе берётся или 13:06 (UTC).
кто записал. из таймзоны сессии.
Закрепим:
timestamp without time zone — хранит дату и время без пояса, 2026-09-17 13:06:39. Это не момент времени, а просто «часы показали 13:06». По какому времени шли эти часы, знает только тот, кто их завёл. Этот тип у всех рабочих колонок проекта: created_at, reserved_at, reserved_until, deleted_at.
timestamptz — момент времени. Хранится в UTC, а при выводе переводится в таймзону сессии, так что один и тот же момент в разных соединениях выглядит по-разному.
time.Time — время вместе с таймзоной. Один и тот же момент Go напечатает как 14:00 (зона +03) или 11:00 (UTC). time.Now() возвращает время в зоне хоста, где запущен процесс, time.Now().UTC() в UTC.
NOW() — момент начала текущей транзакции, тип timestamptz. Внутри длинной транзакции значение не меняется; настоящее «сейчас» возвращает clock_timestamp().
Таймзона сессии — значение параметра TimeZone, своё у каждого соединения с базой. Сессией является одно подключение к БД. Приложение открывает соединение, сервер заводит под него сессию и держит её, пока соединение живо. У пула приложения таких сессий столько, сколько соединений он открыл. Таймзону каждая получает независимо. Либо клиент просит нужную при подключении, либо достаётся дефолт (обычно из конфига сервера), но его также могут перекрывать настройки базы (ALTER DATABASE ... SET TimeZone) и роли (ALTER ROLE ... SET TimeZone). Увидеть её можно только изнутри самой сессии, командой SHOW timezone в том же соединении. Поэтому ответ в psql ничего не говорит о том, какая таймзона досталась соединениям приложения.
Акт 1: «Гнев пользователей: Резерв сгорел за пять минут»
Первый подозреваемый напрашивается сам. Джоба запускается раз в пять минут. Резерв из тикета живёт те же пять минут. Совпадение? Не думаю.
Идём в БД и находим резерв, который умер раньше срока:
-[ RECORD 1 ]--------+-------------------------------------
id | 01a0af78-0228-7964-8afa-a79325bc757e
reserved_at | 2026-09-17 13:06:39.282671
reserved_until | 2026-09-17 13:36:39.282671
released_at | 2026-09-17 13:11:02.516204
created_at | 2026-09-17 16:04:32.804142
prepared_at | 2026-09-17 16:06:21.090645
deleted_at | 2026-09-17 13:20:34.397342
Что видно сразу:
reserved_untilравенreserved_at + 30 минут, ровно, до микросекунды. Расчёт срока корректен, приложение пишет «старт + 30 минут», а не «+5».released_atна двадцать пять минут раньшеreserved_until. Джоба пишет туда фактическое время, когда закрыла резерв. Значит, резерв закрыли задолго до срока. Это и есть жалоба из тикета, только теперь видно её в данных. И ещё одна деталь. Междуreserved_atиreleased_atровно пять минут.deleted_atменьшеcreated_at. Корзину удалили до того, как она была создана. Удаление в прошлом, создание в будущем. Похоже на машину времени.
Нарисуем временную ось этой записи «как она лежит в БД», и машина времени сразу бросается в глаза:
время в БД: 13:06 13:20 13:36 16:04 16:06
│ │ │ │ │
корзина создана │ │ │ ● │ created_at
корзина подготовлена │ │ │ │ ● prepared_at
резерв начался ● │ │ │ │ reserved_at
резерв истекает │ │ ● │ │ reserved_until
корзина удалена │ ● │ │ │ deleted_at
события идут в логичном порядке, а их время вразнобой
Итак, срок посчитан верно, завершила джоба. Но почему свежий резерв попал в выборку «просроченных»? Смотрим сам запрос:
// repositories/reservation_repo.go: выборка истёкших резервов
var reservationIDs []uuid.UUID
err := r.dbFromCtx(ctx).WithContext(ctx).
Model(&models.Reservation{}).
Where("reserved_until IS NOT NULL").
Where("released_at IS NULL").
Where("reserved_until <= NOW() - interval '5 minutes'").
Order("reserved_until ASC").
Limit(limit).
Pluck("id", &reservationIDs).Error
Сравнение колонки с NOW(). От того, что вернёт NOW() в проде, зависит всё.
Акт 2: NOW() отвечает по-московски
Возвращаемся к записи и смотрим на неё ещё раз. Колонки делятся на два лагеря:
13:06 / 13:36 / 13:11 / 13:20 reserved_at, reserved_until, released_at, deleted_at
16:04 / 16:06 created_at, prepared_at
У второго лагеря время ровно на три часа больше. Что объединяет колонки с +03:00? Способ, которым они заполняются:
КТО пишет колонку?
приложение (Go) сервер (PostgreSQL)
pgx → .UTC() → литерал DEFAULT now() / gorm.Expr("NOW()")
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ UTC-время │ │ время ЗОНЫ │
│ │ │ СЕССИИ │
│ reserved_at │ │ created_at │
│ reserved_until │ │ prepared_at │
│ released_at │ │ │
│ deleted_at │ │ │
└──────────────────┘ └──────────────────┘
│ │
└──────────────┬──────────────────────┘
▼
ОДНА строка таблицы, ДВА отсчёта времени
// created_at: дефолт на стороне БД, GORM исключает колонку из INSERT,
// значение заполняет сервер
type Reservation struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
ReservedAt time.Time `gorm:"column:reserved_at;not null"`
ReservedUntil time.Time `gorm:"column:reserved_until;not null"`
ReleasedAt *time.Time `gorm:"column:released_at"`
DeletedAt *time.Time `gorm:"column:deleted_at"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()"`
}
// prepared_at: явный NOW() в SQL
if err := tx.Model(&models.Reservation{}).
Where("id = ?", reservationID).
Update("prepared_at", gorm.Expr("NOW()")).Error; err != nil {
return err
}
И в DEFAULT now(), и в gorm.Expr("NOW()") время считает сервер в таймзоне сессии. А какая таймзона у продовой базы?
shop=> SELECT now();
now
-------------------------------
2026-09-17 16:11:07.85158+03
Москва, +03. Теперь главное. now() возвращает timestamptz, то есть момент. Чтобы записать его в timestamp-колонку или сравнить с ней, PostgreSQL должен перевести его во время без таймзоны, и делает это по таймзоне сессии. Время московское, момент тот же:
shop=> SELECT now()::timestamp;
now
--------------------------
2026-09-17 16:11:23.232007 ← тот же момент, время московское
Схема конвертации: момент один, а время получается разное, смотря в какой зоне на него смотреть:
момент времени (timestamptz)
│
┌───────────────┴───────────────┐
▼ ▼
таймзона сессии UTC таймзона сессии +03
│ │
▼ ▼
timestamp '13:06' timestamp '16:06'
время UTC время московское
═══ один и тот же момент, два разных набора цифр ═══
таймзона сессии = «в какой зоне смотреть на часы»
Собираем арифметику инцидента. Резерв начался в 13:06:39 UTC, то есть в реальные 16:06:39 по Москве. Джоба запускается каждые пять минут; возьмём первый запуск после старта, скажем 16:11 по Москве.
Фильтр в этот запуск:
WHERE reserved_until <= NOW() - interval '5 minutes'
NOW() в цифрах: 16:11 (московские, NOW() оценивается в таймзоне сессии)
порог выборки: 16:06 (16:11 минус 5 минут)
reserved_until в БД: 13:36 (UTC-время, его записал Go)
13:36 ≤ 16:06, и по этой арифметике резерв пару часов как истёк, хотя на самом деле ему минуты от роду. Джоба отобрала его, завершила и расформировала корзину. Пять минут из тикета не имеют отношения ни к таймеру, ни к reserved_until. Столько длится интервал запуска джобы. Резерв умирал при первом же её запуске после создания, поэтому дольше пяти минут пользователь его и не видел.
Та же запись, переведённая в один пояс
Теперь странные даты из Акта 1 можно перечитать. Переведём все колонки в UTC, и машина времени исчезнет, останется обычная история:
13:04:32 UTC created_at (16:04:32 MSK, серверный DEFAULT) корзина создана
13:06:21 UTC prepared_at (16:06:21 MSK, серверный NOW()) корзина подготовлена
13:06:39 UTC reserved_at (Go, UTC-время) резерв начался
13:11:02 UTC released_at (Go, UTC-время) резерв закрыт джобой
13:20:34 UTC deleted_at (Go, UTC-время) корзина расформирована
Корзину подготовили за 18 секунд до старта резерва, тут всё в порядке. А deleted_at оказался «меньше» created_at только потому, что в одной строке лежали цифры из двух поясов. На самом деле пользователь вернулся к мёртвой корзине через 16 минут после её создания и минут через девять после того, как джоба её убила. Машины времени не было, было два часовых пояса в одной строке.
Акт 3: кто и в какой зоне пишет время
Разберём, кто и в какой зоне кладёт время в timestamp-колонки. Сначала роли слоёв:
модель Go (time.Time)
│
▼
GORM время не трогает: собирает SQL и не вставляет колонки с DEFAULT
│
▼
pgx переводит значение в текст запроса, здесь появляется UTC
│
▼
PostgreSQL у литерала срезает оффсет, а NOW(), DEFAULT и сравнения
считает по таймзоне сессии
GORM переданный time.Time не трогает и дальше передаёт как есть, ничего не пересчитывая. Всю работу делает pgx. Отсюда три источника.
Источник 1: параметры из Go всегда дают UTC-время
Вот код, который ставит резерв:
// use case: резерв на 30 минут
now := time.Now()
reservation := models.Reservation{
ReservedAt: now,
ReservedUntil: now.Add(30 * time.Minute),
}
if err := db.WithContext(ctx).Create(&reservation).Error; err != nil {
return err
}
Никаких таймзон здесь нет. Что вернул time.Now(), то и уходит в базу. Всё интересное происходит дальше.
Проект работает в режиме simple protocol: pgx не отправляет параметры отдельно от запроса, а подставляет их значения прямо в SQL-строку, которая уходит на сервер. А раз значение попадает в запрос ещё до того, как сервер сопоставит его с колонкой, pgx решает, как его переводить, по типу в Go. Для него time.Time означает момент времени, то есть timestamptz. Момент он всегда переводит в UTC.
Проследим одно значение целиком. Возьмём худший случай: хост, где TZ=Europe/Moscow, то есть time.Now() вернёт московское время:
Go now = 2026-09-17 16:06:39.282671 +0300 MSK
│
▼ GORM берёт значение как есть и кладёт в INSERT
pgx '2026-09-17 13:06:39.282671+00' ← .UTC(), литерал в SQL-строке
│
▼ INSERT INTO reservations (reserved_at, ...)
VALUES ('2026-09-17 13:06:39.282671+00', ...)
PostgreSQL reserved_at, колонка timestamp: оффсет отбрасывается,
время не пересчитывается
│
▼
в таблице 2026-09-17 13:06:39.282671
Последний шаг и есть тот самый, из-за которого московское время на входе превратилось в UTC-цифры на выходе. Проверим его отдельно, на живой базе:
shop=> SELECT '2026-09-17 13:06:39+00'::timestamp;
2026-09-17 13:06:39
Итог: параметры из Go попадают в базу как UTC-время, независимо от того, в какой зоне было само значение (time.Now() на хосте с TZ=+03 или явно .UTC()). Это не наша заслуга, это свойство simple protocol.
Источник 2: DEFAULT now() и таймзона сессии
Поля с тегом default:now() GORM при INSERT исключает из списка колонок, и значение заполняет сервер. Время берётся в таймзоне сессии, при UTC получится UTC, при +03 московское. Наш created_at из записи именно отсюда.
Источник 3: gorm.Expr(NOW()) и таймзона сессии
Явный NOW() в SQL даёт тот же серверный источник: timestamptz-момент, который при попадании в timestamp-колонку превращается во время таймзоны сессии. В этом проекте таких мест больше десятка. Это обычные UPDATE колонок и присваивания в ON CONFLICT у upsert-запросов, то есть везде, где в запросе явно зовётся NOW().
Опаснее всего сравнение timestamp-колонки с NOW(). Колонка хранит просто время без пояса; чтобы сравнить его с моментом, PostgreSQL должен сначала решить: а каким моментом это время является? Ответ он берёт из таймзоны сессии:
WHERE reserved_until <= NOW()
reserved_until: '13:36' NOW(): момент «сейчас»
в колонке только время, │
пояс НЕ хранится │ сравнить можно только
│ │ момент с моментом
│ «а 13:36 по какому ▼
│ времени вообще?» таймзона сессии: +03
│ → «13:36 значит 13:36 по Москве»
▼ │
сессия UTC: ▼
→ «13:36 значит 13:36 UTC» NOW() в цифрах: '16:06'
│ │
▼ ▼
NOW() в цифрах: '13:06' 13:36 МСК ≤ 16:06 МСК
│ │
▼ ▼
13:36 UTC > 13:06 UTC давно истёк ──► резерв убит
│
▼
ещё жив ──► резерв живёт
Одна и та же строка, один и тот же SQL. Исход решает пояс, который таймзона сессии подставляет к цифрам колонки.
Сводка
Все источники и их судьба на одной картинке:
timestamp-колонка таблицы
▲
┌───────────────────┼───────────────────┐
│ │ │
ИСТОЧНИК 1 ИСТОЧНИК 2 ИСТОЧНИК 3
параметры Go DEFAULT now() NOW() в SQL
│ │ │
pgx: .UTC() сервер, таймзона сервер, таймзона
'13:06+00' сессии сессии
│ │ │
▼ ▼ ▼
UTC-время время таймзоны время таймзоны
(всегда) сессии (INSERT) сессии (UPDATE)
│ │ │
└─────────┬─────────┴───────────────────┘
▼
сессия UTC → все три пишут одно и то же ✓
сессия +03 → в одной строке два времени ✗
│
▼
сравнение с NOW() тоже в таймзоне сессии → сдвиг на оффсет
Пока сессия UTC и протокол simple, все источники пишут одно и то же время. Стоит сессии оказаться не-UTC, и в одной таблице смешиваются два времени, а любое сравнение с NOW() сдвигается на оффсет зоны. Наш прод: сессия +03, запись из Go в UTC, NOW() по-московски.
Почему окружения молчали
Картина в виде релизного конвейера по окружениям:
docker CI stage прод
(UTC) (UTC) (UTC) (MSK)
│ │ │ │
▼ ▼ ▼ ▼
зелёный зелёный зелёный БУМ!
└──────────┴───────────┘ │
всё тихо │
▼
один и тот же код,
единственное отличие в timezone
Код на стейдже и на проде один и тот же; разное поведение создаёт единственная строка конфигурации, дефолт таймзоны сервера. Стейдж прошёл, релиз поехал в прод. Там, где SHOW timezone возвращал UTC, джоба честно дожидалась настоящего истечения. Там, где возвращал MSK, сжигала резерв сразу после создания.
Ошибка была не в расчёте срока, а в неявном контракте между приложением и окружением. Стейдж доказал только то, что код корректен при таймзоне UTC. А негласное правило «приложение пишет timestamp-колонки UTC-цифрами, значит, и NOW() должно отвечать UTC-цифрами» нигде не было записано, оно держалось на совпадении настроек, и прод это совпадение нарушил.
Отсюда практическое правило: если поведение зависит от настройки окружения, эта настройка становится частью контракта приложения, и её нужно либо фиксировать кодом, либо проверять на каждом окружении.
Тесты этого поймать не могли. У разработчика база поднимается в докере, у CI своя, и у обеих таймзона UTC. В такой сессии NOW() отвечает теми же UTC, какие приложение записало в колонку, сравнение сходится, и все тесты зелёные. Расхождение появляется только там, где SHOW timezone вернёт не UTC, а воспроизвести его в тестах можно, лишь подключившись к базе с чужой зоной намеренно. Классическая ловушка, когда тест проверяет логику, а ломается конфигурация.
Рабочее решение
Рассматриваемые варианты:
Зафиксировать таймзону в подключении.
Сменить таймзону серверов БД. Территория DBA. Гарантировать UTC на docker, CI, двух стейджах и проде значит добавить ещё одну вещь, которую надо не забывать при каждом новом окружении.
Перевести колонки на
timestamptz. Это миграция всех рабочих таблиц и аудита всех сравнений перед релизом. Дорого и рискованно.
Выбрали первый путь. Быстро, дёшево, закрывает проблему на всех средах сразу, не требует миграций.
Важно понимать, что это лечение, а не выздоровление. created_at, reserved_at, reserved_until и released_at описывают моменты времени(функция NOW()). Фиксация сохраняет исторический контракт «в timestamp-колонках лежит UTC» и защищает только те соединения, которые открывает наше приложение. Любой, кто придёт мимо него, снова запишет время в поясе БД.
Фиксируем таймзону в startup-параметрах
func Connect(dsn string) *gorm.DB {
syncOnce.Do(func() {
pgxConfig, err := pgx.ParseConfig(dsn)
if err != nil {
log.Fatalf("failed to parse postgres config: %v", err)
}
pgxConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
pgxConfig.RuntimeParams["TimeZone"] = "UTC"
...
Эту строку pgx отправляет вместе с запросом на подключение, поэтому UTC получает каждое новое соединение, а не одно случайное. PgBouncer такие параметры знает и сам выставляет их на серверных соединениях, которые выдаёт из пула. Тот же результат даёт ?TimeZone=UTC прямо в строке подключения.
Fail-fast
Строка RuntimeParams["TimeZone"] = "UTC" выражает намерение, а не гарантию. Она описывает, как мы собираем конфиг, и ничего не говорит о том, что получилось на другом конце.
Потеряться фиксация может ещё в приложении. Строку могут убрать при рефакторинге или мерже, а в цепочке до базы может оказаться прокси, который её не передаёт. Ошибки ни в том, ни в другом случае не будет, будет тихо другая таймзона.
Проверить это самостоятельно окружения не могут. На докере и в CI сервер и так отдаёт UTC, поэтому пропавшая строка там ничем себя не выдаст и всплывёт только на проде, где дефолт другой. Получается, фиксация защищает нас от настройки окружения, но сама не проверена нигде, кроме того места, где ошибка дороже всего. Поэтому следом за ней идёт проверка при старте:
connectionTimeZone, err := readConnectionTimeZone(sqlDB)
if err != nil {
log.Fatalf("failed to read database connection timezone: %v", err)
}
if err := validateTimeZone(connectionTimeZone); err != nil {
log.Fatalf("database connection check failed: %v", err)
}
func validateTimeZone(value string) error {
if !strings.EqualFold(value, "UTC") {
return fmt.Errorf("session timezone is %q, want \"UTC\": "+
"the connection did not get the pinned timezone "+
"(check the connection string, the pooler and the server defaults)", value)
}
return nil
}
Etc/UTC эквивалентна UTC по значению, но это не та строка, которую мы просили. Раз пришла другая, зону выставили не мы. Сегодня она UTC-эквивалентна, завтра кто-нибудь сменит дефолт сервера, и мы снова в Москве.
Стоит понимать и границы этой проверки. Она читает SHOW timezone на одном соединении, которое выдал пул, а не на каждом. Если нужна гарантия для всех, проверку вешают на хук открытия соединения, pgxConfig.AfterConnect, ценой лишнего round-trip на каждое. Отрабатывает она один раз при старте, поэтому SET timezone, выполненный позже внутри сессии, останется незамеченным. И она ничего не скажет про подключения, собранные мимо этой функции. Фоновый воркер со своим пулом или разовый скрипт не проходят через Connect, а значит остаются и без фиксации, и без проверки. Наконец, ответ UTC не доказывает, что доехал именно наш параметр, у сервера мог быть такой же дефолт. Для сохранности данных это и не важно, важна фактическая зона. Упавший на старте контейнер виден в мониторинге, а тихо испорченные данные не видны никому.
Весь путь фиксации, от строки в конфиге до работающей сессии:
pgxConfig.RuntimeParams["TimeZone"] = "UTC"
│ намерение
▼
уходит с каждым новым подключением
│
▼
какая зона у соединения на деле?
│
┌─────────────┴─────────────┐
│ наша │ чужая
▼ ▼
серверная сессия фиксация не доехала,
получает UTC зона осталась чужой
│ │
▼ ▼
SHOW timezone = "UTC" SHOW timezone ≠ "UTC"
│ │
▼ ▼
старт продолжается ✓ log.Fatalf при старте
(падение видно сразу)
Тесты зеркалят прод
Тестовое подключение получает ту же гарантию через DSN:
func ensureDSNTimeZone(dsn string) string {
if !strings.Contains(dsn, "://") {
return dsn
}
if i := strings.Index(dsn, "?"); i >= 0 {
if values, err := url.ParseQuery(dsn[i+1:]); err == nil {
for key := range values {
if strings.EqualFold(key, "timezone") {
values.Del(key)
}
}
values.Set("TimeZone", "UTC")
return dsn[:i] + "?" + values.Encode()
}
return dsn + "&TimeZone=UTC"
}
return dsn + "?TimeZone=UTC"
}
Плюс спека, которая проверяет таймзону тестового подключения. Если кто-то сломает ensureDSNTimeZone или формат DSN, узнаем из теста, а не из прода.
У функции два ограничения. Первое, она понимает только URL-форму DSN. Строку вида host=db.internal user=shop dbname=shop она вернёт как есть, и тестовое подключение останется без фиксации. Второе, она затирает любую зону в DSN, в том числе поставленную намеренно. Прогнать через неё тест с чужой таймзоной не выйдет, ?TimeZone=Europe/Moscow превратится в UTC. Такому тесту нужно открывать соединение самому, мимо общего пула.
После фикса
-[ RECORD 1 ]--------+-------------------------------------
reserved_at | 2026-09-18 08:02:32.021884
reserved_until | 2026-09-18 08:32:32.021884
released_at | 2026-09-18 08:35:46.328199
created_at | 2026-09-18 08:00:28.703221
prepared_at | 2026-09-18 08:02:10.986437
deleted_at |
Все колонки теперь в одном времени, deleted_at больше не опережает created_at, резервы живут свои тридцать минут, released_at на три минуты позже reserved_until. Это время до первого запуска джобы после настоящего истечения.
Всё вместе
Полная функция подключения, с фиксацией, пулом и проверкой при старте:
func Connect(dsn string) *gorm.DB {
syncOnce.Do(func() {
pgxConfig, err := pgx.ParseConfig(dsn)
if err != nil {
log.Fatalf("failed to parse postgres config: %v", err)
}
pgxConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
pgxConfig.RuntimeParams["TimeZone"] = "UTC"
registeredDSN := stdlib.RegisterConnConfig(pgxConfig)
db, err = gorm.Open(
postgres.New(postgres.Config{
DSN: registeredDSN,
DriverName: "pgx",
}),
&gorm.Config{
PrepareStmt: false,
TranslateError: true,
},
)
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("failed to get sql.DB from gorm.DB: %v", err)
}
sqlDB.SetMaxOpenConns(20)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(15 * time.Minute)
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
if err := sqlDB.Ping(); err != nil {
log.Fatalf("failed to ping database: %v", err)
}
var zone string
if err := sqlDB.QueryRow("SHOW timezone").Scan(&zone); err != nil {
log.Fatalf("failed to read database connection timezone: %v", err)
}
if !strings.EqualFold(strings.TrimSpace(zone), "UTC") {
log.Fatalf("session timezone is %q, want \"UTC\": "+
"the connection did not get the pinned timezone "+
"(check the connection string, the pooler and the server defaults)", zone)
}
})
return db
}
Как это отлаживать
Протокол, если подозреваете таймзонный сдвиг:
SHOW timezoneна каждом окружении, прямо из приложения, из того же пула, не только из psql (таймзона psql-сессии может отличаться от таймзоны приложения). Если значение не то, которое стоит в конфиге сервера, ищите дальше. Дефолт перекрывают настройки базы и роли, а фиксация со стороны приложения тем более.Искать «колонки из будущего»:
SELECT created_at, reserved_at FROM ... WHERE created_at > reserved_at. Записи, где серверные дефолты обгоняют клиентские поля, выдают смешение зон сразу.Дифф по источникам: колонки из Go против колонок с
DEFAULT now()/NOW(). Расхождение одинаково во всех строках и равно оффсету таймзоны сессии. Обычно это целое число часов, но бывает и+05:30, и+05:45.Тест с чужой зоной: подключиться к тестовой базе с
TimeZone=Europe/Moscow(илиPGTZ=Europe/Moscow) и прогнать спеки с временем. Если они зелёные и там, логика действительно не зависит от зоны.
Подводные камни
Фиксация живёт только там, где собирается это подключение. Новый сервис, разовый скрипт, миграция, аналитик в psql: все они открывают соединение сами и о нашей строке в конфиге не знают. Ошибки при этом не будет, будет другая таймзона. Поэтому проверка при старте и стоит в коде подключения: она говорит про то соединение, которым мы пользуемся, а не про базу вообще.
У одной колонки несколько писателей. При обычном .Update() GORM сам подставляет в updated_at время из Go, и pgx записывает его как UTC. Но стоит где-то одному upsert-запросу записать в ту же колонку gorm.Expr("NOW()"), и на этом пути она начнёт получать серверное время. Что лежит в конкретной строке, зависит от того, какой код писал её последним.
timestamptz-колонки читаются в таймзоне сессии. Таблицы фреймворка задач отдают текст в таймзоне сессии, но момент при этом корректен, меняется только представление. После фиксации всё единообразно, но при диагностике «почему в служебных таблицах +00, а у меня просто время» вы встретите именно это.
Выводы
timestampхранит время без таймзоны. В какой зоне оно записано, решает тот, кто пишет. Параметры из Go ложатся в UTC, аNOW()иDEFAULT now()пишут время таймзоны сессии. Стоит сессии оказаться не UTC, и в одной строке появляется два времени.В этом инциденте таймзона сессии работает в трёх местах: что запишет
NOW(), с чем сравнитсяtimestamp-колонка и каким текстом покажетсяtimestamptz. Проверять надо эти три точки.Один и тот же код на стейдже и на проде вёл себя по-разному. Это не баг кода, а незаписанное условие. Приложение ждало от базы UTC, но нигде этого не требовало. Такие условия либо фиксируют кодом, либо проверяют на каждом стенде.
Тесты это не ловят, потому что проверяют логику, а сломалась конфигурация. Помогает прогон с чужой таймзоной и та же фиксация в тестовом подключении.
Фиксация остаётся просьбой, а не гарантией. Гарантию даёт проверка при старте.
SHOW timezoneвернул неUTC, приложение не запускается.Фиксация защищает наши соединения, но не сами данные. Пока колонки имеют тип
timestamp, правило «здесь лежит UTC» держится на дисциплине всех, кто в них пишет: другого сервиса, миграции, аналитика в psql.
Незамеченным это теперь не пройдёт. Сменили дефолт на сервере, подняли новый стенд, собрали подключение мимо общего конфига: любая причина выглядит одинаково, приложение не запускается и первой же строкой лога говорит, какая зона пришла вместо UTC. Так происходит на каждом окружении и при каждом запуске, включая те стенды, которых ещё нет.
Разница в цене. Тихое расхождение стоило сгоревших резервов, тикета от поддержки и дня расследования. Падение при старте стоит одного деплоя.
Благодарность
Всё расследование, от инцидента до фикса, провёл мой коллега. Имени он просил не называть, так что пусть остаётся молчаливым стражем и бдительным защитником. Тёмным рыцарем, которого заслуживает наш прод.
Материалы
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.