We added Redis because the database query was expensive.
The table had 10 million rows. The endpoint was read-heavy. The query pattern looked cacheable. On paper, this was exactly the kind of workload where Redis should help.
And in one scenario, it did.
With a proper cache, throughput jumped from 630 requests per second to 9,333 requests per second. Average latency fell from 158 ms to 10.5 ms.
But then we ran a second cached version and the result was strange.
The average looked better, but the median and P95 still behaved almost like the database-bound baseline.
That is the trap this benchmark exposed: caching can make the dashboard look better while the user-facing latency distribution is still unhealthy.
The Benchmark Setup
The benchmark used a Spring Boot 3 application backed by PostgreSQL and Redis.
The test was intentionally simple: run the same read-heavy endpoint under sustained load and compare three versions of the code.
- PostgreSQL with 10 million rows
- Redis cache layer
- k6 load testing
- 100 concurrent virtual users
- 60-second sustained load
- Prometheus and Grafana available for observation
- Local machine benchmark environment
The load script used 100 VUs for 60 seconds:
export const options = {
vus: 100,
duration: '60s'
};The request targeted an endpoint that fetched expensive products by price. The simulated database cost made the cache behavior visible instead of hiding it behind noise.
The Three Scenarios
The benchmark compared three versions of the same operation.
| Scenario | Cache Behavior | Intent |
|---|---|---|
| A – No Cache | Always reads from PostgreSQL | Baseline |
| B – Proper Cache | Caches by price | Expected Redis win |
| C – Misconfigured Cache | Caches and evicts on the same read path | Failure case |
The code difference was small, which is why this kind of bug is easy to miss in review.
// Scenario A - No Cache
public List<Product> getExpensiveProductsA(Double price) {
simulateSlowQuery();
return repository.findExpensiveProducts(price);
}
// Scenario B - Proper Cache
@Cacheable(value = "expensiveProducts", key = "#price")
public List<Product> getExpensiveProductsB(Double price) {
simulateSlowQuery();
return repository.findExpensiveProducts(price);
}
// Scenario C - Cache Gone Wrong
@Cacheable(value = "expensiveProducts", key = "#price")
@CacheEvict(value = "expensiveProducts", allEntries = true)
public List<Product> getExpensiveProductsC(Double price) {
simulateSlowQuery();
return repository.findExpensiveProducts(price);
}Scenario C is the dangerous one. It uses @Cacheable and @CacheEvict(allEntries = true) on the same read method.
That means the method tries to benefit from the cache while also invalidating the cache. Under load, this creates inconsistent behavior: some requests get fast responses, while many still fall back to the slow path.
Measured Results
These were the benchmark results from the sustained 100-VU run.
| Scenario | Throughput | Avg Latency | Median | P95 | Max | Errors |
|---|---|---|---|---|---|---|
| No Cache | 630 req/s | 158 ms | – | 169 ms | 519 ms | 0% |
| Proper Redis Cache | 9,333 req/s | 10.5 ms | – | 14.9 ms | 537 ms | 0% |
| Misconfigured Cache | 1,130 req/s | 88 ms | 152 ms | 157 ms | 674 ms | 0% |
At first glance, Scenario C looks like an improvement over no cache. Throughput increased from 630 req/s to 1,130 req/s. Average latency dropped from 158 ms to 88 ms.
But the latency distribution tells a different story.
The median was 152 ms and the P95 was 157 ms. That is almost the same shape as the no-cache baseline.
A lower average did not mean the cache was healthy. It meant a minority of very fast responses pulled the average down.
The First Wrong Explanation
The easy explanation would be: Redis helped, but not as much as expected.
That is technically true, but it misses the important failure mode.
Scenario C did not merely underperform. It produced a mixed distribution. Some requests were cache-fast. Many requests were still database-slow.
That is worse than a clean baseline in one specific way: it makes the system harder to reason about.
With no cache, the service was slower but predictable. With the broken cache, the average improved while the tail remained close to baseline. That is exactly how a performance regression gets hidden in production dashboards.
Root Cause
The root cause was cache churn.
The cache key itself was simple: #price. That part was fine for this benchmark because every request used the same price value.
The broken part was invalidation. Scenario C evicted all entries while also using the cache for the same read operation.
- Request checks Redis for
expensiveProducts::100. - If the value is present, the request can be fast.
- The same method also evicts all entries.
- The next request often misses the cache.
- The database path runs again.
- The benchmark becomes a mixture of hits and misses.
That explains the strange numbers.
The average latency improved because some requests were served quickly. The median and P95 stayed high because many requests still paid the database cost.
Why Proper Cache Was So Much Faster
Scenario B behaved like a good cache should behave for a repeated read pattern.
After warm-up, most requests could avoid the simulated expensive database path. The database stopped being the critical path for the majority of requests.
| Metric | No Cache | Proper Cache | Improvement |
|---|---|---|---|
| Throughput | 630 req/s | 9,333 req/s | About 15x higher |
| Avg latency | 158 ms | 10.5 ms | About 15x lower |
| P95 latency | 169 ms | 14.9 ms | About 11x lower |
| Errors | 0% | 0% | No reliability trade-off in this test |
This is the outcome people expect when they add Redis. The cache has high reuse, the key is stable, and invalidation is not fighting the read path.
Why The Bad Cache Was Dangerous
Scenario C was not catastrophic. It had 0% errors. That makes it more subtle.
The danger is that a team might look only at average latency and declare the cache successful.
But users do not experience averages. They experience individual requests. If half the requests still feel slow, the cache has not solved the user problem.
This is why median and P95 matter.
- Average latency can improve when a subset of requests becomes very fast.
- Median latency shows what a typical request feels like.
- P95 latency shows whether the slow path still affects a meaningful part of traffic.
- Throughput shows whether the system as a whole can do more useful work.
- Hit ratio explains whether Redis is actually absorbing the workload.
Redis Configuration
The Redis setup used string keys and JSON value serialization.
RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); RedisSerializer<String> keySerializer = RedisSerializer.string(); RedisSerializer<Object> valueSerializer = RedisSerializer.json(); template.setKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashKeySerializer(keySerializer); template.setHashValueSerializer(valueSerializer);
Serialization was not the main issue in this benchmark, but it is still part of the operational cost. Large values, inefficient serialization, or oversized payloads can reduce the benefit of caching even when hit ratio is good.
That is why cache performance should be measured end to end, not assumed from Redis latency alone.
What I Would Monitor In Production
If this were a production incident, I would not stop at the endpoint latency chart.
I would add cache-specific telemetry:
- Cache hit ratio by endpoint and key pattern
- Cache miss count
- Eviction count
- Redis command latency
- Serialized payload size
- Database query count after cache deployment
- P95 and P99 latency split by cache hit and cache miss
The split between cache hits and misses is especially important. Without it, Scenario C can look better than it really is.
The dashboard should answer a simple question: when the request is slow, did it hit Redis or did it go to PostgreSQL?
How To Fix The Misconfigured Cache
The fix is not to remove Redis. The fix is to make cache invalidation intentional.
For this workload, the read method should not evict all cache entries on every call.
- Keep
@Cacheableon read paths that are safe to cache. - Move
@CacheEvictto write paths that change the underlying data. - Avoid
allEntries = trueunless the blast radius is acceptable. - Use stable keys that match the query shape.
- Measure hit ratio after deployment, not just endpoint latency.
A healthy cache strategy needs both sides: reads that can reuse data and writes that invalidate only the data that became stale.
Engineering Lessons
- Caching is a latency distribution problem, not only an average-latency problem.
- A cache with low or unstable hit ratio can hide behind a better average.
- Evicting on the read path can destroy the benefit of
@Cacheable. - No cache can be slower but easier to reason about than a broken cache.
- Production cache dashboards need hit ratio, miss rate, eviction count, and tail latency.
Source Code And Benchmark Project
The complete Spring Boot project, Docker setup, load test script, and raw benchmark results are available on GitHub:
The repository includes:
- Full Spring Boot source code
- Docker Compose configuration
- Redis and PostgreSQL setup
- k6 load test script
- Prometheus and Grafana monitoring configuration
- Raw benchmark results for the three scenarios
Conclusion
Redis did exactly what Redis is good at in Scenario B: it removed repeated database work from a hot read path and delivered a major throughput improvement.
But Scenario C is the lesson worth remembering.
The cache did not fail loudly. It improved the average while leaving median and P95 latency close to the no-cache baseline.
That is how caching makes performance worse from an engineering perspective: not always by increasing every number, but by making the system less predictable and the dashboard easier to misread.
Measure the distribution. Measure the hit ratio. Treat invalidation as part of the design, not as an annotation added at the end.



