How to improve memory allocation when using StackExchange.Redis
Hello, if your code depends on Redis caching and you are using StackExchange.Redis as your middleware for reading and writing, this article may be useful for you.
If you have used the methods made available by the library, you would notice that in most scenarios, the input and output data type is called RedisValue. It somehow automagically works with most common data types, in most cases byte[] or string.
The most common ways to read and write values that I see published online are as follows:
// write
var value = System.Text.Json.JsonSerializer.Serialize(Item);
await _database.StringSetAsync("key", value, TimeSpan.FromMinutes(5));
// read
var value = await _database.StringGetAsync("key");
var item = System.Text.Json.JsonSerializer.Deserialize<BenchmarkItems.Item>(value);
However, working with strings means having to allocate space in the memory. While digging through the code, I found ways to reduce the amount of allocations done while also cutting a bit of time spent. In my previous postings, I demonstrated how to serialize/deserialize directly to streams/buffers using either the RecycleableMemoryStream class or IBufferWriter interface.
Writing to Redis
private static readonly RecyclableMemoryStreamManager _ms = new();
// recyclable memory stream
using var stream = _ms.GetStream();
System.Text.Json.JsonSerializer.Serialize(stream, Item);
var value = StackExchange.Redis.RedisValue.CreateFrom(stream);
await _database.StringSetAsync("key", value, TimeSpan.FromMinutes(5));
// buffer writer
using var buffer= new ArrayPoolBufferWriter<byte>();
var writer = new Utf8JsonWriter(buffer);
System.Text.Json.JsonSerializer.Serialize(writer, Item);
await _database.StringSetAsync("key", buffer.WrittenMemory, TimeSpan.FromMinutes(5));
In the first approach, there is a little-known approach to create a RedisValue from the stream which can be used to keep allocation to a minimum. In the benchmark below you can see that it cut down the allocated space of a 70KB Json file by about half.
| Method | Mean | Error | StdDev | Allocated |
|--------------------------- |---------:|---------:|---------:|----------:|
| Set_String | 12.70 ms | 0.115 ms | 0.102 ms | 8.95 KB |
| Set_RecyclableMemoryStream | 12.82 ms | 0.087 ms | 0.082 ms | 3.86 KB |
| Set_BufferWriter | 12.36 ms | 0.105 ms | 0.093 ms | 3.71 KB |
Reading from Redis
When reading from Redis, I found a method provided by the library called StringGetLeaseAsync which returned a type of Lease which is an implementation of IMemoryOwner<T>. The rationale here is that by exposing the response as IMemoryOwner, the caller can then decide when to dispose of the object.
using var memory = await _database.StringGetLeaseAsync("key");
var item = System.Text.Json.JsonSerializer.Deserialize<BenchmarkItems.Item>(memory.Span);
In benchmarks, I noticed half the allocation and better overall performance.
| Method | Mean | Error | StdDev | Allocated |
|--------------------------- |---------:|---------:|---------:|----------:|
| Get_String | 14.15 ms | 0.099 ms | 0.088 ms | 15.98 KB |
| Get_Lease | 11.76 ms | 0.128 ms | 0.120 ms | 6.81 KB |
Note: I did rerun the same tests with a 2MB file which showed consistent results as well as ensuring no Gen2 heap allocation as compared to the standard approach, but pushing large content to Redis is considered an anti-pattern so I don't think this is applicable for this article.
As usual, I hope this helps improve your code and I hope to learn more from anyone who may have better suggestions on how to read/write with Redis. Thanks for reading.