almdev Разработка с ИИ

Аудит и лента активности одной таблицей
Две задачи, которые легко перепутать, и одна таблица, которая обслуживает обе.

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

Подкатегория: Бэкенд

Чтение
6 мин
Технологии / версии
Symfony · Doctrine · PostgreSQL · Аудит
23 сен 2026 · 6 мин · 24 просмотра · Архив
Различие ленты активности и подробного журнала аудита
Бэкенд 09/2026

«Добавь ленту активности в проект» и «нужен журнал аудита» звучат как одна задача. Я так и подумал, написал одну таблицу и через два месяца обнаружил, что она плохо решает обе.

Разница выясняется на конкретном вопросе: что записать, когда пользователь поменял приоритет задачи с высокого на средний.

Лента и аудит — разные вопросы

Лента активности отвечает на вопрос «что произошло в проекте». Её читает участник команды, ей нужна человеческая формулировка, аватарки, группировка по дням. Записи старше месяца никто не смотрит. Событие «Алексей поменял приоритет» — идеальная запись для ленты.

Журнал аудита отвечает на вопрос «кто и когда это сделал». Его читают в конфликтной ситуации, через полгода после события. Ему нужны точные значения до и после, IP-адрес, идентификатор сессии. Формулировка не нужна вовсе, нужны данные.

Держать их в одной таблице я всё-таки решил, и вот почему. Событие одно и то же. Разделив хранилища, я получил бы двойную запись на каждое действие и постоянный риск, что одно из них забудут. Разница — в объёме контекста, а не в природе события.

Схема записи

Фрагмент сущности с полями и индексами, важными для журнала:

#[ORM\Entity]
#[ORM\Table(name: 'activity_log')]
#[ORM\Index(columns: ['organization_id', 'created_at', 'id'])]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'created_at'])]
class ActivityRecord
{
    #[ORM\Column(type: 'bigint')]
    #[ORM\Id, ORM\GeneratedValue]
    private int $id;

    #[ORM\Column(type: UuidType::NAME)]
    private Uuid $organizationId;

    #[ORM\Column(length: 40)]
    private string $entityType;

    #[ORM\Column(type: UuidType::NAME)]
    private Uuid $entityId;

    #[ORM\Column(length: 60)]
    private string $eventType;

    #[ORM\Column(type: UuidType::NAME, nullable: true)]
    private ?Uuid $actorMemberId;

    #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, options: ['jsonb' => true])]
    private array $context = [];

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $createdAt;
}

Первичный ключ здесь числовой, и это единственная таблица в проекте, где я отступил от UUID. Записей много, пишутся они пачками, наружу идентификатор не уходит. Сам ключ занимает восемь байт вместо шестнадцати, но реальную экономию надо считать по таблице и всем индексам, а не выводить только из размера типа.

actorMemberId допускает null, потому что действие может совершить система: закрытие спринта по расписанию, импорт, миграция данных. Пустой актор — это «система», и в ленте так и пишется.

Тип jsonb, а не json: по контексту иногда нужно искать, а jsonb позволяет добавить GIN-индекс или индекс на конкретное выражение. В показанной схеме такого индекса пока нет: добавлять его заранее для всех ключей было бы дороже редких поисков.

Явная запись против подписки на Doctrine

Самый спорный выбор во всей конструкции.

Подписка на события Doctrine выглядит идеально: один слушатель на onFlush, и всё пишется само. Ни один разработчик не забудет, покрытие стопроцентное.

Я попробовал и отказался через неделю. Три причины.

Слушатель не знает намерения. Doctrine видит, что у сущности поменялось поле status с open на closed. Он не видит, было ли это закрытие задачи пользователем, автоматическое закрытие при завершении спринта или откат неверного действия. В ленте это три разных события с разными формулировками.

Шум. Пересчёт денормализованного счётчика комментариев — изменение сущности. Обновление времени последней активности — изменение. За сутки лента наполовину состояла из событий, которые никому не интересны. Фильтрация превращалась в список полей-исключений, который надо поддерживать.

Сложность отладки. Когда запись появляется сама, найти, откуда она взялась, тяжело. Стек вызовов ведёт в flush().

Явный вызов выглядит скучно и работает:

$this->activity->record(
    entityType: 'issue',
    entityId: $issue->getId(),
    eventType: 'issue.priority_changed',
    context: [
        'title' => $issue->getTitle(),
        'from' => $old->value,
        'to' => $new->value,
    ],
);

Да, это лишняя строка в каждом сервисном методе. Да, её можно забыть. Забытая запись — это отсутствующая строка в ленте, что заметно на первом же тесте функции. Лишняя запись из автоматики — это мусор, который замечаешь через месяц.

Контекст, который читается через год

Главное правило контекста: запись должна быть понятна без обращения к другим таблицам.

Соблазн хранить только идентификаторы велик — они компактнее, и всегда можно подтянуть актуальные значения. Именно «актуальные» и ломает всё. Задачу переименовали, и запись годичной давности показывает новое название рядом со старым событием. Задачу удалили — запись показывает пустое место.

Поэтому в контекст кладутся значения на момент события:

{
  "title": "Починить экспорт в CSV",
  "from": "high",
  "to": "medium",
  "actor_name": "Алексей М.",
  "project_key": "WM"
}

Дублирование намеренное. Журнал — это история, а история не должна меняться задним числом.

Одного соглашения для этого мало. Роль приложения может только добавлять записи, но не обновлять их; очистка идёт отдельной ролью и только по политике хранения. Для чувствительных событий доступ к исходному контексту проверяется отдельно от доступа к обычной ленте.

Для аудиторской части в контекст добавляется технический блок: IP, идентификатор сессии, источник действия (интерфейс, API, консоль). Он пишется не для всех событий, а для тех, что помечены как чувствительные: изменение прав, удаление, экспорт данных.

Чтение ленты

Смещение с большими значениями работает плохо: OFFSET 5000 заставляет базу прочитать и выбросить пять тысяч строк. Для бесконечной прокрутки это лечится курсором.

/**
 * @param null|array{created_at: \DateTimeImmutable, id: int} $cursor
 */
public function feed(Uuid $orgId, ?array $cursor, int $limit = 30): array
{
    $qb = $this->createQueryBuilder('a')
        ->where('a.organizationId = :org')->setParameter('org', $orgId)
        ->orderBy('a.createdAt', 'DESC')
        ->addOrderBy('a.id', 'DESC')
        ->setMaxResults($limit);

    if ($cursor !== null) {
        $qb->andWhere(
            '(a.createdAt < :createdAt OR (a.createdAt = :createdAt AND a.id < :id))'
        )
            ->setParameter('createdAt', $cursor['created_at'])
            ->setParameter('id', $cursor['id']);
    }

    return $qb->getQuery()->getResult();
}

Курсор состоит из времени и числового идентификатора. Один идентификатор не равен времени: параллельные транзакции могут получить номера в одном порядке, а зафиксироваться в другом. id здесь разрешает совпадения времени и делает порядок стабильным. С UUID v7 можно построить похожий курсор, но дополнительный уникальный компонент всё равно нужен.

Индекс по организации, дате и идентификатору обслуживает общую ленту, индекс по типу и идентификатору сущности — историю конкретной задачи. Оба составные, и порядок колонок в них важен: сначала то, по чему фильтруем, потом то, по чему сортируем.

Рост таблицы

Полтора миллиона записей за год на одном активном клиенте. Это не много, но растёт линейно и не остановится.

Я рассматривал секционирование по месяцам. Красивое решение: старые секции отцепляются, архивируются, отбрасываются. Отложил, потому что при полутора миллионах строк нет проблемы, которую оно решает, а сложность появляется сразу.

Что сделал вместо этого — разделил срок хранения по типам событий. Шумные события (просмотры, изменения позиции на доске) чистятся через 90 дней. Значимые (создание, удаление, изменение прав, экспорт) живут три года.

private const RETENTION = [
    'issue.viewed' => 'P30D',
    'issue.position_changed' => 'P90D',
    'member.role_changed' => 'P3Y',
    'organization.data_exported' => 'P3Y',
];

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

Что бы сделал иначе

Отделил бы чувствительные события в отдельную таблицу с самого начала. Не из-за структуры, а из-за прав доступа: сейчас лента и аудит читаются из одной таблицы, и разграничение «участник видит ленту, администратор видит аудит» живёт в условиях запроса. Это работает, но это ровно то место, где однажды кто-то забудет условие.

И завёл бы реестр типов событий как энум с самого начала, а не строкой. Сейчас типов около шестидесяти, они разбросаны по вызовам, и получить полный список можно только поиском по коду.

#symfony #doctrine #postgresql #audit