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