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

-- Запрос фоновой очистки просроченных броней
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
Исправление ситуации потребовало синхронных изменений на трех уровнях архитектуры:
- Явная фиксация таймзоны в 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
}
- Безопасная онлайн-миграция типов: Все колонки таблиц были переведены на тип
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';
- Валидация конфигурации при старте сервиса: В процедуру самодиагностики приложения (HealthCheck) была добавлена проверка реального часового пояса активного подключения:
// health/checker.go: валидация таймзоны сессии при старте микросервиса
func ValidateSessionTimezone(ctx context.Context, pool *pgxpool.Pool) error {
var currentTz string
err := pool.QueryRow(ctx, "SHOW timezone").Scan(¤tTz)
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 на всех уровнях инфраструктуры.
