> Как хранится история переходов статусов платежей? (Go)
Уровень: senior · Роль: backend · Язык: Go · Категория: Технические вопросы
Компании: InDrive
Стек: Go
> Пример ответа
Короткий ответ
История переходов статусов платежей хранится в отдельной таблице payment_status_history с полями: payment_id, from_status, to_status, changed_at, actor_id, reason_code и, при необходимости, metadata (JSONB). Это классический event sourcing для агрегата платежа: текущий статус - денормализованное значение в основной таблице, а полная цепочка переходов - в журнале. Такой подход обеспечивает аудит, возможность восстановления состояния и отладки.
Подробное объяснение
Основная задача - сохранить неизменяемую последовательность событий, каждое из которых описывает факт перехода. Ключевые принципы:
- Иммутабельность: записи в history не обновляются и не удаляются, только добавляются.
- Ссылочная целостность: внешний ключ на
payment_idс каскадным удалением или без него (зависит от требований к retention). - Индексация: составной индекс
(payment_id, created_at)для быстрой выборки цепочки, отдельный индекс поcreated_atдля запросов по времени. - Транзакционность: обновление текущего статуса и вставка записи в history должны происходить в одной транзакции, чтобы избежать рассинхронизации.
Альтернативные подходы:
- Хранение в JSONB-массиве внутри записи платежа - проще, но неудобно для аналитики и запросов по конкретным переходам.
- Отдельный event store (например, на базе Kafka или специализированной БД) - избыточно для большинства платёжных систем, но оправдано при высоких требованиях к аудиту.
Выбор между ними зависит от частоты запросов к истории, объёма данных и требований к консистентности.
На практике
В Go-проектах с PostgreSQL типичная схема выглядит так:
SQLCREATE TABLE payment_status_history (id BIGSERIAL PRIMARY KEY,payment_id UUID NOT NULL REFERENCES payments(id),from_status VARCHAR(32) NOT NULL,to_status VARCHAR(32) NOT NULL,changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),actor_id UUID, -- кто инициировал переход (пользователь, система, cron)reason_code VARCHAR(64), -- машинный код причины (например, 'timeout', 'manual_review')metadata JSONB -- произвольные данные: IP, user_agent, доп. контекст);CREATE INDEX idx_payment_history_payment_id_created_atON payment_status_history (payment_id, changed_at);
В коде переход выполняется в одной транзакции:
GOfunc (s *PaymentService) TransitionStatus(ctx context.Context, paymentID uuid.UUID, newStatus string, actorID uuid.UUID, reason string) error {tx, err := s.db.BeginTx(ctx, nil)if err != nil {return err}defer tx.Rollback()var currentStatus stringerr = tx.QueryRowContext(ctx,`SELECT status FROM payments WHERE id = $1 FOR UPDATE`, paymentID,).Scan(¤tStatus)if err != nil {return err}if !isValidTransition(currentStatus, newStatus) {return ErrInvalidTransition}_, err = tx.ExecContext(ctx,`UPDATE payments SET status = $1, updated_at = now() WHERE id = $2`,newStatus, paymentID,)if err != nil {return err}_, err = tx.ExecContext(ctx,`INSERT INTO payment_status_history (payment_id, from_status, to_status, actor_id, reason_code)VALUES ($1, $2, $3, $4, $5)`,paymentID, currentStatus, newStatus, actorID, reason,)if err != nil {return err}return tx.Commit()}
Важные детали:
SELECT ... FOR UPDATEблокирует строку платежа, предотвращая гонки при параллельных переходах.- Валидация допустимости перехода (например,
pending -> completed, но неrefunded -> completed) должна выполняться до записи. - Для высоконагруженных систем можно использовать оптимистичную блокировку через
version-поле, но для платёжных операций пессимистичная блокировка надёжнее.
Как отвечать на собеседовании
Начни с краткого описания схемы таблицы, затем переходи к транзакционности и конкурентности. Подчеркни, что история - это неизменяемый журнал, а не просто набор записей. Упомяни trade-off между простотой (JSONB) и аналитическими возможностями (отдельная таблица). Если спросят про масштабирование - скажи про партиционирование по changed_at (например, по месяцам) и про то, что старые записи можно архивировать. Хорошо показать понимание, зачем нужен actor_id и reason_code - это критично для аудита и разбора инцидентов.
Что проверяет интервьюер
- Понимание разницы между текущим состоянием и историей изменений.
- Умение проектировать схему с учётом целостности данных (транзакции, внешние ключи, индексы).
- Осознание проблем конкурентного доступа и способов их решения.
- Практический опыт: знание, что хранить в
metadata, как обрабатывать переходы без промежуточных статусов. - Способность обосновать выбор между реляционной таблицей и документной моделью.
Типичные ошибки
- Хранение истории в том же поле, что и текущий статус - например, в виде массива в JSONB. Это усложняет запросы и нарушает нормализацию.
- Отсутствие транзакции при обновлении статуса и записи в history - приводит к рассинхронизации при сбоях.
- Игнорирование гонок: два параллельных запроса могут перевести платеж из одного статуса в два разных, если не использовать блокировки или версионирование.
- Ненужные данные в history: например, хранение полного payload платежа в каждой записи - раздувает таблицу без пользы.
- Удаление записей истории при удалении платежа - нарушает аудит. Лучше использовать мягкое удаление или не удалять вовсе.
> Похожие задачи по Go
Какой механизм используется для отправки событий?
Как отправляются события об изменении статусов?
Вы используете один кластер или геораспределенную систему?
Как исправлять застрявшие платежи вручную при наличии реконсайл-джобы
> Похожие задачи по backend
Какой механизм используется для отправки событий?
Как отправляются события об изменении статусов?
Вы используете один кластер или геораспределенную систему?
Как исправлять застрявшие платежи вручную при наличии реконсайл-джобы
> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?
Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью