Дана упрощённая схема базы данных для онлайн-магазина: таблицы Users(user_id, name), Orders(order_id, user_id, total), OrderItems(item_id, order_id, product_id, qty), Products(product_id, price). Напишите пример сложного SQL запроса для выборки топ‑N покупателей по среднему чеку за последние 6 месяцев, предложите планы индексации, возможные узкие места производительности и варианты денормализации для ускорения выборок

12 Окт в 08:19
4 +4
0
Ответы
1

Ниже — практическое руководство: примерыыы SQL‑запроса для выбора топ‑N покупателей по среднему чеку за последние 6 месяцев, планы индексации, возможные узкие места и варианты денормализации для ускорения выборок.

Предположение

В схеме должна быть колонка с датой заказа например,Orders.orderdateнапример, Orders.order_dateнапример,Orders.orderd ate и, как правило, статус Orders.statusOrders.statusOrders.status чтобы фильтровать только «завершённые» заказы. Если этих колонок нет — их нужно добавить.

1) Простой запрос используетполеOrders.totalиспользует поле Orders.totalиспользуетполеOrders.total Простой и часто достаточно быстрый вариант, если Orders.total корректно хранит сумму чека и есть поле order_date:

WITH recent_orders AS (
SELECT user_id, total
FROM Orders
WHERE order_date >= current_date - interval '6 months'
AND status = 'completed'
)
SELECT u.user_id,
u.name,
COUNTro.totalro.totalro.total AS orders_count,
SUMro.totalro.totalro.total AS total_spent,
AVGro.totalro.totalro.total AS avg_check
FROM Users u
JOIN recent_orders ro ON ro.user_id = u.user_id
GROUP BY u.user_id, u.name
HAVING COUNTro.totalro.totalro.total >= 1 -- опционально: минимум X заказов
ORDER BY avg_check DESC
LIMIT :N; -- заменить :N на число топ‑пользователей

2) Корректный вариант, если Orders.total нет или ненадёжен — вычисляем чеки из OrderItems × Products
Этот вариант точнее, но тяжелее агрегация+joinагрегация + joinагрегация+join:

WITH recent_orders AS (
SELECT order_id, user_id
FROM Orders
WHERE order_date >= current_date - interval '6 months'
AND status = 'completed'
),
order_totals AS SELECToi.orderid,SUM(oi.qty∗p.price)AScalctotalFROMOrderItemsoiJOINProductspONp.productid=oi.productidJOINrecentordersroONro.orderid=oi.orderidGROUPBYoi.orderid SELECT oi.order_id,
SUM(oi.qty * p.price) AS calc_total
FROM OrderItems oi
JOIN Products p ON p.product_id = oi.product_id
JOIN recent_orders ro ON ro.order_id = oi.order_id
GROUP BY oi.order_id
SELECToi.orderi d,SUM(oi.qtyp.price)AScalct otalFROMOrderItemsoiJOINProductspONp.producti d=oi.producti dJOINrecento rdersroONro.orderi d=oi.orderi dGROUPBYoi.orderi d
SELECT u.user_id,
u.name,
COUNTot.orderidot.order_idot.orderi d AS orders_count,
SUMot.calctotalot.calc_totalot.calct otal AS total_spent,
AVGot.calctotalot.calc_totalot.calct otal AS avg_check
FROM Users u
JOIN recent_orders ro ON ro.user_id = u.user_id
JOIN order_totals ot ON ot.order_id = ro.order_id
GROUP BY u.user_id, u.name
ORDER BY avg_check DESC
LIMIT :N;

Вариант с ранжированием PostgresпримерRANK/ROWNUMBERPostgres пример RANK / ROW_NUMBERPostgresпримерRANK/ROWN UMBER:
-- добавить в конец запроса
ROW_NUMBER OVER ORDERBYAVG(ro.total)DESCORDER BY AVG(ro.total) DESCORDERBYAVG(ro.total)DESC AS rn
и затем выбрать rn <= :N в внешнем SELECT.

3) План индексации рекомендациирекомендациирекомендации Основная цель — минимизировать сканирование больших участков таблиц и избежать expensive joins/IO.

Orders:

Индекс по дате дляфильтрапо6месяцамдля фильтра по 6 месяцамдляфильтрапо6месяцам:B-tree: orderdateorder_dateorderd ate или status,orderdatestatus, order_datestatus,orderd ate если часто фильтруете по статусу.Лучше: составной покрывающий индекс userid,orderdate,totaluser_id, order_date, totaluseri d,orderd ate,total — позволит выполнить фильтр + группировку без обращения к строке.При очень больших объёмах млн+строкмлн+ строкмлн+строк, если физически данные упорядочены по order_date — BRIN индекс по order_date экономитместоэкономит местоэкономитместо.Рассмотрите партиционирование по диапазона дат monthly/quarterlymonthly/quarterlymonthly/quarterly — быстрый отбрасывающий эффект при фильтре «последние 6 месяцев».

OrderItems:

Индекс orderid,productid,qtyorder_id, product_id, qtyorderi d,producti d,qty — покрывающий для агрегации по заказу.Если храните price_at_order в OrderItems см.денормализациюнижесм. денормализацию нижесм.денормализациюниже, индекс orderid,productid,price,qtyorder_id, product_id, price, qtyorderi d,producti d,price,qty позволит избежать join'а к Products.Индексы на order_id позволяют быстро собирать все позиции заказа.

Products:

PK productidproduct_idproducti d достаточен, если используется в редких lookups. Если join'ы частые и тяжёлые — запишите price в OrderItems денормализацияденормализацияденормализация.

Users:

PK useriduser_iduseri d — обычно достаточно. Если часто выполняется поиск по name — добавьте индекс, но для агрегации по user_id лишний индекс не нужен.

Дополнительно:

Если часто делаете GROUP BY user_id на отфильтрованном наборе Orders — индекс userid,orderdate,totaluser_id, order_date, totaluseri d,orderd ate,total существенен.Наличие covering индекса всеколонки,которыеиспользуютсявWHERE/SELECT/GROUPBYвсе колонки, которые используются в WHERE/SELECT/GROUP BYвсеколонки,которыеиспользуютсявWHERE/SELECT/GROUPBY позволит index-only scan.

4) Основные узкие места производительности

Сканирование большого числа строк Orders за 6 месяцев — если таблица очень большая и фильтр по дате не использует индекс/партицию.Join OrderItems ↔ Products: умножение объёма и стоимость join'а, особенно если каждый заказ содержит много позиций.Агрегация GROUPBYuseridGROUP BY user_idGROUPBYuseri d по большому множеству пользователей — heavy memory/sort.Сортировка для TOP‑N: после агрегации нужно отсортировать всех пользователей по avg_check можетбытьдорогоможет быть дорогоможетбытьдорого.Частые обновления/вставки: индексы замедляют вставки; триггеры для поддержания агрегатов в реальном времени — нагрузка на запись.Несовпадение статистик/устаревшие статистики — может приводить к плохим планам.Нет covering индексов — приводит к access to heap randomIOrandom IOrandomIO.

5) Варианты денормализации и кэширования для ускорения выборок
Цель — уменьшить объём работы при запросе меньшеjoin′ов,меньшестрокдлясканированияменьше join'ов, меньше строк для сканированияменьшеjoinов,меньшестрокдлясканирования.

Вариант A — хранить Orders.total

Всегда вычислять и записывать сумму заказа при создании/финализации заказа Orders.totalOrders.totalOrders.total.Тогда запрос использует только Orders безjoin′овкOrderItems/Productsбез join'ов к OrderItems/ProductsбезjoinовкOrderItems/Products.
Плюсы: очень быстро для агрегаций. Минусы: риск несоответствий, если цена товара меняется и historical price не зафиксирован.

Вариант B — хранить price_at_order в OrderItems

Добавить колонку unit_price или price_at_order в OrderItems и записывать при создании заказа.Тогда сумма заказа вычисляется локально sum(qty∗priceatorder)sum(qty * price_at_order)sum(qtypricea to rder) — не нужен join к Products.

Вариант C — summary / агрегаты по пользователю recommendedrecommendedrecommended

Создать отдельную таблицу агрегатов, например user_monthly_agguserid,monthstart,orderscount,totalspentuser_id, month_start, orders_count, total_spentuseri d,months tart,ordersc ount,totals pent, заполняемую батчем ETLETLETL или инкрементально при изменениях.Для запроса «последние 6 месяцев» достаточно сделать SUM по 6 строк на пользователя, затем AVG = total_spent / orders_count.Пример таблицы:
CREATE TABLE user_monthly_agg useridBIGINT,monthstartDATE,−−firstdayofmonthorderscountINT,totalspentNUMERIC,PRIMARYKEY(userid,monthstart) user_id BIGINT,
month_start DATE, -- first day of month
orders_count INT,
total_spent NUMERIC,
PRIMARY KEY (user_id, month_start)
useri dBIGINT,months tartDATE,firstdayofmonthordersc ountINT,totals pentNUMERIC,PRIMARYKEY(useri d,months tart)
;Заполнение: nightly job/CDC/triggers. Такой подход уменьшает объём сканируемых данных в тысячи раз.

Вариант D — materialized view

CREATE MATERIALIZED VIEW user_last_6m AS ... агрегат ...Регулярно REFRESH MATERIALIZED VIEW CONCURRENTLY еслиСУБДподдерживаетесли СУБД поддерживаетеслиСУБДподдерживает. Быстро читать, но возможно небольшая задержка актуальности.

Вариант E — хранить rolling value в Users

Users.last_6m_orders_count, Users.last_6m_total_spent, Users.last_6m_avg — обновлять в фоновой задаче или через триггеры.Очень быстрый SELECT, но сложнее поддерживать корректность при ретро-операциях/rollback.

Вариант F — использовать OLAP/columnar хранилище

Перенести события заказов/покупок в хранилище ClickHouse,Redshift,BigQueryClickHouse, Redshift, BigQueryClickHouse,Redshift,BigQuery и вычислять топ‑N там — оптимизировано для таких аналитических запросов.

6) Практические рекомендации по оптимизации запроса

Добавьте и поддерживайте покрывающие индексы: userid,orderdate,totaluser_id, order_date, totaluseri d,orderd ate,total.Партиционирование Orders по дате, чтобы WHERE order_date >= X сканировал только последние партиции.Если используете вариант с OrderItems+Products, денормализуйте price_at_order в OrderItems.Для top‑N:
агрегируйте сначала per‑user, затем сортируйте и LIMIT — DB обычно умеет оптимизировать top‑N, но всё равно выгоднее уменьшить набор входных строк.Для реального времени — инкрементальные агрегаты/триггеры/CDC. Для аналитики — nightly ETL → агрегатная таблица.Обновляйте статистику СУБД регулярно ANALYZE/UPDATESTATISTICSANALYZE/UPDATE STATISTICSANALYZE/UPDATESTATISTICS.Мониторьте план выполнения EXPLAINANALYZEEXPLAIN ANALYZEEXPLAINANALYZE — смотрите Full Table Scans, Hash Join spill to disk, sorts.

7) Пример схемы для быстрого получения топ‑N денормализованныйподходденормализованный подходденормализованныйподход

Orders: хранит total, order_date, statusOrderItems: хранит qty, unit_price priceatorderprice_at_orderpricea to rderuser_monthly_agg каквышекак вышекаквыше

Запрос к user_monthly_agg для последних 6 месяцев:

SELECT u.user_id, u.name,
SUMuma.orderscountuma.orders_countuma.ordersc ount AS orders_count,
SUMuma.totalspentuma.total_spentuma.totals pent AS total_spent,
CASE WHEN SUMuma.orderscountuma.orders_countuma.ordersc ount=0 THEN 0
ELSE SUMuma.totalspentuma.total_spentuma.totals pent::numeric / SUMuma.orderscountuma.orders_countuma.ordersc ount END AS avg_check
FROM Users u
JOIN user_monthly_agg uma ON uma.user_id = u.user_id
WHERE uma.month_start >= date_trunc′month′,currentdate'month', current_datemonth,currentd ate - interval '5 months'
GROUP BY u.user_id, u.name
ORDER BY avg_check DESC
LIMIT :N;

Заключение

Если важна точность исторических цен — храните price_at_order в OrderItems и Orders.total при оформлении заказа.Для оперативных аналитических запросов топ‑Nпоrolling‑6mтоп‑N по rolling‑6mтопNпоrolling‑6m лучший баланс — предагрегированная таблица usermonthlyagguser_monthly_agguserm onthlya gg с регулярным обновлением.Индекс userid,orderdate,totaluser_id, order_date, totaluseri d,orderd ate,total и партиционирование Orders по дате — ключевые моменты для ускорения фильтра по последним 6 месяцам.Всегда проверяйте EXPLAIN ANALYZE и подбирайте оптимизацию индексы/партиции/денормализациюиндексы/партиции/денормализациюиндексы/партиции/денормализацию исходя из реальной нагрузки и шаблонов чтения/записи.

Если нужно — могу:

Подготовить конкретные CREATE INDEX / CREATE TABLE / CREATE MATERIALIZED VIEW команды для вашей СУБД Postgres,MySQL,etcPostgres, MySQL, etcPostgres,MySQL,etc.Протестировать варианты запросов на реальном плане выполнения покажитеEXPLAINпокажите EXPLAINпокажитеEXPLAIN.
12 Окт в 09:26
Не можешь разобраться в этой теме?
Обратись за помощью к экспертам
Гарантированные бесплатные доработки в течение 1 года
Быстрое выполнение от 2 часов
Проверка работы на плагиат
Поможем написать учебную работу
Прямой эфир