Фрагментация блобов Azure Blob Storage в сценариях загрузки и скачивания данных

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Введение

Если вы работали с облачными технологиями Microsoft Azure то наверняка сталкивались, или как минимум читали, про Azure Storage Account и его составляющие – Tables, Queues и Blobs.

В данной статье я хотел бы рассмотреть последнюю из них, блобы, с точки зрения скорости доступа к ним и сделать небольшой обзор возможностей по их модификации без загрузки всего контента блоба на клиент.

В настоящий момент Azure предоставляет три типа блобов:

  1. Блочные блобы (Block BLOBs) хранят бинарные данные в виде отдельных блоков переменного размера и позволяют загрузить до 190Тб данных суммарно в один блоб.

  2. Блобы оптимизированные для добавления (Append BLOBs) представляют собой фактически те же блочные блобы, но инфраструктура Azure Storage Account берет на себя ответственность за добавление данных в конец существующего блоба, а также позволяет множеству отдельных продюсеров писать в один и тот же блоб без блокировок (но и без гарантий обеспечения последовательности, есть только гарантия что каждая отдельная вставка данных будет добавлена к блобу консистентно и не перепишет другую).

  3. Страничные блобы (Page BLOBs) предоставляют случайный доступ к содержимому и преимущественно используются для хранения образов виртуальных машин.

Когда говорят о блобах чаще всего имеют в виду блочные. Я поступлю также и только немного затрону тему блобов, оптимизированных для добавления, т.к. страничные в основном используются в самой инфрастуктуре Azure, а в моей практике не пришлось с ними сталкиваться вообще.

Что может быть проще блоба?

Для начала небольшая история, с которой мы столкнулись на одном из проектов несколько лет назад. Нам требовалось получать, хранить и отдавать через API большие объемы телеметрии от клиентов, причем использовать таблицы было неудобно по нескольким причинам.

Во-первых, они не давали нужной скорости чтения при запросах за длительные периоды. Все банально упиралось в скорость получения данных от Table API, и даже переход на посуточное партиционирование и параллельные запросы по каждым суткам не давал нужного результата.

Во-вторых, уж очень дорого начинало выходить железо наших API сервисов из-за JSON сериализации в которой приходят ответы от Table API.

Начали исследовать варианты, приценились к Cosmos DB, но выходило совсем дорого при наших объемах, и тут наткнулись на Append BLOB-ы, которые как раз вышли в General Availability. Microsoft предлагал использовать их для сценариев добавления данных (журналы, логи), причем у них из коробки был функционал неблокируемой записи в блоб несколькими писателями. Казалось бы, что могло пойти не так?

И вот наш прототип развернут на стенде для нагрузочного тестирования. Вначале все было довольно хорошо – данные лились в блобы шустро, запросы к ним тоже выполнялись быстрее чем при работе с Azure Table Storage, благо чтобы найти их не требовалось сканировать партицию таблицы, достаточно было сформировать имя блоба из типа события и даты, а бинарная protobuf сериализация позволила сильно экономить на процессорных ресурсах.

Все было хорошо до момента, пока число записей в блобах не стало приближаться к ожидаемому суточному количеству. Чем дольше работала заливка данных, тем медленнее наше приложение отдавало данные по запросам к API. Скорость чтения блобов внутри инфраструктуры Azure, в одном дата-центре, от Storage Account до наших Web API сервисов, снизилась до неадекватных значений. Блоб размером в десяток мегабайт мог читаться несколько минут!

Как показал анализ мы столкнулись с проблемой чтения фрагментированных блобов.

Если вы используете Append BLOBs с конкурентной вставкой, то Azure для каждой операции добавления создает отдельный блок, и синхронизирует только операции коммита блоков, никак не группируя блоки с данными. И когда размер блока оказывается очень малым при большом их, блоков, количестве – скорость чтения такого блоба катастрофически падает.

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

Block BLOB и как им заменить Append BLOB

Теперь вернемся к блочным блобам, и заодно я расскажу, как мы решили проблему с телеметрией. В документации по Blob Storage Microsoft приводит пример загрузки блоба целиком одним вызовом.

У данного метода есть ограничение по размеру создаваемого блоба, но, подозреваю, в большинстве случаев из него никто не выходит, так как в текущий момент это около 5Гб (до 2019 – 256Мб, до 2016 – до 64Мб).

Но кроме такого простого API есть еще и расширенное. Что в нем? Три операции – Put Block, Put Block List и Get Block List. Если кратко – вы можете загрузить отдельные блоки размером до 4Гб (до 2019 – 100Мб, до 2016 – 4Мб), каждый блок должен иметь уникальный идентификатор размером до 64 байт, а потом вы вызываете Put Block List передавая ему список идентификаторов блоков и блоб становится доступным и видимым другим клиентам.

Если копнуть глубже, что еще можно сделать с этими методами?

Например, можно при загрузке блоба самостоятельно разбить данные на нужное нам число блоков и загружать их одновременно. Так можно ускорить загрузку больших блобов почти на порядок и при этом практически без изменения логики загрузки данных в целом. Но об этом ниже.

Либо можно реализовать свой собственный Append BLOB, добавляя в конец существующего блоба новые блоки. А можно так – хранить в сервисе, обновляющем блоб, содержимое последнего хвостового блока и актуальный список блоков. Тогда при необходимости добавить немного данных в блоб вы просто добавляете их к этому содержимому, создаете из него новый хвостовой блок и заменяете им старый. Два вызова API (Put Block и Put Block List), ни одного чтения, и у вас практически Append BLOB, только лучше, так как фрагментация у него сильно ниже. Ну а когда хвостовой блок становится слишком большим – начинаем собирать новый. Из минусов - нужно делать привязку клиентов к инстансу сервиса, через который модифицируется блоб. Собственно, это то что получилось у нас, и теперь переваривает довольно большие объемы телеметрии.

Но необязательно останавливаться на этом. Ведь у уже созданного блоба можно менять любые блоки, не только последний. К тому же размеры блоков не обязательно должны быть одинаковыми, так что блоки можно и разделять и склеивать в пределах максимально допустимых 4Гб.

А еще у нас есть ID блока, в который можно положить до 64 байт данных. И если для собственно идентификатора блока в блобе достаточно пары байт (помним – не более 50000 блоков на блоб), то в остальные 62 байта можно класть произвольные данные, так что можно организовать свои собственные небольшие метаданные для блоков, правда в режиме "только для чтения".

Фрагментация, скорость загрузки и скачивания

Но что же со скоростью работы с блобами, всегда ли фрагментация вредна? Ответом тут будет: это зависит от задачи.

Пример - использование блобов для доставки в ДЦ Azure данных из внешней инфраструктуры, когда для импорта данных в Azure Tables выгоднее упаковать данные в блоб, закинуть их в Storage Account в том же ДЦ что и ваша таблица, и уже там заливать данные в таблицу. Скорее всего на стороне Azure ограничивающим фактором будет уже не скорость чтения блоба (если не доводить его фрагментацию до абсурда), а вставка в таблицу, и тогда ускорение процесса заливки блоба может быть полезным.

public static class BlockBlobClientExtensions
{
    public static async Task UploadUsingMultipleBlocksAsync(this BlockBlobClient client, byte[] content, int blockCount)
    {
        if(client == null) throw new ArgumentNullException(nameof(client));
        if(content == null) throw new ArgumentNullException(nameof(content));
        if(blockCount < 0 || blockCount > content.Length) throw new ArgumentOutOfRangeException(nameof(blockCount));

        var position = 0;
        var blockSize = content.Length / blockCount;
        var blockIds = new List<string>();

        var tasks = new List<Task>();

        while (position < content.Length)
        {
            var blockId = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
            blockIds.Add(blockId);
            tasks.Add(UploadBlockAsync(client, blockId, content, position, blockSize));
            position += blockSize;
        }

        await Task.WhenAll(tasks);
        await client.CommitBlockListAsync(blockIds);
    }

    private static async Task UploadBlockAsync(BlockBlobClient client, string blockId, byte[] content, int position, int blockSize)
    {
        await using var blockContent = new MemoryStream(content, position, Math.Min(blockSize, content.Length - position));
        await client.StageBlockAsync(blockId, blockContent);
    }
}

Конечно, можно реализовать такую логику распараллеливая загрузку данных по разным блобам и на стороне Azure собирать из них исходные данные, однако, если в дальнейшем необходимо гарантировать последовательность обработки данных и их целостность, то проще использовать загрузку блоков и Put Block List для атомарного создания блоба из отдельных блоков.

Особенно такой подход имеет смысл рассматривать при необходимости быстро загружать большие объемы данных в географически удаленный ДЦ Azure, когда из-за большой латентности TCP соединения мы не можем полностью утилизировать доступный нам канал.

Но не следует в гонке за скоростью забывать о лимитах Storage Account чтобы не попасть под троттлинг запросов (как в тесте ниже), ну и делать это точно стоит только если при разбиении на блоки их размер будет оставаться достаточно большим.

Немного тестов

Оценить влияние фрагментации на скорость скачивания блоба, а так же то, как параллелизм повлияет на скорость загрузки блоба в Storage Account, можно по результатам небольшого теста ниже.

Берем случайный массив байт размером 100.000.000 байт, загружаем в Storage Account в виде блоба состоящего из 1, 100, 1000, 10000 или 50000 (больше блоков в текущей версии API в один блоб добавить нельзя) блоков, полученных разбиением исходного массива на равные части. После этого полученный блоб скачиваем и удаляем. Замеряем время загрузки и скачивания, скорость рассчитываем, используя время в секундах, округленное до двух знаков после запятой.

Тест №1. Storage Account в ДЦ Azure North Europe, клиент в Москве.

Blocks count

Block size, bytes

Upload time, s

Upload speed, Kb/s

Download time, s

Download speed, Kb/s

1

100 000 000

19.32

5 054

28.90

3 379

100

1 000 000

3.81

25 631

38.49

2 537

1 000

100 000

6.00

16 276

42.16

2 316

10 000

10 000

7.27

13 432

127.73

764

50 000

2 000

31.97

3 054

394.86

247

Тест №2. Storage Account в ДЦ Azure West US, клиент в Москве.

Blocks count

Block size, bytes

Upload time, s

Upload speed, Kb/s

Download time, s

Download speed, Kb/s

1

100 000 000

51.48

1 896

80

1 220

100

1 000 000

8.96

10 899

96

1 017

1 000

100 000

3.48

28 062

105

930

10 000

10 000

2.67

36 575

230

424

50 000

2 000

9.82

9 944

770

127

Увеличение времени загрузки блоба при увеличении числа блоков от 1000 и выше похоже обусловлено троттлингом из-за выхода за лимиты API Storage Account-а - ситуация, доводить до которой в проде ни в коем случае не следует.

Выводы

При работе с большими изменяемыми блобами важно контролировать степень их фрагментации, иначе легко оказаться в ситуации что данные вроде как есть, но скорость доступа к ним стала настолько низкой что их (данных) как бы и нет.

Если вы всетаки столкнулись с такой проблемой то скорее всего придется или менять логику приложения, или задуматься о регулярном "лечении" таких блобов путем пересборки их с объединением блоков в более крупные, благо API позволяет это сделать не создавая промежуточных блобов, прямо in-place, и даже с возможностью через Optimistic concurrency не потерять консистентности ценой повторной переобработки.

А вот для сценария импорта данных Azure небольшая фрагментация (на уровне 100–1000 блоков, и с контролем степени параллелизма) позволит загрузить данные в ДЦ Azure на порядок быстрее и более полно утилизировать ваш канал , что при потери скорости обработки данных в дальнейшем примерно на 25% выглядит приемлемым компромиссом и при этом не потребует глубокой модификации кода приложения.

Источник: https://habr.com/ru/post/652337/


Интересные статьи

Интересные статьи

Мы с радостью сообщаем об общей доступности (GA) PostgreSQL 14 на платформе Azure с опцией Hyperscale (Citus). Насколько нам известно, это первый случай, когда крупный облачный провайдер объявляет GA ...
За последние несколько лет исследования в области генерации текстов естественного языка (Natural Language Generation, NLG), используемой для таких задач, как суммаризация текста, достигли...
Введение Amplitude как инструмент продуктовой аналитики очень хорошо зарекомендовал себя благодаря несложной настройке событий и гибкости визуализаций. И нередко возникает потребност...
Как датасайентисты, мы обязаны уметь анализировать и интерпретировать данные. И мы были очень обеспокоены результатами анализа данных, касающихся covid-19. Наибольшему риску подвержены самые уязв...
Привет, Хабр! Сегодня будем прорабатывать навык использования средств группирования и визуализации данных в Python. В предоставленном датасете на Github проанализируем несколько характери...