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,
);
}
}
Денормализация: цена и выгода
Read model почти всегда денормализована - данные дублируются ради скорости чтения:
Выгода Цена
────────────────────── ──────────────────────────
Быстрое чтение (0 JOIN'ов) Дублирование данных
Индексы под конкретные запросы Нужен механизм обновления
Формат совпадает с API Eventual consistency
Простые запросы Больше места в БД
Главное правило: пишем в write 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
Одно из главных преимуществ - 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 изменилась после миграции - просто пересобрал и всё.
- Go - Работа с базами данных - SQL, транзакции и миграции в Go: реализация read model через database/sql
Мини-задание
- Выбери один UI-список из своего проекта (например, список уроков трека с прогрессом)
- Набросай SQL-схему read model таблицы под этот список
- Реализуй Go-функцию, которая читает из read model одним SELECT (без JOIN)
- Подумай: как бы ты обновлял эту read model - синхронно или через событие?