This article details the implementation of a two-level caching architecture to achieve sub-millisecond lookup times for high-traffic systems. It explains the integration of an in-process cache (Caffeine) for L1 and a distributed cache (Redis) for L2, with a persistent data store (Elasticsearch) as the source of truth, addressing the limitations of single-layer caching.
Read original on Dev.to #systemdesignHigh-traffic systems frequently encounter performance bottlenecks when relying solely on a persistent data store. Even with optimized databases like Elasticsearch, tail latencies can escalate under heavy load, severely impacting user experience. The article highlights how a product lookup exceeding 80ms prompted the need for a more aggressive caching strategy to keep response times under control.
Relying on a single caching layer, whether in-process or distributed, presents distinct trade-offs:
Why Two Levels?
A multi-level caching strategy combines the strengths of different cache types. The goal is to serve the hottest data from the fastest, closest cache, falling back to progressively slower but more resilient layers.
The proposed architecture implements a cache-aside pattern across three layers: an L1 in-process cache, an L2 distributed cache, and a persistent data store (Elasticsearch). The lookup flow prioritizes speed and efficiency:
Caffeine, a high-performance in-process cache for the JVM, is chosen for L1 due to its speed and efficient eviction policies (W-TinyLFU). Key features utilized include:
// CaffeineConfig.java@Beanpublic Cache<String, Product> caffeineProductCache() {return Caffeine.newBuilder().expireAfterWrite(Duration.ofSeconds(ttlSeconds)) // default 30 s.maximumSize(maxSize) // default 5 000.recordStats() // exposes hit rate to Micrometer / Prometheus.build();}Redis acts as the intermediary, providing a shared cache across service instances and surviving application restarts. It absorbs load spikes when new application instances come online with cold Caffeine caches. Important considerations for Redis:
// RedisConfig.java — MessagePack gives ~32% smaller payloads vs JSON@Beanpublic ObjectMapper msgpackObjectMapper() {return new ObjectMapper(new MessagePackFactory()).registerModule(new JavaTimeModule()).disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);}@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory, ObjectMapper msgpackObjectMapper) {var serializer = new Jackson2JsonRedisSerializer<>(msgpackObjectMapper, Object.class);var template = new RedisTemplate<String, Object>();template.setConnectionFactory(connectionFactory);template.setKeySerializer(new StringRedisSerializer());template.setValueSerializer(serializer);template.afterPropertiesSet();return template;}