One workload remains of the four we diagnosed in 06-01, and it is the most absurd of them all. MercadoFresco's catalogue receives 4,100 queries a minute at peak hour and, according to the X-Ray traces from 05-02, 94 % of them return exactly the same result as the one before. Prices and descriptions change once a day, at 06:00, when the delivery from the market arrives.

Aurora resolves each of those queries correctly in about 6 milliseconds. The problem is not that it is slow: it is that doing it 4,100 times a minute in order to contribute no new information at all is wasted work, and that waste is paid for three times —in latency for the customer, in Aurora capacity that has to be paid for even though it produces nothing, and in risk, because that constant load leaves the cluster with no headroom precisely when the Friday peak arrives—.

Amazon ElastiCache is AWS's managed in-memory cache service, compatible with Redis (and its open-source fork Valkey) and with Memcached. Here the catalogue is served from memory in microseconds, sessions stop needing sticky sessions on the ALB —the loose end 03-03 left open— and the module closes with MercadoFresco's data layer complete.

Cost warning. An ElastiCache node is charged by the hour whether there is traffic or not, just like an EC2 instance. A forgotten cache.r7g.large costs around 130 USD a month. Delete the replication groups you create to practise. All data is fictitious.

Contents

  1. Why cache, and what should not be cached
  2. Redis versus Memcached
  3. Architecture: nodes, replication groups and cluster mode
  4. Sharding, hash slots, failover and endpoints
  5. Caching patterns: cache-aside, write-through, write-behind and read-through
  6. MercadoFresco's catalogue with cache-aside
  7. TTL, invalidation and consistency
  8. Cache stampede and hot keys
  9. Redis structures applied to the shop
  10. Atomic counters and the stock limit
  11. Sessions in Redis: goodbye to sticky sessions
  12. Security: network, encryption and access control
  13. Metrics, alarms and measuring before and after
  14. MemoryDB and DAX: when it is not ElastiCache
  15. Costs and measured savings
  16. Common mistakes and tips
  17. Exercises
  18. MercadoFresco's complete data layer
  19. Conclusion

Why cache, and what should not be cached

A cache stores in memory the result of an expensive operation so as not to repeat it. It contributes three different things, and it is worth separating them:

Benefit Before After
Latency 6 ms per query to Aurora 0.3 ms from memory
Cost per query Aurora capacity (ACU) Fixed node, marginal cost almost nil
Protection All the traffic reaches the database The database only sees cache misses

The third is the most underestimated. A cache with a 95 % hit rate turns 4,100 queries a minute into 205: the database stops running at its limit and recovers the headroom it needs for the peak.

What should be cached, in order of return: data that is read far more than it is written (the catalogue, at 4,100:1), the results of expensive calculations, data that tolerates being slightly out of date, and objects that are reconstructed from several sources. What should not be cached: data that changes on every read, data whose momentary accuracy is critical —stock at checkout—, data that is read only once (a search with very specific filters that nobody will ever repeat) and sensitive personal data without a clear reason, because a cache is one more copy that has to be protected and deleted under the GDPR.

Redis versus Memcached

Redis / Valkey Memcached
Data structures Strings, hashes, lists, sets, sorted sets, streams Strings only
Persistence Yes (snapshots and AOF) No
Replicas Yes, with automatic failover No
High availability Multi-AZ with failover None
Sharding Native, with cluster mode In the client
Atomic operations Many (INCR, ZADD, transactions, Lua scripts) Few
Publish and subscribe Yes No
Threading model One thread per logical core (mostly single-threaded) Multi-threaded
Ideal cases Almost all Pure cache, large objects, multi-threading

The choice for MercadoFresco is Redis, for three concrete reasons and not out of popularity. We need structures and not just strings: sorted sets for the Friday ranking, hashes for the fast basket, atomic counters. We need high availability: if the session cache goes down, every customer loses their session at once, and with Memcached there is no replica possible. And we need atomic operations for the counters, which in Memcached are limited.

Memcached still has its niche —a purely volatile, very simple cache, with large objects and where multi-threading gives more performance per node— but this is not that case. About Valkey: it is the open-source fork of Redis following its licence change, compatible at the protocol level, supported by ElastiCache and cheaper. For a new deployment it is the reasonable default, and what is explained here applies just the same.

Architecture: nodes, replication groups and cluster mode

A node is the minimum unit: an instance with memory and a Redis process. A shard is a primary node plus 0 to 5 replicas holding the same data. And a replication group is the set of shards that make up the deployment. Hence the two possible topologies:

graph TD
    subgraph MCD["Cluster mode DISABLED · 1 shard"]
        P1["Primary<br/>the whole data set"] --> R1["Replica AZ b"]
        P1 --> R2["Replica AZ a"]
    end
    subgraph MCA["Cluster mode ENABLED · 3 shards"]
        F1["Shard 1<br/>slots 0-5460"] --> FR1["Replica"]
        F2["Shard 2<br/>slots 5461-10922"] --> FR2["Replica"]
        F3["Shard 3<br/>slots 10923-16383"] --> FR3["Replica"]
    end
Cluster mode disabled Cluster mode enabled
Shards 1 Up to 500
Data limit The memory of one node The sum of all the shards
Scaling Vertical (bigger node) Horizontal (more shards)
Client Any Must support the cluster protocol
Multi-key operations No restriction Only within the same shard

MercadoFresco starts with cluster mode disabled. The catalogue is 1.2 GB and the sessions do not reach 2 GB: it all fits comfortably in a cache.t4g.medium node of 3.09 GiB, with a replica in the other AZ. Enabling cluster mode would add complexity —restrictions on multi-key operations, a compatible client— without solving any problem that exists today. It is exactly the criterion from 06-01: do not add complexity you cannot justify with a measurement.

Sharding, hash slots, failover and endpoints

With cluster mode enabled, Redis divides the key space into 16,384 hash slots. Each key is assigned to a slot by computing CRC16(key) mod 16384, and each shard owns a range of slots. Adding a shard redistributes slots without stopping the service.

Hash tags force several keys to fall into the same slot by putting in curly braces the part that is used for the calculation: carrito:{4471}:lineas and carrito:{4471}:total go together because both of them hash only 4471. That is what makes it possible to operate on several related keys in a sharded cluster.

Multi-AZ with automatic failover promotes a replica if the primary fails, in 15-60 seconds and updating the primary endpoint alone. It has to be configured explicitly, and you need to be clear about what it implies: if persistence is not enabled, anything that was only on the primary is lost. For a cache that is acceptable —it is repopulated from Aurora—; for sessions it is rather less so, and that is why MercadoFresco enables replicas and persistence in the session group.

The endpoints available are four:

Endpoint When it exists Use
Primary Cluster mode disabled Writes and reads that demand the latest data
Reader Cluster mode disabled Spreads reads across replicas
Configuration Cluster mode enabled The only one the application uses; the client discovers the topology
Node Always Diagnostics; never in the application

As with Aurora, using the node endpoint in the application is the mistake you pay for at the first failover.

Caching patterns: cache-aside, write-through, write-behind and read-through

Pattern Who writes to the cache Advantage Drawback
Cache-aside (lazy loading) The application, after a read miss Only what is used gets cached; resilient to cache failures First access is slow; data may be stale
Write-through The application, when writing The cache is always up to date Everything is cached, used or not; makes writes more expensive
Write-behind The cache, asynchronously Very fast writes Risk of data loss; complex
Read-through The cache itself, transparently Clean application code Requires a layer that implements it

MercadoFresco uses cache-aside for the catalogue —the default pattern and the most robust: if the cache disappears, the application keeps working, just more slowly— combined with write-through in the 06:00 market load, which updates the cache at the same time as the database so that the first customer of the morning does not find it empty. Write-behind is explicitly ruled out: writing to memory first and flushing to Aurora afterwards means that a node failure loses orders.

MercadoFresco's catalogue with cache-aside

import json, hashlib, random
import redis
from redis.exceptions import RedisError

# decode_responses=True returns str instead of bytes: more convenient with JSON.
# A low socket_timeout is essential: if the cache does not answer, you have to go
# to the database quickly, not block the customer's request.
cache = redis.Redis(
    host="mercadofresco-catalogo.abc123.ng.0001.euw1.cache.amazonaws.com",
    port=6379, ssl=True, decode_responses=True,
    socket_timeout=0.15, socket_connect_timeout=0.15,
    health_check_interval=30,
)

BASE_TTL = 3600  # 1 hour; prices change once a day

def product_key(sku: str) -> str:
    # A versioned prefix allows the whole catalogue to be invalidated by changing "v3".
    return f"catalogo:v3:producto:{sku}"

def get_product(sku: str, aurora_connection) -> dict:
    key = product_key(sku)

    # 1. Try the cache. Any Redis failure must NOT break the shop.
    try:
        raw = cache.get(key)
        if raw is not None:
            return json.loads(raw)             # cache hit: ~0.3 ms
    except RedisError:
        pass                                   # it is logged and we carry on

    # 2. Cache miss: read from Aurora (the source of truth).
    with aurora_connection.cursor() as cur:
        cur.execute("""
            SELECT p.sku, p.nombre, p.descripcion, p.precio, p.categoria,
                   p.origen, p.alergenos, p.unidad_venta
            FROM   productos p
            WHERE  p.sku = %s AND p.activo = true
        """, (sku,))
        row = cur.fetchone()
    if row is None:
        return None
    product = dict(zip(
        ["sku", "nombre", "descripcion", "precio", "categoria",
         "origen", "alergenos", "unidad_venta"], row))

    # 3. Populate the cache with a randomised TTL (see stampede further down).
    try:
        ttl = BASE_TTL + random.randint(-300, 300)
        cache.setex(key, ttl, json.dumps(product, default=str))
    except RedisError:
        pass

    return product

Five deliberate decisions in that code:

  1. Redis failures never break the request. A try/except around every operation and a 150 ms timeout. A cache that is down must degrade performance, never availability: it is the most serious mistake made when introducing a cache.
  2. The key carries a version (catalogo:v3:). Changing the number invalidates the whole catalogue at once without walking keys, which is what to do when the format of the cached object changes.
  3. setex instead of set + expire. A single atomic operation; with two, a failure in between leaves a key with no expiry, and those immortal keys fill up the cache.
  4. The TTL is randomised by ±5 minutes to prevent everything expiring at the same time.
  5. It is serialised to JSON, which is readable and debuggable. At high volume, binary formats save memory and CPU, but it is worth measuring before complicating things.

TTL, invalidation and consistency

Every cache has the same underlying problem: the cached datum may differ from the real one. There are three ways of managing it and they combine: TTL expiry (the key expires on its own, inconsistency up to the TTL, minimal complexity), explicit invalidation (writing deletes the key, almost zero inconsistency) and write-through (writing updates the key, zero inconsistency). At MercadoFresco the policy is explicit and by type of data:

Data TTL Invalidation Justification
Product page 1 h ± 5 min Yes, on price change Changes once a day
Category listing 15 min No Changes little, tolerates lag
Approximate stock 30 s No "Only a few units left" tolerates lag
Best-seller ranking 5 min No It is informational
User session 30 min sliding Yes, on logout Security

The explicit invalidation in the market load:

def update_price(sku: str, new_price, aurora_connection):
    # 1. The source of truth is ALWAYS written first. If it fails, the cache is untouched.
    with aurora_connection.cursor() as cur:
        cur.execute("UPDATE productos SET precio = %s WHERE sku = %s",
                    (new_price, sku))
    aurora_connection.commit()

    # 2. Then it is invalidated. It is DELETED, not updated: that way the next reader
    #    reloads the complete object and there is no risk of leaving a half-written one.
    try:
        cache.delete(product_key(sku))
    except RedisError:
        pass   # the TTL will fix it in less than an hour

The order matters: database first, cache afterwards. The other way round, a failure between the two operations leaves the cache holding a value the database never had, and that error persists until somebody notices. And it is deleted rather than updated because a partial write to the cache is corrupt data served as if it were good.

Cache stampede and hot keys

A cache stampede (thundering herd) happens when a heavily queried key expires and all the simultaneous requests miss at once and go together to the database. With 4,100 queries a minute and a popular key, that means dozens of identical queries at the very same instant.

Three mitigations, from least to most complex: a randomised TTL, already applied above, which stops many keys expiring in the same second and cheaply solves most of the problem; a repopulation lock, with a single process reloading the key while the rest serve the old value or wait briefly; and early refresh, which reloads before expiry with a probability that increases as the expiry time approaches.

def get_with_lock(key: str, load, ttl: int = 3600):
    value = cache.get(key)
    if value is not None:
        return json.loads(value)

    # SET NX EX: only one process gets the lock. The EX of 10 s guarantees
    # that the lock is released even if the process holding it dies.
    lock = f"lock:{key}"
    if cache.set(lock, "1", nx=True, ex=10):
        try:
            data = load()                          # the only query to Aurora
            cache.setex(key, ttl + random.randint(-300, 300),
                        json.dumps(data, default=str))
            return data
        finally:
            cache.delete(lock)

    # We did not get the lock: wait a moment and retry the cache.
    time.sleep(0.05)
    value = cache.get(key)
    return json.loads(value) if value else load()

A hot key is the other problem: a single key that concentrates so much traffic that it saturates the node holding it. In cluster mode it cannot be spread, because a key lives in one slot. The solutions are to replicate the key with suffixes (ranking:viernes:0:4, picking one at random on read) or to cache it in the application process's memory as well, with a TTL of a few seconds. It is the same concept as the DynamoDB hot key in 06-02.

Redis structures applied to the shop

This is where Redis pulls away from a generic cache: the structures make it possible to solve problems that with strings would require reading, modifying and rewriting the whole object.

# 1. STRINGS · the serialised product page, with an expiry.
cache.setex("catalogo:v3:producto:PESC-SALM-001", 3600, json.dumps(product))

# 2. HASHES · the fast basket. Each field is a SKU and each value, the units.
#    Changing one line does NOT require rewriting the whole basket: HINCRBY is atomic.
cache.hincrby("carrito:rapido:4471", "PESC-SALM-001", 2)
cache.hincrby("carrito:rapido:4471", "VERD-TOMA-014", -1)
cache.expire("carrito:rapido:4471", 1800)
basket = cache.hgetall("carrito:rapido:4471")    # {'PESC-SALM-001': '2', ...}

# 3. LISTS · the customer's most recent searches, capped at 10.
cache.lpush("busquedas:4471", "norwegian salmon")
cache.ltrim("busquedas:4471", 0, 9)              # discards the excess
latest = cache.lrange("busquedas:4471", 0, 9)

# 4. SORTED SETS · the Friday best-seller ranking.
#    ZINCRBY adds to the score atomically; the order maintains itself.
cache.zincrby("ranking:viernes:2026-08-07", 2, "PESC-SALM-001")
top10 = cache.zrevrange("ranking:viernes:2026-08-07", 0, 9, withscores=True)
cache.expire("ranking:viernes:2026-08-07", 604800)   # one week
Structure Key operations Use in MercadoFresco Alternative in Aurora
String SET, GET, SETEX Product page SELECT by key
Hash HSET, HINCRBY, HGETALL Fast basket Line-items table
List LPUSH, LTRIM, LRANGE Recent searches Table with LIMIT 10
Sorted set ZINCRBY, ZREVRANGE Friday ranking GROUP BY + ORDER BY
Counter INCR, DECR, INCRBY Views, rate limits UPDATE ... SET n = n + 1

The sorted set deserves a comment: computing the best-seller ranking in SQL requires aggregating and sorting all the day's line items, whereas with ZINCRBY the order is maintained incrementally on every sale and querying the top 10 costs logarithmic time. It is the difference between a dashboard that refreshes every five minutes and one that runs live.

And a warning about the fast basket: the authoritative basket is still in DynamoDB (06-02). The Redis hash is a hot copy used to paint the header. If the node restarts, the basket is not lost, because the truth lives somewhere else; if Redis were used as the single source, a restart would mean lost sales.

Atomic counters and the stock limit

INCR and DECR are atomic: a thousand concurrent processes produce the correct result without locks. That makes them ideal for view counters, rate limiting by IP and approximate stock.

# Reserve units in the cache BEFORE touching the database:
# this quickly discards most attempts once a product has sold out.
remaining = cache.decrby("stock:aprox:PESC-SALM-001", units)
if remaining < 0:
    cache.incrby("stock:aprox:PESC-SALM-001", units)   # undo
    raise OutOfStock("Product sold out")

A warning that admits no nuance. This is not stock control. It is a pre-filter, an optimisation that stops thousands of requests reaching Aurora once a product has already sold out. Real stock is still transactional in Aurora and is deducted inside the order confirmation transaction, with UPDATE stock SET unidades = unidades - :n WHERE sku = :s AND unidades >= :n checking that it affected one row. If that check fails, the order is rejected even though the cache said there was stock.

The reason is the usual one: a cache can lose data. A node restart, a failover or an eviction for lack of memory can leave the counter at an incorrect value, and with fresh produce that means selling boxes of strawberries that do not exist: calling the customer, refunding and losing them. It is exactly the distinction from 06-01: eventual to display, strong to decide.

Sessions in Redis: goodbye to sticky sessions

In 03-03 we left a loose end. The ALB alb-mercadofresco-tienda has stickiness.enabled=false because the application is stateless, and we said back then that "MercadoFresco will end up using ElastiCache". This is that moment.

The problem it solves: if the session lives in the memory of the EC2 instance that served the user, that user always has to come back to the same instance. That forces sticky sessions on the ALB, and sticky sessions break three things: load distribution becomes unbalanced, when the ASG scales in the users on the terminated instance lose their session, and rolling deployments throw users out. With sessions in Redis, any instance can serve any user:

# The session lives in Redis; the EC2 instance stores nothing about the user.
def load_session(session_id: str) -> dict:
    key = f"sesion:{session_id}"
    data = cache.get(key)
    if data is None:
        return {}
    cache.expire(key, 1800)       # sliding TTL: renewed by activity
    return json.loads(data)

def save_session(session_id: str, data: dict):
    cache.setex(f"sesion:{session_id}", 1800, json.dumps(data))
With a local session With the session in Redis
Sticky sessions on the ALB Necessary Unnecessary
Load distribution Unbalanced Uniform
Scaling the ASG in Throws users out Transparent
Rolling deployment Throws users out Transparent
Replaceable instance Not entirely Yes, completely

This closes the thread that comes from 02-01: the ASG asg-mercadofresco-tienda can at last scale out, scale in and replace instances with complete freedom, because no instance holds anything that belongs to a customer. With two precautions: the session group uses replicas and persistence, because losing every session at once is a visible incident; and session data is limited to the bare essentials —customer identifier and preferences—, never payment data or personal information that would force the cache to be treated as a store subject to the GDPR.

Security: network, encryption and access control

aws elasticache create-replication-group \
  --replication-group-id mercadofresco-catalogo \
  --replication-group-description "Catalogue and session cache" \
  --engine valkey --engine-version 7.2 \
  --cache-node-type cache.t4g.medium \
  --num-cache-clusters 2 \
  --automatic-failover-enabled --multi-az-enabled \
  --cache-subnet-group-name sng-mercadofresco-datos \
  --security-group-ids sg-mercadofresco-cache \
  --at-rest-encryption-enabled \
  --transit-encryption-enabled --transit-encryption-mode required \
  --kms-key-id alias/mercadofresco-datos \
  --snapshot-retention-limit 5 --snapshot-window "03:00-04:00" \
  --tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
         Key=Componente,Value=cache Key=Propietario,Value=luis \
         Key=CentroCoste,Value=plataforma \
  --region eu-west-1 --profile mercadofresco-dev

The five measures that make this secure. Private subnets: sng-mercadofresco-datos has no route to the internet, and a cache must never be reachable from outside —the historical incidents of exposed Redis are numerous and serious—. A dedicated security group: sg-mercadofresco-cache allows port 6379 only from sg-mercadofresco-tienda and from the Lambda role, referencing security groups rather than IP ranges, as in 03-02. Encryption at rest and in transit with alias/mercadofresco-datos, in required mode, because preferred would accept connections without TLS and that makes encryption optional in practice. RBAC: the shop gets a user that can read and write catalogo:* and sesion:* but cannot run FLUSHALL, KEYS or CONFIG, which is the least privilege of 04-01 applied to the cache. And credentials in Secrets Manager, as with everything since 04-03, never in plain-text environment variables.

Metrics, alarms and measuring before and after

Metric What it indicates Suggested alarm
CacheHitRate Percentage of hits < 80 % for 15 min
Evictions Keys evicted for lack of memory > 0 sustained
DatabaseMemoryUsagePercentage Memory used against maxmemory > 80 %
CPUUtilization / EngineCPUUtilization Load on the Redis process > 70 %
CurrConnections Open connections Anomalous rise
ReplicationLag Replica lag > 1 s

Two of them deserve an explanation. Evictions sustained above zero is always a problem: Redis is deleting keys that had not yet expired because there is no more room, the hit rate falls and the load goes back to Aurora; the solution is more memory or less data, not ignoring it. And EngineCPUUtilization is more informative than CPUUtilization because the process is fundamentally single-threaded: a node with 4 vCPUs can show 25 % total CPU with the engine thread at 100 %.

The before-and-after measurement, with the module 5 dashboards:

Metric Source Before After
TiempoConfirmacionPedido p99 MercadoFresco/Tienda 880 ms 310 ms
Product page latency X-Ray 240 ms 28 ms
Queries/min to Aurora (catalogue) AWS/RDS 4,100 215
Aurora average ACUs AWS/RDS 2.4 1.5
CacheHitRate AWS/ElastiCache 94.8 %

This is the moment to remember why module 5 came before module 6: without those metrics, none of the four decisions in this module could be defended to management with data. The product page latency is what the customer notices; the rest is what convinces whoever signs the invoice.

MemoryDB and DAX: when it is not ElastiCache

Service What it is When
ElastiCache In-memory cache; the data can be lost Caching a source of truth that lives elsewhere
MemoryDB Durable in-memory database, Redis-compatible When Redis is the source of truth and cannot lose data
DAX DynamoDB-specific cache, same API Very intensive repetitive reads over DynamoDB

MemoryDB replicates every write to a distributed transaction log across several AZs before acknowledging it, with durability comparable to that of a database, and it costs considerably more. MercadoFresco does not need it: the catalogue is in Aurora and the basket in DynamoDB, so the cache can always be repopulated. DAX was already ruled out in 06-02: the basket has no repetitive reads —each customer reads their own—, so the hit rate would be low, and besides ElastiCache serves data from several sources whereas DAX only understands DynamoDB.

Costs and measured savings

Item Quantity Monthly cost
cache.t4g.medium primary 730 h × 0.073 53.29 USD
cache.t4g.medium replica 730 h × 0.073 53.29 USD
Backups (5 days) ~3 GB 0.25 USD
Inter-AZ transfer Replica ~2 USD
ElastiCache total ≈108.83 USD

And what it gives back, measured on the following month's Aurora bill:

Item Before After Difference
Aurora average ACUs 2.4 1.5 −0.9 ACU
Aurora compute cost 210 USD 131 USD −79 USD
Aurora I/O 36 USD 22 USD −14 USD
ElastiCache cost 0 109 USD +109 USD
Net +16 USD/month

It is worth being honest: the cache does not pay for itself in euros, it costs 16 USD net a month. What it buys is product page latency divided by eight —from 240 ms to 28 ms, which the customer perceives—, a margin of Aurora capacity that did not exist before to absorb the Friday peak, and the distributed sessions that let the ASG scale out and in without throwing anybody out. As with Aurora, the justification is not the saving: it is what the spending buys.

Cleanup. When you finish practising: aws elasticache delete-replication-group --replication-group-id <id>, with --final-snapshot-identifier if you want to keep the data. A forgotten group with two nodes is more than 100 USD a month for nothing. Check in Cost Explorer that the Componente=cache tag disappears the following month.

Common Mistakes and Tips

Letting a cache failure break the shop. The most serious mistake and the most common. Every cache operation is wrapped in try/except and given a short timeout. A cache that is down degrades performance; never availability.

Caching the result before committing it to the database. Database first, cache afterwards. The other way round, an intermediate failure leaves in the cache a value that never existed.

Updating the cache instead of deleting it when invalidating. Deleting is idempotent and simple; updating can leave a partially written object that is served as good for an hour.

Using the cache as the source of truth for stock. It has already been said with complete clarity: real stock is transactional in Aurora. The cache only filters out the obvious attempts.

Ignoring Evictions. A value sustained above zero means Redis is deleting live keys for lack of memory. The hit rate falls and the load goes back to the database, so the cache stops doing its job while you carry on paying for it.

Running KEYS * in production. It walks the entire key space and, since Redis is essentially single-threaded, it blocks the server while it does so. Use SCAN, which iterates in batches. Best of all is for RBAC to forbid KEYS outright.

Setting keys with no TTL. A key with no expiry never goes away. Enough of them and the memory fills up, at which point the evictions start. The rule is simple: every key carries a TTL, barring a justified and documented exception.

Caching personal data without thinking. A cache is one more copy of personal data subject to the GDPR: it has to be encrypted, access has to be restricted and you have to be able to delete it on an erasure request. Cache identifiers and preferences, not addresses or payment data.

Tip: measure the hit rate by key type, not just the global one. A global 94 % can hide 99 % in the catalogue and 40 % in the listings, and that 40 % points to a badly chosen TTL or a pattern that does not repeat often enough to deserve a cache.

Tip: test degraded mode. Turn the cache off in pre-production under load and check that the shop carries on working, more slowly but working. If it falls over, the cache has become a critical dependency without anybody deciding it.

Exercises

Exercise 1: designing the caching policy for a new feature

MercadoFresco is launching "my usual list": a screen showing the 20 products a customer has bought most over the last 6 months, with their current price, their availability and a flag if they are on offer. Computing it in Aurora requires aggregating the customer's history (1.2 s) and querying price and stock for 20 products.

Design the strategy: what is cached and under what key, what TTL for each piece, what pattern you use for each one, what is invalidated and when, how you avoid the stampede on Monday morning, and which Redis structure you choose for each element. Justify why you do not cache the complete response of the screen under a single key.

Exercise 2: diagnosing a cache that does not help

Two weeks after deploying the cache, the metrics are these: CacheHitRate 41 %, DatabaseMemoryUsagePercentage 97 %, Evictions 8,400 per hour, EngineCPUUtilization 88 %, CurrConnections 2,900 and growing, and the shop's p99 latency has got worse than before the cache. It also turns out that the team caches search results under the key busqueda:<full text>:<filters> with a TTL of 24 hours.

Answer: (a) what is the root cause and how you deduce it from the metrics; (b) why latency has got worse instead of better; (c) what part CurrConnections plays; (d) four measures ordered by impact; (e) what policy would have prevented the problem from the design stage.

Exercise 3: closing the stock loop

Write the complete "add to basket and confirm order" flow for a box of strawberries with 3 units left, stating at each step which store is involved (ElastiCache, DynamoDB, Aurora), what kind of consistency is used and why. It must cover: showing the product page with "last few units", adding to the basket, moving to payment, confirmation with the stock deduction, and what is shown to a customer who arrives too late. Also state what happens at each step if ElastiCache goes down at that moment.

Solutions

Solution 1

The complete screen is not cached under a single key because it mixes pieces with radically different rates of change: the list of usual products changes at most once a day, but price and availability change every few minutes. With a single key you have to choose between a short TTL —which wastes the expensive aggregation by recomputing it constantly— or a long one —which shows stale prices—. The general rule is to cache by rate of change, not by screen.

Piece Key Structure TTL Pattern Invalidation
List of usual SKUs habitual:v1:<id_cliente> Sorted set (score = purchases) 24 h ± 1 h Cache-aside with lock On order confirmation, incremental ZINCRBY
Product page catalogo:v3:producto:<sku> JSON string 1 h ± 5 min Cache-aside On price change
Approximate stock stock:aprox:<sku> Counter 30 s Write-through on the load No
Offer flag ofertas:v1:activas Set 10 min Cache-aside On campaign publication

The screen is assembled by reading the sorted set (one operation) and then the 20 product pages with a single MGET call, not twenty GETs: cutting the network round trips is what turns 20 × 0.3 ms into 0.4 ms.

Monday morning stampede: the risk is that thousands of habitual:* keys expire at once. It is mitigated with the three techniques combined: a TTL randomised by ±1 hour, a repopulation lock with SET NX EX so that only one process aggregates per customer, and —given that the aggregation costs 1.2 s— a scheduled early refresh in the small hours for the most active customers, who arrive in the morning with the key already warm.

Incremental instead of recomputing: on order confirmation, instead of invalidating the list you run ZINCRBY habitual:v1:<cliente> <units> <sku>. The list stays up to date without ever running the 1.2 s aggregation again, except when the key genuinely expires. It is the best use of a sorted set in the whole lesson.

Solution 2

(a) Root cause: the search results cache. The metrics say so in a chain. The key busqueda:<full text>:<filters> has practically infinite cardinality: every combination of free text and filters generates a new key that almost nobody will repeat. With a TTL of 24 hours, those keys pile up without stopping until they fill the memory (DatabaseMemoryUsagePercentage 97 %), and then Redis starts evicting (Evictions 8,400/h) —and it also evicts the catalogue keys, which were useful—. Hence the CacheHitRate of 41 %: a huge amount that is not reused gets cached and what is reused gets lost. It is the textbook case of caching data that is read only once.

(b) Why latency got worse. Now every request pays two costs instead of one: first it queries Redis, misses 59 % of the time, and then queries Aurora anyway. On top of that, Aurora receives almost the same load as before —because the useful hits have collapsed— so its latency has not improved either, and the Redis node is saturated (EngineCPUUtilization 88 %), so even the cache operations themselves are slow. A cache with a bad hit rate is worse than having no cache: it adds latency and takes away no load.

(c) CurrConnections growing. It indicates that connections are not being reused: either a connection pool is missing, or each Lambda invocation opens a new one and does not close it. Every connection consumes node memory —aggravating the memory problem— and CPU on the engine thread. With 2,900 connections and rising, the node will end up refusing new connections.

(d) Four measures by impact.

  1. Stop caching free-text searches. It is the root cause and fixing it frees memory immediately. If you want to cache search, only the most frequent queries —the top 100 measured in the logs— with a TTL of minutes, not the 400,000 possible combinations.
  2. Configure the right eviction policy (allkeys-lru or volatile-lru) and review maxmemory. With LRU, at least the least used is evicted instead of whatever comes up.
  3. Introduce a connection pool with a maximum limit in the application and in the Lambdas, reusing the client between invocations.
  4. Review the sizing once the above is corrected: if with only the catalogue and the sessions the memory is still above 80 %, the node type has to go up.

(e) The design policy that prevents it. Two explicit rules, written before the first line of code. First: only what is read many more times than it is written gets cached, and that ratio is measured before caching —the catalogue was 4,100:1; a free-text search is roughly 1:1—. Second: every key has a bounded TTL and every family of keys has a known cardinality limit; if you cannot bound how many distinct keys a pattern is going to generate, then you do not cache with that pattern. Adding an alarm on Evictions > 0 from the first day would have raised the flag in a matter of hours instead of weeks.

Solution 3

1. Showing the product page with "last few units". ElastiCache, eventual consistency. It reads catalogo:v3:producto:PESC-FRES-002 and stock:aprox:PESC-FRES-002 (TTL 30 s). If the counter is 3, "only a few units left" is painted. A 30-second lag here harms nobody. If ElastiCache goes down: cache miss, it reads from Aurora, the page takes 240 ms instead of 28. It works, more slowly.

2. Adding to the basket. DynamoDB, a write to mercadofresco-carritos with UpdateItem, plus an update of the carrito:rapido:<cliente> hash in ElastiCache to paint the header. No stock is deducted: adding to the basket reserves nothing, because reserving on add causes abandoned baskets to block real product. A DECRBY on stock:aprox is done only as an indicative filter if business policy asks for it, accepting that it is approximate. If ElastiCache goes down: the basket is saved in DynamoDB all the same —which is the truth— and the header is painted by reading from DynamoDB. Nothing is lost.

3. Moving to payment. DynamoDB with a strongly consistent read (ConsistentRead=True) of the basket: what is charged depends on this read, so eventual will not do. Prices are recalculated by reading from Aurora, not from the cache, because the amount charged cannot be based on a price that may be up to an hour old. If ElastiCache goes down: no effect, this step does not use it.

4. Confirmation and stock deduction. Aurora, a transaction with strong consistency, and it is the only step that decides:

BEGIN;
UPDATE stock SET unidades = unidades - 3
 WHERE sku = 'PESC-FRES-002' AND unidades >= 3;   -- must affect 1 row
-- if it affects 0 rows: ROLLBACK and reject the order
INSERT INTO pedidos (...) VALUES (...);
INSERT INTO lineas_pedido (...) VALUES (...);
COMMIT;

After the COMMIT, and only after, stock:aprox:PESC-FRES-002 is invalidated in the cache and a ZINCRBY is done on the Friday ranking. The order is the usual one: source of truth first, cache afterwards. If ElastiCache goes down: the order is confirmed correctly; the approximate counter will be out of date until it expires after 30 seconds, with no consequence at all.

5. The customer who arrives too late. They saw "only a few units left" because they read the cache, added to the basket and, on confirming, the UPDATE affects 0 rows because another customer took the three boxes. The correct response is not to confirm the order and to show a clear message —"the strawberries sold out while you were completing your order"— with a suggested equivalent product and the option to continue without that item. An order is never confirmed on the basis of a cache read.

The principle running through all five steps, and the synthesis of the whole module: eventual to display, strong to decide. The cache speeds up what the customer sees; the Aurora transaction decides what the customer buys. And every store has a bounded failure mode: if the cache goes down everything works more slowly; if DynamoDB goes down baskets are lost but not orders; only if Aurora goes down does selling stop, and that is why Aurora is the only one of the three with six copies in three AZs.

MercadoFresco's complete data layer

The four workloads we diagnosed in 06-01 now all have their engine, and every assignment rests on a measurement:

Workload Measured pattern Engine Measured result
Orders and stock Transactional OLTP, ad hoc SQL Aurora PostgreSQL Failover from 96 s to 20 s; replica from 8 s to 15 ms
Basket and sessions Key-value, 180,000 writes/day DynamoDB 78 GB → 8 GB; 51 → 8 USD/month; no VACUUM
Reports OLAP, 6-40 M rows aggregated Redshift Serverless 4-6 min → 2-6 s; out of production
Catalogue 4,100 reads/min, 94 % identical ElastiCache (Valkey) 240 ms → 28 ms; 4,100 → 215 queries/min
graph TD
    CF["CloudFront E2QWERTY123ABC"] --> ALB["alb-mercadofresco-tienda<br/>no sticky sessions"]
    ALB --> ASG["asg-mercadofresco-tienda<br/>stateless instances"]
    ASG --> EC["ElastiCache · mercadofresco-catalogo<br/>catalogue, sessions, ranking, counters"]
    ASG --> DDB["DynamoDB · mercadofresco-carritos<br/>basket and sessions · TTL 30 days"]
    ASG --> AUR["Aurora · aurora-mercadofresco-pedidos<br/>orders, stock, catalogue · source of truth"]
    EC -.->|cache miss| AUR
    AUR -->|nightly pipeline and zero-ETL integration| RS["Redshift · mercadofresco-analitica<br/>hechos_pedidos and dimensions"]
    DDB -.->|Streams: abandoned baskets| S3["S3 · mercadofresco-informes-analitica"]
    S3 --> RS
    RS --> SARA["Sara's reports<br/>average basket, cohorts, Friday ranking"]

And the module's complete economic balance, which is worth looking at head-on:

Component Before After
RDS mercadofresco-pedidos + replica 118 USD
Aurora Serverless v2 153 USD
DynamoDB mercadofresco-carritos 8 USD
Redshift Serverless 16 USD
ElastiCache 109 USD
Data layer total 118 USD 286 USD

The data layer costs 2.4 times more. In exchange: the shop's p99 latency goes from 1,900 ms to 310 ms, the failover from 96 to 20 seconds, Sara's reports from six minutes to six seconds, the sessions table stops growing out of control, and the ASG can scale out and scale in without throwing anybody out. For a shop that does 900 orders an hour at the Friday peak, with an average basket of tens of euros, an extra 168 USD a month is a decision that justifies itself on its own. What matters is that it is now justified with numbers, and that is the difference between architecture and intuition.

Conclusion

The catalogue is served from memory. You know why to cache —latency, cost per query and, above all, protecting the database, because a 95 % hit rate turns 4,100 queries a minute into 205 and gives Aurora back the headroom it needed for the peak— and you know what not to cache: what changes on every read, what demands momentary accuracy, what is read only once and personal data without a clear reason.

You know Redis/Valkey versus Memcached and why MercadoFresco picks the former: data structures beyond strings, replicas with automatic failover and atomic operations. You have mastered the architecture —node, shard, replication group, cluster mode enabled and disabled with their 16,384 hash slots and their tags for co-locating keys, Multi-AZ with failover— and the decision to start with cluster mode disabled, because 1.2 GB of catalogue fits easily in one node and adding complexity without measurement is the mistake this module has spent five lessons fighting. With the primary, reader, configuration and node endpoints, and the rule that the node endpoint never appears in the application.

You handle the four caching patterns and the reasoned choice: cache-aside for the catalogue because it is the most robust —if the cache disappears, the shop carries on—, write-through in the 06:00 market load so that the first customer does not find the cache empty, and write-behind explicitly ruled out because losing business writes is not acceptable. With the complete code and its five decisions: try/except on every operation with a 150 ms timeout, a versioned key to invalidate the whole catalogue at a stroke, atomic setex so that no immortal keys are left behind, a randomised TTL and debuggable serialisation. And with the order that is not negotiable: database first, cache afterwards, and delete rather than update when invalidating.

You know what a cache stampede is and the three mitigations —randomised TTL, repopulation lock with SET NX EX, early refresh— and you recognise the hot key as the same problem as in DynamoDB under another name. You apply Redis structures to real problems: strings for the product page, hashes for the fast basket with atomic HINCRBY, lists with LTRIM for recent searches, sorted sets with ZINCRBY so that the Friday ranking maintains itself on every sale, and atomic counters for approximate stock —with the warning that admits no nuance: real stock is still transactional in Aurora, because a cache can lose data and selling strawberries that do not exist means calling the customer, refunding and losing them—.

And you have tied off the loose end from 03-03: with sessions in Redis, the ALB does not need sticky sessions, load distribution is uniform, and the ASG asg-mercadofresco-tienda can scale out, scale in and replace instances without throwing anybody out, because no instance now holds anything that belongs to a customer. All of it in private subnets with sg-mercadofresco-cache, encryption at rest and in transit in required mode, RBAC forbidding FLUSHALL and KEYS, credentials in Secrets Manager, alarms on CacheHitRate, Evictions, EngineCPUUtilization and DatabaseMemoryUsagePercentage, and the honest comparison with MemoryDB and DAX so you know when the problem is not one of caching. Cost: 109 USD that give back 93 on Aurora, and a product page latency divided by eight.

This closes module 6. The four workloads have their engine and every decision rests on a measurement from module 5: Aurora for orders and stock, DynamoDB for the basket and sessions, Redshift Serverless for Sara's reports, ElastiCache for the catalogue. The data layer costs 2.4 times more and is worth every euro, and what matters is that it can now be demonstrated.

But splitting the data across four stores has thrown up a new problem, and this time it is not one of performance. The shop does too many things synchronously inside the customer's request. When somebody presses "Confirm order", the same thread charges the card, writes to Aurora, updates DynamoDB, invalidates the cache, tells the warehouse to prepare the box, sends the confirmation email, notifies the delivery driver and publishes the event for analytics. Eight things chained together, and if any one of them fails, the whole order breaks: if the email provider takes four seconds, the customer waits four seconds; if the warehouse system is down, a sale that had already been paid for is lost. There is no longer a database to query in order to fix it, because the problem is not in any store: it is that everything depends on everything else, all at the same time.

In module 7, "Application Integration", starting with 07-01, "Amazon SQS", we will see how to decouple those eight tasks: queues so that the order is confirmed as soon as it has been charged and the rest happens afterwards, topics so that one event reaches several interested parties without the shop knowing who they are, events to route according to content, and workflows to orchestrate long processes with retries and compensations. The aim is for confirming an order to go back to being a single fast, reliable thing, and for the rest of the world to find out at its own pace.

© Copyright 2026. All rights reserved