Время — самый частый источник тихих багов в продакшене. Заказ от 23:30 не попадает в дневной отчёт, события приходят «в будущем», cron срабатывает дважды. В 90% случаев виноват не код приложения, а тип колонки в БД.
1. Главное правило
PG-T-030 Для бизнес-времени — всегда timestamptz. Никогда timestamp (он же timestamp without time zone).
CREATE TABLE order_event (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at timestamptz NOT NULL, -- правильно
created_at timestamp NOT NULL -- неправильно
);
2. Что хранится на самом деле
Главное недопонимание про PG: timestamptz не хранит зону. Он хранит UTC.
- При записи: PG берёт значение, конвертирует в UTC по текущему
TimeZoneсессии, сохраняет как количество микросекунд от эпохи. - При чтении: PG берёт UTC, конвертирует обратно в
TimeZoneсессии, возвращает.
То есть timestamptz — это «timestamp в UTC + автоматическая конвертация на границе I/O». Зона нужна, чтобы PG знал, в какой зоне трактовать ваш ввод и в какой отдавать вывод.
timestamp — это просто число «год-месяц-день-час-минута-секунда» без привязки к зоне. PG считает его «локальным временем непонятно где». Если в одну колонку с разных серверов / клиентов / контейнеров приедут значения, получится мусор: 2026-05-07 12:00:00 от UTC-сервера и от MSK-клиента — это разные моменты, но в БД они одинаковые.
3. Откуда берутся проблемы
SET TIME ZONE 'Europe/Moscow';
INSERT INTO order_event (occurred_at) VALUES ('2026-05-07 14:00:00');
-- хранится как 2026-05-07 11:00:00+00 (UTC)
SET TIME ZONE 'UTC';
SELECT occurred_at FROM order_event;
-- вернёт 2026-05-07 11:00:00+00
SET TIME ZONE 'America/New_York';
SELECT occurred_at FROM order_event;
-- вернёт 2026-05-07 07:00:00-04
Один и тот же момент времени, отображается по-разному в зависимости от зоны сессии. Это правильно — это и есть смысл timestamptz.
С timestamp без зоны:
INSERT INTO bad (created_at) VALUES ('2026-05-07 14:00:00');
-- хранится буквально '2026-05-07 14:00:00' — без зоны
-- что это значит? UTC? MSK? Зона приложения? Зона клиента?
-- ответа нет, и через год никто уже не помнит, какая была договорённость.
4. JDBC и Java-сторона
PG-T-031 На Java-стороне используем Instant или OffsetDateTime для timestamptz. LocalDateTime — никогда.
| Колонка PG | Java тип | Корректно |
|---|---|---|
timestamptz | Instant | да, рекомендуется |
timestamptz | OffsetDateTime | да |
timestamptz | ZonedDateTime | да, но избыточно |
timestamptz | LocalDateTime | нет — потеряется зона на конверсии |
timestamp (без TZ) | LocalDateTime | да (но сам тип нежелателен) |
date | LocalDate | да |
time | LocalTime | да |
Instant — это «момент времени в UTC». Идеально ложится на timestamptz. jOOQ при включённой конфигурации генерирует Instant, JdbcTemplate тоже умеет.
Типичный баг: jOOQ сгенерил LocalDateTime для timestamptz-колонки → при чтении PG отдаёт UTC, JDBC-драйвер конвертирует в локальную зону JVM → получаем LocalDateTime без указания, в какой зоне он. На сервере с TZ=UTC и на ноутбуке разработчика с TZ=Europe/Moscow — разные значения.
// правильно
record OrderEventRow(long id, Instant occurredAt) {}
// неправильно
record OrderEventRow(long id, LocalDateTime occurredAt) {}
5. Когда timestamp without time zone всё-таки нужен
PG-T-032 timestamp (без TZ) допустим только для «локального времени без привязки к моменту» — например, расписание открытия магазина, рабочее время, время вылета по локальному рейсу.
shop_opens_at time NOT NULL, -- 09:00, в любой локали
shop_closes_at time NOT NULL, -- 18:00
holiday_date date NOT NULL, -- 2026-01-01, в локальной таймзоне магазина
flight_local_departure timestamp NOT NULL -- 14:00 по локальному рейса
В этих случаях зона хранится отдельно (например, в колонке timezone text NOT NULL), и приложение конвертирует при необходимости.
Это редкие случаи. Дефолт — timestamptz.
6. now(), clock_timestamp(), statement_timestamp()
PG-T-033 Знайте разницу:
| Функция | Что возвращает |
|---|---|
now() / transaction_timestamp() | время начала транзакции. Внутри одной транзакции возвращает то же самое. |
statement_timestamp() | время начала текущего statement (одного INSERT/UPDATE/...). |
clock_timestamp() | время в момент вызова. Каждое обращение даёт новое значение. |
Для created_at / updated_at обычно нужен now() — тогда у всех строк, вставленных в одной транзакции, одна и та же отметка. Это полезно для отладки и аудита.
created_at timestamptz NOT NULL DEFAULT now()
Если делаете замер «сколько шёл цикл вставки 10 000 строк» внутри транзакции — берёте clock_timestamp().
7. Тестируемое время
PG-T-034 В приложении не вызывайте Instant.now() / LocalDateTime.now() напрямую. Заведите DateTimeService и моките его в тестах.
Тривиальный сервис:
public interface DateTimeService {
Instant now();
}
@Component
@Profile("!integration-test")
public class SystemDateTimeService implements DateTimeService {
@Override public Instant now() { return Instant.now(); }
}
В тесте:
@MockitoBean DateTimeService dateTimeService;
@BeforeEach
void freezeTime() {
when(dateTimeService.now()).thenReturn(Instant.parse("2026-05-07T12:00:00Z"));
}
Без этого тесты, где сравнивается время в БД с временем расчёта, становятся flaky на ровном месте: сравнение assertEquals(expected, actual) по Instant иногда отличается на 100 микросекунд.
То же самое применимо к now() в SQL-DEFAULT: для проверяемых сценариев лучше передавать значение из приложения, а не полагаться на DEFAULT now().
8. INTERVAL и арифметика
PG-T-035 Используйте INTERVAL для смещений в SQL, не вычисляйте секунды вручную.
-- читаемо
SELECT * FROM session WHERE last_seen_at < now() - interval '15 minutes';
-- нечитаемо
SELECT * FROM session WHERE last_seen_at < now() - 900 * interval '1 second';
INTERVAL корректно учитывает летнее время, високосные секунды, разницу длительности месяцев.
9. Важная мелочь: +infinity и -infinity
PG поддерживает специальные значения:
INSERT INTO subscription (expires_at) VALUES ('infinity');
SELECT * FROM subscription WHERE expires_at > now(); -- найдёт
Полезно для «бессрочных» подписок, политик, ролей. Лучше, чем NULL или 9999-12-31 — выражает намерение явно и поддерживается арифметикой.
Чек-лист на ревью
- [ ] Все «когда что-то произошло» поля —
timestamptz.timestampбез TZ — только для локального времени с явной отдельной зоной. - [ ] На Java-стороне
timestamptzмапится наInstantилиOffsetDateTime, не наLocalDateTime. - [ ] Время не берётся через
Instant.now()напрямую — естьDateTimeServiceи он мокается в тестах. - [ ] В SQL для смещений —
INTERVAL, не магические числа секунд. - [ ]
created_at/updated_atимеютDEFAULT now()(или, если важна детерминированность — пишутся явно из приложения).
Связанные
- UUID и идентификаторы — UUID v7 включает временную метку, тоже завязан на время.
- Антипаттерны.
- Стратегия тестов — про детерминированное время через
@MockitoBean.