Caching Patterns
A cache is a close, fast copy of data, so that not every request goes to the database. The hardest part is not building the cache. It is keeping data from going stale, popular keys expiring at the same time, and the cache itself going down.
Author: bezzad
The problem: a database under read load
In our shop, each product page is read from the database on every request. A product’s price and description change a few times a day, but they are read thousands of times. The result:
- The database CPU is high.
- Pages get slow.
- With every ad campaign, the database reaches its limit.
The idea of a cache is simple: read data that is read a lot and changes rarely once, and keep a copy of it in a faster place.
The Cache-Aside pattern
The most common pattern is that the app manages the cache itself:
- First, look for the key in the cache. For example, the key for product 42.
- If it is there, return it.
- If it is not, read it from the database. Then put it in the cache with an expiration time and return it.
Why is an expiration time (TTL) always needed? Because sooner or later, a bug somewhere means the cache is not cleared. The expiration time makes sure stale data stays only a few minutes, not forever.
Where should the cache be?
Local cache (In-Memory)
- It is in the memory of the same server.
- It is very fast, because there is no network.
- Each server has its own separate copy.
- It becomes empty when the server restarts.
Distributed cache (for example, Redis)
- One shared copy for all servers.
- Each read has a network round trip.
- It is not lost when the app restarts.
- It is another service that may go down.
The problem with a local cache shows up when you have several servers. An admin changes a price. Only the server that got the edit request clears its own cache:
The common fix is to combine both: a local cache with a short expiration, in front of a Redis cache with a longer expiration. In .NET, the HybridCache class does exactly this.
Code with HybridCache
The HybridCache class comes from the Microsoft.Extensions.Caching.Hybrid package. If you also register a distributed cache like Redis, it uses it as the second level:
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = builder.Configuration.GetConnectionString("Redis"));
builder.Services.AddHybridCache(o =>
{
o.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // Redis
LocalCacheExpiration = TimeSpan.FromSeconds(30) // memory of each server
};
});
Reading and removing a product:
public sealed class ProductService(ShopDb db, HybridCache cache)
{
// Note: a missing product (null) is cached too, until it expires.
public async Task<ProductDto?> GetAsync(int id, CancellationToken ct) =>
await cache.GetOrCreateAsync(
$"product:{id}",
async token => await db.Products
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstOrDefaultAsync(token),
cancellationToken: ct);
public async Task ChangePriceAsync(int id, decimal price, CancellationToken ct)
{
await db.Products
.Where(p => p.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, price), ct);
await cache.RemoveAsync($"product:{id}", ct); // first the database, then the cache
}
}
The GetOrCreateAsync method does the same three Cache-Aside steps. It also has one more benefit that we will see below.
Invalidating the cache: remove, do not update
When the price changes, we have two ways: write the new value into the cache, or remove the key. Removing is safer. Imagine two admins change the price at the same time:
The right order is:
- First, change the database.
- Then remove the cache key.
- The next first read brings the new value from the database and caches it again.
Cache Stampede
The “home page settings” key is read on every request, about 2000 times a second. Its expiration time is 10 minutes. Every 10 minutes, the database CPU hits 100 percent for a few seconds. Why?
- While the key exists, all requests get the answer from the cache.
- At the moment of expiry, all requests see the cache as empty.
- All of them send the same query to the database together.
- This goes on until one of them fills the cache again. If the query is slow, thousands of concurrent queries arrive.
Click the buttons and compare the database load:
The "home page settings" key is read on 5 servers. Building it again from the database takes 3 time units. Each bar is the number of database queries in one time unit.
The fixes:
- One builder per key. Only one request builds the data, and the rest wait for its result. The GetOrCreateAsync method in HybridCache does this inside each server. So with 5 servers, at most 5 queries.
- Refresh before expiry. For a few very important keys, a background job fills the cache again a little before it expires.
- A little randomness in the expiration time (Jitter). If thousands of keys were created together, they also expire together. A little randomness spreads them out.
If Redis goes down
The cache must be optional. This means the site gets slower without the cache, but does not go down:
- Catch cache errors and read directly from the database.
- Set a short timeout for Redis. Otherwise every request waits a few seconds for a dead Redis, and the whole site gets slow.
- Know your database capacity. If the database cannot handle the load at all without the cache, the cache is no longer “optional”. Have a plan for this case.
What not to cache?
- Data that must always be exact. Like a wallet balance, or the stock at the moment of purchase.
- Data that changes sooner than the expiration time, when stale data matters.
- Data that is rarely read. The cache only uses memory and gives no benefit.
- A “not found” answer, without thinking. If a product does not exist and you cache that, when it is created it is not seen until expiry. If you need it, set a short expiration time.
Common mistakes
| Mistake | Result | Right way |
|---|---|---|
| Cache with no expiration time | Stale data forever | Always an expiration time |
| Updating the cache instead of removing | Wrong value in the cache until expiry | First the database, then remove the key |
| Local cache with long expiration on several servers | Each server shows a different price | Short expiration or a remove message |
| No protection for a popular key | The database goes down at the moment of expiry | One builder per key, or refresh |
| A Redis error causes a page error | A cache outage takes down the whole site | Optional cache and a short timeout |
| Copying cache logic into every method | Changing a key or expiration in ten places | One service or Decorator for caching |
Summary in six lines
- A cache is for data that is read a lot and changes rarely.
- The Cache-Aside pattern: first the cache; if not there, the database; then save to the cache with an expiration time.
- After changing data, first change the database, then remove the cache key.
- Each server’s local cache is separate. Set a short expiration, or combine it with Redis.
- The expiry of a popular key causes a rush to the database. Let only one request build the data.
- The cache must be optional. If Redis is gone, the site gets slow, not down.