Read Model: быстрые списки без боли

Read Model: быстрые списки без боли

Read Model - это представление данных «как удобно читать». Она не обязана быть источником правды - её всегда можно пересобрать из write-модели.

Зачем отдельная модель для чтения

Типичная проблема: UI требует данные из трёх-пяти таблиц. Каждый запрос - это JOIN, агрегация, форматирование. На 10 000 записей терпимо, на 1 000 000 - уже больно.

Без Read Model:                     С Read Model:
──────────────────                  ──────────────────
SELECT l.*, p.*, t.*                SELECT * FROM track_lessons_view
FROM lessons l                      WHERE track_id = $1
JOIN tracks t ON ...                  AND user_id = $2
LEFT JOIN progress p ON ...         ORDER BY sort_order
WHERE t.id = $1 AND p.user_id = $2
ORDER BY l.order

→ 3 таблицы, 2 JOIN'а                → 1 таблица, 0 JOIN'ов
→ Индексы конфликтуют               → Индекс точно под запрос
→ Формат не совпадает с UI          → Формат = то, что нужно фронту

Варианты реализации Read Model

1. Отдельная таблица (самый распространённый)

CREATE TABLE track_lessons_view (
    track_id      BIGINT NOT NULL,
    lesson_id     BIGINT NOT NULL,
    user_id       BIGINT NOT NULL,
    lesson_title  TEXT NOT NULL,
    sort_order    INT NOT NULL,
    completed     BOOLEAN NOT NULL DEFAULT false,
    completed_at  TIMESTAMPTZ,
    duration_min  INT NOT NULL DEFAULT 0,
    PRIMARY KEY (track_id, lesson_id, user_id)
);

-- Индекс под основной запрос: «все уроки трека для пользователя»
CREATE INDEX idx_track_lessons_view_track_user
    ON track_lessons_view(track_id, user_id, sort_order);

2. Materialized View

CREATE MATERIALIZED VIEW track_stats_mv AS
SELECT
    t.id AS track_id,
    t.title,
    COUNT(l.id) AS total_lessons,
    COUNT(p.id) FILTER (WHERE p.completed) AS completed_lessons,
    ROUND(COUNT(p.id) FILTER (WHERE p.completed)::numeric /
          NULLIF(COUNT(l.id), 0) * 100) AS progress_percent
FROM tracks t
JOIN lessons l ON l.track_id = t.id
LEFT JOIN progress p ON p.lesson_id = l.id AND p.user_id = current_setting('app.user_id')::bigint
GROUP BY t.id, t.title;

-- Обновляем по расписанию или по событию
REFRESH MATERIALIZED VIEW CONCURRENTLY track_stats_mv;

3. Денормализованный JSON в Redis (для редко меняющихся данных)

// Кэшируем в Redis как готовый JSON
type TrackOverview struct {
    TrackID      int64            `json:"track_id"`
    Title        string           `json:"title"`
    Lessons      []LessonSummary  `json:"lessons"`
    TotalLessons int              `json:"total_lessons"`
}

func (r *ReadRepo) CacheTrackOverview(ctx context.Context, overview TrackOverview) error {
    data, _ := json.Marshal(overview)
    return r.redis.Set(ctx,
        fmt.Sprintf("track:%d:overview", overview.TrackID),
        data,
        30*time.Minute,
    ).Err()
}
<?php
declare(strict_types=1);

// Кэшируем в Redis как готовый JSON
final readonly class TrackOverview
{
    public function __construct(
        public int $trackId,
        public string $title,
        /** @var LessonSummary[] */
        public array $lessons,
        public int $totalLessons,
    ) {}
}

final class ReadRepository
{
    public function __construct(
        private readonly \Redis $redis,
    ) {}

    public function cacheTrackOverview(TrackOverview $overview): void
    {
        $payload = json_encode($overview, JSON_THROW_ON_ERROR);
        $this->redis->setex(
            "track:{$overview->trackId}:overview",
            1800, // 30 минут
            $payload,
        );
    }
}
Отдельная таблица - универсальный выбор, работает для 90% случаев. Materialized view - если данные обновляются редко и допустима задержка. JSON в Redis - для «горячих» данных с высоким RPS (см. [паттерны кеширования](../redis/03-caching-patterns.md)).

Денормализация: цена и выгода

Read model почти всегда денормализована - данные дублируются ради скорости чтения:

Выгода                              Цена
──────────────────────              ──────────────────────────
Быстрое чтение (0 JOIN'ов)         Дублирование данных
Индексы под конкретные запросы      Нужен механизм обновления
Формат совпадает с API              Eventual consistency
Простые запросы                     Больше места в БД

Главное правило: пишем в write model, читаем из read model. Если кто-то обновляет read model напрямую - архитектура сломана.

Как обновлять Read Model

Три подхода:

Три способа обновлять Read Model: синхронно в транзакции, через событие, по расписанию

Синхронно (в той же транзакции)

func (h *CompleteLessonHandler) Handle(ctx context.Context, cmd CompleteLesson) error {
    return h.db.Transaction(func(tx *gorm.DB) error {
        // Write
        if err := tx.Model(&Progress{}).
            Where("user_id = ? AND lesson_id = ?", cmd.UserID, cmd.LessonID).
            Update("completed", true).Error; err != nil {
            return err
        }
        // Read model update (в той же транзакции)
        return tx.Model(&TrackLessonsView{}).
            Where("user_id = ? AND lesson_id = ?", cmd.UserID, cmd.LessonID).
            Update("completed", true).Error
    })
}
<?php
declare(strict_types=1);

final class CompleteLessonHandler
{
    public function __construct(
        private readonly Connection $db,
    ) {}

    public function __invoke(CompleteLesson $cmd): void
    {
        $this->db->transactional(function (Connection $tx) use ($cmd): void {
            // Write
            $tx->update(
                'progress',
                ['completed' => true],
                ['user_id' => $cmd->userId, 'lesson_id' => $cmd->lessonId],
            );
            // Read model update (в той же транзакции)
            $tx->update(
                'track_lessons_view',
                ['completed' => true],
                ['user_id' => $cmd->userId, 'lesson_id' => $cmd->lessonId],
            );
        });
    }
}

Просто, но связывает write и read.

Асинхронно через событие (рекомендуемый)

// Write model публикует событие
func (h *CompleteLessonHandler) Handle(ctx context.Context, cmd CompleteLesson) error {
    // ... обновляем progress ...
    return h.publisher.Publish(ctx, "lesson.completed", LessonCompletedEvent{
        UserID:   cmd.UserID,
        LessonID: cmd.LessonID,
    })
}

// Отдельный воркер обновляет read model
func (w *ReadModelUpdater) HandleLessonCompleted(ctx context.Context, evt LessonCompletedEvent) error {
    return w.readRepo.MarkCompleted(ctx, evt.UserID, evt.LessonID)
}
<?php
declare(strict_types=1);

// Write model публикует событие
final class CompleteLessonHandler
{
    public function __construct(
        private readonly ProgressRepository $progress,
        private readonly EventPublisher $publisher,
    ) {}

    public function __invoke(CompleteLesson $cmd): void
    {
        // ... обновляем progress ...
        $this->publisher->publish(
            'lesson.completed',
            new LessonCompletedEvent(
                userId: $cmd->userId,
                lessonId: $cmd->lessonId,
            ),
        );
    }
}

// Отдельный воркер обновляет read model
final class ReadModelUpdater
{
    public function __construct(
        private readonly ReadRepository $readRepo,
    ) {}

    public function handleLessonCompleted(LessonCompletedEvent $evt): void
    {
        $this->readRepo->markCompleted($evt->userId, $evt->lessonId);
    }
}

В Symfony Messenger такой обработчик помечается атрибутом #[AsMessageHandler], а транспорт настраивается на async (RabbitMQ / Redis) - это и есть «асинхронный консьюмер».

По расписанию (для materialized views)

// Cron-задача каждые 5 минут
func (j *RefreshJob) Run(ctx context.Context) error {
    _, err := j.db.ExecContext(ctx,
        "REFRESH MATERIALIZED VIEW CONCURRENTLY track_stats_mv")
    return err
}
<?php
declare(strict_types=1);

// Symfony Console команда, дёргается cron'ом каждые 5 минут
#[AsCommand(name: 'app:refresh-track-stats')]
final class RefreshTrackStatsCommand extends Command
{
    public function __construct(
        private readonly Connection $db,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $this->db->executeStatement(
            'REFRESH MATERIALIZED VIEW CONCURRENTLY track_stats_mv'
        );
        return Command::SUCCESS;
    }
}
При асинхронном обновлении read model может отставать от write на секунды. Для большинства UI это незаметно. Но если пользователь завершил урок и тут же открывает список - он может не увидеть галочку. Решение: показать оптимистичное обновление на фронте.

Пересборка Read Model

Одно из главных преимуществ - read model можно пересобрать с нуля:

func (r *ReadModelRebuilder) RebuildTrackLessonsView(ctx context.Context) error {
    // Очищаем
    if err := r.db.Exec("TRUNCATE track_lessons_view").Error; err != nil {
        return err
    }

    // Заполняем из write-модели
    return r.db.Exec(`
        INSERT INTO track_lessons_view
            (track_id, lesson_id, user_id, lesson_title, sort_order,
             completed, completed_at, duration_min)
        SELECT
            l.track_id, l.id, p.user_id, l.title, l.sort_order,
            p.completed, p.completed_at, l.duration_min
        FROM lessons l
        CROSS JOIN users u
        LEFT JOIN progress p ON p.lesson_id = l.id AND p.user_id = u.id
    `).Error
}
<?php
declare(strict_types=1);

final class ReadModelRebuilder
{
    public function __construct(
        private readonly Connection $db,
    ) {}

    public function rebuildTrackLessonsView(): void
    {
        // Очищаем
        $this->db->executeStatement('TRUNCATE track_lessons_view');

        // Заполняем из write-модели
        $this->db->executeStatement(
            'INSERT INTO track_lessons_view
                (track_id, lesson_id, user_id, lesson_title, sort_order,
                 completed, completed_at, duration_min)
             SELECT
                l.track_id, l.id, p.user_id, l.title, l.sort_order,
                p.completed, p.completed_at, l.duration_min
             FROM lessons l
             CROSS JOIN users u
             LEFT JOIN progress p ON p.lesson_id = l.id AND p.user_id = u.id'
        );
    }
}

Это спасает, когда структура read model изменилась после миграции - просто пересобрал и всё.

Мини-задание

  • Выбери один UI-список из своего проекта (например, список уроков трека с прогрессом)
  • Набросай SQL-схему read model таблицы под этот список
  • Реализуй Go-функцию, которая читает из read model одним SELECT (без JOIN)
  • Подумай: как бы ты обновлял эту read model - синхронно или через событие?

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.