Введение
Если вы работали с облачными технологиями Microsoft Azure то наверняка сталкивались, или как минимум читали, про Azure Storage Account и его составляющие – Tables, Queues и Blobs.
В данной статье я хотел бы рассмотреть последнюю из них, блобы, с точки зрения скорости доступа к ним и сделать небольшой обзор возможностей по их модификации без загрузки всего контента блоба на клиент.
В настоящий момент Azure предоставляет три типа блобов:
Блочные блобы (Block BLOBs) хранят бинарные данные в виде отдельных блоков переменного размера и позволяют загрузить до 190Тб данных суммарно в один блоб.
Блобы оптимизированные для добавления (Append BLOBs) представляют собой фактически те же блочные блобы, но инфраструктура Azure Storage Account берет на себя ответственность за добавление данных в конец существующего блоба, а также позволяет множеству отдельных продюсеров писать в один и тот же блоб без блокировок (но и без гарантий обеспечения последовательности, есть только гарантия что каждая отдельная вставка данных будет добавлена к блобу консистентно и не перепишет другую).
Страничные блобы (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% выглядит приемлемым компромиссом и при этом не потребует глубокой модификации кода приложения.