Setting a TTL on a million session keys does not free a million keys when it elapses. Redis deletes an expired key when something touches it, plus a sample of random keys ten times a second, so memory comes down over minutes or hours rather than at the deadline — and a key nothing ever reads again can sit there for a very long time.
redis-cli INFO stats | grep expired_keys
# expired_keys:412088 cumulative, not a rate
redis-cli CONFIG GET hz
# 1) "hz"
# 2) "10" active expiry cycles per second
redis-cli CONFIG GET maxmemory-policy
# 1) "maxmemory-policy"
# 2) "noeviction" the default, and rarely what a cache wants
redis-cli --stat
# keys mem clients requests connections
# 184220 412.55M 12 84102931 (+41) 9012
The active half of the cycle is governed by hz: ten times a second, take twenty keys from the set that has a TTL, delete the dead ones, and repeat immediately if more than a quarter of the sample was dead. That adapts well to a burst of expiries and badly to a long tail, which is why the memory graph descends in steps rather than smoothly. Raising hz makes the sweep more aggressive and costs CPU in a single-threaded server, so it is rarely the right lever. The one that matters is maxmemory with an eviction policy — without them, an instance whose expiry is running behind keeps allocating until the OOM killer settles the argument. allkeys-lru for a pure cache, volatile-lru when the same instance also holds data that must not vanish.