In this post we'll look at what cache means when talking about software and how to implement a distributed cache server using Redis and C#.
Index
1 - What is cache storage
When we use the term cache we can mainly refer to two things, the first one is the cache memory located in our machine's processor.
The second (and the one we'll cover today) is the cache we refer to when we develop software. This cache memory is an in-memory storage for our data.
For example, in a web environment, keeping a List/Dictionary in memory all the time could be considered as "cache"; obviously it's not the best solution, but it gives you the idea.
2 - Why do we want to use cache
As I mentioned before, the information in cache is stored in memory, meaning its access is almost instantaneous.
With this in mind, we can assume we use cache for data we're going to access multiple times.
Of course, using only in-memory storage reduces resources used by the application, for example if we access another microservice, we use network resources.
Another benefit is that, by not consuming the external service, we reduce the load on it, which makes it less likely to fail or collapse.
2.1 - Cache use case
Often we need to keep this cache information in a single application.
For example, we have an application that performs a certain action for all our users and then prints it along with the company name.
In a world of microservices, we get the company info by making a call to the company microservice.
But clearly this action can get out of control since nothing guarantees we only make one call per company to the microservice. Instead, we'd be making a call for each user, which means if two users are in the same company, we'd fetch this data multiple times.
To fix this scenario, we normally make the call to the microservice ahead of time, just before the loop, but if this process will be run more than once, not just in a single request but in multiple ones, then...
Note: The code is not structured in the best possible way; it's focused on using and demonstrating how cache works.
To see how to properly structure your applications, visit the following link: Application Structure.
A very common example is having a user list and wanting to print each user together with the company they work for, but in our user microservice we only have the company's ID, not its details:
public record UsuarioEntity{ public string Nombre { get; init; } public string Apellido { get; init; } public int IdEmpresa { get; init; }}
To get the company data, we'll query the company microservice which returns the following object:
public record EmpresaDto{ public int Id { get; init; } public string Nombre { get; init; } public string Ciudad { get; init; } public string Pais { get; init; }}
The logic is really simple; we read all users and loop through them, making a call for each one:
public async Task<List<UsuarioDto>> GetAllUsuarioDto(){ List<UsuarioDto> resultUsuariosDto = new List<UsuarioDto>(); List<UsuarioEntity> usuarios = await _dependencies.GetAllUsers(); foreach(var usuario in usuarios) { EmpresaDto empresa =await _dependencies.GetEmpresa(usuario.IdEmpresa); UsuarioDto usuarioDto = new UsuarioDto { Nombre = usuario.Nombre, Apellido = usuario.Apellido, NombreEmpresa = empresa.Nombre }; resultUsuariosDto.Add(usuarioDto); } return resultUsuariosDto;}
Of course, we must implement _dependencies
, where we'll make calls to both the database and the company microservice:
public class ListUsersWithCompanyNameDependencies : IListUsersWithCompanyNameDependencies{ private readonly IHttpClientFactory _httpClientFactory; private readonly IUsuarioRepository _userRepo; public ListUsersWithCompanyNameDependencies(IHttpClientFactory httpClientFactory, IUsuarioRepository userRepo) { _httpClientFactory = httpClientFactory; _userRepo = userRepo; } public async Task<List<UsuarioEntity>> GetAllUsers() { return await _userRepo.GetAllUsers(); } public async Task<EmpresaDto> GetEmpresa(int id) { HttpClient client = _httpClientFactory.CreateClient("EmpresaMS"); return await client.GetFromJsonAsync<EmpresaDto>($"empresa/{id}"); }}
The result would look like the following image, a request to a microservice for each user we need to look up.
In the long run, this solution is completely unviable as it adds a lot of network load, latency, etc.
Note: Visit this link to learn how to properly implement HttpClient.
3 - In-memory cache for a single application
To solve the problem of making too many calls, we can implement in-memory caching within our application. For this, Microsoft gives us a class that allows us to cache, called MemoryCache
.
With this feature, our goal is to reduce the number of calls to the company microservice—reducing it to one call per company and using the cache for the rest.
To implement this feature, we simply need to create a service that encapsulates our calls to the second microservice.
In our new service, we instantiate the cache in the constructor:
public class EmpresaServicio{ private readonly MemoryCache _cache; public EmpresaServicio() { _cache = new MemoryCache(new MemoryCacheOptions()); }}
Then we implement the service, where we make an HTTP call to the microservice if the element we're looking for in the cache doesn't exist:
public interface IEmpresaServicio{ Task<EmpresaDto> GetEmpresa(int id);}public class EmpresaServicio : IEmpresaServicio{ private readonly MemoryCache _cache; private readonly IHttpClientFactory _httpClientFactory; public EmpresaServicio(IHttpClientFactory httpClientFactory) { _cache = new MemoryCache(new MemoryCacheOptions()); _httpClientFactory = httpClientFactory; } public async Task<EmpresaDto> GetEmpresa(int id) { //Check if exists if(!_cache.TryGetValue(id, out EmpresaDto empresa)) { //Get item from microservice empresa = await GetFromMicroservicio(id); _cache.Set(id, empresa); return empresa; } return empresa; } private async Task<EmpresaDto> GetFromMicroservicio(int id) { HttpClient client = _httpClientFactory.CreateClient("EmpresaMS"); return await client.GetFromJsonAsync<EmpresaDto>($"empresa/{id}"); }}
Remember to add it as a singleton in the dependency injector, since we want to keep this cache between all incoming requests.
services.AddSingleton<IEmpresaServicio, EmpresaServicio>();
Finally, we just need to update our dependencies to use the EmpresaServicio
we just created instead of making direct HTTP calls.
public async Task<EmpresaDto> GetEmpresa(int id){ HttpClient client = _httpClientFactory.CreateClient("EmpresaMS"); return await client.GetFromJsonAsync<EmpresaDto>($"empresa/{id}");}//New GET Empresaprivate readonly IEmpresaServicio _empersaServicio;public async Task<EmpresaDto> GetEmpresa(int id){ return await _empersaServicio.GetEmpresa(id);}
If you run the application you'll see that for the client the result is the same, but for our internal network it's now a much lighter load, and also faster.
3.1 - MemoryCache vs Dictionary<string, T>
It used to be very common to solve this problem using a dictionary in C#, but this isn't the best option since with MemoryCache
we can define some options.
These options include features like the ability to remove expired objects or set a maximum size for our cache.
Whereas with a dictionary, the data stays there forever unless we manually remove it.
Also, MemoryCache is thread-safe.
4 - Distributed cache for multiple microservices
But what if we want to access this same information from another microservice?
For this example, let's say we have another microservice for cars that performs a similar process, displaying the make and model as well as the company it belongs to.
We could repeat the same process as before—having a cache for companies inside our car microservice.
But this isn't the best way to do it, since this data will be read not by one, but two microservices. So why have it duplicated?
The ideal operation is to have a common cache for all services that need to access this information.
The diagram would be as follows:
As we can see, there's only one call to the microservice, while the rest go to the cache server.
And for this cache server, we're going to use Redis.
5 - What is redis?
Redis is open source software that allows us to store data structures in memory, use it as a cache layer, or as a message broker.
Unlike the in-memory cache we just saw, Redis is a "server" itself and as such can be deployed and configured as such.
Note: Technically it's not a server, but an application on the server, but usually the whole server is used for redis.
The information in Redis is stored in RAM, so our limitation is memory, not disk space. This setup makes data access much faster.
Redis also provides data persistence if we need it, or policies for data expiration.
6 - Implementing a redis cache server in C#
First of all, before getting started, I want to point out that to follow this example we need either a redis server or a docker container with redis.
In my case, I've created a small docker-compose file that contains the redis information.
version: '2'services: redis: image: 'bitnami/redis:latest' ports: - 6379:6379 environment: - REDIS_PASSWORD=password123
Once we have our server up and running, let's continue with the code.
First, we need to install the package Microsoft.Extensions.Caching.Redis
from nuget, which is built on StackExchange.Redis
, an open source package maintained by the StackOverflow team. Install this package in all projects where you'll use the cache.
This process is similar to what we saw before, but we must change our type from MemoryCache
to IDistributedCache
, but this time we inject it into our dependencies instead of instantiating it.
public interface IDistributedEmpresaServicio{ Task<EmpresaDto> GetEmpresa(int id);}public class EmpresaServicio : IDistributedEmpresaServicio{ private readonly IDistributedCache _cache; private readonly IHttpClientFactory _httpClientFactory; public EmpresaServicio(IHttpClientFactory httpClientFactory, IDistributedCache cache) { _cache = cache; _httpClientFactory = httpClientFactory; } public Task<EmpresaDto> GetEmpresa(int id) { throw new System.NotImplementedException(); }}
The logic for our GetEmpresa
method is the same. We must first check the cache, and if it doesn't exist, call the company microservice and insert into the cache.
Note: When inserting in IDistributedCache
, we do it using SetAsync
, which takes a string
as Key and a byte[]
as Value.
That means before inserting the value in our cache we must convert it to bytes, and do the same when reading.
public async Task<EmpresaDto> GetEmpresa(int id){ byte[] value = await _cache.GetAsync(id.ToString()); if (value == null) { EmpresaDto empresaDto = await GetFromMicroservicio(id); if (empresaDto != null) await AddToCache(empresaDto); return empresaDto; } return FromByteArray(value);}private async Task<EmpresaDto> GetFromMicroservicio(int id){ HttpClient client = _httpClientFactory.CreateClient("EmpresaMS"); return await client.GetFromJsonAsync<EmpresaDto>($"empresa/{id}");}private async Task AddToCache(EmpresaDto empesa){ await _cache.SetAsync(empesa.Id.ToString(), ToByteArray(empesa));}private byte[] ToByteArray(EmpresaDto obj){ return JsonSerializer.SerializeToUtf8Bytes(obj);}private EmpresaDto FromByteArray(byte[] data){ return JsonSerializer.Deserialize<EmpresaDto>(data);}
Of course, we must instantiate IDistributedCache
as one of the services:
//"hardcoded" values to demonstrate how it worksservices.AddDistributedRedisCache(options =>{ options.Configuration = "localhost:6379,password=password123"; options.InstanceName = "localhost";});
Now if we had another microservice, we should use similar code—first check the cache, then call the microservice, and then insert into the cache.
7 - Implementing in-memory and redis cache
Another solution to this problem—for data that is constantly queried—is to use both options: keep an in-memory cache in each microservice, and if the data is not found, look it up in redis, and finally use the HTTP call
Conclusion
- In this post, we saw why we use cache in our microservices.
- We looked at how to implement in-memory cache when we have a single application accessing that information.
- We had an introduction to redis and how to use redis in our microservices to provide distributed caching.
If there is any problem you can add a comment bellow or contact me in the website's contact form