Введение

Если вы работали с облачными технологиями 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% выглядит приемлемым компромиссом и при этом не потребует глубокой модификации кода приложения.

Комментарии (0)