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.largecosts around 130 USD a month. Delete the replication groups you create to practise. All data is fictitious.
Contents
- Why cache, and what should not be cached
- Redis versus Memcached
- Architecture: nodes, replication groups and cluster mode
- Sharding, hash slots, failover and endpoints
- Caching patterns: cache-aside, write-through, write-behind and read-through
- MercadoFresco's catalogue with cache-aside
- TTL, invalidation and consistency
- Cache stampede and hot keys
- Redis structures applied to the shop
- Atomic counters and the stock limit
- Sessions in Redis: goodbye to sticky sessions
- Security: network, encryption and access control
- Metrics, alarms and measuring before and after
- MemoryDB and DAX: when it is not ElastiCache
- Costs and measured savings
- Common mistakes and tips
- Exercises
- MercadoFresco's complete data layer
- 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 productFive deliberate decisions in that code:
- Redis failures never break the request. A
try/exceptaround 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. - 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. setexinstead ofset+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.- The TTL is randomised by ±5 minutes to prevent everything expiring at the same time.
- 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 hourThe 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 >= :nchecking 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-devThe 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-identifierif 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 theComponente=cachetag 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.
- 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.
- Configure the right eviction policy (
allkeys-lruorvolatile-lru) and reviewmaxmemory. With LRU, at least the least used is evicted instead of whatever comes up. - Introduce a connection pool with a maximum limit in the application and in the Lambdas, reusing the client between invocations.
- 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.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
