BigQuery: аналитика на петабайтах

Киллер-фича Google Cloud: SQL по терабайтам за секунды, без единого сервера. И главная ловушка новичка — счёт, который выставляют не за время запроса, а за просканированные байты.

BigQuery — serverless-хранилище аналитических данных: вы пишете обычный SQL, Google сам поднимает тысячи машин на несколько секунд, а платите вы за объём данных, который запрос прочитал.

Зачем нужен ещё один SQL, если есть Cloud SQL

Cloud SQL — это OLTP-база: тысячи мелких запросов вида «дай заказ №42», «обнови статус». Она отлично держит нагрузку приложения и абсолютно беспомощна, когда аналитик пишет:

SELECT country, COUNT(*), SUM(amount)
FROM events
WHERE event_date BETWEEN '2020-01-01' AND '2026-07-01'
GROUP BY country;

По таблице на 3 миллиарда строк такой запрос в PostgreSQL будет идти минут сорок и попутно положит продакшен. BigQuery выполнит его секунд за пять — и не потому, что «Google быстрее», а потому что устроен принципиально иначе.

Два архитектурных решения делают всю разницу:

  • Разделение хранения и вычислений. Данные лежат в распределённой файловой системе, а вычислительные мощности берутся из общего пула на время запроса. Нет «сервера БигКвери», который вы арендуете. Есть запрос — есть машины, нет запроса — нет счёта за вычисления.
  • Колоночное хранилище. Строки не хранятся целиком. Каждая колонка лежит отдельным сжатым массивом.

Колоночное хранилище: почему это меняет всё

Представьте таблицу событий с 60 колонками, где 55 из них — тяжёлый JSON-payload, user-agent и прочий мусор. Строчная база (PostgreSQL) для запроса «посчитай события по странам» вынуждена прочитать каждую строку целиком: страна лежит вперемешку со всем остальным на одной странице диска.

BigQuery хранит колонку country отдельным файлом. Нужен только country — читается только он. Соседние 59 колонок не трогаются вообще. Плюс однотипные значения в колонке сжимаются в разы лучше, чем разнородная строка.

Отсюда — главное правило BigQuery, из которого следуют все остальные:

Вы платите за просканированные байты, а объём скана определяется тем, какие колонки вы перечислили в SELECT и какие партиции отсекли в WHERE.

Сколько это стоит

За чтоПорядок ценыБесплатный лимит
Запросы (on-demand)≈ $6,25 за просканированный ТиБ1 ТиБ в месяц
Активное хранение≈ $0,02 за ГиБ/мес10 ГиБ
Долговременное хранение≈ $0,01 за ГиБ/мес
Streaming inserts≈ $0,01 за 200 МиБ

Долговременное хранение включается само: если таблицу (или конкретную партицию) не меняли 90 дней, цена за неё падает вдвое. Ничего настраивать не нужно.

А теперь арифметика, ради которой написан этот урок. Таблица событий весит 2 ТиБ. Аналитик пишет:

SELECT * FROM analytics.events LIMIT 10;

Кажется, что это «посмотреть десять строчек» — операция на копейку. На самом деле: * означает «прочитать все колонки», отсутствие WHERE — «все партиции». Просканировано 2 ТиБ, к оплате около $12,50. За просмотр десяти строк.

LIMIT не уменьшает скан. Это самая контринтуитивная вещь во всём BigQuery. Лимит применяется после того, как данные прочитаны и обработаны движком: он ограничивает размер результата, а не объём чтения. Запомните это раз и навсегда — на этом сгорел не один бюджет.

Как смотреть данные бесплатно

  • Вкладка Preview в консоли (и bq head) читает данные напрямую, минуя движок запросов, — бесплатно. Именно так и надо «просто посмотреть, что в таблице».
  • SELECT COUNT(*) не читает колонки — тоже почти бесплатно, метаданные.
  • Кэш результатов: повторный идентичный запрос к неизменившейся таблице отдаётся из кэша бесплатно (кэш живёт 24 часа). Он ломается, если в запросе есть недетерминированные функции вроде CURRENT_TIMESTAMP().

Три приёма, чтобы не разориться

1. Dry run: узнать цену до запуска

BigQuery умеет посчитать объём скана, не выполняя запрос. Это бесплатно и занимает доли секунды — привыкайте делать это перед любым тяжёлым запросом.

bq query --use_legacy_sql=false --dry_run \
  'SELECT * FROM `my-project.analytics.events`'

Результат:

Query successfully validated. Assuming the tables are not modified,
running this query will process 2199023255552 bytes of data.

2 199 023 255 552 байт — это ровно 2 ТиБ, то есть примерно $12,50. А теперь то же самое, но аккуратно:

bq query --use_legacy_sql=false --dry_run \
  'SELECT event_name, COUNT(*) AS c
   FROM `my-project.analytics.events`
   WHERE event_time >= TIMESTAMP("2026-07-01")
   GROUP BY event_name'

Результат:

Query successfully validated. Assuming the tables are not modified,
running this query will process 1476395008 bytes of data.

1,4 ГиБ вместо 2 ТиБ — в полторы тысячи раз меньше. Разница только в том, что мы назвали нужные колонки и отсекли партиции.

2. Предохранитель maximum_bytes_billed

Запрос, который собирается прочитать больше разрешённого, просто не запустится. Это ставят и на конкретный запрос, и на весь проект — обязательная гигиена, если к данным есть доступ у команды.

# не дороже ~1 ГиБ скана: всё, что больше, — упадёт с ошибкой, а не со счётом
bq query --use_legacy_sql=false --maximum_bytes_billed=1073741824 \
  'SELECT country, COUNT(*) FROM `my-project.analytics.events` GROUP BY country'

3. Партиционирование и кластеризация

Партиционирование физически разрезает таблицу на куски по значению одной колонки (чаще всего — по дате). Запрос с фильтром по этой колонке читает только нужные куски. Таблица за пять лет, запрос за один день — сканируется 1/1800 данных.

CREATE TABLE analytics.events (
  event_time TIMESTAMP,
  user_id    STRING,
  event_name STRING,
  country    STRING,
  payload    JSON
)
PARTITION BY DATE(event_time)
CLUSTER BY event_name, country
OPTIONS (
  require_partition_filter = TRUE,
  partition_expiration_days = 400
);

Здесь сделано четыре важных вещи:

  • PARTITION BY DATE(event_time) — по одной партиции на календарный день.
  • CLUSTER BY — внутри партиции данные ещё и физически отсортированы по event_name и country. Фильтр по этим полям позволяет движку пропускать целые блоки. Кластеризация — это «второй этаж» отсечения, уже внутри партиции.
  • require_partition_filter = TRUEзолотая настройка. Запрос без фильтра по партиции просто не выполнится: BigQuery вернёт ошибку вместо счёта на $12,50. Ставьте её на каждой большой таблице.
  • partition_expiration_days — партиции старше 400 дней удаляются сами, вы не платите за древние данные.

Ошибка при отсутствующем фильтре выглядит так — и это лучшая ошибка в вашей жизни:

Cannot query over table 'analytics.events' without a filter over column(s)
'event_time' that can be used for partition elimination.

Из кода запросы выглядят обычно — важно лишь передавать параметры, а не склеивать SQL строками:

from google.cloud import bigquery

client = bigquery.Client()

job_config = bigquery.QueryJobConfig(
    # предохранитель: не дороже 1 ГиБ скана
    maximum_bytes_billed=1_073_741_824,
    query_parameters=[
        bigquery.ScalarQueryParameter("day", "DATE", "2026-07-01"),
    ],
)

sql = """
    SELECT event_name, COUNT(*) AS c
    FROM `my-project.analytics.events`
    WHERE DATE(event_time) = @day
    GROUP BY event_name
    ORDER BY c DESC
"""

for row in client.query(sql, job_config=job_config).result():
    print(row.event_name, row.c)

Как это работает

Когда вы жмёте «Выполнить», происходит примерно следующее. Планировщик разбирает SQL и строит дерево выполнения. Данные лежат колонками в сжатом формате Capacitor поверх распределённой файловой системы Colossus. Из общего пула выделяются слоты — единицы вычислений; каждый слот берёт свой кусок колонок и считает частичный результат. Промежуточные данные между этапами (например, для GROUP BY) прогоняются через shuffle по сети, которая внутри дата-центра работает на сотнях гигабит. Результаты сливаются и отдаются вам.

Практические следствия этой архитектуры:

  • BigQuery не для OLTP. Точечный UPDATE ... WHERE id = 42 здесь выполним, но это дорогая перезапись блоков, да ещё и с квотами на количество DML-операций. Оперативные данные приложения живут в Cloud SQL, а в BigQuery попадают уже как поток событий.
  • Нет индексов в привычном смысле. Ускоряют не индексы, а партиционирование, кластеризация и отказ от лишних колонок.
  • Скорость почти не зависит от размера таблицы — зависит от объёма скана. Запрос по 1 ГиБ из таблицы в 100 ТиБ будет быстрым.
  • JOIN гигантских таблиц дорог по shuffle. Маленький справочник BigQuery сам «размножит» по узлам (broadcast join), а вот два многотерабайтных монстра честно поедут по сети.

Частые ошибки

  • SELECT * на большой таблице. Убивает всю выгоду колоночного хранения: вы читаете все 60 колонок ради двух. Перечисляйте колонки явно — всегда.
  • Верить, что LIMIT 10 дёшев. Не дёшев: лимит режет результат, а не скан. Чтобы «посмотреть данные», пользуйтесь Preview или bq head — они бесплатны.
  • Таблица без партиционирования. Каждый запрос читает всю историю. Партиция по дате — первое, что делают с любой таблицей событий.
  • Партиционирование есть, а фильтра нет. Партиции не работают магически: без WHERE по партиционной колонке сканируется всё. Ставьте require_partition_filter = TRUE и спите спокойно.
  • Фильтр по обёрнутой колонке. Условие вида WHERE CAST(event_time AS STRING) LIKE '2026-07%' ломает отсечение партиций: BigQuery не может понять, какие куски нужны, и читает всё. Фильтруйте по колонке напрямую.
  • Нет maximum_bytes_billed. Один невнимательный аналитик с доступом к продовым данным способен за минуту сжечь месячный бюджет. Предохранитель на уровне проекта стоит поставить в первый же день.
  • Стриминг вместо батча. Streaming inserts удобны, но платны за каждый мегабайт. Если данные не нужны в реальном времени, грузите файлами из Cloud Storage — загрузка бесплатна.
  • Использовать BigQuery как основную базу приложения. Он не даёт ни низкой задержки на точечный запрос, ни транзакций в привычном смысле. Это хранилище для аналитики.

Итоги

  • BigQuery — serverless-аналитика: хранение и вычисления разделены, серверов нет, SQL обычный.
  • Колоночное хранение означает, что цена запроса зависит от перечисленных колонок и отсечённых партиций, а не от размера таблицы.
  • Оплата — примерно $6,25 за просканированный ТиБ; первый 1 ТиБ в месяц и 10 ГиБ хранения бесплатны.
  • SELECT * дорог, а LIMIT не спасает: он режет результат, а не объём чтения.
  • Три обязательных привычки: --dry_run перед тяжёлым запросом, maximum_bytes_billed как предохранитель, PARTITION BY плюс require_partition_filter на каждой большой таблице.
  • Кластеризация — второй уровень отсечения внутри партиции; долговременное хранение (90 дней без изменений) само делает данные вдвое дешевле.
  • BigQuery не заменяет Cloud SQL: оперативные данные — там, аналитика — здесь.
Проверьте себя
1. Таблица events весит 2 ТиБ и не партиционирована. Сколько данных просканирует запрос SELECT * FROM events LIMIT 10?
AНесколько килобайт — прочитаются только 10 строк
BОколо 2 ТиБ — LIMIT ограничивает размер результата, а не объём чтения
CНичего не просканирует: запросы с LIMIT берутся из кэша
DРовно 1/10 таблицы, пропорционально лимиту
2. Какая настройка таблицы физически не даст выполнить запрос без фильтра по партиционной колонке, защищая от случайного скана всей истории?
ACLUSTER BY по этой колонке
Bpartition_expiration_days
Crequire_partition_filter = TRUE
DКэш результатов запросов