Files
homework/dmedvedev/11.7 Pet Project Additional tasks.sql
T
Dmitry Medvedev f443cd4855 fix mistakes
2026-07-22 21:15:19 +05:00

437 lines
20 KiB
SQL
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 1. Задание 1: Анализ drop-off точек
-- Используя conversion_funnel, найди курсы с самым большим процентом отвала между этапами enrolled → started (WHERE stage_name IN (...))
WITH
drop_off
AS
(
SELECT
course_id,
stage_name,
users_count,
LAG(users_count) OVER (PARTITION BY course_id) AS prev
FROM conversion_funnel
)
SELECT
course_id,
ROUND(1 - users_count * 1.0 / NULLIF(prev, 0), 2) * 100 || '%' AS percent_of_drop
FROM drop_off
WHERE stage_name = 'started'
ORDER BY percent_of_drop DESC
-- ОТВЕТ: Самые большие проценты отвала между enrolled и started замечены на курсе 1 и 3 (62 и 67% соответственно).
-- 2. Задание 2: Топ-3 урока с повторными просмотрами
-- Используя lesson_popularity_ranking, найди уроки, где avg_views_per_viewer > 1 (пользователи пересматривают)
SELECT *
FROM lesson_popularity_ranking
WHERE avg_views_per_viewer > 1;
--Ответ: Это урок 1 'select и from' и урок 4 'агрегации и group by', больше уроков нет с где avg_views_per_viewer > 1.
-- 3. Задание 3: Лучшая когорта по retention
-- Из user_cohort_analysis выбери месяц с максимальным retention_rate и проанализируй, почему (можно объединить с другими витринами)
SELECT *
FROM user_cohort_analysis;
-- ОТВЕТ: По данному запросу это 2 когорты - январь и февраль 2023 года (по 75%).
-- К сожалению, сейчас не могу понять с какими витринами можно ее объединить...
-- 4. Задание 4: Анализ плана выполнения
-- Запусти `EXPLAIN ANALYZE` на всех витринах. Найди узкие места: Seq Scan, высокие cost, JOIN без индексов, DISTINCT, подзапросы в SELECT.
-- Перепиши запросы чтобы достигнуть минимального времени выполнения.
-- Напиши что сделал, почему, и насколько быстрее стал запрос
*** ACTIVITY USERS ***
Стоимость я писал прогнозную, в конце указал реальные до/после. В Activity users у меня проблема с чтением таблиц, как я вижу оптимизатор показывает, что в какой-то момент он читает 3625 строк, хочу если подумать, таблица та не большие по факту, а когда будет рабочий проект, то там строк будет намного больше и из-за таких же ошибок как у меня сейчас, строк может стать в сотни раз больше. Причем итоговая стоимость составляет 380.
Сам я не смог придумать что можно было бы оптимизировать, обратился к ИИ, он предлагает оптимизировать таблицу, которую оптимизатор читает аж 3 раза (я с этим согласен полностью, вижу что в 3х СТЕ обращаюсь к одной и той же таблице). Также еще говорит, что у меня бесполезный GROUP BY в конце. Теперь я попробую это оптимизировать самостоятельно без использования ИИ.
После сокращения 3х СТЕ до одной получилась стоимость 364, лучше, но все равно как-будто много, однако, максимальное количество строк уже не 3625, а 210.
Также я сам увидел бесполезную сортировку внутри СТЕ, убрал ее, стоимость стала 356.
Потом я понял, что зря вообще использовал оконные функции DENSE_RANK, FIRST_VALUE, LAST_VALUE, можно обойтись агрегатными функциями, я сделал замену и получил стоимость 342.
Я убрал подзапрос из конца и сделал его проще в СТЕ, теперь нет подзапроса.
По итогу стоимость теперь 325. А фактическая 0.183, а изначально было 2.174 (снижение более чем в 10 раз).
ОПТИМИЗИРОВАННЫЙ КОД
EXPLAIN ANALYZE
--Активность пользователей
CREATE MATERIALIZED VIEW user_activity_summary AS
--CTE кол-во курсов на которые записан пользователь
WITH enrollment_count AS (
SELECT
pe.user_id,
COUNT(*) AS enrollment_count
FROM pet_enrollments pe
GROUP BY pe.user_id
),
--CTE просмотренные уникальные уроки пользователем (кол-во), last view and first view
viewed_lessons_count_max_min AS (
SELECT
plv.user_id,
COUNT(DISTINCT plv.lesson_id) AS viewed_lessons_count,
MIN(plv.viewed_at) AS first_view,
MAX(plv.viewed_at) AS last_view
FROM pet_lesson_views plv
GROUP BY plv.user_id
),
--CTE кол-во доступных уроков пользователю
lesson_user_all AS (
SELECT
pe.user_id,
COUNT(pl.id) AS lesson_user_all_open
FROM pet_enrollments pe
LEFT JOIN pet_lessons pl ON pl.course_id = pe.course_id
GROUP BY pe.user_id
)
--main запрос
SELECT
pu.id AS user_id,
pu.name AS name,
ec.enrollment_count AS enrollment_count,
vlcmm.viewed_lessons_count,
vlcmm.first_view,
vlcmm.last_view,
ROUND(vlcmm.viewed_lessons_count * 100.0 / lua.lesson_user_all_open, 2) || '%' AS engagement_rate
FROM pet_users pu
LEFT JOIN enrollment_count ec ON pu.id = ec.user_id
LEFT JOIN viewed_lessons_count_max_min vlcmm ON pu.id = vlcmm.user_id
LEFT JOIN lesson_user_all lua ON lua.user_id = pu.id
ORDER BY pu.id;
*** EFFECTIVE COURSES ***
actual time моего кода составляет 0.992, количество строк при сортировке максимум 1022.
Для начала убрал ненужные сортировки в СТЕ.
Для поиска первых и последних времен просмотра уроков в курсе убрал из использования оконные функции, заменил на агрегаты MIN, MAX и объединил эти 2 СТЕ в одну плюс к ним также внес еще одну СТЕ внутрб, которая ссылалась на pet_lesson_views.
Почему-то у меня в главном селекте была группировка, убрал ее.
По итогу фактическое время стало 0.120, а максимальное количество строк 510.
--Эффективность курсов
--ОПТИМИЗИРОВАННЫЙ КОД
CREATE MATERIALIZED VIEW course_summary AS
--кол-во уникальных пользователей в рамках одного курса (ready)
WITH unique_users AS (
SELECT
pe.course_id,
COUNT(DISTINCT pe.user_id) AS unique_users
FROM pet_enrollments pe
GROUP BY pe.course_id
),
--общее кол-во уроков по курсу
lesson_count AS (
SELECT
pl.course_id,
COUNT(*) AS lesson_count
FROM pet_lessons pl
GROUP BY pl.course_id
),
--дата и время первого и последнего просмотра любого урока курса, суммарное число просмотров уроков по курсу
first_last_viewed_at AS (
SELECT
pl.course_id,
MIN(plv.viewed_at) AS first_viewed_at,
MAX(plv.viewed_at) AS last_viewed_at,
COUNT(plv.id) AS total_views
FROM pet_lessons pl
LEFT JOIN pet_lesson_views plv ON pl.id = plv.lesson_id
GROUP BY pl.course_id
)
SELECT
pc.id AS course_id,
pc.title AS course_title,
COALESCE(uu.unique_users, 0),
COALESCE(lc.lesson_count, 0),
COALESCE(flva.total_views, 0),
ROUND(COALESCE(flva.total_views, 0) * 1.0 / NULLIF(uu.unique_users, 0), 2) AS avg_views_per_user,
flva.first_viewed_at,
flva.last_viewed_at
FROM pet_courses pc
LEFT JOIN unique_users uu ON pc.id = uu.course_id
LEFT JOIN lesson_count lc ON pc.id = lc.course_id
LEFT JOIN first_last_viewed_at flva ON pc.id = flva.course_id
ORDER BY course_id;
*** PLATFORM SUMMARY ***
У меня и так маленький базовый запрос, поэтому я попытался его оптимизировать как знаю, но думаю, что это не ведет к чему-то более эффективному.
actual time примерно одинаковое .
execution time тоже скачет везде , но как-будто одинаковое.
По итогу я убрал СТЕ и все поместил внутрь одного селекта. У меня как и было 8 Seq Scan, так и осталось.
EXPLAIN ANALYZE
SELECT
(SELECT COUNT(*) total_users FROM pet_users pu) AS total_users,
(SELECT COUNT(*) total_courses FROM pet_courses pc) AS total_courses,
(SELECT COUNT(*) total_lessons FROM pet_lessons pl) AS total_lessons,
(SELECT COUNT(DISTINCT user_id) FROM pet_lesson_views plv) AS users_with_views,
ROUND(((SELECT COUNT(*) total_lessons FROM pet_lessons pl) * 1.0 / (SELECT COUNT(*) total_courses FROM pet_courses pc)), 2) AS avg_lessons_per_course,
ROUND(((SELECT COUNT(*) FROM pet_lesson_views plv) * 1.0 / (SELECT COUNT(*) total_lessons FROM pet_lessons pl)), 2) AS avg_views_per_lesson;
НО по итогу я зашел в ИИ и он сказал, что для решения такой задачи можно использовать CROSS JOIN для которого кстати как я теперь понимаю не нужно соединение по индексу через ON.
КОНЕЧНЫЙ ВАИАНТ СЛЕДУЮЩИЙ, В НЕМ УЖЕ 5 РАЗ POSTGRE ЧИТАЮТ ТАБЛИЦЫ, А НЕ 8. Execution time упало на 30%.
EXPLAIN ANALYZE
SELECT
tu.total_users,
tc.total_courses,
tl.total_lessons,
fplv.users_with_views,
ROUND((tl.total_lessons * 1.0 / tc.total_courses), 2) AS avg_lessons_per_course,
ROUND((fplv.total_views * 1.0 / tl.total_lessons), 2) AS avg_views_per_lesson
FROM
(SELECT COUNT(*) AS total_users FROM pet_users pu) tu
CROSS JOIN
(SELECT COUNT(*) AS total_courses FROM pet_courses pc) tc
CROSS JOIN
(SELECT COUNT(*) AS total_lessons FROM pet_lessons pl) tl
CROSS JOIN
(SELECT
COUNT(DISTINCT user_id) AS users_with_views,
COUNT(*) AS total_views
FROM pet_lesson_views plv
) fplv;
*** COHORT ANALYSIS ***
В данном случае исходный код показывал Execution time 0.45, а после преобразований 0.33. Вижу проблему множественного обращения к одним и тем же таблицам.
Теперь код реализован через СТЕ, сначала считаются метрика по зарегист пользователям, потом по записавшимся, потом по перввым просмотрам (эти же пользователи берутся как активные в дальнейших расчетах).
Далее делаем таблицу под каждого пользователя в другой СТЕ.
И в конечном запросе собираем все в когорты.
ИТОГОВЫЙ КОД:
--CREATE MATERIALIZED VIEW user_cohort_analysis AS
EXPLAIN ANALYZE
WITH
user_registraion AS (
SELECT
pu.id AS user_id,
TO_CHAR(DATE_TRUNC('month', pu.registration_date),'YYYY-MM') AS MONTH
FROM pet_users pu
),
user_enrollments_summary AS (
SELECT
pe.user_id AS user_id,
COUNT(DISTINCT pe.course_id) AS unique_courses_count
FROM pet_enrollments pe
GROUP BY user_id
),
first_viewed AS (
SELECT
plv.user_id AS user_id,
MIN(viewed_at) AS first_viewed_date_for_each_user
FROM pet_lesson_views plv
GROUP BY user_id
),
user_metrics AS (
SELECT
pu.id AS user_id,
DATE_TRUNC('month', pu.registration_date) AS MONTH,
CASE WHEN ur.user_id IS NOT NULL THEN 1 ELSE 0 END AS registration_user,
CASE WHEN ues.user_id IS NOT NULL THEN 1 ELSE 0 END AS enrolled_user,
CASE WHEN fv.user_id IS NOT NULL THEN 1 ELSE 0 END AS active_user,
COALESCE(ues.unique_courses_count, 0) AS courses_count,
fv.first_viewed_date_for_each_user::date - registration_date AS differens_between_reg_and_fv
FROM pet_users pu
LEFT JOIN user_registraion ur ON pu.id = ur.user_id
LEFT JOIN user_enrollments_summary ues ON pu.id = ues.user_id
LEFT JOIN first_viewed fv ON pu.id = fv.user_id
)
SELECT
TO_CHAR(user_metrics.MONTH, 'YYYY-MM') AS cohort_month,
COUNT(DISTINCT user_metrics.user_id) AS users_in_cohort,
SUM(user_metrics.enrolled_user) AS enrolled_users,
SUM(user_metrics.active_user) AS active_users,
ROUND(SUM(user_metrics.registration_user * 100.0) / SUM(user_metrics.enrolled_user), 2) || '%' AS retention_rate,
ROUND(SUM(user_metrics.courses_count) / SUM(user_metrics.enrolled_user), 2) AS avg_courses_per_user,
AVG(user_metrics.differens_between_reg_and_fv) AS avg_time_to_first_view
FROM user_metrics
GROUP BY cohort_month
ORDER BY cohort_month;
*** LESSON POPULARITY RANKING***
Вижу раздутость кода. Колчиество строк достигает 1850. В одном запросе множество подзапросов, для начала лучше все закинуть в разные СТЕ.
По исходному коду Execution time от 0.4 до 1.8, по оптимизированному 0.27-0.4.
Я обращался к одной таблице 3 раза, а также переписал запрос и в конце не нужна группировка.
Но проблему 1850 строк не смог решить. Вообще возможно ли ее решить?
WITH
lesson_views_summary AS (
SELECT
lesson_id,
COUNT(*) AS total_views,
COUNT(DISTINCT user_id) AS unique_v,
COUNT(*) * 1.0 / COUNT(DISTINCT user_id) AS views_one_user_per_one_lesson
FROM pet_lesson_views
GROUP BY lesson_id
),
table_enrolled_users AS (
SELECT
course_id,
COUNT(DISTINCT user_id) AS enrolled_to_course
FROM pet_enrollments pe
GROUP BY course_id
)
SELECT
pl.id AS lesson_id,
pl.title AS lesson_title,
pc.id AS course_id,
pc.title AS course_title,
COALESCE(lvs.total_views, 0) AS total_views,
COALESCE(lvs.unique_v, 0) AS unique_viewers,
COALESCE(ROUND(lvs.views_one_user_per_one_lesson, 2), 0) AS avg_views_per_viewer, --также это можно было посчитать total_views/unique_viewers
DENSE_RANK() OVER (
PARTITION BY pc.id
ORDER BY lvs.total_views DESC NULLS LAST) AS popularity_rank,
ROUND(COALESCE(lvs.unique_v, 0) * 100.0 / NULLIF(teu.enrolled_to_course, 0), 2) || '%' AS completion_rate
FROM pet_lessons pl
LEFT JOIN lesson_views_summary lvs ON lvs.lesson_id = pl.id
LEFT JOIN pet_courses pc ON pc.id = pl.course_id
LEFT JOIN table_enrolled_users teu ON teu.course_id = pc.id
ORDER BY popularity_rank;
*** CONVENSION FUNNEL ***
В моем запросе выполняется порядка 7 SubPlan, execution time 60 ms.
Я в своем новом запросе создал СТЕ, которая один раз считает количество пользователей на платформе.
Далее 1 раз считаем кол-во уроков в каждом курсе, собираем все метрики на каждого юсера на курсе. И строим воронку.
У оптимизированного запроса execution time составляет 0.8 ms.
--CREATE MATERIALIZED VIEW conversion_funnel AS
--Считаем общее число пользователей на платформе
WITH total_platform_users AS (
SELECT COUNT(*) AS total_users FROM pet_users
),
--Считаем количество уроков в каждом курсе
lessons_per_course AS (
SELECT
course_id,
COUNT(*) AS total_lessons
FROM pet_lessons
GROUP BY course_id
),
-- Собираем ВСЕ метрики каждого студента на курсе за один проход
user_course_progress AS (
SELECT
pe.course_id,
pe.user_id,
COUNT(DISTINCT plv.lesson_id) * 1.0 / NULLIF(lpc.total_lessons, 0) AS progress
FROM pet_enrollments pe
LEFT JOIN pet_lessons pl ON pl.course_id = pe.course_id
-- Строго проверяем просмотры только для этого записанного пользователя
LEFT JOIN pet_lesson_views plv ON plv.lesson_id = pl.id AND plv.user_id = pe.user_id
LEFT JOIN lessons_per_course lpc ON lpc.course_id = pe.course_id
GROUP BY pe.course_id, pe.user_id, lpc.total_lessons
),
-- Получаем метрики студентов по курсу
course_funnel_aggregates AS (
SELECT
course_id,
COUNT(DISTINCT user_id) AS enrolled_count,
-- Начал смотреть = посмотрел хотя бы 1 урок (прогресс > 0)
COUNT(DISTINCT CASE WHEN progress > 0 THEN user_id END) AS started_count,
-- Прошел половину = прогресс >= 50%
COUNT(DISTINCT CASE WHEN progress >= 0.5 THEN user_id END) AS mid_count,
-- Завершил = прогресс >= 100%
COUNT(DISTINCT CASE WHEN progress >= 1.0 THEN user_id END) AS completed_count
FROM user_course_progress
GROUP BY course_id
),
-- Строим воронку
main_table AS (
-- Зарегистрированы
SELECT
pc.id AS course_id, pc.title AS course_title, 'registered' AS stage_name, 0 AS stage_order,
tpu.total_users AS users_count,
'100.00%' AS conversion_from_previous,
'100.00%' AS conversion_from_start
FROM pet_courses pc
CROSS JOIN total_platform_users tpu
UNION ALL
--Записались на курс
SELECT
pc.id AS course_id, pc.title AS course_title, 'enrolled' AS stage_name, 1 AS stage_order,
COALESCE(cfa.enrolled_count, 0) AS users_count,
ROUND(COALESCE(cfa.enrolled_count, 0) * 100.0 / NULLIF(tpu.total_users, 0), 2) || '%' AS conversion_from_previous,
ROUND(COALESCE(cfa.enrolled_count, 0) * 100.0 / NULLIF(tpu.total_users, 0), 2) || '%' AS conversion_from_start
FROM pet_courses pc
CROSS JOIN total_platform_users tpu
LEFT JOIN course_funnel_aggregates cfa ON cfa.course_id = pc.id
UNION ALL
--Начали смотреть
SELECT
pc.id AS course_id, pc.title AS course_title, 'started' AS stage_name, 2 AS stage_order,
COALESCE(cfa.started_count, 0) AS users_count,
ROUND(COALESCE(cfa.started_count, 0) * 100.0 / NULLIF(cfa.enrolled_count, 0), 2) || '%' AS conversion_from_previous,
ROUND(COALESCE(cfa.started_count, 0) * 100.0 / NULLIF(tpu.total_users, 0), 2) || '%' AS conversion_from_start
FROM pet_courses pc
CROSS JOIN total_platform_users tpu
LEFT JOIN course_funnel_aggregates cfa ON cfa.course_id = pc.id
UNION ALL
--Прошли половину курса
SELECT
pc.id AS course_id, pc.title AS course_title, 'mid_progress' AS stage_name, 3 AS stage_order,
COALESCE(cfa.mid_count, 0) AS users_count,
ROUND(COALESCE(cfa.mid_count, 0) * 100.0 / NULLIF(cfa.started_count, 0), 2) || '%' AS conversion_from_previous,
ROUND(COALESCE(cfa.mid_count, 0) * 100.0 / NULLIF(tpu.total_users, 0), 2) || '%' AS conversion_from_start
FROM pet_courses pc
CROSS JOIN total_platform_users tpu
LEFT JOIN course_funnel_aggregates cfa ON cfa.course_id = pc.id
UNION ALL
--Полностью завершили курс
SELECT
pc.id AS course_id, pc.title AS course_title, 'completed' AS stage_name, 4 AS stage_order,
COALESCE(cfa.completed_count, 0) AS users_count,
ROUND(COALESCE(cfa.completed_count, 0) * 100.0 / NULLIF(cfa.mid_count, 0), 2) || '%' AS conversion_from_previous,
ROUND(COALESCE(cfa.completed_count, 0) * 100.0 / NULLIF(tpu.total_users, 0), 2) || '%' AS conversion_from_start
FROM pet_courses pc
CROSS JOIN total_platform_users tpu
LEFT JOIN course_funnel_aggregates cfa ON cfa.course_id = pc.id
)
SELECT
course_id,
course_title,
stage_name,
users_count,
conversion_from_previous,
conversion_from_start
FROM main_table
ORDER BY course_id, stage_order;