{ "cells": [ { "cell_type": "markdown", "id": "section_0", "metadata": {}, "source": [ "# Модуль 6. Одна таблица, два движка: Spark и Trino\n", "\n", "В этом модуле мы увидим главное практическое следствие архитектуры Lakehouse: таблица, записанная одним движком, может быть прочитана другим без копирования данных.\n", "\n", "**Цели модуля:**\n", "- записать таблицу через Spark и тут же прочитать её через Trino;\n", "- выполнить SQL-запросы к Iceberg-таблицам из Trino;\n", "- понять роль общего каталога и общего хранилища;\n", "- осознать отличие Lakehouse-модели (decoupled compute) от классического DWH.\n", "\n", "**Prerequisite:** пройден Модуль 5 (silver-таблица существует).\n", "\n", "### Связка с привычным DWH (Greenplum)\n", "\n", "| | Классический DWH (Greenplum) | Lakehouse |\n", "|---|---|---|\n", "| Где лежат данные | Внутри СУБД, на управляемых дисках | В объектном хранилище (MinIO), отдельно от движков |\n", "| Где лежат метаданные | `pg_catalog` внутри той же СУБД | Внешний JDBC-каталог (PostgreSQL) + metadata в MinIO |\n", "| Кто может читать таблицу | Только сама СУБД | Любой движок с доступом к каталогу и хранилищу |\n", "| Чтобы дать доступ другому инструменту | Подключиться к СУБД или скопировать данные | Подключить тот же каталог — данные уже доступны |\n", "\n", "**Ключевая идея:** в Lakehouse данные не заперты в одном движке. Это называется *decoupled compute*. \n", "\n", "**Как устроен этот ноутбук:** Spark-код и Trino-запросы выполняются здесь. Для Trino используется Python-клиент `trino`. Если у вас установлен DBeaver (и подключен по инструкции из START_HERE.md) — каждый Trino-запрос можно выполнить и там (в markdown будут подсказки)." ] }, { "cell_type": "markdown", "id": "section_1_title", "metadata": {}, "source": [ "## 1. Spark-сессия, Trino-клиент и проверка таблиц\n", "\n", "Инициализируем Spark и настраиваем helper для отправки запросов в Trino." ] }, { "cell_type": "code", "execution_count": null, "id": "section_1_code", "metadata": {}, "outputs": [], "source": [ "import pandas as pd\n", "from pyspark.sql import SparkSession\n", "from trino.dbapi import connect\n", "import trino\n", "\n", "CATALOG_NAME = \"lakehouse\"\n", "BRONZE_TABLE = f\"{CATALOG_NAME}.bronze.nyc_taxi_yellow\"\n", "SILVER_TABLE = f\"{CATALOG_NAME}.silver.nyc_taxi_yellow\"\n", "\n", "spark = SparkSession.builder \\\n", " .appName(\"module-06-spark-and-trino\") \\\n", " .getOrCreate()\n", "\n", "spark.sparkContext.setLogLevel(\"ERROR\")\n", "\n", "assert spark.catalog.tableExists(BRONZE_TABLE), f\"Таблица {BRONZE_TABLE} не найдена (пройди Модуль 4).\"\n", "assert spark.catalog.tableExists(SILVER_TABLE), f\"Таблица {SILVER_TABLE} не найдена (пройди Модуль 5).\"\n", "\n", "def trino_query(sql: str) -> pd.DataFrame:\n", " \"\"\"Выполняет SQL-запрос в Trino и возвращает результат как Pandas DataFrame.\"\"\"\n", " conn = connect(\n", " host=\"trino\",\n", " port=8080,\n", " user=\"jupyter\",\n", " catalog=\"lakehouse\"\n", " )\n", " cur = conn.cursor()\n", " try:\n", " cur.execute(sql)\n", " rows = cur.fetchall()\n", " columns = [desc[0] for desc in cur.description]\n", " return pd.DataFrame(rows, columns=columns)\n", " except trino.exceptions.ProgrammingError as e:\n", " if \"No nodes available to run query\" in str(e):\n", " print(\"Ошибка: Trino ещё не готов. Подождите пару минут после старта контейнеров.\")\n", " raise\n", " # Запросы типа CREATE/DROP не возвращают строк\n", " return pd.DataFrame([{\"status\": \"Success\"}])\n", " finally:\n", " conn.close()\n", "\n", "print(\"Spark готов.\")\n", "print(f\"Версия Trino клиента: {trino.__version__}\")\n", "print(\"Trino-клиент готов. Проверка...\")\n", "display(trino_query(\"SELECT 1 AS test_col\"))" ] }, { "cell_type": "markdown", "id": "section_1_note", "metadata": {}, "source": [ "*Если ячейка выше упала с ImportError: trino — нужно пересобрать образ Jupyter (в терминале: `docker compose build jupyter && docker compose up -d`).*\n", "\n", "Обе таблицы на месте. Spark может их читать. Trino-клиент готов. Вопрос: может ли Trino прочитать те же таблицы?" ] }, { "cell_type": "markdown", "id": "section_2_title", "metadata": {}, "source": [ "## 2. Читаем silver через Spark — фиксируем метрики\n", "\n", "Сначала получим числа из Spark. Потом сравним с Trino." ] }, { "cell_type": "code", "execution_count": null, "id": "section_2_code", "metadata": {}, "outputs": [], "source": [ "spark_metrics = spark.sql(f\"\"\"\n", " SELECT \n", " count(*) AS row_count, \n", " avg(fare_amount) AS avg_fare, \n", " avg(trip_distance) AS avg_distance, \n", " count(DISTINCT pickup_borough) AS boroughs \n", " FROM {SILVER_TABLE}\n", "\"\"\").toPandas()\n", "\n", "display(spark_metrics)\n", "\n", "spark.table(SILVER_TABLE).select(\"tpep_pickup_datetime\", \"fare_amount\", \"pickup_borough\").show(5)" ] }, { "cell_type": "markdown", "id": "section_2_note", "metadata": {}, "source": [ "Запомни эти числа. Сейчас выполним тот же запрос через Trino." ] }, { "cell_type": "markdown", "id": "section_3_title", "metadata": {}, "source": [ "## 3. Навигация по каталогу через Trino\n", "\n", "*DBeaver: тот же запрос можно выполнить в DBeaver, если он подключён к Trino по инструкции из START_HERE.md.*" ] }, { "cell_type": "code", "execution_count": null, "id": "section_3_schemas", "metadata": {}, "outputs": [], "source": [ "trino_query(\"SHOW SCHEMAS FROM lakehouse\")" ] }, { "cell_type": "code", "execution_count": null, "id": "section_3_tables", "metadata": {}, "outputs": [], "source": [ "trino_query(\"SHOW TABLES FROM lakehouse.silver\")" ] }, { "cell_type": "code", "execution_count": null, "id": "section_3_describe", "metadata": {}, "outputs": [], "source": [ "trino_query(\"DESCRIBE lakehouse.silver.nyc_taxi_yellow\")" ] }, { "cell_type": "markdown", "id": "section_3_note", "metadata": {}, "source": [ "Trino видит те же namespace (bronze, silver, default) и ту же таблицу с не менее чем 24 колонками. Различия в нотации типов (varchar vs STRING, double vs DOUBLE) нормальны — это разница синтаксиса движков, а не данных." ] }, { "cell_type": "markdown", "id": "section_4_title", "metadata": {}, "source": [ "## 4. «Aha-момент» — одни и те же данные\n", "\n", "Центральный момент модуля. Trino читает таблицу, записанную Spark в Модуле 5.\n", "\n", "*DBeaver: `SELECT * FROM lakehouse.silver.nyc_taxi_yellow LIMIT 10;`*" ] }, { "cell_type": "code", "execution_count": null, "id": "section_4_select", "metadata": {}, "outputs": [], "source": [ "trino_query(\"SELECT tpep_pickup_datetime, fare_amount, pickup_borough FROM lakehouse.silver.nyc_taxi_yellow LIMIT 5\")" ] }, { "cell_type": "code", "execution_count": null, "id": "section_4_metrics", "metadata": {}, "outputs": [], "source": [ "trino_metrics = trino_query(\"\"\"\n", " SELECT \n", " count(*) AS row_count, \n", " avg(fare_amount) AS avg_fare, \n", " avg(trip_distance) AS avg_distance, \n", " count(DISTINCT pickup_borough) AS boroughs \n", " FROM lakehouse.silver.nyc_taxi_yellow\n", "\"\"\")\n", "\n", "print(\"Метрики Trino:\")\n", "display(trino_metrics)\n", "\n", "print(\"Метрики Spark (для сравнения):\")\n", "display(spark_metrics)" ] }, { "cell_type": "markdown", "id": "section_4_note", "metadata": {}, "source": [ "Строки совпадают. Оба движка читают одни и те же data files из MinIO (с точностью до floating-point округления в функции `avg`)." ] }, { "cell_type": "markdown", "id": "section_5_title", "metadata": {}, "source": [ "## 5. Почему это работает — роль общего каталога\n", "\n", "**Общий каталог:** оба движка подключены к одному инстансу PostgreSQL (`postgres-iceberg:5432/iceberg`). Когда Spark создаёт таблицу, он записывает ссылку на неё в PostgreSQL. Trino читает тот же PostgreSQL и находит эту ссылку.\n", "\n", "**Общее хранилище:** data files (Parquet) и metadata-файлы лежат в MinIO (`s3://lakehouse/warehouse`). Оба движка имеют сетевой доступ к бакету и могут прочитать файлы.\n", "\n", "**Параллель с Greenplum:** В классическом DWH данные лежат внутри СУБД на её собственных дисках. Доступ возможен только через саму СУБД. В Lakehouse данные лежат снаружи, а движки взаимозаменяемы.\n", "\n", "**Decoupled compute:** Вычислительные ресурсы отделены от хранения. Если нам потребуется добавить третий движок (например, Apache Flink для streaming), нужно будет просто подключить его к тому же каталогу и хранилищу.\n", "\n", "```\n", " ┌─────────────────┐ ┌──────────────────┐\n", " │ Spark (Jupyter)│ │ Trino (DBeaver) │\n", " └────────┬────────┘ └────────┬─────────┘\n", " │ │\n", " ▼ ▼\n", " ┌──────────────────────────────────────────┐\n", " │ PostgreSQL (JDBC catalog) │\n", " │ metadata_location -> s3://... │\n", " └────────────────────┬─────────────────────┘\n", " ▼\n", " ┌──────────────────────────────────────────┐\n", " │ MinIO (S3-compatible storage) │\n", " │ metadata/ + data/ (Parquet files) │\n", " └──────────────────────────────────────────┘\n", "```" ] }, { "cell_type": "markdown", "id": "section_6_title", "metadata": {}, "source": [ "## 6. Spark записывает — Trino читает (live write → read)\n", "\n", "До этого мы читали таблицы, созданные в предыдущих модулях. Теперь — живой цикл: Spark создаёт таблицу прямо сейчас, и Trino её тут же видит. \n", "\n", "Создаём агрегатную таблицу `lakehouse.default.borough_summary` через Spark:" ] }, { "cell_type": "code", "execution_count": null, "id": "section_6_write", "metadata": {}, "outputs": [], "source": [ "spark.sql(\"\"\"\n", " CREATE OR REPLACE TABLE lakehouse.default.borough_summary\n", " USING iceberg AS\n", " SELECT pickup_borough,\n", " count(*) AS trips,\n", " avg(fare_amount) AS avg_fare,\n", " avg(trip_distance) AS avg_distance\n", " FROM lakehouse.silver.nyc_taxi_yellow\n", " WHERE pickup_borough IS NOT NULL\n", " GROUP BY pickup_borough\n", "\"\"\")\n", "\n", "spark.table(\"lakehouse.default.borough_summary\").show()" ] }, { "cell_type": "markdown", "id": "section_6_note_1", "metadata": {}, "source": [ "Spark записал таблицу. Мы не делали никакой синхронизации. Видит ли её Trino?\n", "\n", "*DBeaver: `SELECT * FROM lakehouse.default.borough_summary ORDER BY trips DESC;`*" ] }, { "cell_type": "code", "execution_count": null, "id": "section_6_read", "metadata": {}, "outputs": [], "source": [ "trino_query(\"SELECT * FROM lakehouse.default.borough_summary ORDER BY trips DESC\")" ] }, { "cell_type": "markdown", "id": "section_6_note_2", "metadata": {}, "source": [ "Trino видит таблицу мгновенно. Spark записал metadata в PostgreSQL и data files в MinIO. Trino прочитал тот же каталог — увидел таблицу. Никакого копирования, никакой синхронизации.\n", "\n", "Это и есть decoupled compute: один движок создал, другой прочитал. В Greenplum для этого пришлось бы либо подключиться к тому же движку, либо скопировать данные." ] }, { "cell_type": "markdown", "id": "section_7_title", "metadata": {}, "source": [ "## 7. Аналитические запросы в Trino\n", "\n", "Trino как аналитический SQL-движок. Выполним несколько аналитических запросов.\n", "\n", "*DBeaver: скопируй запрос ниже без обёртки trino_query().*" ] }, { "cell_type": "code", "execution_count": null, "id": "section_7_query1", "metadata": {}, "outputs": [], "source": [ "trino_query(\"\"\"\n", " SELECT \n", " pickup_borough, \n", " count(*) AS trips, \n", " avg(fare_amount) AS avg_fare, \n", " avg(trip_distance) AS avg_distance, \n", " avg(tip_amount) AS avg_tip \n", " FROM lakehouse.silver.nyc_taxi_yellow \n", " WHERE pickup_borough IS NOT NULL \n", " GROUP BY pickup_borough \n", " ORDER BY trips DESC\n", "\"\"\")" ] }, { "cell_type": "markdown", "id": "section_7_note_1", "metadata": {}, "source": [ "Топ зон посадки:" ] }, { "cell_type": "code", "execution_count": null, "id": "section_7_query2_trino", "metadata": {}, "outputs": [], "source": [ "trino_top_zones = trino_query(\"\"\"\n", " SELECT \n", " pickup_zone, \n", " count(*) AS trips, \n", " avg(total_amount) AS avg_total \n", " FROM lakehouse.silver.nyc_taxi_yellow \n", " WHERE pickup_zone IS NOT NULL \n", " GROUP BY pickup_zone \n", " ORDER BY trips DESC \n", " LIMIT 10\n", "\"\"\")\n", "display(trino_top_zones)" ] }, { "cell_type": "code", "execution_count": null, "id": "section_7_query2_spark", "metadata": {}, "outputs": [], "source": [ "spark_top_zones = spark.sql(\"\"\"\n", " SELECT \n", " pickup_zone, \n", " count(*) AS trips, \n", " avg(total_amount) AS avg_total \n", " FROM lakehouse.silver.nyc_taxi_yellow \n", " WHERE pickup_zone IS NOT NULL \n", " GROUP BY pickup_zone \n", " ORDER BY trips DESC \n", " LIMIT 10\n", "\"\"\").toPandas()\n", "\n", "display(spark_top_zones)" ] }, { "cell_type": "markdown", "id": "section_7_note_2", "metadata": {}, "source": [ "Результаты совпадают (с точностью до floating-point). Два движка, одна таблица." ] }, { "cell_type": "markdown", "id": "section_8_title", "metadata": {}, "source": [ "## 8. Навигация по bronze из Trino\n", "\n", "До этого bronze читали только из Spark. Проверим через Trino." ] }, { "cell_type": "code", "execution_count": null, "id": "section_8_code1", "metadata": {}, "outputs": [], "source": [ "trino_query(\"SHOW TABLES FROM lakehouse.bronze\")" ] }, { "cell_type": "code", "execution_count": null, "id": "section_8_code2", "metadata": {}, "outputs": [], "source": [ "trino_query(\"SELECT count(*) AS row_count FROM lakehouse.bronze.nyc_taxi_yellow\")" ] }, { "cell_type": "code", "execution_count": null, "id": "section_8_code3", "metadata": {}, "outputs": [], "source": [ "trino_query(\"SELECT * FROM lakehouse.bronze.nyc_taxi_yellow LIMIT 5\")" ] }, { "cell_type": "markdown", "id": "section_8_note", "metadata": {}, "source": [ "Trino видит все таблицы из всех namespace каталога. Каталог — общий, хранилище — общее. \n", "\n", "*DBeaver: те же запросы работают аналогично.*" ] }, { "cell_type": "markdown", "id": "section_9", "metadata": {}, "source": [ "## 9. DBeaver — SQL-клиент для Trino (рекомендация)\n", "\n", "В этом ноутбуке мы работали с Trino через Python-клиент — для воспроизводимости. В реальной аналитической работе Trino чаще используют через SQL-клиенты: DBeaver, DataGrip, DbVisualizer. DBeaver — де-факто стандарт для SQL, знакомый всем, кто работал с PostgreSQL/Greenplum.\n", "\n", "Если DBeaver установлен и подключён (инструкция в START_HERE.md), попробуйте выполнить в нём любой запрос из этого модуля. Результат будет тот же — тот же протокол, тот же движок, та же таблица. Разница: DBeaver — для интерактивной SQL-работы, Python-клиент — для автоматизации и ноутбуков." ] }, { "cell_type": "markdown", "id": "section_10_title", "metadata": {}, "source": [ "## 10. Самостоятельное задание\n", "\n", "Четыре задачи. Центральная — самостоятельный цикл write → read, как в Секции 6.\n", "\n", "**Задача 1 (Spark → write).** Создай через Spark новую агрегатную таблицу `lakehouse.default.zone_summary` с CTAS: для каждой `pickup_zone` посчитай `count(*)`, `avg(total_amount)`, `avg(tip_amount)` по silver-таблице (WHERE pickup_zone IS NOT NULL).\n", "\n", "**Задача 2 (Trino → read).** Прочитай `lakehouse.default.zone_summary` через Trino (Python-клиент или DBeaver). Убедись, что Trino видит таблицу без синхронизации.\n", "\n", "**Задача 3 (сравнение).** Выполни тот же агрегатный запрос напрямую по silver через Trino (без промежуточной таблицы). Сравни результат с `zone_summary`. Числа должны совпасть.\n", "\n", "**Задача 4 (ответ).** Ответь в markdown-ячейке ниже: почему Trino увидел `zone_summary` сразу после создания через Spark? Какие три компонента стенда это обеспечивают? Что произошло бы, если бы у Trino был другой каталог?" ] }, { "cell_type": "code", "execution_count": null, "id": "section_10_task1", "metadata": {}, "outputs": [], "source": [ "# Ваш код (Spark): CREATE TABLE zone_summary\n" ] }, { "cell_type": "code", "execution_count": null, "id": "section_10_task2", "metadata": {}, "outputs": [], "source": [ "# Ваш код (Trino): чтение zone_summary\n" ] }, { "cell_type": "code", "execution_count": null, "id": "section_10_task3", "metadata": {}, "outputs": [], "source": [ "# Ваш код (Trino): тот же агрегат напрямую по silver\n" ] }, { "cell_type": "markdown", "id": "section_10_task4", "metadata": {}, "source": [ "Ваш ответ на Задачу 4: ...\n" ] }, { "cell_type": "code", "execution_count": null, "id": "section_10_cleanup", "metadata": {}, "outputs": [], "source": [ "spark.sql(\"DROP TABLE IF EXISTS lakehouse.default.zone_summary\")" ] }, { "cell_type": "markdown", "id": "section_10_optional", "metadata": {}, "source": [ "Дополнительная задача (по желанию): аналитический запрос по bronze через Trino и через Spark, сравнение." ] }, { "cell_type": "code", "execution_count": null, "id": "section_10_opt_trino", "metadata": {}, "outputs": [], "source": [ "# Ваш код (Trino): аналитический запрос по bronze\n" ] }, { "cell_type": "code", "execution_count": null, "id": "section_10_opt_spark", "metadata": {}, "outputs": [], "source": [ "# Ваш код (Spark): тот же запрос по bronze\n" ] }, { "cell_type": "markdown", "id": "section_11", "metadata": {}, "source": [ "## 11. Что мы НЕ сравниваем\n", "\n", "Ограничение модуля: мы не сравниваем Spark и Trino как движки. Не обсуждаем: какой быстрее, какой лучше, каковы их внутренние различия оптимизаторов. \n", "\n", "Цель — показать, что оба работают с одной таблицей через общий каталог. Это фундаментальное свойство архитектуры Lakehouse, а не конкретного движка." ] }, { "cell_type": "markdown", "id": "section_12", "metadata": {}, "source": [ "## 12. Checkpoint\n", "\n", "Проверь себя:\n", "1. Почему Trino видит таблицы, созданные Spark, без синхронизации?\n", "2. Какие два компонента стенда общие для Spark и Trino?\n", "3. Чем доступ к данным в Lakehouse отличается от Greenplum?\n", "4. Нужно ли копировать данные, чтобы Trino прочитал таблицу Spark?\n", "5. Что такое «decoupled compute» в одном предложении?\n", "6. Если добавить третий движок (Flink), что нужно сделать, чтобы он увидел те же таблицы?\n", "7. Что произошло, когда Spark создал `borough_summary`, а Trino её тут же прочитал?" ] }, { "cell_type": "markdown", "id": "section_13", "metadata": {}, "source": [ "## 13. Завершение\n", "\n", "Удаляем демо-таблицу, она больше не нужна. Таблицы `bronze` и `silver` остаются для следующих модулей.\n", "\n", "В Модуле 7 — schema evolution и time travel. В Модуле 8 — финальная практика." ] }, { "cell_type": "code", "execution_count": null, "id": "section_13_stop", "metadata": {}, "outputs": [], "source": [ "spark.sql(\"DROP TABLE IF EXISTS lakehouse.default.borough_summary\")\n", "spark.stop()" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.10.12" } }, "nbformat": 4, "nbformat_minor": 5 }