Redis и Asp.Net Core
Дмитрий БахтенковВведение
В этой статье мы разберём СУБД Redis, рассмотрим её различные варианты использования, и напишем приложение на C#, которое будет взаимодействовать с Redis.
Эта статья подойдёт тоем, кто уже пробовал писать API на C# с помощью фреймворка asp.net core. Перед её прочтением рекомендую прочитать предыдущие статьи:
Что такое Redis?

Redis - это open source система управления базами данных (СУБД) класса NoSQL, работающая со структурами данных “ключ-значение”. Она может использоваться как в качестве основной БД, так и в качестве реализации кэшей, брокеров сообщений и т.д.
БД хранит все данные в оперативной памяти, но при этом снабжена механизмами снимков для обеспечения постоянного хранения. Поддерживает репликацию данных с основных узлов на несколько подчинённых (master → slave replication) и пакетную обработку команд. Предоставляет операции для обмена сообщениями, реализующие паттерн publisher-subscriber: с помощью Redis приложения могут создавать каналы, подписываться на них и помещать туда сообщения, которые будут получены всеми подписчиками..
Redis хранит данные в виде словаря, в котором ключи связаны со своими значениями. Значения этих ключей не ограничиваются строками: поддерживаются строки, списки, множества, хэш-таблицы. Тип данных определяет, какие операции будут для него доступны.
Поднимаем
Redis очень просто установить: в ОС Linux можно воспользоваться пакетными менеджерами, в Windows использовать WSL или порт Redis под Windows. Также можно воспользоваться Docker. В приложении Docker Desktop можно очень удобно загрузить образ редиски и запустить его, или воспользоваться командой docker run redis -p 6379:6379


У Redis нет официального графического клиента, но есть несколько сторонних, информацию о которых можно посмотреть тут. Также есть интерфейс командной строки redis-cli - я буду использовать его
Я зашёл в терминал развёрнутого контейнера и выполнил команду redis-cli, чтобы войти в интерфейс командной строки. Команда INFO SERVER показывает информацию о текущем сервере:

С помощью команды SELECT мы можем выбрать необходимую нам БД. В аргументе мы должны указать номер необходимой нам БД. По-умолчанию создаётся 16 бд, и мы можем взять любой номер. Посмотреть кол-во баз данных можно с помощью команды CONFIG GET DATABASES.

Чтобы добавить какое-либо значение, можно воспользоваться командой SET. Чтобы получить значение по ключу - используем GET:

Подробнее про команды можно посмотреть в документации, также есть отличная статья - “Шпаргалка по Redis”.
Кодим
С Redis вроде бы разобрались, теперь можно и покодить. Мы рассмотрим следующие кейсы применения редиски в приложениях:
- Кэш
- Rate Limiter
- Механизм Publisher/Subscriber
Соединяем ASP.NET Core и Redis
Создаём пустой проект asp.net core:

и добавляем библиотеку StackExchange.Redis:

За подключение к Redis отвечает статический метод ConnectionMultiplexer.Connect(). В файле Program.cs добавим коннект к Redis как синглтон сервис в DI-контейнер:
builder.Services.AddSingleton<IConnectionMultiplexer(
ConnectionMultiplexer.Connect("localhost"));
Далее добавим класс RedisService, который будет отвечать за работу с редиской:
public class RedisService
{
// объект IDatabase, описывающий отдельную БД в Redis
private readonlyIDatabase_database;
// в конструкторе указана зависимость IConnecctionMultiplexer
public RedisService(IConnectionMultiplexerconnectionMultiplexer)
{
// из коннекта к редису получаем объект БД для взаимодействия
_database = connectionMultiplexer.GetDatabase();
}
public async Task SetValue<T>(string key, T value)
{
// используем метод SetStringAsync для установки значения по ключу.
// Метод JsonSerializer.Serialize преобразует объект в json-строку
await _database.StringSetAsync(key, JsonSerializer.Serialize(value));
}
public async Task<T?> GetValue<T>(string key)
{
// получаем строку из БД. Переменная value имеет тип RedisValue
var value = await _database.StringGetAsync(key);
if (value.HasValue)
{
// преобразуем json-строку в объект с помощью JsonSerializer.Deserialize
return JsonSerializer.Deserialize<T>(value.ToString());
}
return default;
}
}
С помощью этого сервиса мы сможем удобно работать с Redis. В методах указаны generic-параметры T - в них можно указать конкретный тип данных, с которым работаем в БД, например redisService.SetValue<User>(”123”, new User(”username”, “password”))
Добавим этот класс в DI-контейнер в файле Program.cs:
builder.Services.AddSingleton<RedisService>();
Для проверки механизма добавим класc User и напишем UserController, который будет использовать Redis для хранения данных о пользователе.
Класс User:
public class User
{
public Guid Id { get; set; }
public string Username { get; set; }
public string Password { get; set; }
}
Для идентификатора пользователя используем тип данных GUID, который представляет уникальный идентификатор.
Класс UserController:
[ApiController]
[Route("api/user")]
public class UserController : ControllerBase
{
private readonly RedisService _redisService;
public UserController(RedisService redisService)
{
_redisService = redisService;
}
[HttpPost]
public async Task<User> CreateUser([FromBody] User user)
{
await _redisService.SetValue(user.Id.ToString(), user);
return user;
}
[HttpGet("{id}")]
public async Task<User?> GetUser(Guid id)
{
return await _redisService.GetValue<User>(id.ToString());
}
}
В контроллере мы, используя RedisService, с помощью метода POST добавляем пользователя в БД, а с помощью метода GET - получаем его по ключу-идентификатору. Попробуем запустить и проверить в интерфейсе Swagger:

Создаём пользователя. Приходит ответ 200 Ok:

Пытаемся получить пользователя по идентификатору:

Если у вас возникают какие-то ошибки с подключением к Redis - проверьте пожалуйста, что БД развёрнута на порту 6379.
Давайте посмотрим что у нас в redis-cli:

Наши данные лежат в нашей БД. Всё хорошо. Теперь приступим к конкретным кейсам.
Rate Limiter
Что такое Rate Limiting?
Это установка ограничения количества запросов в определённый период времени. Например при ограничении в 10 запросов в одну секунду, в рамках этой секунды первые 10 запросов будут успешны, а следующие запросы - откинутся сервером с http-кодом 429 (Too many requests).
Существует несколько алгоритмов rate limit:
- Leaky Bucket - это алгоритм, который обеспечивает наиболее простой, интуитивно понятный подход к ограничению скорости обработки при помощи очереди, которую можно представить в виде «ведра», содержащего запросы. Когда запрос получен, он добавляется в конец очереди. Через равные промежутки времени первый элемент в очереди обрабатывается. Это также известно как очередь FIFO. Если очередь заполнена, то дополнительные запросы отбрасываются (или “утекают”).
- Fixed Window - в этом алгоритме для отслеживания запросов используется окно, равное n секундам. Обычно используются значения вроде 60 секунд (минута) или 3600 секунд (час). Каждый входящий запрос увеличивает счётчик для этого окна. Если счётчик превышает некое пороговое значение, запрос отбрасывается.
- Sliding log - данный алгоритм предполагает отслеживание временных меток каждого запроса пользователя. Эти записи сохраняются, например, в hash set или таблицу и сортируются по времени; записи за пределами отслеживаемого интервала отбрасываются. Когда поступает новый запрос, мы вычисляем количество записей, чтобы определить частоту запросов. Если запрос выходит за рамки допустимого количества, то он отбрасывается.
- Sliding Window - это гибридный подход, который сочетает в себе низкую стоимость обработки Fixed Window и улучшенную обработку граничных ситуаций Sliding Log. Как и в простом Fixed Window, мы отслеживаем счётчик для каждого окна, а затем учитываем взвешенное значение частоты запросов предыдущего окна на основе текущей временной метки, чтобы сгладить всплески трафика.
Подробнее об алгоритмах ограничения скорости можно прочитать в этой статье - с объяснениями, подробностями и визуализацией
Реализация
Мы используем алгоритм Fixed Window, а реализуем его с помощью скрипта на языке Lua - Redis поддерживает его для написания скриптов. Гайд из официальной документации Redis предлагает следующий вариант реализации:
local requests = redis.call('INCR', @key)
redis.call('EXPIRE', @key, @expiry)
if requests < tonumber(@maxRequests) then
return 0
else
return 1
end
Разберём этот пример:
- В первой строке мы создаём переменную requests с помощью вызова функции INCR (increment) у redis. Так как при первом запуске скрипта значение в redis ещё не определено, оно будет равняться единице.
- Во второй строке вызываем функцию EXPIRE, которая устанавливает временное “окно” - по истечении этого времени значение в БД будет удалено.
- Далее происходит проверка - если кол-во запросов меньше максимального - успешное завершение функции с возвращаемым значением 0.
Параметры, указанные через символ “@”, например @key - это динамические параметры, в которые можно подставить значения при выполнении скрипта через .net.
Добавим статический класс Scripts, в котором укажем наш скрипт на Lua:
public static class Scripts
{
public static LuaScript RateLimitScript => LuaScript.Prepare(RateLimiter);
private const string RateLimiter = @"
local requests = redis.call('INCR',@key)
redis.call('EXPIRE', @key, @expiry)
if requests < tonumber(@maxRequests) then
return 0
else
return 1
end
";
}
В класс RedisService добавим метод для выполнения скрипта:
public async Task<int> ExecuteScript(LuaScript script, object? parameters = null)
{
return (int)await _database.ScriptEvaluateAsync(script, parameters);
}
Теперь добавим контроллер RateLimitController, в котором проверим ограничение трафика:
[Route("api/rate-limit")]
[ApiController]
public class RateLimitController : ControllerBase
{
private readonly RedisService _redisService;
public RateLimitController(RedisService redisService)
{
_redisService = redisService;
}
[HttpGet]
public async Task<IActionResult> Get()
{
// создаём ключ для хранения в Redis
var key = $"{Request.Path.Value}:{DateTime.UtcNow:hh:mm}";
// выполняем скрипт. В параметрах указываем время жизни записи (60 секунд) и максимальное кол-во запросов
var res = await _redisService.ExecuteScript(Scripts.RateLimitScript, new
{
key = new RedisKey(key),
expiry = 60,
maxRequests = 10
});
return res == 0 ? Ok() : StatusCode(429);
}
}
Для проверки проще всего выполнить скрипт на Bash, который в цикле отправляет запросы на сервер:
for n in {1..21}; do echo $(curl -s -w " HTTP %{http_code}, %{time_total} s" -X GET -H "Content-Length: 0" <http://localhost:5229/api/rate-limit>); sleep 0.5;
Будьте внимательны, возможно у вас другой порт в приложении.
Результат следующий:

Для удобства можно было бы сделать middleware или использовать готовые библиотеки. Ну а мы идём дальше.
Redis Cache
Кэширование данных - это процесс сохранения данных локально, который позволяет быстрее получить к ним доступ при будущих запросах.
Redis в первую очередь ассоциируется именно с кэшированием данных - он быстрый, надёжный, реализовать кэширование с ним очень просто.
Мы попробуем реализовать эндпоинт для получения информации о пользователях. Исходную информацию будем читать из файла, и на некоторое время кэшируем её в Redis. Мы сравним показатели скорости первого и второго запроса.
Кодим
В RedisService реализуем метод GetOrAdd(), который будет принимать ключ для кэширования и функцию обратного вызова, возвращающую данные:
public async Task<TData> GetOrAdd<TData>(
string key, Func<Task<TData>> callback)
{
// пытаемся получить данные из кэша
var existingData = await _database.StringGetAsync(key);
// если данные существуют, возвращаем их в виде
// необходимого объекта
if (existingData.HasValue)
{
return JsonSerializer.Deserialize<TData(
existingData.ToString())!;
}
// получаем данные из колбэк-функции
var data = await callback();
// сохраняем данные с помощью метода, написанного ранее,
// с истечением через минуту
await _database.StringSetAsync(key,
JsonSerializer.Serialize(data),
expiry: TimeSpan.FromMinutes(1));
return data;
}
Теперь сделаем json-файл с информацией о пользователях users.json:
[
{
"Id": "a4fff1d7-b3b5-4d98-a02a-33b6a5c3f000",
"Username": "firstuser",
"Password": "12345"
},
{
"Id": "a7d63511-1e0c-4250-82d3-1bca4523f433",
"Username": "seconduser",
"Password": "12345"
},
{
"Id": "984ae84e-6065-4eaa-b598-2921da469a97",
"Username": "thirduser",
"Password": "12345"
}
]
Добавим в UserController новые методы. С помощью класса Stopwatch мы также будем измерять скорость выполнения запроса:
[HttpGet]
public async Task<User[]> GetUsers()
{
// запускаем отсчёт времени выполнения кода
var stopwatch = Stopwatch.StartNew();
// получаем пользователей. В качестве параметра GetOrAdd
// мы передаём функцию
// GetUsersFromFile, результат которой будет кеширован
var result = await _redisService.GetOrAdd("users.json",
GetUsersFromFile);
// сохраняем время выполнения коде
var time = stopwatch.ElapsedMilliseconds;
Console.WriteLine("Result time: " + time);
// останавливаем таймер
stopwatch.Stop();
return result;
}
private async Task<User[]> GetUsersFromFile()
{
// читаем файл users.json и сериализуем его в объект User[]
using (var reader = new StreamReader("users.json"))
{
return JsonSerializer.Deserialize<User[]>(await
reader.ReadToEndAsync())!;
}
}
Запустим наш проект и посмотрим на результат:

Выполним два запроса подряд:

Первый результат - чтение данных из файла, второй - из кэша Redis. Redis действительно быстр!
Publisher/Subscriber
С помощью редис можно реализовать паттерн издатель-подписчик. Давайте разберёмся, что это за паттерн.
Описание паттерна
У нас есть некий объект, который издаёт события. На факт того, что это событие произошло, можно подписаться с помощью функции обратного вызова. Когда происходит это событие, все функции обратного вызова вызываются.

Чтобы было проще понять, можно провести аналогию. Представим YouTube - там есть каналы, у которых можно подписаться на уведомления. Далее происходит событие - публикация нового видео. Мы подписаны на это событие и получаем уведомление о том, что видео вышло.

Примерно таким образом работают events в C#. Но существуют реализации, когда между издателем и подписчиками добавляется некоторый посредник - брокер сообщений. Это позволяет реализовывать паттерн не только в рамках одной системы, но и в рамках нескольких систем - когда одна публикует сообщения в брокер, а другая принимает сообщения и как-то реагирует на них.

Redis может выступать в роли брокера сообщений.
Реализация
Мы будем подписываться на название определённого класса, который будет описывать информацию о событии. Добавим несколько методов в RedisService:
public async Task Publish<TEvent>(TEvent @event)
{
// выполняем метод Publish для публикации сообщения в Redis
// первым параметром методя является название канала,
// в который мы публикуем сообщение
// в данном случае это название типа события, например "CreateUserEvent"
await _database.PublishAsync(typeof(TEvent).Name, JsonSerializer.Serialize(@event));
}
public async Task Subscribe<TEvent>(Action<TEvent> callback)
{
// Из объекта ConnectionMultiplexer Redis позволяяет получить объект ISubscriber
// с помощью которого можно подписаться на события в определённом канале
await _database.Multiplexer
.GetSubscriber()
.SubscribeAsync(
typeof(TEvent).Name,
// выполняем callback-функцию с десериализованным сообщением в параметрах
(channel, value) => callback(JsonSerializer.Deserialize<TEvent>(value!)!));
}
Теперь определим событие. Пусть это будет событие создания пользователя. Определим тип события:
public class CreateUserEvent
{
public Guid UserId { get; set; }
}
Опубликуем событие после создания пользователя в UserController.CreateUser:
[HttpPost]
public async Task<User> CreateUser([FromBody] User user)
{
// сохраняем пользователя
await _redisService.SetValue(user.Id.ToString(), user);
// публикуем событие
await _redisService.Publish<CreateUserEvent>(new CreateUserEvent
{
UserId = user.Id
});
return user;
}
Теперь нам нужно подписаться на событие. Делать это удобнее всего в файле Program.cs, так как в нём происходит инициализация и запуск приложения.
var redisService = app.Services.GetRequiredService<RedisService>();
await redisService.Subscribe<CreateUserEvent>((@event =>
{
// выполнение какой-то полезной логики, например отправка email
// новому пользователю
Console.WriteLine($"User with id {@event.UserId} created");
}));
app.Run();
Запускаем приложение и пробуем создать пользователя:

В консоли мы увидим реакцию на это событие:

С помощью брокера сообщений можно настраивать взаимодействие не только в рамках одной системы, но и в рамках нескольких систем. Это взаимодействие будет асинхронным (неблокирующим). Такой подход часто используют в микросервисной архитектуре.
Заключение
В этой статье мы разобрали СУБД Redis и её различные варианты использования. Код приложения доступен на гитхаб.
С вами Flex Code