-- 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 ORDER BY stage_name ) AS prev FROM conversion_funnel WHERE stage_name IN ('enrolled', 'started') ) SELECT course_id, ROUND((1 - users_count * 1.0 / NULLIF(prev, 0)) * 100, 2) || '%' AS percent_of_drop FROM drop_off WHERE stage_name = 'started' ORDER BY (1 - users_count * 1.0 / NULLIF(prev, 0)) 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;