Filter concepts by levelShowing all levels.

Django · Section 58

Caching

Level
advanced
Read
30 min
Concepts
3

Django offers four caching levels that differ in how much of a response they store: per-site middleware (whole pages, `GET`/`HEAD` and status 200 only, with `UpdateCacheMiddleware` first and `FetchFromCacheMiddleware` last), per-view `@cache_page`, template fragments via `{% cache 500 name vary_on %}`, and the low-level API, which is the only level that caches a value rather than rendered output and the only one with precise invalidation. Choose the narrowest level that removes the cost you measured — per-site caching is almost always wrong once users log in, because the key is the URL and a whole page is the largest thing that can go stale. The backends that matter in production are Redis and Memcached; `LocMemCache` is per process and will mislead you. The key you write is never the key stored — `KEY_FUNCTION` combines it with `KEY_PREFIX` (which separates tenants of the server) and `VERSION` (which separates generations of your own data, making a version bump the cheapest bulk invalidation available). `timeout=None` never expires and `timeout=0` means do not cache. The hard part is everything after the write: invalidate on write rather than trusting a TTL, and do it in `transaction.on_commit()` or a concurrent read will repopulate the pre-write value; defeat stampedes with an atomic `cache.add()` lock and jittered TTLs, since a stampede is caused by a *successful* cache concentrating traffic on one expiry instant; absorb penetration by caching the "not found" answer behind a sentinel, because `cache.get()` returns `None` for both a miss and an absent value; warm the hot keys after a deploy; and where two copies genuinely must not disagree, do not cache that field at all.

What is true here

  1. Four levels — site, view, fragment, value — and the right one is the narrowest that removes the measured cost.
  2. Per-site and per-view caches key on the URL and only cover GET/HEAD 200 responses.
  3. The stored key is KEY_PREFIX:VERSION:key; bumping VERSION orphans every earlier entry with no scan.
  4. A stampede is caused by a successful cache; cache.add() is the atomic lock that stops it.
  5. Invalidate in transaction.on_commit(), never inside the transaction, or the delete is undone before the write is visible.

What you will be able to do

  • Pick the caching level that matches what is actually expensive, rather than caching the whole page
  • Configure Redis or Memcached with key prefixes, versions and database separation that make invalidation and clearing safe
  • Recognise a stampede and a penetration pattern from their signatures in database load
  • Invalidate correctly around a transaction, and know when a field should not be cached at all
Two ways to answer the same request

No cache — correct, and expensive

  • +Every request pays the full query cost.
  • +Always consistent: there is only one copy of the data.
  • +Load scales linearly with traffic.
  • +No invalidation problem, because nothing is duplicated.
  • +Often the right answer once an index and select_related are in place.

Cached — cheap, and now your problem

  • One request pays; the rest are nearly free.
  • A second copy exists, so two readers can disagree.
  • Expiry concentrates load onto one instant — the stampede.
  • A missing row is a miss every time unless you cache the absence.
  • Invalidation is now a thing you must write, and get right around transactions.
  • No cache — correct, and expensive
    • Every request pays the full query cost.
    • Always consistent: there is only one copy of the data.
    • Load scales linearly with traffic.
    • No invalidation problem, because nothing is duplicated.
    • Often the right answer once an index and select_related are in place.
  • Cached — cheap, and now your problem
    • One request pays; the rest are nearly free.
    • A second copy exists, so two readers can disagree.
    • Expiry concentrates load onto one instant — the stampede.
    • A missing row is a miss every time unless you cache the absence.
    • Invalidation is now a thing you must write, and get right around transactions.

The four levels

Per-site, per-view, template fragment, and the low-level API — and how much each one owns.

Per-site, per-view, template fragment, and the low-level API

coreintermediate

Django gives you four places to cache, and they differ in how much of the response they store. **Per-site** caches whole pages through two middleware, and only for `GET`/`HEAD` requests that returned 200. **Per-view** does the same for one view, via `@cache_page(seconds)`. **Template fragment** caches a block inside a template with `{% cache 500 sidebar %}`, so the surrounding page stays dynamic. **The low-level API** — `cache.get()`, `cache.set()`, `cache.get_or_set()` — caches any value you choose, which is the only one of the four that can cache something that is not part of a rendered response. Go as narrow as the problem allows: the wider the level, the more of the page becomes stale at once.

Think of it as

The four levels are a ladder from "cache everything, control nothing" to "cache exactly one thing, control all of it", and the right rung is the narrowest one that removes the cost you measured. Per-site caching is tempting and almost always wrong for an application with logged-in users, because a whole page is the largest unit that can go stale and the cache key does not know about the user unless a `Vary` header makes it. Per-view narrows the blast radius to one URL. Fragment caching is usually the sweet spot for a page that is mostly cheap with one expensive region — a sidebar, a navigation tree, a leaderboard — because it leaves the personalised parts alone. And the low-level API is what you reach for when the expensive thing is not a piece of HTML at all: a computed aggregate, a response from a third-party API, a permission set. The other reason to prefer the narrow rungs is invalidation: `cache.delete("leaderboard")` is a line you can write when the leaderboard changes, while "invalidate every page that contained the leaderboard" is not a thing you can express at all.

python
from django.core.cache import cache

value = cache.get_or_set("leaderboard:v1", compute_leaderboard, 300)

What we're doing: Cache the one expensive region of an otherwise personalised page, at two levels, without caching the page itself.

dashboard/views.py + dashboard/templates/dashboard.htmlpython
# views.py
def dashboard(request):
    return render(request, "dashboard.html", {
        "greeting": f"Hello, {request.user.first_name}",   # never cached
        "leaderboard": cache.get_or_set(
            "leaderboard:v1", compute_leaderboard, 300),   # value cache, 5 min
    })


def compute_leaderboard():
    return list(
        Score.objects.values("user__username")
              .annotate(total=Sum("points"))
              .order_by("-total")[:20]
    )

# dashboard.html
#   {% load cache %}
#   <p>{{ greeting }}</p>
#   {% cache 600 nav request.user.id %}
#     {% include "_navigation.html" %}     {# expensive to render, per-user #}
#   {% endcache %}
4
The personalised line is outside every cache. This is the whole reason per-site caching was not used — one greeting would have frozen the entire page for everyone.
5–6
`get_or_set` does the read, the miss, the compute and the write in one call, so there is no window where two code paths disagree about whether the key exists.
11
`list(...)` materialises the queryset. Caching a lazy `QuerySet` stores something that re-queries when iterated, which caches nothing and costs a pickle round trip.
20
`request.user.id` as a `vary_on` value gives each user their own fragment entry. Omitting it would serve one user's navigation to everyone.

Why this works: Two narrow caches remove the two measured costs — the aggregate query and the navigation render — while every personalised byte still comes from the request. A per-view or per-site cache could not have expressed that at all.

Caching a `QuerySet` instead of its results

Wrong

python
cache.set("top_scores", Score.objects.order_by("-points")[:20], 300)
# Stores a lazy QuerySet. Reading it back and iterating re-runs the SQL.

Better

python
cache.set("top_scores", list(Score.objects.order_by("-points")[:20]), 300)

What you see: The cache reports hits, the code looks correct, and query counts do not drop at all — because every "hit" returns an unevaluated queryset that queries again on first iteration.

Why: A `QuerySet` is lazy: it holds a query, not rows. Pickling one into the cache stores the query, so retrieving it and iterating executes the SQL exactly as if there had been no cache — while additionally paying to serialise and deserialise. Forcing evaluation with `list()` is what makes the cached value the data rather than the intent to fetch it.

How much of the response each level owns

Per-site middleware

the entire page, for every GET/HEAD that returned 200 — the largest thing that can go stale

@cache_page on one view

one URL's full response, keyed by path and query string

{% cache %} template fragment

one block; the rest of the page still renders per request, so personalisation survives

cache.get_or_set() in Python

one value — an aggregate, an API response, a permission set. The only level with precise invalidation.

The queryset itself

no cache at all — often the right answer once select_related and an index are in place

  1. Per-site middleware — the entire page, for every GET/HEAD that returned 200 — the largest thing that can go stale
  2. @cache_page on one view — one URL's full response, keyed by path and query string
  3. {% cache %} template fragment — one block; the rest of the page still renders per request, so personalisation survives
  4. cache.get_or_set() in Python — one value — an aggregate, an API response, a permission set. The only level with precise invalidation.
  5. The queryset itself — no cache at all — often the right answer once select_related and an index are in place

The four levels, narrowest last

The four levels, narrowest last
LevelCachesInvalidate byReach for it when
Per-siteevery GET/HEAD 200 responsewaiting for the TTLa genuinely anonymous, mostly-static site
Per-view (`@cache_page`)one view's whole responsethe TTL, or a key-prefix bumpone expensive read-only page
Fragment (`{% cache %}`)a block of template outputthe TTL, or changing a `vary_on` valuea mostly-dynamic page with one costly region
Low-level APIany Python value`cache.delete(key)` — precisean expensive computation or upstream call

Together

python
def leaderboard():
    return cache.get_or_set("leaderboard:v1", compute_leaderboard, 300)

Remember: Four levels, from the whole site down to a single value, and the right one is the narrowest that removes the cost you measured. Per-site and per-view only cache `GET`/`HEAD` 200 responses and key on the URL — which makes per-site caching wrong for anything personalised. Fragment caching keeps the rest of the page live; the low-level API is the only level that caches a value rather than output, and the only one with precise invalidation. Always `list()` a queryset before caching it.

See also: cache backends keys and ttl · invalidation stampede and consistency · built in tags

Advertisement

Backends, keys, and TTL

Redis against Memcached, the two-part key structure, and what each timeout value really means.

Redis, Memcached, cache keys, and TTL

coreintermediate

Django ships backends for Redis (`django.core.cache.backends.redis.RedisCache`), Memcached (`PyMemcacheCache` or `PyLibMCCache`), local memory, the database, and a dummy that caches nothing. Redis and Memcached are the two real choices for a deployed application: both are shared across processes and hosts, Redis additionally persists and supports data structures, Memcached is a pure LRU cache and slightly simpler. The key you pass is not the key that is stored — Django prepends `KEY_PREFIX` and `VERSION` through `KEY_FUNCTION`, which is what lets two projects share one server and what lets you invalidate a whole class of keys by bumping a number. TTL is the `timeout` argument, where `None` means never expire and `0` means do not cache at all.

Think of it as

Choose the backend by what happens when it restarts. Memcached is deliberately amnesiac: it holds nothing on disk, evicts by LRU when full, and a restart empties it — which is exactly right for a cache and exactly wrong if anything in your system has quietly started treating it as storage. Redis persists by default, which is convenient and is also how a "cache" turns into an undeclared database that nobody backs up. Since the same Redis is usually already present as a Celery broker and a rate-limit store, the discipline is to give the cache its own database number or key prefix so a `clear()` cannot take the queue with it. On keys, the two-level structure exists for two different jobs: `KEY_PREFIX` separates *tenants of the server*, and `VERSION` separates *generations of your own data*. That second one is the most useful invalidation tool in the framework — when a serializer changes shape, bumping the version orphans every old entry atomically, with no scan and no delete loop, and the orphans expire on their own. On TTL, the number is a statement about how stale you are willing to be, so pick it from that rather than from habit: `0` is a real value meaning "do not cache", and `None` means the entry outlives every deploy until something evicts it.

python
cache.set(key, value, timeout=300, version=None)
# stored key = KEY_FUNCTION(key, KEY_PREFIX, VERSION)  ->  "prefix:version:key"

What we're doing: Separate the cache from the broker on one Redis server, and make a schema change invalidate every stale entry in one line.

config/settings.pypython
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": env("REDIS_URL") + "/1",     # db 1 — the broker uses db 0
        "KEY_PREFIX": "orders",
        "VERSION": 3,                            # bumped when OrderSerializer changed
        "TIMEOUT": 300,
    },
    "sessions": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": env("REDIS_URL") + "/2",
        "KEY_PREFIX": "sess",
        "TIMEOUT": None,                          # sessions expire by their own rules
    },
}

# A cache.clear() on "default" now cannot touch sessions or the Celery queue,
# because each lives in a different Redis database.
4
A distinct Redis database per role. `cache.clear()` issues `FLUSHDB`, so sharing db 0 with the Celery broker means clearing the cache can delete queued tasks.
5–6
`KEY_PREFIX` separates tenants of the server; `VERSION` separates generations of your own data — two different jobs, often confused. Bumping the version from 2 to 3 orphaned every entry written by the old serializer instantly, without a scan, and the orphans expire on their own.
13
`TIMEOUT: None` on the session cache — Django's session framework manages its own expiry, so a cache-level TTL would evict live sessions early.

Why this works: Keeping caches, sessions and the broker in separate Redis databases means the most destructive cache operation there is — `clear()` — has a blast radius you can state exactly.

Sharing one Redis database between the cache and the Celery broker

Wrong

python
CACHES = {"default": {"BACKEND": "...redis.RedisCache", "LOCATION": "redis://redis:6379/0"}}
CELERY_BROKER_URL = "redis://redis:6379/0"
# A single cache.clear() — from a management command or a deploy hook — runs
# FLUSHDB and deletes every queued task along with the cache.

Better

python
CACHES = {"default": {"BACKEND": "...redis.RedisCache", "LOCATION": "redis://redis:6379/1"}}
CELERY_BROKER_URL = "redis://redis:6379/0"

What you see: Queued emails, exports and webhooks vanish with no error anywhere. The cache clear succeeded, Celery reports an empty queue, and nothing in either log connects the two events.

Why: `cache.clear()` on the Redis backend flushes the whole database it is pointed at, not just the keys carrying your prefix — the prefix scopes reads and writes, not the flush. Since a broker keeps its queues as ordinary Redis keys in the same database, they are deleted too. Separate database numbers make the isolation structural rather than a convention someone has to remember.

The key you write is not the key that is stored

cache.set("leaderboard:weekly", rows, timeout=300, version=3)

cache.set

writes unconditionally — Overwrites whatever was there. Use cache.add() when the point is to claim a key only if it is free — that is the lock primitive.

"leaderboard:weekly"

your part of the key — Namespace it by hand with colons. Memcached caps the FINAL key at 250 characters, and the prefix and version count toward that.

rows

the value — pickled — Must already be evaluated. A lazy QuerySet stores the query, not the rows, and re-runs the SQL on read.

timeout=300

how stale you accept — None never expires; 0 means do not cache. Omitting it falls back to the backend TIMEOUT option, 300 seconds by default.

version=3

the generation — Combined with KEY_PREFIX by KEY_FUNCTION into the real key. Bumping it orphans every earlier entry at once — bulk invalidation with no scan.

  • Whole: cache.set("leaderboard:weekly", rows, timeout=300, version=3)
  • cache.set — writes unconditionally: Overwrites whatever was there. Use cache.add() when the point is to claim a key only if it is free — that is the lock primitive.
  • "leaderboard:weekly" — your part of the key: Namespace it by hand with colons. Memcached caps the FINAL key at 250 characters, and the prefix and version count toward that.
  • rows — the value — pickled: Must already be evaluated. A lazy QuerySet stores the query, not the rows, and re-runs the SQL on read.
  • timeout=300 — how stale you accept: None never expires; 0 means do not cache. Omitting it falls back to the backend TIMEOUT option, 300 seconds by default.
  • version=3 — the generation: Combined with KEY_PREFIX by KEY_FUNCTION into the real key. Bumping it orphans every earlier entry at once — bulk invalidation with no scan.

Choosing a backend

Choosing a backend
BackendShared across processes?Survives a restart?Use it for
`RedisCache`yesyes, if persistence is onthe default choice when Redis is already in the stack
`PyMemcacheCache`yesno — deliberatelya pure LRU cache you can never mistake for storage
`LocMemCache`**no** — per processnotests and local development only
`DatabaseCache`yesyeswhen you cannot run another service; it competes with your own queries
`DummyCache`development, to prove a page is correct without caching

Together

python
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": env("REDIS_URL"),
        "KEY_PREFIX": "orders",
        "VERSION": 3,
    },
}

What `timeout` actually means

What `timeout` actually means
ValueEffect
omitteduses the backend's `TIMEOUT` option (300 seconds if unset)
a numberexpires after that many seconds
`None`never expires — survives until evicted or deleted
`0`expires immediately, i.e. do not cache

Together

python
cache.set("config", payload, None)     # cache until explicitly deleted
cache.set("preview", html, 0)          # deliberately not cached

Remember: Redis and Memcached are the two deployable backends — Redis persists and shares a server with your broker, Memcached deliberately forgets. `LocMemCache` is per process and will mislead you in production. The stored key is `KEY_PREFIX:VERSION:key`, where the prefix separates tenants and the version separates generations of your data — bumping the version is the cheapest bulk invalidation available. `timeout=None` never expires, `timeout=0` means do not cache, and give each Redis role its own database so `clear()` cannot take the queue with it.

See also: the four caching levels · invalidation stampede and consistency · redis roles in a django stack

Advertisement

Everything after the write

Invalidation, warming, stampede, penetration, and the consistency you are choosing to accept.

Invalidation, warming, stampede, penetration, and consistency

coreadvanced

Writing to a cache is easy; the hard parts are all about what happens next. **Invalidation** is deciding when a cached value stops being true — by TTL, by deleting the key when the source changes, or by bumping a version. **Warming** is populating a cache before traffic needs it, so a deploy does not start with a cold miss on every request. **Stampede** is what happens when a popular key expires and a thousand concurrent requests all miss it and all recompute it at once. **Penetration** is repeated misses for a key that will never exist — a lookup for an id that is not in the database — so every request reaches the database anyway. **Consistency** is the honest admission that a cache is a second copy, and two copies of anything can disagree.

Think of it as

Every one of these is the same question asked at a different moment: *for how long, and to whom, is this copy allowed to be wrong?* Answering it deliberately is the entire discipline. Two of the failure modes are the cache making things worse rather than better, and they are worth recognising by shape. A stampede is a *self-inflicted* thundering herd: the more effective your cache, the more traffic is waiting behind that one key, so the moment it expires you send your peak load at the database in a single instant. Penetration is the mirror image — the cache never absorbs anything because the answer is always "not found", which is why caching a negative result (briefly, with a short TTL) is a real technique rather than a hack. On invalidation, prefer deleting on write over shortening the TTL: a TTL is a guess about how long staleness is tolerable, while a delete is a fact about when the data changed, and the two are not interchangeable. And accept the ordering problem honestly — write-then-delete leaves a window in which a concurrent reader can repopulate the cache with the pre-write value, so if that window matters, the cache is the wrong tool for that field.

python
@receiver(post_save, sender=Product)
def drop_product_cache(sender, instance, **kwargs):
    cache.delete(f"product:{instance.pk}")

What we're doing: A read-through cache that survives a stampede, absorbs penetration, and invalidates on write rather than on hope.

catalog/cache.py + catalog/signals.pypython
MISSING = "__missing__"


def get_product(pk):
    key = f"product:{pk}"
    cached = cache.get(key)
    if cached == MISSING:
        return None                      # negative hit — no database call
    if cached is not None:
        return cached

    if not cache.add(f"lock:{key}", 1, timeout=30):
        # Another request is already rebuilding. Serve slightly stale, or wait.
        return cache.get(key) or _fetch(pk)

    try:
        product = Product.objects.filter(pk=pk).first()
        if product is None:
            cache.set(key, MISSING, 30)  # short negative TTL
            return None
        cache.set(key, product, 300 + random.randint(0, 60))
        return product
    finally:
        cache.delete(f"lock:{key}")


@receiver([post_save, post_delete], sender=Product)
def drop_product_cache(sender, instance, **kwargs):
    cache.delete(f"product:{instance.pk}")


def warm_catalog():                      # called from a post-deploy command
    for pk in Product.objects.filter(is_featured=True).values_list("pk", flat=True):
        get_product(pk)
6–8
A sentinel, not `None`. `cache.get()` returns `None` for a miss too, so without a distinct marker you cannot tell "not cached" from "cached as absent".
12
`cache.add()` is atomic and returns `False` when the key exists — that is the whole lock. `get()` then `set()` would let two requests both believe they won.
19
Thirty seconds for a negative entry. Long enough to absorb a scan for non-existent ids, short enough that a newly created product appears almost immediately.
21
Jitter on the TTL. Without it, every key warmed in the same loop expires in the same second, and the stampede you avoided at write time arrives at expiry time.
27–29
Invalidation on write, from a signal, so the cache and the row cannot disagree for the length of a TTL. This is what the `finally`-released lock protects.

Why this works: Each addition answers one of the section's five problems, and none of them is optional at scale: without the sentinel a missing id hits the database forever, without the lock a hot key takes the database down at expiry, and without the signal a price change is invisible for five minutes.

Rebuilding a hot key with `get()` then `set()`

Wrong

python
value = cache.get("leaderboard")
if value is None:
    value = compute_leaderboard()      # 800 concurrent requests all get here
    cache.set("leaderboard", value, 300)

Better

python
value = cache.get("leaderboard")
if value is None and cache.add("lock:leaderboard", 1, 30):
    try:
        value = compute_leaderboard()
        cache.set("leaderboard", value, 300 + random.randint(0, 60))
    finally:
        cache.delete("lock:leaderboard")

What you see: Database CPU spikes to 100% on a precise five-minute cadence, matching the TTL exactly, and the spikes get *worse* as traffic grows — because a more popular key means more requests waiting behind the same expiry.

Why: Between the `get()` returning `None` and the `set()` completing, every other concurrent request also sees `None` and also recomputes. The cache does not make this less likely; it makes it worse, because it concentrates all the traffic for that value onto a single expiry instant. `cache.add()` is atomic at the backend, so exactly one caller gets `True` — that is what turns a thousand rebuilds into one.

The section's own hit/miss diagram, with the three places it goes wrong
HITMISSlock alreadyheldlockacquiredno rowrows

Request

Cache lookup

the only cheap branch in this diagram

HIT → response

possibly stale — that is the trade you accepted

MISS

now everything below runs, per request

cache.add(lock) — one winner

without this, every concurrent miss recomputes: a stampede

Losers serve stale or wait

the database sees one rebuild, not a thousand

Django → database

the expensive path the cache exists to avoid

No such row

penetration: uncached misses reach the database every time

Cache the "not found", briefly

a 30-second negative entry absorbs the flood

Write to cache with a jittered TTL

300 + random(0, 60) so keys do not expire in lockstep

Response

  • Request
    • leads to Cache lookup
  • Cache lookup — the only cheap branch in this diagram
    • leads to HIT → response (HIT)
    • leads to MISS (MISS)
  • HIT → response — possibly stale — that is the trade you accepted
  • MISS — now everything below runs, per request
    • leads to cache.add(lock) — one winner
  • cache.add(lock) — one winner — without this, every concurrent miss recomputes: a stampede
    • leads to Losers serve stale or wait (lock already held)
    • leads to Django → database (lock acquired)
  • Losers serve stale or wait — the database sees one rebuild, not a thousand
    • leads to Response
  • Django → database — the expensive path the cache exists to avoid
    • on error, leads to No such row (no row)
    • leads to Write to cache with a jittered TTL (rows)
  • No such row — penetration: uncached misses reach the database every time
    • leads to Cache the "not found", briefly
  • Cache the "not found", briefly — a 30-second negative entry absorbs the flood
    • leads to Response
  • Write to cache with a jittered TTL — 300 + random(0, 60) so keys do not expire in lockstep
    • leads to Response
  • Response

Five failure modes, and the specific remedy for each

Five failure modes, and the specific remedy for each
ProblemWhat it looks likeRemedy
Stale dataa change is invisible for the TTLdelete the key on write, or bump `VERSION`
Cold cache after deployp99 spikes for minutes after every releasewarm the top keys from a management command
Stampededatabase CPU spikes on a regular perioda lock via `cache.add()`, plus jittered TTLs
Penetrationhigh miss rate, database load unchangedcache the "not found" answer briefly
Inconsistencytwo users see different valuesaccept it, or stop caching that field

Together

python
if cache.add(f"lock:{key}", 1, timeout=30):     # exactly one winner
    try:
        value = recompute()
        cache.set(key, value, 300 + random.randint(0, 60))
    finally:
        cache.delete(f"lock:{key}")

Remember: All five problems are one question: how long, and to whom, may this copy be wrong? Delete on write (via `on_commit`, never inside the transaction) rather than trusting a TTL. A stampede is caused by a *successful* cache — fix it with an atomic `cache.add()` lock plus jittered TTLs. Penetration is misses for keys that will never exist — cache the "not found" with a short TTL and a sentinel, since `get()` returns `None` for both. Warm the hot keys after a deploy. And where two copies genuinely must not disagree, do not cache that field.

See also: cache backends keys and ttl · the four caching levels · on commit and transaction timing · recognizing the pattern

Advertisement