В продолжение вчерашнего поста про xxHash
Коллеги в комментариях подсказали ещё один интересный алгоритм — GxHash. Я посмотрел репозиторий вчера вечером и быстро прогнал бенчмарк (результаты на обложке).
Что понравилось:
– Действительно быстрый. Даже быстрее xxHash.
– Как и xxHash, возвращает хэш в виде uint, ulong или UInt128.
Недостатки:
– Нет NuGet-пакета. Пришлось копировать исходники вручную.
Спасибо за наводку @NSent
UPD: @viruseg нашёл NuGet-пакет. Большое спасибо.
Коллеги в комментариях подсказали ещё один интересный алгоритм — GxHash. Я посмотрел репозиторий вчера вечером и быстро прогнал бенчмарк (результаты на обложке).
Что понравилось:
– Действительно быстрый. Даже быстрее xxHash.
– Как и xxHash, возвращает хэш в виде uint, ulong или UInt128.
– Нет NuGet-пакета. Пришлось копировать исходники вручную.
Спасибо за наводку @NSent
UPD: @viruseg нашёл NuGet-пакет. Большое спасибо.
👍3
Используйте индексаторы вместо методов LINQ
Это часть серии, в которой я разбираю правила качества кода .NET с точки зрения производительности. Сегодня поговорим про CA1826: Use property instead of Linq Enumerable method.
Описание правила
Правило рекомендует не использовать методы First(), Last(), Count() из LINQ с IReadOnlyList<T>:
Вместо этого стоит использовать индексатор или свойство Count:
Анализ производительности
Я написал простой бенчмарк, чтобы проверить, насколько LINQ действительно влияет на производительность. Результаты в репозитории и на диаграмме.
Как видно, разница есть. Но она совсем небольшая — всего несколько микросекунд на моём ноутбуке.
Стоит ли заморачиваться?
В большинстве случаев нет. Прирост производительности минимальный. Но если вы пишете высокопроизводительный код или используете LINQ методы в цикле — возможно код стоит отрефакторить.
Это часть серии, в которой я разбираю правила качества кода .NET с точки зрения производительности. Сегодня поговорим про CA1826: Use property instead of Linq Enumerable method.
Описание правила
Правило рекомендует не использовать методы First(), Last(), Count() из LINQ с IReadOnlyList<T>:
IReadOnlyList<string> list = ["str1", "str2", "str3"];
var first = list.First(); // CA1826
var last = list.Last(); // CA1826
var count = list.Count(); // CA1826
Вместо этого стоит использовать индексатор или свойство Count:
IReadOnlyList<string> list = ["str1", "str2", "str3"];
var first = list[0];
var last = list[^1];
var count = list.Count;
Анализ производительности
Я написал простой бенчмарк, чтобы проверить, насколько LINQ действительно влияет на производительность. Результаты в репозитории и на диаграмме.
Как видно, разница есть. Но она совсем небольшая — всего несколько микросекунд на моём ноутбуке.
Стоит ли заморачиваться?
В большинстве случаев нет. Прирост производительности минимальный. Но если вы пишете высокопроизводительный код или используете LINQ методы в цикле — возможно код стоит отрефакторить.
👍6
Скриптинг на C# всё ближе
Microsoft продолжает двигаться в сторону упрощения C#. В свежем видео они показали функционал, позволяющий запускать cs-файлы без sln и csproj. Фича доступна в Preview версии .NET 10.
Пример:
Работает даже с NuGet-пакетами прямо в файле:
Как выглядит результат - на скриншоте.
Пока не совсем ясно, будет ли возможность запускать такие файлы в Docker, то есть через Runtime, а не через SDK.
Microsoft продолжает двигаться в сторону упрощения C#. В свежем видео они показали функционал, позволяющий запускать cs-файлы без sln и csproj. Фича доступна в Preview версии .NET 10.
Пример:
dotnet run hello.cs
Работает даже с NuGet-пакетами прямо в файле:
#:package Spectre.Console@0.50
#:package Spectre.Console.Cli@0.50
using Spectre.Console;
AnsiConsole.Markup("[underline red]Hello[/] [underline green]World![/]");
Как выглядит результат - на скриншоте.
Пока не совсем ясно, будет ли возможность запускать такие файлы в Docker, то есть через Runtime, а не через SDK.
👍8👎2
StringBuilder: избегайте ToString при использовании Append
Read on website.
Читать на сайте.
Это новая часть серии, в которой я разбираю правила качества кода .NET с точки зрения производительности. Сегодня поговорим про CA1830: Prefer strongly-typed Append and Insert method overloads on StringBuilder.
Описание правила
Правило рекомендует не преобразовывать значения в строку при вызове методов Append или Insert.
Вместо этого лучше передать значение напрямую:
Это правило касается примитивных типов вроде byte, short, int, double, long и других.
Анализ производительности
Я написал простой бенчмарк, чтобы оценить, насколько эти подходы отличаются по производительности. Результаты в репозитории и на диаграмме.
Разница по времени — небольшая: около 5 микросекунд на моём ноутбуке. Разница по памяти более заметная: около 20 кБ.
Как работает эта оптимизация?
Когда вы используете метод Append(int), под капотом StringBuilder вызываются методы AppendSpanFormattable<T> и InsertSpanFormattable<T>.
Например, упрощённая версия AppendSpanFormattable<T> выглядит так:
Этот метод использует интерфейс ISpanFormattable.TryFormat, чтобы записать значение напрямую во внутренний буфер StringBuilder как Span<char>. Это позволяет: избежать аллокации строки; упаковки (boxing) и вызова виртуального метода ToString().
Стоит ли заморачиваться?
Скорее да. Даже если не брать в расчёт прирост производительности, такой код:
и выглядит проще и чище, чем:
И в качестве бонуса, он работает быстрее и потребляет меньше памяти. Win-win.
Read on website.
Читать на сайте.
Это новая часть серии, в которой я разбираю правила качества кода .NET с точки зрения производительности. Сегодня поговорим про CA1830: Prefer strongly-typed Append and Insert method overloads on StringBuilder.
Описание правила
Правило рекомендует не преобразовывать значения в строку при вызове методов Append или Insert.
var i = 123;
var sb = new StringBuilder();
sb.Append(i.ToString()); // CA1830
Вместо этого лучше передать значение напрямую:
var i = 123;
var sb = new StringBuilder();
sb.Append(i);
Это правило касается примитивных типов вроде byte, short, int, double, long и других.
Анализ производительности
Я написал простой бенчмарк, чтобы оценить, насколько эти подходы отличаются по производительности. Результаты в репозитории и на диаграмме.
Разница по времени — небольшая: около 5 микросекунд на моём ноутбуке. Разница по памяти более заметная: около 20 кБ.
Как работает эта оптимизация?
Когда вы используете метод Append(int), под капотом StringBuilder вызываются методы AppendSpanFormattable<T> и InsertSpanFormattable<T>.
Например, упрощённая версия AppendSpanFormattable<T> выглядит так:
private StringBuilder AppendSpanFormattable<T>(T value) where T : ISpanFormattable
{
if (value.TryFormat(
RemainingCurrentChunk,
out int charsWritten,
format: default,
provider: null))
{
m_ChunkLength += charsWritten;
return this;
}
return Append(value.ToString());
}
Этот метод использует интерфейс ISpanFormattable.TryFormat, чтобы записать значение напрямую во внутренний буфер StringBuilder как Span<char>. Это позволяет: избежать аллокации строки; упаковки (boxing) и вызова виртуального метода ToString().
Стоит ли заморачиваться?
Скорее да. Даже если не брать в расчёт прирост производительности, такой код:
sb.Append(i);
и выглядит проще и чище, чем:
sb.Append(i.ToString());
И в качестве бонуса, он работает быстрее и потребляет меньше памяти. Win-win.
👍8
Два месяца назад публиковал список бесплатных сервисов PostgreSQL. Напомню, что это список провайдеров, которые предоставляют бесплатный хостинг PostgreSQL и соответствуют следующим критериям:
- Бесплатная регистрация без банковской карты.
- Нет лимита по времени, то есть используемая база данных не будет удалена через определённый промежуток времени.
За это время проект получил 23 звезды и 1 пул реквест. Два сервиса были удалены из списка, и два новых добавлены.
✅ Новые сервисы
Xata.io Lite – предоставляет бесплатный тариф с 15 ГБ и ежедневными бэкапами.
Rapidapp.io – бесплатный план с 20 МБ хранилища.
❌ Удалённые сервисы
Tembo.io – отменили бесплатный план с 30 мая. Жалко, т.к., на мой взгляд, у этого провайдера были самые адекватные условия.
Turso.tech – удалён, так как это SQLite as a Service, а не PostgreSQL (добавил по ошибке).
Please open Telegram to view this post
VIEW IN TELEGRAM
👍5
Последние несколько недель максимально погружен в работу, поэтому публикаций не так много. Но появился интересный кейс, о котором хочется рассказать.
Задача
Нужно было скачать большие csv-файлы, прочитать их, отфильтровать нужные строки и загрузить в базу данных. Под большими я имею в виду файлы по 0,5–1 ГБ – это месячные отчёты от облачных провайдеров о расходах.
Это стандартная ETL-задача. Сложность была в том, что у подов в нашем Kubernetes-кластере ограничение по памяти на 250 МБ. Можно было просто поднять лимит, но это слишком просто и неинтересно. Я решил попробовать сделать всё с минимальными затратами памяти.
Пост не влезает в Telegram, поэтому продолжение на сайте.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍10
Последние два месяца я пишу мало, т.к. работа отнимает много сил. Но не могу не рассказать, как в прошлом месяце я мигрировал проект с SQL Server на PostgreSQL.
Предыстория
Проект, над которым работаю в данный момент, – это FinOps приложение для аналитики трат на облачные провайдеры. В первоначальной версии дашборда, которую мы сделали 2 года назад, в качестве хранилища использовалась SQL Server. Это основная база данных для приложений у нас в команде. Данные обрабатывались при помощи OLAP-кубов из SQL Server Analytics Services (SSAS).
В начале лета к нам пришёл запрос на доработку дашборда. Доработка затрагивала многие аспекты приложения, из-за чего приложение пришлось практически переписать. Поэтому заодно я решил и мигрировать проект на Postgres.
Почему отказались от SSAS
1. Для OLAP-кубов SSAS есть отдельный тип проектов, который поддерживается только Visual Studio. Ничего не имею против Visual Studio, но когда ты Full Stack инженер, которому приходится работать с C#, TypeScript, React, Docker, Kubernetes, Helm, то Visual Studio не самый подходящий выбор.
2. Несмотря на то, что Visual Studio – это единственная IDE, поддерживающие такие проекты, она периодически крэшится и зависает при работе с ними.
3. Деплой OLAP-куба возможен только через Visual Studio. Настроить деплой через CI/CD нельзя.
4. Синтаксис запросов Analytics Services (MDX) отличается от SQL:
-- SQL
SELECT service, SUM(cost) AS cost
FROM costs
WHERE provider='AWS' AND environment_tag='prod'
AND date >= DATE '2025-08-01' AND date < DATE '2025-09-01'
GROUP BY service
ORDER BY cost DESC
FETCH FIRST 3 ROWS ONLY;
-- MDX
SELECT
{ [Measures].[Cost] } ON COLUMNS,
TopCount([Service].[Name].Members, 3, [Measures].[Cost]) ON ROWS
FROM [Cloud FinOps]
WHERE ( [Date].[Calendar].[Month].&[2025]&[8],
[Provider].[Provider].&[AWS],
[Tags].[Environment].&[prod] )
5. Запуск перерасчёта OLAP-куба из приложения в Docker требует танцев с бубнами. Есть кроссплатформенные библиотеки от Microsoft, но они не работают из коробки.
Всё это послужило причиной переезда на Postgres с использованием материализованных представлений (Materialized Views). В SQL Server тоже есть похожий функционал (Indexed Views), но их создание и поддержка сложнее.
Lessons Learned
Теперь о том, какой опыт я приобрёл, пока делал миграцию проекта.
1. Потоковая вставка данных в Postgres проще, чем в SQL Server.
В предыдущем посте я рассказывал, как записать большой массив данных в SQL Server. Так вот, в Postgres это делается значительно проще. Не нужно писать портянки кода, реализовывая интерфейс IDataReader.
2. Материализованные представления способны заменить OLAP-куб.
Пара десятков представлений покрывают все потребности нашего дашборда. При этом, используется обычный SQL синтаксис, который знаком любому инженеру.
3. Использование каскада представлений позволяет уменьшить время обновления.
Каскад материализованных представлений (cascade / nested materialized views) – это техника, при которой одно материализованное представление строится на основе другого MV, а то на основе третьего, и так далее. Например, сначала агрегируем данные по дням, потом по месяцам, а затем, агрегируем траты по категориям ресурсов для конкретного месяца.
4. Главная таблица (Fact Table) должна занимать как можно меньше места.
Уменьшение размера строки даже на несколько байт может сэкономить гигабайты памяти, что улучшит скорость выполнения запросов.
К примеру, у нас каждый месяц от трёх облачных провайдеров суммарно приходит около 12 млн событий. Строка Fact Table сейчас состоит из 3-х столбцов: Timestamp (Date, 4 байт), Cost (decimal, примерно 8 байт), ResourceId (integer, 4 байт). Добавление, например, столбца Id в качестве первичного ключа спокойно может увеличить размер таблицы на 100 – 200 Мб в месяц (размер столбца + размер индекса первичного ключа).
Please open Telegram to view this post
VIEW IN TELEGRAM
👍8
Read on website.
Читать на сайте.
Очень часто в проектах приходится поддерживать в актуальном состоянии типы и описание эндпоинтов. Если делать это вручную, то высока вероятность допустить ошибку. В результате получаются баги и тратиться время на исправление. Сегодня я пошагово покажу:
1. Как настроить бэкенд-проект ASP.NET Web API для генерации OpenAPI-спецификаций.
2. Как из спецификации автоматически генерировать http-клиент и TypeScript-типы для фронта.
В итоге синхронизировать бек и фронт можно будет всего двумя командами буквально за секунды.
Настраиваем бекенд
Создаём новый проект:
mkdir -p backend
cd backend
dotnet new webapi
По умолчанию файл проекта (*.csproj) использует пакет Swashbuckle.AspNetCore. Этот пакет автоматически генерирует OpenAPI (Swagger) спецификацию для всех контроллеров и моделей в ASP.NET Web API.
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
Важно запомнить версию пакета. Она должна совпадать с версией dotnet‑утилиты, которую мы установим дальше. В моём проекте стояла версия 6.6.2, я обновил до последней (9.0.4 на момент написания):
dotnet add package Swashbuckle.AspNetCore –version 9.0.4
Следующий шаг – установка утилиты, которая будет генерировать спецификацию из сборки:
dotnet new tool-manifest
dotnet tool install Swashbuckle.AspNetCore.Cli --version 9.0.4
Первая команда создаёт файл‑манифест для dotnet‑утилит (аналог package.json или Directory.Packages.props). Манифест полезен тем, что чтобы восстановить нужные для проекта утилиты, достаточно будет выполнить команду dotnet tool restore. Это удобно как и для остальных членов вашей команды, так и при сборке/деплое проекта в CI/CD.
Осталось сгенерировать спецификацию:
dotnet build && dotnet swagger tofile \
--output openapi.yaml \
--yaml \
bin/Debug/net8.0/backend.dll \
v1
Файл спецификации будет лежать в backend/openapi.yaml.
Настраиваем фронтенд
Создаём Svelte приложение (я использую pnpm).
pnpx sv create \
--template minimal \
--types ts \
--install pnpm \
--no-add-ons \
./frontend
Генерируем клиента со всеми типами.
pnpx swagger-typenoscript-api generate \
--path ./backend/openapi.yaml \
-o ./frontend/src/generated \
-n WebApi.ts
Готово. Сгенерированный файл лежит в frontend/src/generated/WebApi.ts.
Заключение
Теперь, после изменений в API, для синхронизации достаточно выполнить 2 команды:
dotnet build && dotnet swagger tofile \
--output openapi.yaml \
--yaml \
bin/Debug/net8.0/backend.dll \
v1
pnpx swagger-typenoscript-api generate \
--path ./backend/openapi.yaml \
-o ./frontend/src/generated \
-n WebApi.ts
Полный пример скрипта лежит в GitHub.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍8
В мае этого года я писал про алгоритм хэширования xxHash в .NET. Тогда я исследовал эту тему, чтобы использовать хэш для ускорения SQL-запросов. Некоторые таблицы БД фактически работали, как key-value хранилище.
В комментариях советовали так не рисковать, но я рискнул… и выстрелил себе в ногу. 😬 Объясняю, почему так делать не надо.
Недавно я случайно наткнулся на объяснение парадокса дней рождения. Суть в том, что в группе из 23 человек вероятность совпадения дня рождения (число и месяц) хотя бы у двоих людей превышает 50%. У этого утверждения есть математическое доказательство, можете ознакомиться с ним на Википедии, например.
В алгоритмах хэширования этот парадокс проявляется следующим образом. В нашем FinOps дашборде для аналитики трат, таблица Resources – одна из самых больших (не считая Fact-таблицы). В Resources использовалось хеширование. Размером она чуть больше 200 000 строк. Тип значения хэша был int32, то есть количество уникальных значений хэша 4 294 967 296. Кажется, что 200 тысяч – это мелочь, по сравнению с 4 миллиардами. И все хэши ресусов должны быть уникальными. Но сравнивать нужно не отдельные значения, а пары. А 200 тысяч ресурсов образуют примерно 20 миллиардов пар комбинаций.
Мои примерные расчёты показали, что в таблице Resources с вероятностью 99.98% были коллизии. Я полез проверять, а так ли это на самом деле. Оказалось, что математика не обманывает. На более чем 200 000 строк набралось около 15 записей с одинаковым хэшем, но разным InternalId (внутренний ID ресурса в облаке). На момент обнаружения, этот баг практически не влиял на точность аналитики, но очевидно, что дальше было бы только хуже. Поэтому пришлось потратить 1.5 дня на выпиливание этого функционала.
Вывод: не делайте как я. Сейчас мы всё также используем суррогатные первичные ключи, но их значения последовательно генерируются базой данных. Потоковая вставка реализована через денормализованную стейдж таблицу. Расскажу об этом подробнее в другой раз.
Под конец, анекдот в тему:
Один батюшка строил церковь. И всё бы хорошо, но колокольня всё время падала. Построят - упадёт. Снова построят - снова упадёт. И вот явился к батюшке ангел божий и изрёк: "Вмуруй жену свою в фундамент и в веках простоит Храм твой". Жену юную батюшка очень любил, но Господа тоже любил. И храм строить нужно. Погоревал, поплакал, попрощался с женой (а она была барышня богобоязненная, надо так надо) и таки замуровал. А колокольня все равно упала. Потому что сопромат не обманешь.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍8
Потоковая вставка данных в Postgres через денормализованные таблицы
В прошлый раз обещал рассказать про потоковую вставку данных через денормализованные таблицы. Сегодня разберём этот подход и посмотрим на замеры производительности в разных сценариях. На первой картинке процесс вставки изображён схематично.
Подготовительная часть
Нам нужна промежуточная таблица. В Postgres удобно использовать временные таблицы, которые автоматически удаляются после коммита:
Процесс вставки
1️⃣ Данные из источника, например из csv-файла, вставляются напрямую в SQL промежуточную таблицу с помощью COPY.
2️⃣ Денормализованные данные из stage_table объединяются с данными в нормализованных таблицах при помощи INSERT … ON CONFLICT:
RETURNING нужен для того, чтобы получить суррогатные PK для дальнейшей вставки в зависимые таблицы.
3️⃣ Полученные ID используются для вставки данных в billing_data тем же способом через INSERT … ON CONFLICT:
Использовать “GROUP BY resource, billing_date” необязательно. В моём случае, было допустимо сгруппировать стоимость по дням, т.к. более детализированные данные не нужны. Если нужна детализация, то GROUP BY лучше убрать, тогда в billing_data попадут все исходные строки.
Бенчмарки
Как известно, индексы могут ускорить запросы, но также и замедлить вставку, ведь каждое изменение таблицы требует поддержания индекса в актуальном состоянии. Поэтому я сравнил насколько сильно индексы могут замедлить вставку. Проверял несколько сценариев:
- без индекса;
- create index idx_billing_data on billing_data(resource);
- create index idx_billing_data on billing_data(resource, billing_date);
- create index idx_billing_data on billing_data(resource, billing_date, cost);
- create index idx_billing_data on billing_data(resource, billing_date) include (cost).
Результаты на второй картинке. Исходный код и результаты тут.
Самая быстрая вставка — без индексов. Чем больше столбцов в индексе, тем сильнее падение производительности. Чуть быстрее сценарий, когда индекс создаётся уже после вставки.
Влияние индексов на SELECT и GROUP BY
В этом подходе используется SELECT и GROUP BY. Когда я реализовывал его в FinOps-дашборде, я отдельно исследовал, как индексы влияют на выполнение вот такого запроса:
Я перепробовал разные варианты индексов, чтобы добиться максимальной скорости. Как думаете, с каким индексом на stage_table такой запрос отработает быстрее всего? Варианты оставляю в опросе 👇 О результатах расскажу на следующей неделе — сейчас как раз обрабатываю результаты бенчмарков.
В прошлый раз обещал рассказать про потоковую вставку данных через денормализованные таблицы. Сегодня разберём этот подход и посмотрим на замеры производительности в разных сценариях. На первой картинке процесс вставки изображён схематично.
Подготовительная часть
Нам нужна промежуточная таблица. В Postgres удобно использовать временные таблицы, которые автоматически удаляются после коммита:
CREATE TEMP TABLE … ON COMMIT DROP;
Процесс вставки
1️⃣ Данные из источника, например из csv-файла, вставляются напрямую в SQL промежуточную таблицу с помощью COPY.
var sql =
"""
copy stage_table
(resource, billing_date, cost)
from stdin (format binary)
""";
using var importer = conn.BeginBinaryImport(sql);
foreach (var d in DataRows)
{
importer.StartRow();
importer.Write(d.Resource, NpgsqlDbType.Text);
importer.Write(d.BillingDate, NpgsqlDbType.Date);
importer.Write(d.Cost, NpgsqlDbType.Integer);
}
2️⃣ Денормализованные данные из stage_table объединяются с данными в нормализованных таблицах при помощи INSERT … ON CONFLICT:
-- Группируем стоимость ресурсов по датам
WITH src AS (
SELECT resource, billing_date, sum(cost) AS cost
FROM stage_table
GROUP BY resource, billing_date
),
-- Вставляем (обновляем) ресурсы
res_map AS (
INSERT INTO resources(resource)
SELECT DISTINCT resource
FROM src
ON CONFLICT (resource) DO UPDATE
SET resource = EXCLUDED.resource
RETURNING id, resource
)
RETURNING нужен для того, чтобы получить суррогатные PK для дальнейшей вставки в зависимые таблицы.
3️⃣ Полученные ID используются для вставки данных в billing_data тем же способом через INSERT … ON CONFLICT:
INSERT INTO billing_data(resource_id, billing_date, cost)
-- вставляем id из пред. шага
SELECT m.id, s.billing_date, s.cost
FROM src s
JOIN res_map m USING (resource)
ON CONFLICT (resource_id, billing_date) DO UPDATE
SET cost = EXCLUDED.cost
Использовать “GROUP BY resource, billing_date” необязательно. В моём случае, было допустимо сгруппировать стоимость по дням, т.к. более детализированные данные не нужны. Если нужна детализация, то GROUP BY лучше убрать, тогда в billing_data попадут все исходные строки.
Бенчмарки
Как известно, индексы могут ускорить запросы, но также и замедлить вставку, ведь каждое изменение таблицы требует поддержания индекса в актуальном состоянии. Поэтому я сравнил насколько сильно индексы могут замедлить вставку. Проверял несколько сценариев:
- без индекса;
- create index idx_billing_data on billing_data(resource);
- create index idx_billing_data on billing_data(resource, billing_date);
- create index idx_billing_data on billing_data(resource, billing_date, cost);
- create index idx_billing_data on billing_data(resource, billing_date) include (cost).
Результаты на второй картинке. Исходный код и результаты тут.
Самая быстрая вставка — без индексов. Чем больше столбцов в индексе, тем сильнее падение производительности. Чуть быстрее сценарий, когда индекс создаётся уже после вставки.
Влияние индексов на SELECT и GROUP BY
В этом подходе используется SELECT и GROUP BY. Когда я реализовывал его в FinOps-дашборде, я отдельно исследовал, как индексы влияют на выполнение вот такого запроса:
select resource, billing_date, sum(cost)
from stage_table
group by resource, billing_date
Я перепробовал разные варианты индексов, чтобы добиться максимальной скорости. Как думаете, с каким индексом на stage_table такой запрос отработает быстрее всего? Варианты оставляю в опросе 👇 О результатах расскажу на следующей неделе — сейчас как раз обрабатываю результаты бенчмарков.
👍4❤1
С каким индексом на stage_table запрос выполнится быстрее всего?
Final Results
17%
без индекса
4%
create index idx_billing_data on billing_data(resource)
33%
create index idx_billing_data on billing_data(resource, billing_date)
4%
create index idx_billing_data on billing_data(resource, billing_date, cost)
42%
create index idx_billing_data on billing_data(resource, billing_date) include (cost)
Потоковая вставка данных в Postgres через денормализованные таблицы. Часть 2.
Это дополнение к предыдущему посту. Про SELECT будет чуть позже на этой неделе.
В прошлый раз я советовал использовать временные таблицы командой CREATE TEMP TABLE. У них есть удобный функционал автоматического удаления таблицы ON COMMIT DROP.
В процессе экспериментов и чтения документации Postgres я обнаружил другой тип таблиц: UNLOGGED. Они, как и TEMP таблицы, не используют Write-Ahead Log (WAL). То есть нет накладных расходов из-за записи в лог, но также нет и возможности восстановить данные в случае сбоя. Но, поскольку стейдж-таблица будет удалена сразу после завершения обработки данных, нам это и не нужно.
Я решил сравнить производительность трёх видов таблиц: обычные, UNLOGGED и TEMP. Я ожидал, что UNLOGGED и TEMP таблицы будут примерно одинаковыми по скорости и при этом быстрее обычных таблиц. Как оказалось, всё не так просто.
1️⃣ Если таблицы без индекса, то да, UNLOGGED и TEMP быстрее обычных таблиц (диаграмма 1) и показывают плюс-минус одинаковые результаты.
2️⃣ Ситуация меняется, если у таблиц есть индекс. Я создал индекс billing_data(resource, billing_date) include(cost). При относительно большом объёме данных (в моём случае больше 100 000 – 200 000 строк), TEMP таблицы становятся медленнее UNLOGGED таблиц и даже обычных таблиц (диаграмма 2).
Причина этого в буфере, который Postgres выделяет для временных таблиц. Размер буфера задаётся параметром temp_buffers и по умолчанию равен 8 Мб. При вставке в таблицу с индексом Postgres также должен поддерживать сам индекс в актуальном состоянии. В какой-то момент все данные перестают помещаться в буфер и Postgres начинает использовать диск. Это значительно влияет на скорость доступа к данным.
3️⃣ Чтобы проверить гипотезу, я сделал ещё один бенчмарк: вставка 1 млн строк в таблицы с индексом и разными значениями temp_buffer. Результаты (диаграмма 3) её подтверждают: с увеличением размера буфера, скорость записи в TEMP таблицы становится практически такой же, как у UNLOGGED таблиц.
Промежуточные выводы
1. Вставка в TEMP и UNLOGGED таблицы быстрее, чем в обычные таблицы.
2. Изменяя размер буфера TEMP таблиц можно влиять на скорость вставки данных.
Про индексы, UNLOGGED таблицы и их влияние на SELECT будет в следующем посте. Там тоже много чего интересного.
Это дополнение к предыдущему посту. Про SELECT будет чуть позже на этой неделе.
В прошлый раз я советовал использовать временные таблицы командой CREATE TEMP TABLE. У них есть удобный функционал автоматического удаления таблицы ON COMMIT DROP.
В процессе экспериментов и чтения документации Postgres я обнаружил другой тип таблиц: UNLOGGED. Они, как и TEMP таблицы, не используют Write-Ahead Log (WAL). То есть нет накладных расходов из-за записи в лог, но также нет и возможности восстановить данные в случае сбоя. Но, поскольку стейдж-таблица будет удалена сразу после завершения обработки данных, нам это и не нужно.
Я решил сравнить производительность трёх видов таблиц: обычные, UNLOGGED и TEMP. Я ожидал, что UNLOGGED и TEMP таблицы будут примерно одинаковыми по скорости и при этом быстрее обычных таблиц. Как оказалось, всё не так просто.
1️⃣ Если таблицы без индекса, то да, UNLOGGED и TEMP быстрее обычных таблиц (диаграмма 1) и показывают плюс-минус одинаковые результаты.
2️⃣ Ситуация меняется, если у таблиц есть индекс. Я создал индекс billing_data(resource, billing_date) include(cost). При относительно большом объёме данных (в моём случае больше 100 000 – 200 000 строк), TEMP таблицы становятся медленнее UNLOGGED таблиц и даже обычных таблиц (диаграмма 2).
Причина этого в буфере, который Postgres выделяет для временных таблиц. Размер буфера задаётся параметром temp_buffers и по умолчанию равен 8 Мб. При вставке в таблицу с индексом Postgres также должен поддерживать сам индекс в актуальном состоянии. В какой-то момент все данные перестают помещаться в буфер и Postgres начинает использовать диск. Это значительно влияет на скорость доступа к данным.
3️⃣ Чтобы проверить гипотезу, я сделал ещё один бенчмарк: вставка 1 млн строк в таблицы с индексом и разными значениями temp_buffer. Результаты (диаграмма 3) её подтверждают: с увеличением размера буфера, скорость записи в TEMP таблицы становится практически такой же, как у UNLOGGED таблиц.
Промежуточные выводы
1. Вставка в TEMP и UNLOGGED таблицы быстрее, чем в обычные таблицы.
2. Изменяя размер буфера TEMP таблиц можно влиять на скорость вставки данных.
Про индексы, UNLOGGED таблицы и их влияние на SELECT будет в следующем посте. Там тоже много чего интересного.
👍10
Потоковая вставка данных в Postgres через денормализованные таблицы. Часть 3.
Часть 1.
Часть 2.
В прошлый части я писал, что TEMP таблицы не используют Write-Ahead Log. Кроме того, такие таблицы существуют только в рамках текущей сессии. То есть они недоступны, если, например, открыть другое подключение и попытаться прочитать их.
TEMP таблицы и параллелизм
У этих таблиц есть ещё одна особенность. Postgres помечает SELECT для TEMP таблиц как PARALLEL RESTRICTED. Это значит, что при выполнении любых операций SELECT, планировщик не может распараллелить запрос, даже когда это возможно.
Что это значит на практике? Запрос, который при других условиях выполнялся бы параллельно и быстрее, не будет выполнен параллельно. Проверить это просто. Можно выполнить уже знакомый по первой части SELECT с GROUP BY на таблицах разного размера и типа (диаграмма 1).
У обычных и UNLOGGED таблиц при количестве строк от 800к – 900к меняется план выполнения с HashAggregate Seq Scan на Parallel HashAggregate Seq Scan. У TEMP таблиц такого не происходит.
Важно уточнить, что решающим фактором здесь является не количество строк, а общий размер таблицы и стоимость выполнения запроса параллельно. В моём примере именно при 800к – 900к строк таблица достигла такого объёма, при котором планировщик посчитал возможным включить параллельное выполнение.
Настройка параллелизма
Это поведение можно настроить при помощи параметров в таблице pg_settings:
min_parallel_table_scan_size = 1024 x 8 kB – минимальный размер таблицы, при котором может использоваться параллелизм;
min_parallel_index_scan_size = 64 x 8 kB – минимальный размер индекса (если есть), при котором может использоваться параллелизм;
parallel_setup_cost = 1000 – условная стоимость запуска параллельных воркеров (чем выше, тем меньше вероятность использования параллелизма);
parallel_tuple_cost 0.1– условная стоимость передачи одной строки между воркерами.
Зададим значение 0 параметраам min_parallel_table_scan_size, parallel_setup_cost и parallel_tuple_cost, назовём этот режим forced parallelism и повторим замеры на обычной таблице.
Примерно до 60–70 тысяч строк запросы с такими настройками выполняются медленнее, чем при стандартных значениях. Но при большем объёме данных параллелизм становится выгоднее. В результате время выполнения запросов сокращается почти в два раза (диаграмма 2).
Промежуточные выводы
1. Особенность TEMP таблиц не позволяет Postgres выполнять запросы на этой таблице параллельно.
2. Обычные и UNLOGGED таблицы не имеют таких ограничений. И при определённых условиях планировщик выберет параллельное выполнение.
3. Параметры базы данных можно настроить так, чтобы планировщик активнее использовал параллелизм.
Следующая часть будет завершающая на эту тему. В ней я наконец-таки расскажу про влияние индексов.
Часть 1.
Часть 2.
В прошлый части я писал, что TEMP таблицы не используют Write-Ahead Log. Кроме того, такие таблицы существуют только в рамках текущей сессии. То есть они недоступны, если, например, открыть другое подключение и попытаться прочитать их.
TEMP таблицы и параллелизм
У этих таблиц есть ещё одна особенность. Postgres помечает SELECT для TEMP таблиц как PARALLEL RESTRICTED. Это значит, что при выполнении любых операций SELECT, планировщик не может распараллелить запрос, даже когда это возможно.
Что это значит на практике? Запрос, который при других условиях выполнялся бы параллельно и быстрее, не будет выполнен параллельно. Проверить это просто. Можно выполнить уже знакомый по первой части SELECT с GROUP BY на таблицах разного размера и типа (диаграмма 1).
У обычных и UNLOGGED таблиц при количестве строк от 800к – 900к меняется план выполнения с HashAggregate Seq Scan на Parallel HashAggregate Seq Scan. У TEMP таблиц такого не происходит.
Важно уточнить, что решающим фактором здесь является не количество строк, а общий размер таблицы и стоимость выполнения запроса параллельно. В моём примере именно при 800к – 900к строк таблица достигла такого объёма, при котором планировщик посчитал возможным включить параллельное выполнение.
Настройка параллелизма
Это поведение можно настроить при помощи параметров в таблице pg_settings:
min_parallel_table_scan_size = 1024 x 8 kB – минимальный размер таблицы, при котором может использоваться параллелизм;
min_parallel_index_scan_size = 64 x 8 kB – минимальный размер индекса (если есть), при котором может использоваться параллелизм;
parallel_setup_cost = 1000 – условная стоимость запуска параллельных воркеров (чем выше, тем меньше вероятность использования параллелизма);
parallel_tuple_cost 0.1– условная стоимость передачи одной строки между воркерами.
Зададим значение 0 параметраам min_parallel_table_scan_size, parallel_setup_cost и parallel_tuple_cost, назовём этот режим forced parallelism и повторим замеры на обычной таблице.
Примерно до 60–70 тысяч строк запросы с такими настройками выполняются медленнее, чем при стандартных значениях. Но при большем объёме данных параллелизм становится выгоднее. В результате время выполнения запросов сокращается почти в два раза (диаграмма 2).
Промежуточные выводы
1. Особенность TEMP таблиц не позволяет Postgres выполнять запросы на этой таблице параллельно.
2. Обычные и UNLOGGED таблицы не имеют таких ограничений. И при определённых условиях планировщик выберет параллельное выполнение.
3. Параметры базы данных можно настроить так, чтобы планировщик активнее использовал параллелизм.
Следующая часть будет завершающая на эту тему. В ней я наконец-таки расскажу про влияние индексов.
👍4
Потоковая вставка данных в Postgres через денормализованные таблицы. Часть 4.
Часть 1.
Часть 2.
Часть 3.
Заключительная часть серии статей. Сегодня отвечу на вопрос, заданный в 1 части: с каким индексом на stage_table такой запрос отработает быстрее всего:
Ответ
Индекс неважен и результаты одинаковые, так как планировщик всегда выбирает HashAggregate с Seq Scan (диаграмма 1), потому что любой другой алгоритм агрегирования окажется медленнее.
Объяснение
Убедится в том, что Postgres неспроста использует HashAggregate, можно на примере, если добавить к таблице индекс и принудительно отключить HashAggregate с помощью опции enable_hashagg:
С включённым HashAggregate видим знакомую картину (диаграмма 2): HashAggregate с Seq Scan для относительно небольших таблиц и Parallel HashAggregate с Seq Scan для крупных таблиц, даже несмотря на наличие индекса.
С отключённым HashAggregate Postgres выбирает GroupAggregate с Index Only Scan или Group Aggregate с Seq Scan, в зависимости от размера таблицы. Наконец-таки индекс используется, но общая производительность всё равно хуже, чем HashAggregate.
Причины следующие:
1. HashAggregate выполняет один последовательный проход по таблице и считает сумму во внутренней хеш‑таблице. Это минимизирует непоследовательный доступ, и данные попадают в кэш CPU. Для нашей задачи – сгруппировать всё содержимое таблицы – это идельный сценарий.
2. Index Only Scan медленнее из-за непоследовательного доступа. Поскольку индекс по умолчанию – это B-Tree, его чтение сопряжено с чтением данных из разных частей памяти. Индексы хороши, когда нужно получить небольшой кусочек таблицы, но не когда нужно обработать всё сразу.
3. GroupAggregate с Seq Scan требует отсортированного входа по ключам, используемым в GROUP BY. Поскольку Seq Scan, который в нашем случае ещё и выполняется параллельно, не гарантирует порядок данных, то планировщик добавляет в план сортировку. Очевидно, это ухудшает время выполнения.
Выводы
Если нужно быстро загрузить большой объём данных в Postgres с использованием промежуточных таблиц, то:
1. Не используйте индексы в промежуточных таблицах, потому что
- вы потратите время на создание индекса;
- индексы замедляют вставку в таблицу;
- индексы не дают преимуществ, если требуется агрегировать данные по всей промежуточной таблице;
2. Используйте UNLOGGED таблицы, вместо TEMP таблиц. Оба типа не используют Write-Ahead Log, но UNLOGGED позволяет распараллеливать запросы SELECT, что может значительно ускорить выполнения запросов. Главное не забыть удалить UNLOGGED таблицу в конце транзакции. Они не удаляются автоматически, как TEMP таблицы.
Важно помнить, что всё вышесказанное верно для рассматриваемого сценария: массовая вставка с последующей агрегацией по всей промежуточной таблице. Для CRUD функционала лучше использовать обычные таблицы и добавлять индексы.
Часть 1.
Часть 2.
Часть 3.
Заключительная часть серии статей. Сегодня отвечу на вопрос, заданный в 1 части: с каким индексом на stage_table такой запрос отработает быстрее всего:
select resource, billing_date, sum(cost)
from stage_table
group by resource, billing_date
Ответ
Индекс неважен и результаты одинаковые, так как планировщик всегда выбирает HashAggregate с Seq Scan (диаграмма 1), потому что любой другой алгоритм агрегирования окажется медленнее.
Объяснение
Убедится в том, что Postgres неспроста использует HashAggregate, можно на примере, если добавить к таблице индекс и принудительно отключить HashAggregate с помощью опции enable_hashagg:
create index idx_billing_data
on billing_data (resource, billing_date)
include (cost);
set enable_hashagg = off;
С включённым HashAggregate видим знакомую картину (диаграмма 2): HashAggregate с Seq Scan для относительно небольших таблиц и Parallel HashAggregate с Seq Scan для крупных таблиц, даже несмотря на наличие индекса.
С отключённым HashAggregate Postgres выбирает GroupAggregate с Index Only Scan или Group Aggregate с Seq Scan, в зависимости от размера таблицы. Наконец-таки индекс используется, но общая производительность всё равно хуже, чем HashAggregate.
Причины следующие:
1. HashAggregate выполняет один последовательный проход по таблице и считает сумму во внутренней хеш‑таблице. Это минимизирует непоследовательный доступ, и данные попадают в кэш CPU. Для нашей задачи – сгруппировать всё содержимое таблицы – это идельный сценарий.
2. Index Only Scan медленнее из-за непоследовательного доступа. Поскольку индекс по умолчанию – это B-Tree, его чтение сопряжено с чтением данных из разных частей памяти. Индексы хороши, когда нужно получить небольшой кусочек таблицы, но не когда нужно обработать всё сразу.
3. GroupAggregate с Seq Scan требует отсортированного входа по ключам, используемым в GROUP BY. Поскольку Seq Scan, который в нашем случае ещё и выполняется параллельно, не гарантирует порядок данных, то планировщик добавляет в план сортировку. Очевидно, это ухудшает время выполнения.
Выводы
Если нужно быстро загрузить большой объём данных в Postgres с использованием промежуточных таблиц, то:
1. Не используйте индексы в промежуточных таблицах, потому что
- вы потратите время на создание индекса;
- индексы замедляют вставку в таблицу;
- индексы не дают преимуществ, если требуется агрегировать данные по всей промежуточной таблице;
2. Используйте UNLOGGED таблицы, вместо TEMP таблиц. Оба типа не используют Write-Ahead Log, но UNLOGGED позволяет распараллеливать запросы SELECT, что может значительно ускорить выполнения запросов. Главное не забыть удалить UNLOGGED таблицу в конце транзакции. Они не удаляются автоматически, как TEMP таблицы.
Важно помнить, что всё вышесказанное верно для рассматриваемого сценария: массовая вставка с последующей агрегацией по всей промежуточной таблице. Для CRUD функционала лучше использовать обычные таблицы и добавлять индексы.
👍4
Иду на AI-хакатон в Белграде от Yandex и Reputeo
На следующей неделе, 21 — 23 ноября, в Белграде пройдёт хакатон, в котором мы с товарищем участвуем. Сейчас ищем в команду ещё двух человек:
- ML/LLM/AI Engineer (RAG, tool calling, LLM stability, prompting, MCP).
- Product Manager (сформулировать проблему и скоуп, нарисовать user flow, уметь в UI/UX).
Какие навыки конкретно потребуются пока не знаем: описания челленджей ещё нет. Хорошо, если вы T-shaped и разбираетесь в смежных областях.
Формат хакатона:
- Старт в пятницу, 17:30. Окончание в воскресенье, 14:00.
- Локация: офис Yandex на Bulevar vojvode Bojovića 12.
- Площадка открыта всю ночь. Можно уехать ночевать домой или остаться - места для отдыха есть.
- Организаторы обеспечивают питание, снеки, напитки.
- Основной язык для коммуникаций с оргами и для презентаций - английский.
- Результаты работы команд будут выложены в open source.
Если вы в Белграде и вам интересно — пишите в лс.
--
P.S. После завершения обязательно расскажу, как всё прошло. Пишите вопросы, если хочется узнать что-то конкретное — постараюсь разузнать в процессе.
На следующей неделе, 21 — 23 ноября, в Белграде пройдёт хакатон, в котором мы с товарищем участвуем. Сейчас ищем в команду ещё двух человек:
- ML/LLM/AI Engineer (RAG, tool calling, LLM stability, prompting, MCP).
- Product Manager (сформулировать проблему и скоуп, нарисовать user flow, уметь в UI/UX).
Какие навыки конкретно потребуются пока не знаем: описания челленджей ещё нет. Хорошо, если вы T-shaped и разбираетесь в смежных областях.
Формат хакатона:
- Старт в пятницу, 17:30. Окончание в воскресенье, 14:00.
- Локация: офис Yandex на Bulevar vojvode Bojovića 12.
- Площадка открыта всю ночь. Можно уехать ночевать домой или остаться - места для отдыха есть.
- Организаторы обеспечивают питание, снеки, напитки.
- Основной язык для коммуникаций с оргами и для презентаций - английский.
- Результаты работы команд будут выложены в open source.
Если вы в Белграде и вам интересно — пишите в лс.
--
P.S. После завершения обязательно расскажу, как всё прошло. Пишите вопросы, если хочется узнать что-то конкретное — постараюсь разузнать в процессе.
👍6
🥉 Мы взяли 3 место!
Закончился хакатон от Yandex и Reputeo, и наша интернациональная команда заняла 3 место. Было сделано сильное, технологичное и полностью рабочее решение — мы этим гордимся.
За 36 часов мы:
- Обработали десятки миллионов постов и комментариев Reddit, отобрав самые залайканные или обсуждаемые треды.
- На этом датасете дообучили RoBERTa-base для предсказания «вирусности» текста.
- Сделали REST API на .NET 8, который принимает текст, генерирует до 250 потенциально лучших вариантов при помощи GPT-5, вызывает RoBERTa для ранжирования и возвращает самый «вирусный» вариант с объяснениями, почему он лучше.
- Собрали адаптивный UI на React: исходный текст, улучшенный текст и наглядную разницу между ними.
- Настроили CI/CD с автоматической сборкой и деплоем фронта и бэкенда на VM.
- Развернули полноценный работающий демо-сайт, доступный по ссылке viraly.site.
Мы были единственной командой, которая сделала не просто очередной враппер для ChatGPT, а построила приложение с кастомной дообученной LLM. И одной из немногих, у кого был полностью работоспособный продукт доступный онлайн.
Возможно, нам немного не хватило продуктовой подачи, чтобы лучше раскрыть инвесторам потенциал того, что мы построили.
Закончился хакатон от Yandex и Reputeo, и наша интернациональная команда заняла 3 место. Было сделано сильное, технологичное и полностью рабочее решение — мы этим гордимся.
За 36 часов мы:
- Обработали десятки миллионов постов и комментариев Reddit, отобрав самые залайканные или обсуждаемые треды.
- На этом датасете дообучили RoBERTa-base для предсказания «вирусности» текста.
- Сделали REST API на .NET 8, который принимает текст, генерирует до 250 потенциально лучших вариантов при помощи GPT-5, вызывает RoBERTa для ранжирования и возвращает самый «вирусный» вариант с объяснениями, почему он лучше.
- Собрали адаптивный UI на React: исходный текст, улучшенный текст и наглядную разницу между ними.
- Настроили CI/CD с автоматической сборкой и деплоем фронта и бэкенда на VM.
- Развернули полноценный работающий демо-сайт, доступный по ссылке viraly.site.
Мы были единственной командой, которая сделала не просто очередной враппер для ChatGPT, а построила приложение с кастомной дообученной LLM. И одной из немногих, у кого был полностью работоспособный продукт доступный онлайн.
Возможно, нам немного не хватило продуктовой подачи, чтобы лучше раскрыть инвесторам потенциал того, что мы построили.
👍18