ORM и SQL в серверных компонентах: слой данных владеет запросами, мемоизацией и древовидным N+1
RSC ходят в базу напрямую, и дисциплина переезжает в слой данных: запросы живут в lib/data рядом с auth-проверкой, React cache() дедуплицирует один проход рендера, Promise.all и preload убивают водопады, а древовидный N+1 — 50 карточек, 50 запросов — поднимается или батчится.
Ops-дашборд — гордость миграции на App Router: каждый виджет — самодостаточный серверный компонент, который сам забирает ровно те данные, что показывает. Карточка выручки, карточка error budget, таблица топ-клиентов — всего 23 виджета, красиво развязанных, тривиально тестируемых, каждый — async-функция, которая ждёт свой запрос. Потом кто-то открывает страницу из офиса в Сиднее и ждёт первый байт 4,6 секунды. Трейс объясняет, почему локально этого никто не видел: виджеты вложены в секции лейаута, каждая секция ждёт свой await до рендера детей, и 23 запроса выстраиваются лестницей — примерно по 200 мс на ступень до базы в us-east. Ничто не тормозит; всё последовательно. Команда усваивает урок App Router дорогой ценой: когда компоненты умеют говорить с базой, дерево компонентов становится вашим планировщиком запросов — а древесный планировщик по умолчанию строит водопад.
Запросы живут в lib/data, а не в телах компонентов
Прежде чем писать первый await db.select() в теле компонента, спросите себя: где будет лежать auth-проверка, и смогу ли я это протестировать без рендера? Ответ на оба вопроса диктует одно и то же решение — слой данных.
Серверный компонент может сделать await db.select() прямо в теле — и именно это удобство надо дозировать. Дисциплина, которая выживает в проде, — слой доступа к данным: модуль lib/data/ на каждый домен, экспортирующий функции, которые зовут компоненты, — компоненты никогда не импортируют клиент базы. Три причины по возрастанию важности. Тестируемость: функцию, принимающую id и возвращающую строки, можно тестировать без рендера. Ревьюируемость: все запросы кодовой базы лежат в одной папке, которую можно аудировать, а не размазаны по дереву. И решающая: проверка авторизации живёт рядом с доступом к данным, так что пути к строкам в обход неё не существует — тот же аргумент о границе, что вы встречали в юните про auth, теперь применённый к чтениям.
// lib/data/orders.ts
import 'server-only'; // ошибка сборки, если модуль импортирует клиентский компонент
import { cache } from 'react';
import { db } from '~/lib/db';
import { requireUser } from '~/lib/auth';
export const getOrder = cache(async (orderId: string) => {
const user = await requireUser(); // auth живёт ВМЕСТЕ с доступом к данным
const order = await db.query.orders.findFirst({
where: (o, { eq }) => eq(o.id, orderId),
});
if (!order || order.accountId !== user.accountId) return null; // проверка владения — в одном месте
return order;
});Импорт 'server-only' делает границу механической: если модуль когда-нибудь попадёт в клиентский бандл, упадёт сборка, а не утечёт строка подключения.
Prisma, Drizzle, сырой SQL — честное взвешивание
В мире RSC вопрос об ORM получает serverless-измерение. У Prisma самая зрелая история сгенерированных типов и лучшая реляционная эргономика, но классически она везёт Rust-бинарь движка запросов — порядка 10–15 МБ в пакете деплоя, — который на холодном старте должен подняться и подключиться, исторически добавляя сотни миллисекунд на маленькой lambda. Недавние мажоры заметно срезали накладные расходы, а режим driver adapters убирает бинарь целиком — реальный прогресс, который всё равно стоит проверить на собственном бюджете холодного старта, а не принимать на веру. Drizzle описывает схему в TypeScript и выводит типы на компиляции: без шага кодогенерации, без бинаря, сильно меньше мегабайта JS, и API формой повторяет SQL — то, что вы читаете, примерно то, что выполняется. Сырой SQL через postgres.js или pg — самый лёгкий и самый выразительный: оконные функции, CTE, никакого потолка абстракции, — но историю типов строить вам: объявленные интерфейсы тихо расходятся со схемой, если не добавить кодогенерацию или парсинг строк на границе. Сеньорский вывод: выбор важен меньше, чем место, где живут запросы, — но на serverless холодный вес и поведение соединений — критерии первого класса, а не вкусовщина.
Мемоизация запроса — один проход рендера, а не кеш
Next.js автоматически дедуплицирует одинаковые вызовы fetch внутри одного прохода рендера. Запросам к базе такой милости не положено — они не fetch, — поэтому слой данных оборачивает их в React cache(). Внутри одного запроса десять компонентов, зовущих getOrder('o_42'), дают один SQL-запрос: все делят один in-flight промис. Будьте точны с границей, потому что именно здесь команды обжигаются: cache() мемоизирует на один проход рендера. Он охватывает generateMetadata, лейаут, страницу и каждый компонент этого запроса — а потом доска стирается. Следующий запрос выполняет SQL заново. Разные аргументы — разные записи мемо: дедуплицируется повторение, а не разнообразие. Это не Data Cache, он ничего не делит между пользователями и не ускоряет второго посетителя. Кросс-запросное кеширование — другой механизм с другими проблемами инвалидации; это урок про cache-теги, не этот.
Команда оборачивает getProduct в React cache() и ждёт, что второго посетителя обслужат из памяти. Функция зовётся в generateMetadata, лейауте и странице. Сколько запросов выполнится для первого посетителя и что получит второй?
Параллельность — ваша работа; дерево по умолчанию серийно
Когда видите в трейсе лестницу — каждый виджет стартует строго после предыдущего, — знайте: это не медленный ORM, это структура ожидания. И лечение структурное.
Дерево RSC сериализуется везде, где родитель ждёт await до рендера детей, которые тоже ждут: глубина водопада равна глубине вложенности — ровно инцидент с дашбордом: 23 виджета по ~200 мс, лестница на 4,6 с. Два структурных лекарства. Первое — поднять и распараллелить: родитель запускает все промисы и ждёт их вместе — Promise.all([getRevenue(), getErrors(), getCustomers()]) — и спускает результаты пропсами; общее время становится самым медленным запросом, а не суммой. Второе — паттерн preload, «позови рано, жди поздно»: поскольку cache() делит in-flight промис, родитель может выстрелить void getOrder(id) без await, сразу рендерить детей, и ребёнок, который позже сделает await getOrder(id), присоединится к запросу, бегущему с вершины дерева.
// lib/data/orders.ts — рядом с getOrder
export function preloadOrder(orderId: string) {
void getOrder(orderId); // запускает запрос; кто бы ни ждал позже — присоединится к этому промису
}Древовидный N+1
Классический N+1 рождался из ленивой подгрузки связей в цикле ORM. Версия App Router архитектурна: страница списка рендерит 50 компонентов ProductCard, каждая карточка — самодостаточная по дизайну — ждёт getPrice(product.id), и страница выпускает 50 запросов на один HTTP-запрос. Соседи рендерятся конкурентно, так что это не 50 последовательных раундтрипов, — но это 50 запросов, бьющих в пул одновременно, 50 занятых слотов, и при p50 в 1,5 мс на каждый — реальная нагрузка, умноженная на каждый просмотр страницы. Два лекарства с честным компромиссом между ними. Поднять запрос: родитель списка выполняет один WHERE id IN (...50 id) и спускает строки пропсами — дешевле и яснее всего, ценой того, что родитель знает нужды детей. Или батчить в стиле DataLoader: батчер на запрос (построенный на cache(), чтобы жить в скоупе запроса) собирает вызовы getPrice, выпущенные за один тик, и сливает их в один IN-запрос — компоненты остаются самодостаточными, а вы теперь владеете слоем батчинга с тонкостями тайминга микротасок. В обоих случаях важное число превращается из 50 в 1.
▸Почему это работает
Зачем фреймворк вообще придвинул базу к компоненту, если это воскрешает N+1? Потому что альтернатива, которую он заменил, была хуже: API-слой, чьи эндпоинты существовали только чтобы возить данные страницам, добавляя сериализацию, сетевой хоп и второй деплой, за которым надо следить. RSC удаляют этот средний ярус — компонент рендерится там, где живут данные, и запрос становится вызовом функции. Цена — исчезло архитектурное принуждение: API-слой заставлял батчить на границе эндпоинта, криво, но неизбежно. Теперь не заставляет никто, и дисциплина должна прийти из слоя данных, который вы строите, вместо яруса, который вы удалили.
Страница списка рендерит 50 ProductCard; каждая ждёт priceFor(product.id), обёрнутый в React cache(). Что реально прилетает в базу за один запрос страницы?
- 01Что именно мемоизирует React cache() и чего он не делает никогда?
- 02Страница рендерит 50 карточек, каждая запрашивает свою цену. Назовите два структурных лекарства и компромисс между ними.
Серверные компоненты превратили запрос в вызов функции — это удалило средний API-ярус, а с ним и единственную структурную силу, когда-либо заставлявшую команды батчить. Заменяющая дисциплина — слой доступа к данным: каждый запрос живёт в lib/data за экспортированной функцией, проверка авторизации сидит в этой функции, так что пути к строкам в обход неё нет, а ‘server-only’ делает границу ошибкой сборки, а не надеждой на код-ревью. В вопросе ORM судите по физике serverless, а не по эстетике: Prisma даёт сильнейшую эргономику сгенерированных типов и исторически 10–15 МБ бинаря движка с реальной ценой холодного старта — в последнее время сильно лучше, а driver adapters убирают бинарь; Drizzle выводит типы из TypeScript-схемы без бинаря и почти без веса; сырой SQL — самый лёгкий и мощный, но историю типов строить вам. React cache() даёт запросам к базе ту дедупликацию прохода рендера, которую fetch получает бесплатно, — один SQL на десять вызовов внутри одного запроса, от generateMetadata до листьев — и ничего больше: это не кеш между запросами, и разные аргументы — разные записи. Дерево сериализуется там, где родители ждут раньше детей: поднимайте промисы в Promise.all или прелоадьте — зовите обёрнутую в cache() функцию без await наверху, и потомки присоединятся к бегущему промису. И следите за древовидным N+1: 50 самодостаточных карточек — это 50 запросов на каждый просмотр; лечится либо поднятым IN-запросом ценой связывания родителя с детьми, либо DataLoader-батчером на запрос ценой владения слоем батчинга. Дерево компонентов — план рендера, а не план запросов; урок дашборда на 4,6 секунды — не дать ему стать вторым случайно. Теперь, когда встретите медленную RSC-страницу, первым делом откройте трейс: лестница ожиданий — сигнал не оптимизировать запросы, а поднять их параллельно.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.