Дайджесты новостей
Концептуальная иллюстрация сбоя часовых поясов: корзина с товаром сгорает за пять минут из-за рассинхронизации между Go и PostgreSQL.

Сгорание резерва за пять минут вместо тридцати: ловушка таймзон в PostgreSQL и Go

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

Реальный инцидент в крупном сервисе вскрыл коварную архитектурную ловушку на стыке языка Go, драйвера pgx, настроек часовых поясов в PostgreSQL и пулера соединений PgBouncer.

Загадочные улики в базе данных: удаление в прошлом

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

  • Колонка created_at (время создания записи): 16:04:32
  • Колонка deleted_at (время удаления фоновым процессом): 13:20:34

Если верить сырым числам в базе данных, резерв был удален на три часа раньше, чем пользователь вообще нажал кнопку в корзине! При этом на тестовом стенде (стейджинге) те же самые операции создавали и удерживали резервы ровно положенные полчаса. Чтобы понять, как время повернуло вспять, пришлось разобрать по винтикам весь путь передачи даты от Go-структуры до физических страниц на диске.

Главная иллюзия: почему опасен timestamp without time zone

Корень проблемы скрывался в определении типов схемы данных. Исторически разработчики создали таблицу, использовав для всех временных меток стандартный тип timestamp without time zone:

-- Исходная структура таблицы: скрытая бомба замедленного действия
CREATE TABLE reservations (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    item_id BIGINT NOT NULL,
    reserved_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
    reserved_until TIMESTAMP WITHOUT TIME ZONE NOT NULL,
    created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL,
    deleted_at TIMESTAMP WITHOUT TIME ZONE
);

Название timestamp without time zone часто вводит инженеров в заблуждение. Кажется, что этот тип просто хранит универсальную абстрактную дату. На самом деле он хранит всего лишь «настенные часы» — набор цифр года, месяца, часа и минут без какой-либо географической привязки.

В архитектуре сервиса возник конфликт двух независимых источников правды о времени:

  1. Приложение на Go: При создании резерва сервис брал текущее системное время time.Now(), прибавлял 30 минут и сохранял в поля reserved_at и reserved_until. Драйвер pgx в простом протоколе (simple protocol) добросовестно форматировал структуру времени в UTC-строку с явным смещением нуля: '2026-09-17 13:06:39+00'. Но колонка в PostgreSQL была объявлена без таймзоны! Движок СУБД просто отсек хвостик +00 и записал в таблицу 13:06.
  2. Сервер базы данных: Колонка created_at заполнялась серверным выражением по умолчанию DEFAULT now(). Сервер базы данных был сконфигурирован системным администратором с параметром TimeZone = 'Europe/Moscow' (UTC+3). Функция now() возвращала локальное московское время, и СУБД записала в created_at значение 16:04!

В одной строке таблицы одновременно оказались значения в двух разных часовых поясах, ни один из которых не был явно обозначен.

Роковой цикл джобы очистки

Каждые 5 минут на сервере запускался фоновый воркер очистки просроченных броней. Запрос выглядел стандартно и безобидно:

Схема логической коллизии в фоновом воркере: сервер сравнивает время брони в UTC со смещенным порогом в MSK.

-- Запрос фоновой очистки просроченных броней
UPDATE reservations 
SET deleted_at = NOW() 
WHERE deleted_at IS NULL 
  AND reserved_until <= NOW() - INTERVAL '5 minutes';

Когда джоба стартовала в 16:11 по московскому времени, функция NOW() возвращала серверное время 16:11. С вычетом пятиминутного интервала порог проверки составлял 16:06.

Сервер начинал сравнивать значение колонки reserved_until (куда приложение записало 13:06 + 30 минут = 13:36 в UTC) с порогом 16:06 (вычисленным в MSK). Поскольку обе величины для СУБД были просто безликими числами без пояса, база честно проверяла: 13:36 <= 16:06 → ИСТИНА (TRUE)!

Резерв, созданный всего пять минут назад, мгновенно помечался удаленным в первый же прогон фоновой задачи.

Фактор PgBouncer: почему молчал стейджинг

Почему же дефект не обнаружили на этапе тестирования? Виновником маскировки бага выступил пул соединений PgBouncer, работающий в режиме транзакций (pool_mode = transaction).

На стейджинге драйвер приложения при установке соединения выполнял команду SET TimeZone = 'UTC', и в отсутствии пулера все последующие запросы выполнялись в UTC. Однако в продакшене перед базой стоял PgBouncer в транзакционном режиме. В этом режиме клиент получает соединение только на время выполнения одной транзакции, после чего сессия сбрасывается к дефолтным настройкам сервера базы данных (Europe/Moscow). Любая попытка выставить таймзону на уровне сессии в клиенте мгновенно стиралась пулером.

Стратегия устранения: переход на timestamptz и явный UTC

Исправление ситуации потребовало синхронных изменений на трех уровнях архитектуры:

  1. Явная фиксация таймзоны в DSN: В строке подключения к пулеру и базе был жестко зафиксирован часовой пояс UTC:
// database/connection.go: жесткая фиксация часового пояса в DSN
package database

import (
    "context"
    "fmt"
    "log"

    "github.com/jackc/pgx/v5/pgxpool"
)

func InitPool(ctx context.Context) (*pgxpool.Pool, error) {
    dsn := "postgres://app_user:secret@pgbouncer:6432/shop?sslmode=disable&timezone=UTC"
    config, err := pgxpool.ParseConfig(dsn)
    if err != nil {
        return nil, fmt.Errorf("ошибка парсинга DSN: %w", err)
    }

    pool, err := pgxpool.NewWithConfig(ctx, config)
    if err != nil {
        return nil, fmt.Errorf("не удалось создать пул соединений: %w", err)
    }

    return pool, nil
}
  1. Безопасная онлайн-миграция типов: Все колонки таблиц были переведены на тип timestamp with time zone (timestamptz). При миграции критически важно явно указать, в каком часовом поясе были записаны старые данные, чтобы PostgreSQL не исказил исторические значения:
-- Миграция: корректный перевод колонок с учетом исторического контекста
ALTER TABLE reservations 
  ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'Europe/Moscow',
  ALTER COLUMN reserved_at TYPE TIMESTAMPTZ USING reserved_at AT TIME ZONE 'UTC',
  ALTER COLUMN reserved_until TYPE TIMESTAMPTZ USING reserved_until AT TIME ZONE 'UTC',
  ALTER COLUMN deleted_at TYPE TIMESTAMPTZ USING deleted_at AT TIME ZONE 'UTC';
  1. Валидация конфигурации при старте сервиса: В процедуру самодиагностики приложения (HealthCheck) была добавлена проверка реального часового пояса активного подключения:
// health/checker.go: валидация таймзоны сессии при старте микросервиса
func ValidateSessionTimezone(ctx context.Context, pool *pgxpool.Pool) error {
    var currentTz string
    err := pool.QueryRow(ctx, "SHOW timezone").Scan(&currentTz)
    if err != nil {
        return fmt.Errorf("не удалось прочитать timezone из базы данных: %w", err)
    }

    if currentTz != "UTC" {
        return fmt.Errorf("критическая ошибка конфигурации: ожидается UTC, текущая таймзона БД: %s", currentTz)
    }

    log.Printf("Конфигурация базы данных подтверждена: сессия работает в UTC")
    return nil
}

Главный урок для инженеров

Тип timestamp without time zone в реляционных базах данных должен быть признан устаревшим антипаттерном для любых событий реального мира. Любая фиксация пользовательского действия, заказа, сессии или резерва обязана храниться в timestamp with time zone с единым серверным стандартом UTC на всех уровнях инфраструктуры.