One shared Redis cache for every replica, so the database answers only on a miss
The gateway runs as 3 replicas, each JVM with its own local cache. Replica 1 caches account 42. The next request for account 42 lands on replica 2. What happens?
GET account:42
nil
SELECT * FROM accounts WHERE id = 42
SET account:42 ... EX 300
The database stays the source of truth; the cache only saves round-trips.
Remote Dictionary Server: cache, session store, message broker — the de-facto distributed cache.
SET
GET
INCR
EXPIRE
HSET
HGET
HGETALL
LPUSH
RPOP
LRANGE
SADD
SMEMBERS
SISMEMBER
ZADD
ZRANGE
ZRANK
XADD
XREAD
XACK
Unlike Memcached (strings only), Redis has native data structures with O(1) or O(log n) operations.
This hands-on enables AOF: --appendonly yes.
--appendonly yes
internet → :80 Nginx → gateway ×3 → auth, account → Redis :6379 → PostgreSQL (on a miss only)
:80
auth
account
:6379
redis: image: redis:7-alpine hostname: redis ports: - 6379:6379 volumes: - redis_data:/data command: redis-server --appendonly yes volumes: redis_data:
account: # ... build, DATABASE_* ... environment: REDIS_HOST: redis REDIS_PORT: 6379 deploy: replicas: 2 depends_on: - db - redis
Same REDIS_HOST / REDIS_PORT on every service that uses the cache (e.g. auth).
REDIS_HOST
REDIS_PORT
spring-boot-starter-data-redis
spring-boot-starter-cache
@Cacheable
@CachePut
@CacheEvict
@EnableCaching
spring: data: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} cache: type: redis redis: time-to-live: 300000 cache-null-values: false
type: redis replaces the default in-memory ConcurrentMapCache; TTL of 5 minutes; null is not cached.
type: redis
ConcurrentMapCache
null
@Cacheable(value = "accounts", key = "#id") public AccountOut findById(String id) { return accountRepository.findById(id) .map(AccountOut::from) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); }
id = "42"
accounts::42
Annotations go on service-layer methods, not on controllers or repositories.
@CachePut(value = "accounts", key = "#id") public AccountOut update(String id, AccountIn dto)
@CacheEvict(value = "accounts", key = "#id") public void delete(String id)
@CacheEvict(value = "accounts", allEntries = true) clears the whole cache (bulk updates).
@CacheEvict(value = "accounts", allEntries = true)
Default Java serialisation stores unreadable binary. A RedisConfig bean switches to JSON:
RedisConfig
@Bean public RedisCacheConfiguration cacheConfiguration(ObjectMapper objectMapper) { return RedisCacheConfiguration.defaultCacheConfig() .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))) .entryTtl(Duration.ofMinutes(5)) .disableCachingNullValues(); }
Cached classes (AccountOut) must be JSON-serialisable: no-arg constructor, public fields or getters, @JsonIgnoreProperties(ignoreUnknown = true).
AccountOut
@JsonIgnoreProperties(ignoreUnknown = true)
docker compose up -d --build curl -s http://localhost:8080/accounts/42 | jq # 1st: miss, hits the database docker compose exec redis redis-cli > KEYS accounts::* # 1) "accounts::42" > GET "accounts::42" # {"id":"42","name":"Ada Lovelace",...} curl -s http://localhost:8080/accounts/42 | jq # 2nd: hit docker compose logs account --tail 20 # no database query this time
curl -o /dev/null -s -w "Cache miss: %{time_total}s\n" http://localhost:8080/accounts/42 curl -o /dev/null -s -w "Cache hit: %{time_total}s\n" http://localhost:8080/accounts/42
TTL "accounts::42"
redis-cli INFO stats
keyspace_hits
keyspace_misses
High miss rate? Increase the TTL or pre-warm the cache on startup.
A cache can serve stale data: @CachePut / @CacheEvict on writes and a TTL keep it eventually consistent.
redis-cli
MONITOR
6379
KEYS
TTL
INFO stats
Outline: (1) why a shared cache once the gateway scales; (2) in-memory databases and cache-aside; (3) Redis data structures and persistence; (4) compose; (5) Spring Cache annotations; (6) run, measure.