Нормально делай, нормально будет
И добавить нечего
Welcome to my personal place for love, peace and happiness 🤖
И добавить нечего
ПредположенИИе 🤖
Ray — это унифицированный фреймворк с открытым исходным кодом для масштабирования AI- и Python-приложений. Он предоставляет простой API для создания распределённых приложений, которые могут масштабироваться от одного ноутбука до целого кластера без изменения кода. Ray эффективно обрабатывает разнообразные рабочие нагрузки: от пакетной обработки данных и распределённого обучения моделей до гиперпараметрической оптимизации и serving-а инференса моделей в продакшене. Ray не ограничивается только задачами ML: он также предоставляет Ray Data и потоковые примитивы для эффективных входных пайплайнов, пакетной обработки и онлайн-инференса.
Для визуализации данных и создания интерактивных дашбордов на Python сегодня доступны два мощных инструмента: проверенный временем Streamlit и современный Marimo.
Streamlit — это open-source Python-библиотека, которая позволяет превратить скрипты анализа данных в полноценные веб-приложения за считанные минуты, без необходимости писать HTML, CSS или JavaScript. Streamlt поддерживает:
Marimo — это реактивный Python-ноутбук нового поколения, который также можно использовать для создания веб-приложений. Главное отличие от Streamlit — реактивная модель выполнения: при изменении одной ячейки или взаимодействии с UI-элементом автоматически пересчитываются только зависимые ячейки, а не весь скрипт. Marimo подходит для сложного исследовательского анализа и интерактивных дашбордов, где важна производительность и детальный контроль выполнения.
Рассмотрим практический пример построения масштабируемой системы отчётности, где Ray выступает в роли мощного бэкенда для обработки и serving-а данных, а Streamlit (или Marimo) — в роли фронтенда для визуализации. Код визуализаций хранится в Git, что упрощает версионирование, совместную работу и развёртывание.
import ray
from ray import serve
from fastapi import FastAPI, HTTPException
import trino
import pandas as pd
app = FastAPI()
@serve.deployment(
ray_actor_options={"num_cpus": 0.5},
autoscaling_config={"min_replicas": 1, "max_replicas": 2},
)
@serve.ingress(app)
class TrinoQuery:
def __init__(self):
self.conn = trino.dbapi.connect(
host="192.168.0.125",
port=9999,
user="jupyter",
catalog="test_warehouse",
schema="test_schema",
http_scheme="http",
)
print("Соединение с Trino установлено.")
@app.get("/query")
async def execute_query(self, query: str):
if not query:
raise HTTPException(status_code=400, detail="Query parameter is required.")
try:
cursor = self.conn.cursor()
cursor.execute(query)
rows = cursor.fetchall()
col_names = [desc[0] for desc in cursor.description]
df = pd.DataFrame(rows, columns=col_names)
return df.to_dict(orient="records")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
ray.init(ignore_reinit_error=True)
serve.start(http_options={"host": "0.0.0.0", "port": 8000})
serve.run(TrinoQuery.bind(), blocking=True)import streamlit as st
import pandas as pd
import requests
BACKEND_URL = "http://127.0.0.1:8000/query"
st.set_page_config(page_title="Аналитическая панель", layout="wide")
st.title("Дашборд данных из Trino через Ray")
with st.sidebar:
st.header("Параметры запроса")
query = st.text_area(
"SQL-запрос:",
value="SELECT nationkey, COUNT(*) as cnt FROM test_warehouse.test_schema.my_table1 GROUP BY nationkey",
height=200,
)
execute_button = st.button("Выполнить запрос", type="primary")
if execute_button:
if not query:
st.warning("Введите SQL-запрос.")
else:
with st.spinner("Выполняется запрос через Ray Serve..."):
try:
response = requests.get(BACKEND_URL, params={"query": query}, timeout=30)
response.raise_for_status()
data = response.json()
if data:
df = pd.DataFrame(data)
st.success(f"Запрос выполнен. Получено строк: {len(df)}")
st.dataframe(df, use_container_width=True)
if df.select_dtypes(include='number').shape[1] > 0:
st.subheader("Статистика по числовым колонкам")
st.dataframe(df.describe(), use_container_width=True)
else:
st.info("Запрос вернул пустой результат.")
except Exception as e:
st.error(f"Ошибка: {e}")Код фронтенда (Streamlit-скрипты или Marimo-ноутбуки) должен храниться в Git-репозитории. Это обеспечивает:
Репозиторий может иметь следующую структуру:
.
├── app.py # Основной файл Streamlit-приложения
├── pages/ # Дополнительные страницы (если используются)
├── marimo_notebooks/ # Marimo-ноутбуки (если используются)
├── requirements.txt # Зависимости
├── .gitignore
└── README.mdВ распределённой системе, где множество пользователей одновременно обращаются к дашборду, а сам бэкенд масштабируется на множество реплик, управление состоянием (state management) становится критически важным. Ошибка может привести к тому, что пользователь увидит чужие данные или потеряет свой прогресс в сессии.
Ray поддерживает оба подхода:
Для большинства BI-дашбордов идеальна следующая схема:
Иногда возникает необходимость, чтобы бэкенд хранил какое-то состояние для повышения производительности. Например, каждая реплика может загружать большую модель машинного обучения в свою память. В таком случае используется подход Soft Session Affinity: все запросы от одного пользователя направляются на одну и ту же реплику, используя уникальный ключ (`X-SERVE-SHARD-KEY`).
Рассмотрим сценарий, где бизнес-пользователь хочет “заказать” отчёт, который генерируется 10 минут, и вернуться за ним через час. Stateless архитектура здесь не подойдёт, так как бэкенд “забудет” о задаче.
Ray, Streamlit и Marimo образуют мощный тандем для построения современных систем отчётности и аналитики. Ray обеспечивает масштабируемый и производительный бэкенд, способный обрабатывать большие объёмы данных. Streamlit и Marimo предоставляют удобные средства для создания интерактивных и красивых дашбордов, а Git гарантирует контроль версий и простоту развёртывания. Ключом к успешной архитектуре является правильный выбор стратегии управления состоянием: в большинстве случаев подходит stateless бэкенд с хранением состояния во фронтенде, что обеспечивает простоту и отказоустойчивость. Для более сложных сценариев (долгие задачи, кэширование моделей) можно использовать stateful подход с Ray акторами и внешним хранилищем.
Если вы хотите увидеть полный рабочий пример с кодом, архитектурной схемой и инструкцией по развёртыванию, дайте знать — я подготовлю подробный гайд.
Сегодня еще кстати крылатое выражение на уме или цитата, как хотите. «Когда выручка не растет, кровати 🛌 передвинуты, ш..х сменили и все против вас, то на помощь приходят ИИгрушки) 😁☺️😉 (с)
В первой части Утиных историй мы детально разбирали, как DuckDB переворачивает принципы локальной и встраиваемой аналитики. Сегодня на календаре 19 апреля 2026 года, и экосистема «утки» развивается с невероятной скоростью. На днях вышел юбилейный, 40-й выпуск информационного бюллетеня от команды MotherDuck.
В этой статье мы разберем самые горячие новинки обновления: релиз DuckLake 1.0, нативную поддержку протокола PostgreSQL, векторный поиск и то, как DuckDB покоряет новые горизонты программирования (от Elixir к Rust).
Главная новость апреля — релиз DuckLake 1.0. Это lakehouse-формат, в котором все метаданные хранятся непосредственно в каталоге базы данных (в PostgreSQL, SQLite или самой DuckDB), а не в разрозненных файлах, как это сделано в Delta Lake или Apache Iceberg.
Отказ от чтения разрозненных файлов метаданных дает феноменальный прирост производительности базовых операций агрегации. Если сравнивать время выполнения запросов до оптимизации (T old) и с использованием чтения исключительного из каталога метаданных DuckLake (T new), то выигрыш в скорости можно выразить формулой:
Speedup =T new / T old
Для запросов вида `COUNT(*)` этот Speedup составляет от 8 до 258 раз! А вызов системной функции `duckdb_views()` ускорился примерно в 70 раз.
Неудивительно, что DuckLake уже входит в топ-10 расширений по количеству скачиваний и поддерживается клиентами Apache DataFusion, Spark, Trino и Pandas. Издательство O’Reilly даже готовит книгу *“DuckLake: The Definitive Guide”*. (Фича доступна в DuckDB v1.5.2).
Чтобы внедрить мощь DuckDB в свою инфраструктуру разработчикам часто приходилось искать специальные драйверы и коннекторы. Это в прошлом!
MotherDuck запустили PostgreSQL wire-protocol endpoint. Теперь вы можете выполнять аналитические SQL-запросы к DuckDB, используя совершенно любой клиент, пулер (pooler) или BI-инструмент, совместимый с Postgres. Устанавливать библиотеки DuckDB на клиент больше не нужно!
Достаточно направить ваш текущий клиент по адресу:
pg.us-east-1-aws.motherduck.com:5432Авторизация происходит с помощью токена MotherDuck. При этом диалект SQL остается утиным (хотя он в значительной степени и совместим с PostgreSQL). Миграция данных возможна через обычные ETL-утилиты или расширение `pg_duckdb`.
Мощным толчком для развития комьюнити-плагинов стал релиз `quack-rs`. До сих пор написание расширений для DuckDB на Rust требовало создания слоев совместимости (C++ glue) и возни с CMake.
`quack-rs` — это SDK на чистом Rust, который оборачивает *C Extension API* (v1.1+). Инструмент предоставляет безопасные абстракции и устраняет 16 задокументированных проблем с FFI (Foreign Function Interface), предотвращая “тихую” порчу данных через NULL и ошибки “double-free” в callback-функциях агрегации.
Для старта нового расширения достаточно вызвать функцию:
generate_scaffold();Она сгенерирует все 11 файлов, необходимых для подачи плагина в репозиторий сообщества. Теперь безопасность памяти Rust и скорость DuckDB идут рука об руку.
Открытый колоночный формат Lance, оптимизированный под ML и векторный поиск, теперь доступен и в DuckDB! Hao Ding реализовал поддержку чтения и записи таблиц Lance.
Писать данные можно так:
COPY (...) TO 'path/dataset.lance' (FORMAT lance, MODE 'overwrite');Для поиска доступны функции: `lance_vector_search()`, `lance_fts()` и `lance_hybrid_search()`.
Появилась библиотека `dux` — lazy-by-default (ленивые по умолчанию) датафреймы для Elixir поверх DuckDB. Конвейеры данных аккумулируются в AST структуре `%Dux{}` и компилируются в SQL CTE. Заявлено, что на тестах ($10$ млн строк, Apple M4 Max) Dux обгоняет Polars (Explorer) до 2.5 раз на операциях фильтрации.
Инструмент для трассировки ядра Linux `systing` (написанный Josef Bacik) перешел с сохранения логов Perfetto на прямую запись в DuckDB. А интеграция с Claude Code MCP (Model Context Protocol) позволяет ИИ динамически анализировать эти базы данных DuckDB в реальном времени.
Создано полноценное Go-ядро DuckDB для Jupyter, которое напрямую отправляет поток данных (Arrow IPC) во встроенный WASM-просмотрщик `hugr-perspective-viewer`. На панели также агрегируются метрики без написания SQL: `approx_unique`, `avg`, `min`, `max`, `count`.
Способен ли базовый ноутбук переваривать серьезную аналитику? Gábor проверил работу DuckDB на новом MacBook Neo с процессором Apple A18 Pro.
| Бенчмарк | Параметры | Результат (медиана) |
| ClickBench | 100M строк, лимит RAM: 5GB | < 1 секунды (cold run) |
| TPC-DS | SF100 | 1.63 секунды на запрос |
| TPC-DS | SF300 | 79 минут (высокий disk spill) |
Даже при 5 гигабайтах оперативной памяти DuckDB демонстрирует субсекундные ответы, эффективно утилизируя NVMe-память, когда RAM исчерпан (disk spill).
Стоит отдельно отметить профессора Dr. Torsten Grust из Тюбингенского университета (Германия). Его исследовательская группа, стоящая на стыке баз данных и технологий языков программирования, недавно запустила открытый курс DiDi (*Design and Implementation of DuckDB Internals*).
Курс использует DuckDB для обучения студентов архитектуре СУБД: от управления памятью и векторизованного исполнения до оптимизации запросов (включает около 50 рабочих примеров кода).
Экосистема DuckDB перестала быть просто *“SQLite для аналитики”*. С релизом DuckLake, нативной интеграцией протокола Postgres и появлением SDK для Rust, “утка” окончательно закрепилась как основополагающий инструмент в стеке современных данных.
Сегодня Gemini 3.1 Pro Preview расскажет свое мненИИе))
Связывание транзакционных баз (PostgreSQL) и аналитических хранилищ (ClickHouse) через прямые агрегации и `JOIN` часто приводит к жесточайшим блокировкам и деградации продакшена. Когда бизнес требует быстрый результат, а внедрение полноценного CDC (Debezium + Kafka) откладывается из-за сроков и сложности, лучшим решением становится пакетная и микро-пакетная выгрузка данных в озеро (в форматы Parquet и Apache Iceberg).
С точки зрения архитектуры, наша главная цель — минимизировать время загрузки данных T load и усилия инженеров на развертывание E setup. Наша целевая функция: min(T load × E setup)
В этой статье собраны исключительно рабочие, протестированные подходы для быстрой интеграции с озером данных (Data Lake) и аналитическим движком Trino.
Мы полностью исключаем создание и восстановление тяжелых дампов (`pg_dump`). Вся транзитная нагрузка ложится исключительно на асинхронные реплики.
Для задачи “результат нужен вчера и без сложного стека” идеально подходит OLake. Это высокопроизводительный движок репликации баз данных напрямую в Apache Iceberg (или Parquet), минуя промежуточные шины сообщений.
Шаг 1. Запуск сервиса (конфигурация `docker-compose.yml`):
version: '3.8'
services:
olake:
image: olakeio/olake:latest
ports:
- "8080:8080"
environment:
# Настройки доступов к вашему S3/MinIO
- AWS_ACCESS_KEY_ID=your_access_key
- AWS_SECRET_ACCESS_KEY=your_secret_key
- AWS_REGION=us-east-1Шаг 2. Запуск репликации:
Вы отправляете JSON-манифест в OLake (через UI или REST API). Движок самостоятельно делает первоначальный слепок PostgreSQL (Full Load со скоростью до 580K RPS), а затем переключается на чтение инкрементов (CDC):
{
"pipeline_name": "pg_to_iceberg_fast",
"source": {
"type": "postgres",
"connection_url": "postgresql://readonly_user:password@replica_host:5432/prod_db",
"tables": ["public.customer", "public.orders"]
},
"destination": {
"type": "iceberg",
"catalog_type": "rest",
"catalog_uri": "http://iceberg-rest:8181",
"warehouse_path": "s3://my-datalake/warehouse/"
},
"replication_mode": "full_and_cdc"
}Если вы хотите управлять выгрузкой через свои `cron`-задачи или Airflow, идеальным инструментом выступает аналитическая in-memory СУБД DuckDB. Ниже приведен протестированный Python-скрипт, который напрямую подключается к реплике и потоково перегоняет данные в Parquet на S3.
Рабочий скрипт на Python (`export_to_lake.py`):
import duckdb
# Открываем in-memory соединение DuckDB
con = duckdb.connect()
# 1. Устанавливаем и загружаем необходимые расширения
con.execute("INSTALL postgres;")
con.execute("INSTALL httpfs;")
con.execute("LOAD postgres;")
con.execute("LOAD httpfs;")
# 2. Настраиваем подключение к объектному хранилищу
con.execute("""
SET s3_region='us-east-1';
SET s3_access_key_id='YOUR_KEY';
SET s3_secret_access_key='YOUR_SECRET';
SET s3_endpoint='s3.your-domain.com';
""")
# 3. Подключаемся к реплике PostgreSQL
# Команда ATTACH монтирует Postgres прямо в DuckDB под именем 'pg'
con.execute("""
ATTACH 'host=replica_host port=5432 dbname=postgres user=postgres password=password'
AS pg (TYPE postgres);
""")
# 4. Копируем таблицу public.customer в S3 в сжатом формате Parquet
con.execute("""
COPY pg.public.customer
TO 's3://my-datalake/raw/customer.parquet'
(FORMAT PARQUET, COMPRESSION ZSTD);
""")
print("Выгрузка в Data Lake успешно завершена!")Данные из ClickHouse также необходимо перегружать в Озеро (для Trino), чтобы избежать дублирования логики таблиц и нагрузки на саму СУБД тяжелыми сторонними `JOIN`-ами.
Самый простой и не требующий дополнительной инфраструктуры способ — использовать встроенную функцию `s3()`. Она позволяет в один SQL-запрос отправить результат выборки прямо в объектное хранилище в нужном формате.
Пример выгрузки из ClickHouse в Parquet (выполняется в `clickhouse-client`):
-- Прямая вставка данных из локальной MergeTree таблицы в файл Parquet на S3
INSERT INTO FUNCTION s3(
'https://s3.us-east-1.amazonaws.com/my-datalake/raw/clickhouse_export/events_{_partition_id}.parquet',
'YOUR_KEY',
'YOUR_SECRET',
'Parquet'
)
SELECT id, event_type, payload, event_date
FROM local_events_mergetree
WHERE event_date = today();*Совет: Используйте макрос `{_partition_id}` в пути файла для автоматического разбиения больших выгрузок.*
Для построения архитектуры на десятилетие вперед разработчики из Altinity создали сборку Project Antalya. Она позволяет использовать таблицы Iceberg в S3 как *полноценное разделяемое хранилище*, работающее со скоростью локального диска, но обходящееся в 10 раз дешевле.
Пример прозрачного монтирования:
-- 1. Подключаем готовую Iceberg-таблицу прямо как движок ClickHouse
CREATE TABLE iceberg_customer
ENGINE = Iceberg('s3://my-datalake/warehouse/customer', 'aws_key', 'aws_secret');
-- 2. Запрашиваем данные. Теперь Trino и ClickHouse читают одни и те же Parquet-файлы!
SELECT count(*) FROM iceberg_customer WHERE status = 'active';
1. Управление оперативной памятью (OOM) в DuckDB
При скриптовой выгрузке гигантских таблиц in-memory движок может исчерпать RAM сервера.
Решение: Обязательно ограничивайте ресурсы сразу после
duckdb.connect():
con.execute("PRAGMA memory_limit='16GB'")
con.execute("PRAGMA threads=4")
2. Консолидация сложных типов данных PostgreSQL
Если в вашей таблице есть
JSONB,
UUIDили пользовательские массивы, Parquet может упасть с ошибкой соответствия типов.
Решение: Вместо
COPY pg.tableнапишите явный SQL-запрос с приведением к строке (
::VARCHAR):
con.execute("""
COPY (
SELECT id, metadata::VARCHAR AS metadata
FROM pg.public.customer
)
TO 's3://my-datalake/raw/customer.parquet' (FORMAT PARQUET);
""")Внутри Trino эти строки легко парсятся функциями вроде
json_extract().
3. Защита асинхронных реплик PostgreSQL от разрывов
Длительный процесс
SELECT *(или
COPY) мешает мастеру применять WAL-логи на реплике (из-за очистки строк VACUUM-ом).
Решение: На аналитической реплике (в файле
postgresql.conf) обязательно пропишите:
max_standby_streaming_delay = -1
max_standby_archive_delay = -1
hot_standby_feedback = onЭто позволит реплике “ставить на паузу” конфликтующие обновления и не обрывать ваш транзит данных.
В современных data-архитектурах часто возникает задача переноса реляционных данных в озера данных (Data Lakes). Если ваш стек включает PostgreSQL, Trino и Iceberg (например, с REST-каталогом Lakekeeper), возникает архитектурный вопрос: как переносить данные и обращаться к ним максимально эффективно?
В этой статье мы разберем два мощных подхода: использование “нативного” для Trino проталкивания через `system.query()` и применение расширения `pg_lake` на стороне базы данных.
Обычно в Trino мы пишем простой федеративный запрос:
SELECT * FROM postgres_catalog.public.customer WHERE acctbal > 1000;В идеальном сценарии оптимизатор Trino считывает предикат (`acctbal > 1000`) и транслирует его в SQL-диалект PostgreSQL. Это называется Pushdown (проталкивание).
Но на практике аналитические запросы гораздо сложнее. Если запрос содержит специфичную бизнес-логику, нестандартные оконные функции, сложные JOIN-ы или функции обработки строк, которых нет в базовом словаре коннектора Trino, оптимизатор не сможет транслировать этот кусок SQL. В результате Trino принимает решение скачать всю таблицу в память своих воркеров и применить фильтрацию уже там.
Особую роль при JOIN-ах играет механизм динамической фильтрации (Dynamic Filtering). Когда вы джоините большую таблицу из Postgres с маленькой таблицей (например, справочником из Hive/Iceberg), Trino сначала читает справочник (Build side), извлекает ключи, формирует SQL-фильтр (например, `IN (1, 2, 3)`) и на лету отправляет его в Postgres (Probe side).
Два критичных параметра в конфигурации коннектора управляют этим процессом:
В чем кроется опасность?
Если вычисление справочника на стороне Trino занимает больше времени, чем задано в `dynamic-filtering.wait-timeout` (например, 25 секунд против 20), координатор Trino прерывает ожидание. Чтобы не блокировать выполнение, он отправляет в Postgres “голый” запрос: `SELECT * FROM table`.
Вместо пары тысяч строк по сети внезапно начинают передаваться миллионы. Если загрузка сети — B, а объем таблицы PostgreSQL — V total, то время выполнения стремится к: T pull = B V total
что может привести к Out-of-Memory на воркерах Trino и падению кластера.
Чтобы гарантировать, что вычисления и фильтры 100% выполнятся на мощностях PostgreSQL, мы можем использовать специальную табличную функцию `system.query()`.
Этот подход разделяет обязанности: PostgreSQL занимается фильтрацией и тяжелой математикой локально, а Trino просто оркестрирует запись результата в Parquet/Iceberg.
-- Создаем таблицу в Iceberg (Lakekeeper) и наполняем её результатами из Postgres
CREATE TABLE iceberg_catalog.raw_data.customer_metrics WITH (
format = 'PARQUET',
partitioning = ARRAY['mktsegment']
) AS
SELECT
*
FROM
TABLE(
postgres_catalog.system.query(
query => '
-- Этот SQL выполняется СТРОГО внутри PostgreSQL
SELECT
custkey,
name,
mktsegment,
acctbal,
array_agg(acctbal) OVER (
PARTITION BY mktsegment
ORDER BY custkey
ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING
EXCLUDE GROUP
) AS rolling_bals
FROM public.customer
WHERE acctbal > 1000
AND created_at >= current_date - interval ''1 month''
'
)
);Преимущество: Если селективность нашего фильтра S равна 0.05 (остается 5% строк), то объем передаваемых по сети данных составит строго V total \ times S. Никакие таймауты Trino не заставят Postgres отдать лишние данные.
Если первый метод идеально подходит для использования Trino как движка трансформации, то зачем вообще существует проект `pg_lake`?
`pg_lake` внедряет под капот PostgreSQL движок DuckDB через `pgduck_server`. Это позволяет базе данных самостоятельно подключаться к S3 и читать/писать формат Iceberg, минуя Trino.
pg_lakeпрямо в PostgreSQL?
-- Объединение горячих данных из кучи (heap) PG и холодных данных из Iceberg
SELECT * FROM public.orders_current
UNION ALL
SELECT * FROM iceberg.orders_archive WHERE order_date < '2023-01-01';
https://github.com/lakekeeper/lakekeeper/tree/main/authz/opa-bridge
или тут https://docs.lakekeeper.io/docs/nightly/opa/
Много всего нового появилось у хранителя – роли, уточка и многое другое, статистика запросов
Немного сборной сборки про качество и ML
https://github.com/andkret/Cookbook
https://podcast.ru/e/3Ldlf9-6ebG
https://habr.com/ru/companies/vtb/news/762384/
Полезные ресурсы и ссылки:
Курс MLOps (OTUS): https://otus.ru/lessons/ml-bigdata/
Основные идеи из книги «Сотрудничество в DevOps-культуре»: http://agilemindset.ru/основные-идеи-из-книги-сотрудничест/
MLOps: Continuous delivery and automation pipelines in machine learning: https://cloud.google.com/architecture/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning
Как создавать качественные ML-системы. Часть 1: каждый проект должен начинаться с плана: https://habr.com/ru/companies/vk/articles/749850/
Как создавать качественные ML-системы. Часть 2: приручаем хаос: https://habr.com/ru/companies/vk/articles/749852/
The Data Engineering Cookbook: https://github.com/andkret/Cookbook
Стандарты:
ISO/IEC DIS 5259-1: https://www.iso.org/standard/81088.html
SO/IEC DIS 5259-4: https://www.iso.org/standard/81093.html
ISO/IEC 8183:2023: https://www.iso.org/standard/83002.html
Работа с Big Data часто упирается в классическое “узкое горлышко”: кластер может обработать терабайты данных за секунды, но передача результатов (Result Set) обратно на сторону клиента (например, в Jupyter или скрипт) занимает часы. На дворе апрель 2026 года, и современные аналитические движки предлагают эффективные методы обхода этой проблемы — концепцию Spooling.
Немного душноты: https://www.starburst.io/blog/trino-spooling-protocol/
Архитектура Client Spooling в Trino создавалась с параноидальным акцентом на безопасность, в S3 выкидываются куски сырых, возможно, чувствительных данных.
Когда Trino решает сбросить данные в объектное хранилище, он всегда шифрует их на лету.
Для этого используется механизм S3 SSE-C (Server-Side Encryption with Customer-provided keys). Trino генерирует уникальный случайный AES-ключ для каждого запроса, отправляет его в MinIO вместе с данными, а клиенту (вашему Jupyter) отдает ссылку + этот же ключ для расшифровки.
Если мы используем локальный MinIO по адресу http://minio:9000 (без SSL/TLS), сервер MinIO видит, что ему пытаются передать секретный пароль (SSE-C ключ) по открытому незащищенному HTTP-каналу.
MinIO (как и настоящий AWS S3) строго запрещает это по спецификации. Он возвращает HTTP 400 Bad Request с ошибкой: “Requests specifying Server Side Encryption... must be made over a secure connection”. Поэтому тестировать лучше на реальном s3. И еще
Мгновенное удаление (Сборка мусора)
Главное правило Client Spooling: Trino удаляет файлы сразу же, как только они были прочитаны клиентом.
Как только ваш Python-скрипт или Jupyter получает ссылку на файл, скачивает его и отправляет координатору Trino HTTP-сигнал (ACK), что кусок получен, координатор дает команду немедленно удалить этот объект из S3.
Если запрос отменен или упал с ошибкой, Trino тоже моментально зачищает за собой fs.location. Вы просто не успеете их там увидеть.
Данных слишком мало (Thresholds)
Писать 10 строк в S3, генерировать для них Pre-signed URLs и отдавать клиенту — это дольше, чем просто плюнуть эти 10 строк текстом через координатор. Trino использует эвристику: если Result Set маленький, он отдается “инлайн” (внутри JSON-ответа самого координатора), и S3 не задействуется.
В этой статье мы разберем, как передавать результаты запросов через промежуточное S3-хранилище, на примере движков Trino и Apache DataFusion.
В классической архитектуре все воркеры кластера отправляют вычисленные строки на главный узел (Coordinator), а тот уже отдает их по одному каналу клиенту.
Если D — это объем результирующей выборки, а B c — пропускная способность сети координатора, то время выгрузки данных клиенту без спулинга равно:
T classic = B / Dc
В режиме Spooling координатор не гоняет данные через себя. Воркеры напрямую, параллельно пишут куски результата в дешевое объектное хранилище (S3/MinIO). Клиент получает лишь ссылки на эти файлы и скачивает их напрямую. Если у нас N файлов в S3, доступных для многопоточного скачивания с пропускной способностью клиента B client: T spooling ≈ min(N×B s3,B client)D
Это позволяет ускорить выгрузку в десятки раз, так как $B_{client}$ и распределенный $B_{s3}$ обычно значительно больше ограничений одного координатора.
Для демонстрации двух подходов мы убрали из нашего кластера все тяжелые клиентские среды (Jupyter, Spark) и оставили только “голое” ядро: хранилище S3, REST-каталог и SQL-движок.
docker-compose.yml
version: '3.8'
services:
minio:
image: minio/minio:latest
ports:
- "19000:9000"
- "19001:9001"
environment:
MINIO_ROOT_USER: "minio-root-user"
MINIO_ROOT_PASSWORD: "minio-root-password"
command: server /data --console-address ":9001"
minio-setup:
image: minio/mc:latest
depends_on:
- minio
entrypoint: >
/bin/sh -c "
sleep 5;
mc alias set myminio http://minio:9000 minio-root-user minio-root-password;
mc mb myminio/warehouse || true;
"
lakekeeper:
image: dalongrong/lakekeeper:latest
ports:
- "8181:8181"
environment:
- S3_ENDPOINT=http://minio:9000
- S3_REGION=us-east-1
- S3_ACCESS_KEY_ID=minio-root-user
- S3_SECRET_ACCESS_KEY=minio-root-password
depends_on:
- minio-setup
trino:
image: trinodb/trino:latest
ports:
- "8080:8080"
Сначала мы генерируем данные в Trino. Запрос
CREATE CATALOGиспользует динамическое подключение к Lakekeeper REST API. Скрипт записывает файлы в формате Parquet в MinIO:
config.properties
protocol.spooling.enabled=true
# 256-битный ключ в формате base64. Вы можете сгенерировать свой с помощью команды `openssl rand -base64 32`
protocol.spooling.shared-secret-key=jxTKysfCBuMZtFqUf8UJDQ1w9ez8rynEJsJqgJf66u0=
catalog.management=dynamicspooling-manager.properties
spooling-manager.name=filesystem
# Включаем чтение/запись в S3 для Spooling
fs.s3.enabled=true
# Путь внутри MinIO (указываем через s3://)
fs.location=s3://warehouse/client-spooling/
# Системные настройки S3 (MinIO)
s3.endpoint=http://minio:9000
s3.region=us-east-1
s3.aws-access-key=minio-root-user
s3.aws-secret-key=minio-root-password
s3.path-style-access=true-- 1. Подключение каталога Iceberg
CREATE CATALOG test_warehouse USING iceberg
WITH (
"iceberg.catalog.type" = 'rest',
"iceberg.rest-catalog.uri" = 'http://lakekeeper:8181/catalog/',
"iceberg.rest-catalog.warehouse" = '00000000-0000-0000-0000-000000000000/test_warehouse',
"iceberg.rest-catalog.security" = 'OAUTH2',
"iceberg.rest-catalog.nested-namespace-enabled" = 'true',
"iceberg.rest-catalog.vended-credentials-enabled" = 'true',
"fs.native-s3.enabled" = 'true',
"s3.region" = 'us-east-1',
"s3.path-style-access" = 'true',
"s3.endpoint" = 'http://minio:9000'
);-- 2. Создание структуры
CREATE SCHEMA test_warehouse.test_schema;
CREATE TABLE test_warehouse.test_schema.my_table (
id BIGINT,
data VARCHAR
) WITH (format = 'PARQUET');-- 3. Запись данных
INSERT INTO test_warehouse.test_schema.my_table VALUES (1, 'hello'), (2, 'world');Если написать Select – должно быть как-то так
Trino поддерживает протокол *Client Spooling* “из коробки” — когда Python-клиент запрашивает огромный `SELECT`, Trino сам незаметно пишет куски в S3 и отдает клиенту готовые ссылки.
В Apache DataFusion (который часто работает как локальный движок `datafusion-cli` или встраиваемая библиотка поверх S3) применяется более прозрачный паттерн делегирования (Explicit Spooling). Мы вручную инструктируем движок сохранить результаты агрегации в распределенное хранилище, чтобы позже забрать их в удобном формате — например, упаковав их в `JSON` и сжав алгоритмом `ZSTD`.
Запускаем `datafusion-cli`, передав доступы как переменные среды (для предотвращения ошибок парсинга опций):
AWS_ACCESS_KEY_ID="minio-root-user" \
AWS_SECRET_ACCESS_KEY="minio-root-password" \
AWS_ENDPOINT="http://localhost:19000" \
AWS_REGION="us-east-1" \
AWS_ALLOW_HTTP="true" \
datafusion-cliВнутри консоли подключаем директорию с Parquet-файлами, сгенерированными Trino:
CREATE EXTERNAL TABLE my_parquet_data
STORED AS PARQUET
LOCATION 's3://warehouse/019d81a3-c2d6-7ed2-ab15-070becf62582/my_table-13e4b91a2b4e47d98f312b1384263880/data/';Вместо того чтобы тянуть миллионы строк на локальный терминал, мы просим DataFusion выполнить преобразование и записать итог запроса обратно в MinIO.
Мы выбираем построчный JSON с экстремальным сжатием:
COPY (
-- Тут может быть любая сложная агрегация:
-- SELECT id, count(data) FROM my_parquet_data GROUP BY id
SELECT * FROM my_parquet_data
)
TO 's3://warehouse/019d81a3-c2d6-7ed2-ab15-070becf62582/my_table-13e4b91a2b4e47d98f312b1384263880/json_export/'
STORED AS JSON
OPTIONS (
'format.compression' 'zstd'
);Результат:
+-------+
| count |
+-------+
| 2 |
+-------+
1 row(s) fetched.
Elapsed 0.270 seconds.За миллисекунды (0.270 sec) DataFusion прочитал партиции, трансформировал бинарные столбцы в текст и сжал его.
Описанный паттерн выполнения команды `COPY TO` с сохранением `.json.zst` в MinIO полностью воспроизводит механику Spooling:
Еще немного про Fault-Tolerant Execution (FTE), нужно провести важную границу между архитектурой Trino (готовый распределенный кластер) и архитектурой DataFusion (ядро/библиотека выполнения запросов).
В самом “голом” ядре DataFusion (которое вы запускаете в `datafusion-cli` или в Jupyter) нет встроенного механизма Task Retries, потому что процессы выполняются на одной машине в рамках одного приложения. Если сервер падает — запрос прерывается.
Однако, в экосистеме DataFusion есть механизмы отказоустойчивости, которые делятся на два уровня: локальный (Spilling) и распределенный (Apache Ballista / Ray).
В Trino частой причиной падения задач является нехватка памяти (Out of Memory). В DataFusion реализован мощный механизм управления памятью.
Если DataFusion понимает, что оперативной памяти для агрегации или JOIN’а не хватает, он не “роняет” задачу, а начинает сбрасывать промежуточные данные на диск (Spill to Disk).
Trino использует архитектуру Fault-Tolerant Execution (FTE), при которой промежуточные результаты (Shuffle Exchange) пишутся в S3, а упавшие воркеры заменяются, и их задачи (Tasks) перезапускаются координатором.
В мире DataFusion эту задачу решает не само ядро, а распределенные планировщики, построенные поверх него:
Ballista — это надстройка над DataFusion, превращающая его в полноценный кластер (с Coordinator и Executors), архитектурно очень похожая на Apache Spark и Trino.
Сейчас огромную популярность набирает запуск DataFusion поверх кластера Ray.
Ray — это супер-устойчивый распределенный фреймворк. Интеграция `datafusion-ray` позволяет разбить SQL-запрос на граф задач прямо в Ray.
UPD: В локальном тестировании есть некоторые особенности. Когда контейнеры внутри имеют свою сеть, то трино посылает в dbeaver ссылки. А есть хост не знает что это за минива или localstack-spooling, то оно отдаст кусок данных, а остальные части просто не доедут. Квери упадет как отмененная, так как клиент получил не все результаты. Короче, надо просто так сделать
sudo nano /etc/hostsи вставить строку вашего s3 хоста.
127.0.0.1 localstack-spoolingто есть при спулинге клиент должен не только иметь сетевую связанность с s3 но различать dns имена корректно.
Короче сравния строк пройдено, все сошлося :)
со спулингом 2.2 сек
без спулинга 4.4 сек
Питончик 2.16 сек с чанками
в самом трино еще быстрее
все строки на месте: 150тыщъ
код !!
from trino.dbapi import connect
import json
TRINO_HOST = “localhost”
TRINO_PORT = 9999
TRINO_USER = “trino”
TRINO_CATALOG = “test_warehouse”
TRINO_SCHEMA = “test_schema”
OUTPUT_FILE = “output.json”
CHUNK_SIZE = 10000 # Количество строк, обрабатываемых за один раз
def export_to_json():
conn = connect(
host=TRINO_HOST,
port=TRINO_PORT,
user=TRINO_USER,
catalog=TRINO_CATALOG,
schema=TRINO_SCHEMA,
)
cursor = conn.cursor()
try:
cursor.execute(“SET SESSION retry_policy = ‘NONE’”)
cursor.execute(“SELECT * FROM my_table2”)
column_names = [desc[0] for desc in cursor.description]
row_count = 0
with open(OUTPUT_FILE, “w”, encoding=“utf-8”) as f:
while True:
rows = cursor.fetchmany(CHUNK_SIZE)
if not rows:
break
for row in rows:
row_dict = dict(zip(column_names, row))
f.write(json.dumps(row_dict, ensure_ascii=False, default=str) + “\n”)
row_count += len(rows)
print(f“Processed {row_count} rows...”)
print(f“Successfully exported {row_count} rows to {OUTPUT_FILE}”)
finally:
cursor.close()
conn.close()
if __name__ == “__main__”:
export_to_json()
Вот еще с уточкой и чанками
код
import duckdb
import json
OUTPUT_FILE = “/home/jovyan/examples/output_duckdb.json”
CHUNK_SIZE = 10000
conn = duckdb.connect()
conn.execute(“INSTALL httpfs; LOAD httpfs;”)
conn.execute(“INSTALL iceberg; LOAD iceberg;”)
conn.execute(“SET memory_limit = ‘4GB’;”)
conn.execute(“SET s3_region = ‘us-east-1’;”)
conn.execute(“‘’
CREATE OR REPLACE SECRET minio_secret (
TYPE S3,
KEY_ID ‘minio-root-user’,
SECRET ‘minio-root-password’,
ENDPOINT ‘minio:9000’,
USE_SSL false,
URL_STYLE ‘path’
);
‘‘’)
conn.execute(‘‘’
CREATE OR REPLACE SECRET iceberg_secret (
TYPE ICEBERG,
TOKEN ‘dummy’
);
‘‘’)
conn.execute(‘‘’
ATTACH ‘test_warehouse’ AS lakekeeper_db (
TYPE ICEBERG,
ENDPOINT ’http://lakekeeper:8181/catalog/',
ACCESS_DELEGATION_MODE ‘none’,
SECRET iceberg_secret
);
‘‘’)
cursor = conn.cursor()
cursor.execute(‘SELECT * FROM lakekeeper_db.test_schema.my_table2’)
col_names = [desc[0] for desc in cursor.description]
total_rows = 0
with open(OUTPUT_FILE, ‘w’, encoding=’utf-8’) as f:
while True:
rows = cursor.fetchmany(CHUNK_SIZE)
if not rows:
break
for row in rows:
row_dict = dict(zip(col_names, row))
f.write(json.dumps(row_dict, ensure_ascii=False, default=str) + ‘\n’)
total_rows += len(rows)
print(f’Обработано строк: {total_rows}’)
print(f’✅ Загружено и сохранено строк: {total_rows}”)
print(f“📁 Данные сохранены в {OUTPUT_FILE}”)
conn.close()
Можно даже так внутри уточки
import duckdb
OUTPUT_FILE = “/home/jovyan/examples/output_duckdb_direct.json”
conn = duckdb.connect()
conn.execute(“INSTALL httpfs; LOAD httpfs;”)
conn.execute(“INSTALL iceberg; LOAD iceberg;”)
conn.execute(“SET memory_limit = ‘4GB’;”)
conn.execute(“SET s3_region = ‘us-east-1’;”)
conn.execute(“‘’
CREATE OR REPLACE SECRET minio_secret (
TYPE S3,
KEY_ID ‘minio-root-user’,
SECRET ‘minio-root-password’,
ENDPOINT ‘minio:9000’,
USE_SSL false,
URL_STYLE ‘path’
);
‘‘’)
conn.execute(‘‘’
CREATE OR REPLACE SECRET iceberg_secret (
TYPE ICEBERG,
TOKEN ‘dummy’
);
‘‘’)
conn.execute(‘‘’
ATTACH ‘test_warehouse’ AS lakekeeper_db (
TYPE ICEBERG,
ENDPOINT ’http://lakekeeper:8181/catalog/',
ACCESS_DELEGATION_MODE ‘none’,
SECRET iceberg_secret
);
‘‘’)
conn.execute(f’’’
COPY (
SELECT * FROM lakekeeper_db.test_schema.my_table2
) TO ‘{OUTPUT_FILE}’ (FORMAT JSON);
‘‘’)
print(f’✅ Данные сохранены в {OUTPUT_FILE}’)
conn.close()
К конце концов я использовал
localstack-spooling
protocol.spooling.enabled=true
# 256-битный ключ в формате base64. Вы можете сгенерировать свой с помощью команды `openssl rand -base64 32`
protocol.spooling.shared-secret-key=jxTKysfCBuMZtFqUf8UJDQ1w9ez8rynEJsJqgJf66u0=
catalog.management=dynamicтак
spooling-manager.name=filesystem
fs.s3.enabled=true
fs.location=s3://spooling-bucket/client-spooling/
s3.endpoint=http://localstack-spooling:4566
s3.region=us-east-1
s3.aws-access-key=test
s3.aws-secret-key=test
s3.path-style-access=trueи так
services:
trino:
build: ./trino
environment:
- CATALOG_MANAGEMENT=dynamic
- LANCE_ALLOW_HTTP=true
- AWS_ALLOW_HTTP=true
- AWS_ACCESS_KEY_ID=minio-root-user
- AWS_SECRET_ACCESS_KEY=minio-root-password
- AWS_REGION=us-east-1
- AWS_ENDPOINT_URL=http://minio:9000
- CATALOG_MANAGEMENT=dynamic
- JDK_JAVA_OPTIONS=--add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED
healthcheck:
test: ["CMD", "curl", "-I", "http://localhost:8080/v1/status"]
interval: 2s
timeout: 10s
retries: 2
start_period: 10s
ports:
- "9999:8080"
volumes:
- ./lance5.properties:/etc/trino/catalog/lance5.properties
- ./lance_rest.properties:/etc/trino/catalog/lance_rest.properties
- ./lance_ice.properties:/etc/trino/catalog/lance_ice.properties
# --- ДОБАВЬТЕ ЭТУ СТРОКУ ---
- ./spooling-manager.properties:/etc/trino/spooling-manager.properties
# (При необходимости пробросьте и config.properties, если он не копируется при build: ./trino)
- ./config.properties:/etc/trino/config.properties
- spooling-data:/tmp/spooling
networks:
- lakekeeper-network
depends_on:
localstack-setup: # <--- Trino ждет, пока AWS CLI не создаст бакет!
condition: service_completed_successfully
localstack-spooling:
image: localstack/localstack:3.4.0 # Жестко фиксируем бесплатную рабочую версию!
container_name: localstack-spooling
ports:
- "4566:4566"
environment:
- SERVICES=s3
- AWS_DEFAULT_REGION=us-east-1
networks:
- lakekeeper-network
localstack-setup:
image: amazon/aws-cli:latest
container_name: localstack-setup
depends_on:
- localstack-spooling
restart: "no"
environment:
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=us-east-1
entrypoint: >
/bin/sh -c "
echo 'Waiting for LocalStack to fully start...';
sleep 10;
aws --endpoint-url=http://localstack-spooling:4566 s3 mb s3://spooling-bucket;
echo 'LocalStack bucket created successfully!';
"
networks:
- lakekeeper-network
jupyter:
image: quay.io/jupyter/pyspark-notebook:2024-10-14
depends_on:
lakekeeper:
condition: service_healthy
# Исправлено: теперь зависим от рабочего setup сервиса
lakekeeper-setup:
condition: service_completed_successfully
trino:
condition: service_healthy
# Удалено: starrocks (сервис не описан в compose файле)
command: start-notebook.sh --NotebookApp.token=''
volumes:
- ./notebooks:/home/jovyan/examples/
- spooling-data:/tmp/spooling
networks:
- lakekeeper-network
ports:
- "8888:8888"
# Сервис initialwarehouse УДАЛЕН, так как он дублировал lakekeeper-setup
# и ссылался на несуществующие сервисы (bootstrap, createbuckets).
postgres-lakekeeper:
image: postgres:17
container_name: postgres-lakekeeper
environment:
POSTGRES_USER: lakekeeper
POSTGRES_PASSWORD: lakekeeper
POSTGRES_DB: lakekeeper
ports:
- "5435:5432"
volumes:
- lakekeeper-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lakekeeper -d lakekeeper"]
interval: 2s
timeout: 10s
retries: 5
networks:
- lakekeeper-network
minio:
image: minio/minio:latest
container_name: minio-lakekeeper
environment:
MINIO_ROOT_USER: minio-root-user
MINIO_ROOT_PASSWORD: minio-root-password
# MINIO_DOMAIN: minio
command: server /data --console-address ":9001"
ports:
- "19000:9000"
- "19001:9001"
volumes:
- lakekeeper-minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 2s
timeout: 10s
retries: 5
networks:
- lakekeeper-network
minio-setup:
image: minio/mc:latest
container_name: minio-setup
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set myminio http://minio:9000 minio-root-user minio-root-password &&
mc mb myminio/warehouse --ignore-existing &&
echo 'MinIO bucket created'
"
networks:
- lakekeeper-network
lakekeeper-migrate:
image: quay.io/lakekeeper/catalog:latest-main
container_name: lakekeeper-migrate
depends_on:
postgres-lakekeeper:
condition: service_healthy
environment:
- LAKEKEEPER__PG_ENCRYPTION_KEY=test-encryption-key-not-secure
- LAKEKEEPER__PG_DATABASE_URL_READ=postgresql://lakekeeper:lakekeeper@postgres-lakekeeper:5432/lakekeeper
- LAKEKEEPER__PG_DATABASE_URL_WRITE=postgresql://lakekeeper:lakekeeper@postgres-lakekeeper:5432/lakekeeper
restart: "no"
command: ["migrate"]
networks:
- lakekeeper-network
lakekeeper:
image: quay.io/lakekeeper/catalog:latest-main
container_name: lakekeeper
depends_on:
lakekeeper-migrate:
condition: service_completed_successfully
minio-setup:
condition: service_completed_successfully
environment:
- LAKEKEEPER__PG_ENCRYPTION_KEY=test-encryption-key-not-secure
- LAKEKEEPER__PG_DATABASE_URL_READ=postgresql://lakekeeper:lakekeeper@postgres-lakekeeper:5432/lakekeeper
- LAKEKEEPER__PG_DATABASE_URL_WRITE=postgresql://lakekeeper:lakekeeper@postgres-lakekeeper:5432/lakekeeper
- LAKEKEEPER__AUTHZ_BACKEND=allowall
- RUST_LOG=info
command: ["serve"]
healthcheck:
test: ["CMD", "/home/nonroot/lakekeeper", "healthcheck"]
interval: 2s
timeout: 10s
retries: 5
start_period: 5s
ports:
- "8282:8181"
networks:
- lakekeeper-network
lakekeeper-bootstrap:
image: curlimages/curl
container_name: lakekeeper-bootstrap
depends_on:
lakekeeper:
condition: service_healthy
restart: "no"
command:
- -w
- "%{http_code}"
- "-X"
- "POST"
- "-v"
- "http://lakekeeper:8181/management/v1/bootstrap"
- "-H"
- "Content-Type: application/json"
- "--data"
- '{"accept-terms-of-use": true}'
- "-o"
- "/dev/null"
networks:
- lakekeeper-network
lakekeeper-setup:
image: curlimages/curl
container_name: lakekeeper-setup
depends_on:
lakekeeper-bootstrap:
condition: service_completed_successfully
restart: "no"
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo "Creating test_warehouse..."
curl -sf -X POST "http://lakekeeper:8181/management/v1/warehouse" \
-H "Content-Type: application/json" \
-d '{
"warehouse-name": "test_warehouse",
"project-id": "00000000-0000-0000-0000-000000000000",
"storage-profile": {
"type": "s3",
"bucket": "warehouse",
"endpoint": "http://minio:9000",
"region": "us-east-1",
"path-style-access": true,
"flavor": "minio",
"sts-enabled": false
},
"storage-credential": {
"type": "s3",
"credential-type": "access-key",
"aws-access-key-id": "minio-root-user",
"aws-secret-access-key": "minio-root-password"
}
}' && echo "Warehouse created successfully" || echo "Failed to create warehouse"
networks:
- lakekeeper-network
volumes:
lakekeeper-postgres-data:
lakekeeper-minio-data:
spooling-data:
networks:
lakekeeper-network:
driver: bridgeСсылка на оригинальную публикацию есть тут The 49MB Web Page.
Опубликовано: 12 апреля 2026 г. | Оригинал: 12 марта 2026 г.
МненИИе 🤖
Если бы отвлечение внимания пользователей было олимпийским видом спорта, новостные издания забирали бы все золотые медали. Зайдя на сайт крупного новостного портала вроде New York Times, чтобы просто прочитать пару заголовков, вы столкнетесь с лавиной: 422 сетевых запроса и 49MB загруженных данных. После того как страница наконец-то «успокоится» спустя пару минут, отпадает любой вопрос о том, почему каждый уважающий себя IT-специалист устанавливает блокировщики рекламы на все устройства своих близких.
Чтобы осознать масштаб феномена «49-мегабайтной веб-страницы», давайте вернемся в прошлое. Размер этой страницы превышает объем операционной системы Windows 95 (которая помещалась на 28 дискетах!). В эпоху расцвета iPod стандартный MP3-трек высокого качества (битрейт 192 kbps) занимал около 4-5MB. Таким образом, одна современная статья весит как полноценный музыкальный альбом из 10–12 песен.
Время загрузки в 2006 году ≈ 1.5 Mbps 49 MB×8 бит ≈ 261 секунда
Спустя 20 лет аппаратное обеспечение шагнуло далеко вперед, но современные рекламные технологии (ad-tech) полностью нивелировали этот прогресс своей плохой архитектурой и бесконечным раздуванием кода.
Издатели не злодеи, они просто в отчаянии. Попав в «смертельную спираль» programmatic-рекламы, они жертвуют долгосрочной лояльностью читателей ради сиюминутных копеек с показов (CPM). Современная рекламная индустрия разделила создателя контента и рекламодателя.
Каждое враждебное UX-решение проистекает из одной формулы: чем дольше вы заперты на странице взаимодействия, тем выше доход. Ваше разочарование — это их продукт. Мы можем описать общую стоимость взаимодействия (Interaction Cost) как математическую сумму:
C total =∑ ( C mental + C physical)
Вместо комфортного чтения пользователи сталкиваются с системой, которая максимизирует $C_{total}$, чтобы выжать максимум метрик из человеческого когнитивного ресурса.
Если маркетинговая команда настаивает на автовоспроизведении видео, разработчики обязаны использовать `IntersectionObserver`. Это позволит уважать ресурсы пользователя (батарею и CPU) при прокрутке страницы:
// Пример базовой реализации для паузы видео вне зоны видимости
const videoElement = document.querySelector('video.ads-player');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
videoElement.play();
} else {
videoElement.pause(); // Уважаем выбор пользователя!
}
});
});
observer.observe(videoElement);Также шапки сайтов следует скрывать при событии `scrollDown` и показывать только при `scrollUp`, освобождая драгоценное вертикальное пространство на мобильных устройствах.
Оригинальная статья поднимает важную проблему UI/UX, однако дискуссию стоит разбавить долей конструктивной критики:
Современный новостной веб-дизайн оказался в заложниках у метрик. Системы, созданные для вовлечения, трансформировались в «цифровую враждебную архитектуру», доводящую пользователя до ментального истощения. Страницы, превышающие по объему старые операционные системы, использование «тёмных паттернов» (модальные окна, микроскопические крестики закрытия) и беспощадная нагрузка на процессор телефона убивают самое главное — доверие между читателем и изданием.
Создателям контента следует помнить: если пользователь тратит свой когнитивный бюджет на то, чтобы закрыть 4 баннера до прочтения первого слова, никакая «оптимизация конверсии» не заставит его оформить платную подписку. Лучший веб-дизайн — это тот, который уважает время и внимание читателя.
Ventoy — это бесплатная утилита с открытым исходным кодом, которая навсегда изменит ваш подход к созданию загрузочных USB-накопителей. Вместо того чтобы каждый раз форматировать флешку для записи нового образа Windows или Linux, Ventoy позволяет просто копировать файлы образов на накопитель, как на обычную флешку.
Традиционные инструменты (например, Rufus или UltraISO) извлекают содержимое ISO-образа и записывают его на флешку, форматируя её. Если вам нужна другая операционная система, весь процесс приходится повторять.
Преимущества Ventoy:
Процесс использования максимально прост и состоит из нескольких шагов:
Несмотря на всю свою гениальность, у Ventoy есть несколько нюансов, о которых стоит знать:
Ventoy — это инструмент категории “must-have” для системных администраторов, энтузиастов и всех, кому приходится периодически переустанавливать операционные системы или пользоваться загрузочными инструментами. Один раз подготовив такую флешку, вы забудете о рутине с форматированием навсегда.