Бекенд на C# Web Api

To Kaiten

Чтение и запись большого числа данных

Рассмотрим некоторые оптимизации при чтении и записи больших объемов данных в БД.

1. Стрим контента из запроса HttpClient

Если серверу нужно что-то прочитать из стороннего источника используя HTTP запрос, то это можно сделать с помощью класса HttpClient. Однако если получаемые данные большие, то вызов методов client.GetAsync() или client.Get() приведут к тому что оперативная память быстро забьётся, т.к. приложение будет пытаться держать в памяти все данные, получаемые запросом.

Вместо этого используйте метод client.GetStreamAsync() и работайте с результатом как с потоком. Однако этот метод не позволяет узнать статус-код запроса. Чтобы быстро получить статус-код запроса без нагрузки на оперативную память можно использовать обычный метод client.GetAsync(), но с дополнительным параметром HttpCompletionOption.ResponseHeadersRead.

HttpClient client = new();

// читаем только заголовки
var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);

if (response.StatusCode == HttpStatusCode.OK)
{
    // создаем Stream контента
    using Stream contentStream = await client.GetStreamAsync(url);
}

2. Чтение стрима

Чтобы прочитать большой объем данных последовательно, не нагрузив оперативную память, используют стримы.

Рассмотрим данные, которые приходят в формате json. Допустим в массиве data очень большой объем данных.

{
  "metadata": {
    "version": "1.0",
    "source": "example"
  },
  "data": [
    {
      "id": 1,
      "name": "Item 1",
      "value": 100
    },
    {
      "id": 2,
      "name": "Item 2",
      "value": 200
    },
    {
      "id": 3,
      "name": "Item 3",
      "value": 300
    },
    ...
  ]
}

Чтобы прочитать эти данные можно использовать такой метод:

public static async Task ReadStreamData<T>(Stream jsonStream, bool gzip, Func<T, Task> onFetched)
{
    using var gzipStream = gzip ? new GZipStream(jsonStream, CompressionMode.Decompress) : null;
    using var textReader = new StreamReader(gzipStream ?? jsonStream);
    using var reader = new JsonTextReader(textReader);

    JsonSerializer serializer = new JsonSerializer();

    while (await reader.ReadAsync())
    {
        if (reader.ValueType == typeof(string) && (string)reader.Value == "data")
        {
            await reader.ReadAsync();
            break;
        }
    }

    while (await reader.ReadAsync())
    {
        if (reader.TokenType == JsonToken.EndArray)
            break;

        var data = serializer.Deserialize<T>(reader);
        await onFetched?.Invoke(data);
    }
}
var headerResponse = await _client.GetAsync(responseUrl, HttpCompletionOption.ResponseHeadersRead);

if (headerResponse.StatusCode != HttpStatusCode.OK)
    return;

using Stream jsonStream = await _client.GetStreamAsync(streamUrl);

await UploadStreamData<MyData>(
    jsonStream,
    headerResponse.Content.Headers.ContentEncoding.Contains("gzip"),
    async (analyticsData) => {...}
);

Рассмотрим подробнее метод ReadStreamData.

  • Если флаг gzip установлен в true, поток jsonStream оборачивается в GZipStream для декомпрессии. В противном случае используется сам jsonStream. Подробнее про использование gzip смотрите в документации, откуда скачиваются данные.

  • Создается StreamReader для чтения из потока и передается в JsonTextReader, который работает с JSON.

  • Метод асинхронно читает JSON, пока не находит свойство "data". Как только "data" обнаружен, чтение продолжается, переходя к массиву данных, который нужно обработать.

  • Чтение данных:

    • После ключа "data" предполагается, что идет массив JSON-объектов, каждый из которых соответствует типу T.

    • Каждый элемент массива десериализуется из reader в объект T.

    • Метод onFetched вызывается для каждого объекта T (асинхронно), передавая этот объект как параметр.

  • Когда достигнут конец массива (JsonToken.EndArray), метод завершает выполнение.

3. Bulk Extensions для работы с БД

Библиотека EFCore.BulkExtensions используется для эффективного выполнения массовых операций с данными в Entity Framework Core (EF Core), таких как Bulk Insert, Bulk Update, Bulk Delete, и Bulk Read. Она позволяет значительно улучшить производительность при обработке больших объемов данных, обходя ограничения производительности стандартных методов EF Core.

Используйте эту библиотеку, если необходимо работать с большим числом записей.

await _dbContext.BulkInsertAsync(...);
await _dbContext.BulkInsertOrUpdateAsync(...);
await _dbContext.BulkInsertOrUpdateOrDeleteAsync(...);
await _dbContext.BulkUpdateAsync(...);
await _dbContext.BulkDeleteAsync(...);
await _dbContext.BulkSaveChangesAsync(...);

4. Батчинг запросов

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

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

5. Используйте транзакции

В C# WebAPI использование транзакций позволяет управлять согласованностью данных при выполнении нескольких операций в базе данных. Методы BeginTransactionAsync, CommitAsync, и RollbackAsync помогают гарантировать, что все операции внутри транзакции будут завершены успешно, или ни одна из них не будет применена, если произошла ошибка.

using (var transaction = await _dbContext.Database.BeginTransactionAsync())
{
    try
    {
        await _dbContext.BulkInsertAsync(insertData);
        await transaction.CommitAsync();
    }
    catch (Exception)
    {
        await transaction.RollbackAsync();
        throw;
    }
}

6. "Сырой" SQL запрос

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

_dbContext.Database.ExecuteSqlRawAsync("...")

7. Осторожно используйте IEnumerable/IQueryable и ToList/ToArray

При неправильном использовании IEnumerable или IQueryable можно очень быстро забить оперативную память. Нужно понимать различие этих интерфейсов, если они используются.

Старайтесь также не использовать методы ToList или ToArray, либо используйте с осторожностью, т.к. эти методы загружают сразу все данные в оперативную память.

8. Использование кеша

Может быть полезно использовать кеш, который хранит некоторые данные в небольшом промежутке времени жизни приложения. Для примера можно использовать Memory Cache (хранит данные в оперативной памяти).

builder.Services.AddMemoryCache(); // подключаем в Program.cs
// Сохраняем значение на 6 часов
var cacheEntryOptions = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(6) };
_memoryCache.Set("key", "value", cacheEntryOptions);

// читаем значение из кеша
_memoryCache.TryGetValue("key", out valueObject);

Автор документа: Артём Ветик