В современном мире разработки программного обеспечения, особенно при создании масштабируемых распределённых систем, проблема согласованности времени приобретает ключевое значение. В приложениях, которые работают на множестве узлов и серверов, время на каждом из них может отличаться из-за незначительных дрейфов системных часов. Несмотря на широкое использование протоколов синхронизации времени, таких как NTP, небольшие расхождения сохраняются и могут влиять на корректность обработки данных, логирование, системы очередей и многое другое. Одним из решений для устранения расхождений времени на уровне приложений является использование единого источника временных меток, которым в таких системах может стать база данных PostgreSQL. Внедрение и использование «иногда внедряемых часов» в Postgres позволяет добиться точного и согласованного времени записи данных, что приносит пользу как в продуктивных системах, так и в процессе тестирования.
В этой статье подробно рассмотрим, что собой представляет концепция иногда внедряемых часов в Postgres, как их можно использовать в реальных проектах на примере языка программирования Go с помощью библиотеки sqlc, а также обсудим преимущества и недостатки данного подхода. Весь код и идеи, описываемые далее, помогают разработчикам создавать более предсказуемое, тестируемое и точное взаимодействие с временем в приложениях на PostgreSQL. Проблема времени в распределённых системах Традиционно приложения используют системное время, доступное через стандартные функции операционной системы или среды выполнения. В Go, например, это time.Now().
При горизонтальном масштабировании приложения на несколько серверов, каждое из которых имеет собственный системный часовой механизм, не исключены небольшие расхождения. И если эти расхождения не критичны для многих приложений, то в сценариях, где важна абсолютная согласованность времени, например, при логировании, мониторинге или обработке событий по времени, такая ситуация может привести к серьёзным проблемам. NTP и прочие системы синхронизации сокращают расхождения, но не решают проблему полностью, особенно учитывая, что время системы может меняться, а транзакции и операции распределены по разным географическим локациям и серверам. Использование базы данных как источника времени постулирует идею, что в распределённой системе существует один центральный источник истины. В случае PostgreSQL этот источник - это вызываемая функция now(), которая возвращает время базы данных на момент начала текущей транзакции.
Таким образом, все узлы, записывающие данные, могут синхронизироваться на одну временную отметку, избегая расхождений. Однако у этого решения есть собственные подводные камни и ограничения, которые стоит учитывать при внедрении. Гибридный подход с иногда внедряемым временем Как известно, простой вызов now() в SQL не всегда удобен для разработки и тестирования. Одним из главных неудобств становится сложность заглушки (стаббинга) времени. При тестировании часто необходимо контролировать время исполнения кода, чтобы обеспечить предсказуемость результатов и воспроизводимость сценариев.
Однако time в Postgres нельзя легко «заглушить» или подменить из кода приложений. Именно здесь появляется идея иногда внедряемых часов, которая заключается в следующем: при работе с базой данных приложение передаёт в SQL-запрос параметр, который может быть либо явно заданным временем (при тестировании или специальной операции), либо NULL. В SQL функция coalesce позволяет использовать первый из непустых аргументов, то есть если параметр времени передан — используется он, если нет — вызывается стандартная now(). Получается своего рода гибридное решение, которое позволяет и использовать общее время базы данных, и в то же время подставлять собственное время при необходимости. Рассмотрим пример запроса к таблице queue, где происходит обновление поля paused_at.
Если значение paused_at ещё не установлено, то прописывается время либо из параметра, либо из now(). Такой приём обеспечит, что все записи будут иметь согласованное время, если параметр не задан, и даст возможность подменять время, если это необходимо тестам или бизнес-логике. Пример использования с sqlc и Go В качестве генератора SQL-запросов и кода в Go часто применяется sqlc. В конфигурации sqlc.yaml можно задать, что timestamptz будут превращаться в указатели на time.
Time, это позволяет передавать null-значения и гибко управлять вводимыми параметрами. В сгенерированном коде sqlc создаёт структуру параметров с полем Now типа *time.Time, которое либо содержит время, либо nil. При передаче nil в SQL коалесцирование сработает и вернёт текущее время now(). Таким образом, из кода Go можно управлять временем в базе, вызывая обновления и подставляя либо собственное жёстко заданное время (для тестов, фиксаций), либо позволяя базе использовать собственное время.
Кроме того, для упрощения управления временем в приложении стоит выделить отдельный интерфейс TimeGenerator, который реализует методы NowUTC() и NowUTCOrNil(). Первый возвращает текущее время, второй — либо указатель на текущее время, либо nil. Это позволяет единой точкой управления времени стать базовый сервис приложения и распространяться на подмодули и сервисы для одинакового поведения по времени. Тестирование и стаббинг Важное качество такого подхода — возможность создавать stub-объекты времени в тестах, которыми можно явно задавать фиксированное время. Так достигается стабильность тестов и возможность отладки без необходимости управлять фактическим системным временем или создавать сложные имитации базы.
В Go для реализации такого стаббинга используется тип TimeStub, который хранит внутреннее время и отдает его при вызове NowUTC() и NowUTCOrNil(). При отсутствии стаббинга он возвращает реальное время системы, что удобно для нормального функционирования приложения. Противоположно, в продакшн-среде используется UnstubbableTimeGenerator, который не дает установить фиксированное время, чтобы избежать случайных сбоев и обеспечить надёжность. Внедрение и распространение времени происходит через базовые сервисы, которые в тестах получают TimeStub с фиксацией времени, передаваемой затем дальше во все компоненты. Тем самым достигается единый источник времени на уровне приложения.
Ограничения и особенности использования При всей привлекательности использования базы данных как единого источника времени, разработчики должны помнить о некоторых особенностях. Во-первых, вызов now() в Postgres возвращает время начала транзакции, а не текущее системное время на момент вызова. Это значит, что если транзакция длится долго, время, записанное в поля, может не соответствовать реальному моменту записи или события. Для некоторых приложений это может быть преимуществом, поскольку гарантирует одинаковый временной штамп для всех действий в рамках одной транзакции, но в других случаях это может стать источником путаницы и неточностей. Во-вторых, чтобы использовать гибридный подход, необходимо немного усложнить архитектуру приложения, включая реализацию дополнительного интерфейса и передачу параметров времени в SQL-запросах.
Это требует дополнительных усилий и поддержки кода, что не всегда оправдано в менее критичных к времени системах. Тем не менее, для проектов, где временная консистентность имеет критическое значение, например, для финансовых сервисов, систем управления очередями или логирования, преимущества метода бесспорны. Кроме того, гибкость решения позволяет адаптировать его под конкретные задачи и уровни тестирования. Выводы и рекомендации Использование иногда внедряемых часов в Postgres — это эффективный способ борьбы с проблемой рассинхронизации системного времени в распределённых приложениях. Эта практика даёт возможность использовать базу данных как единый источник временных меток для всех узлов, обеспечивая согласованность и предсказуемость данных.