Skip to content
academia.sh

Lesson 12 / 14

Managed Cache Service

Measuring the renting of the in-memory layer as a service: two scaling axes giving the same hit rate and the same bill, the 100% hit-rate row turning out the most expensive, the 3,370,000 keys lost when a node changes putting the purchased capacity 325% over, warm-up not shortening with shard count, and a cache in another zone writing a 722-unit transfer item and coming out pricier than no cache at all.

Published Updated

In this lesson

  • Shows that node size and shard count give the same hit rate and the same fee, and which table the difference actually opens up in
  • Computes the keys lost on a node change, the wave that lands on the data layer, and the warm-up time, as a function of shard count
  • Measures how the zone the cache sits in affects the transfer fee, and separates out the working set where the managed cache gains nothing
  • Counts, item by item, how many of the version, maintenance, scaling, data, and network decisions are delegated
Contents

The previous lesson widened the access pattern: the secondary index and the change stream reopened a share of the queries the partition key had closed off. But every reopened query still lands on the data layer.

In-memory stores and eviction policies were measured earlier; this lesson does not repeat that mechanics. Here the in-memory layer is a rented service, and the three things measured are what the scaling unit is, what is lost when a node changes, and what the cache’s location writes onto the bill.

  • MD25. The fictional regional measurement network’s read set is three families: latest meter reading (2.4 million keys, 180 bytes), invoice summary (600 thousand, 2,400 bytes), work-order record (4 million, 600 bytes); 4.272 GB total.
  • MD26. Each family is modeled with three popularity tiers. The model fills the cache in order of per-key probability, does not count first-touch misses, and uses no randomness.
  • MD27. Reads average four hundred, peak three thousand requests/s; there is also a steady four hundred requests/s of writes, and writes never touch the cache. A month is counted as 2,592,000 seconds.
  • MD28. Prices are fictional, the currency’s name is a fictional unit: a cache node runs sixty units per GB a month; read capacity is bought in hundred-requests/s steps, and a step is thirty units a month. The provider opens up 75% of the node’s memory for use.
  • MD29. Latencies are fictional: the data layer is 6 ms, a managed cache in the same zone 0.8 ms, in another zone 1.9 ms, in-process 0.02 ms. Cross-zone transfer is one unit per GB.
  • MD30. The meter collector accumulates its minute-by-minute totals in the cache and flushes every five seconds; the cache copy’s replication lag is 0.25 seconds. Both are fictional.
// cache/cache.mjs — model: what changes when the in-memory layer is rented as a service.
// Eviction policies were measured in M17/K06 and are not repeated. The working set is modeled
// with popularity tiers, no randomness; prices are fictional and 1 GB is counted as 1e9 bytes.
const TIER = [                        // [family, tier, keys, bytes, access share]
  ["meter", "hot", 150000, 180, 0.15], ["meter", "warm", 850000, 180, 0.20],
  ["meter", "cold", 1400000, 180, 0.16],
  ["invoice", "hot", 18000, 2400, 0.10], ["invoice", "warm", 140000, 2400, 0.09],
  ["invoice", "cold", 442000, 2400, 0.04],
  ["workorder", "hot", 80000, 600, 0.06], ["workorder", "warm", 620000, 600, 0.10],
  ["workorder", "cold", 3300000, 600, 0.10],
];
const ORDER = [...TIER].sort((a, b) => b[4] / b[2] - a[4] / a[2]);  // probability per key
const fill = (capacity) => {          // the cache fills from the most-requested key down
  let remaining = capacity, keys = 0, hitRate = 0;
  const retained = [];
  for (const [, , n, bytes, share] of ORDER) {
    const taken = Math.min(n, Math.floor(remaining / bytes));
    if (taken === 0) break;
    remaining -= taken * bytes; keys += taken; hitRate += share * taken / n;
    retained.push([share * taken / n, share / n]);   // [share retained, probability per key]
  }
  return { keys, hitRate, retained };
};
const PEAK = 3000, WRITES = 400, USABLE_SHARE = 0.75, GB_PRICE = 60;
const capacityFee = (toBackend) => Math.ceil((toBackend + WRITES) / 100) * 30;
const columns = (widths, left = 1) => (row) =>
  row.map((d, i) => String(d)[i < left ? "padEnd" : "padStart"](widths[i])).join("");
const fmt1 = columns([14, 5, 16, 14, 11, 20, 10, 8, 15]);
console.log(fmt1(["node x GB", "GB", "usable", "keys(k)", "hit(%)",
  "to backend(req/s)", "capacity", "node", "total(unit)"]));
for (const [label, nodes, gbPerNode] of [["no cache", 0, 0], ["1 x 1", 1, 1], ["1 x 2", 1, 2],
  ["2 x 1", 2, 1], ["1 x 4", 1, 4], ["1 x 8", 1, 8]]) {
  const o = nodes ? fill(nodes * gbPerNode * 1e9 * USABLE_SHARE) : { keys: 0, hitRate: 0 };
  const toBackend = Math.round(PEAK * (1 - o.hitRate));
  const cap = capacityFee(toBackend), nodeFee = nodes * gbPerNode * GB_PRICE;
  console.log(fmt1([label, nodes * gbPerNode, (nodes * gbPerNode * USABLE_SHARE).toFixed(2), (o.keys / 1e3).toFixed(0),
    (o.hitRate * 100).toFixed(1), toBackend, cap, nodeFee, cap + nodeFee]));
}

// --- When a node changes -----------------------------------------------------------
// 2 GB layout. Since the hash spreads key names, every shard gets an equal share of every tier.
const BASE = fill(2 * 1e9 * USABLE_SHARE), TO_BACKEND = Math.round(PEAK * (1 - BASE.hitRate)) + WRITES;
const PURCHASED = capacityFee(TO_BACKEND - WRITES) / 30 * 100, PENDING = WRITES * 5, LAG = 0.25;
const fmt2 = columns([16, 18, 16, 20, 12, 17]);
console.log("\n" + fmt2(["shards", "lost keys", "lost access", "to backend(req/s)",
  "overage(%)", "pending records"]));
for (const [shards, replicated] of [[1, 0], [2, 0], [4, 0], [8, 0], [16, 0], [4, 1]]) {
  const lostKeys = Math.round((replicated ? TO_BACKEND * LAG : BASE.keys) / shards);
  const lostAccess = replicated ? 0 : BASE.hitRate / shards;
  const toBackend = Math.round(PEAK * (1 - BASE.hitRate + lostAccess)) + WRITES;
  console.log(fmt2([replicated ? `${shards} (replicated)` : shards, lostKeys, (lostAccess * 100).toFixed(1), toBackend,
    ((toBackend / PURCHASED - 1) * 100).toFixed(0),
    Math.round((replicated ? WRITES * LAG : PENDING) / shards)]));
}
const recovered = (t) => BASE.retained.reduce((a, [share, r]) => a + share * (1 - Math.exp(-r * PEAK * t)), 0);
const warmup = (target) => {
  let lo = 1, hi = 1e6;
  for (let k = 0; k < 60; k++) {
    const t = Math.sqrt(lo * hi);
    if (recovered(t) < target * BASE.hitRate) lo = t; else hi = t;
  }
  return hi.toFixed(0);
};
console.log(`warm-up, independent of shard count: 50% ${warmup(0.5)} s, 90% ${warmup(0.9)} s`);

// --- Where the cache sits -----------------------------------------------------------
const DATA_MS = 6, ZONE_PRICE = 1, MONTH = 2592000;   // ms; cross-zone unit/GB; seconds/month
const AVG_BYTES = TIER.reduce((a, [, , , bytes, share]) => a + bytes * share, 0);
const fmt3 = columns([12, 26, 11, 14, 11, 11, 10, 15], 2);
const row = (set, label, h, ms, reads, bytes, peak, writes, cacheFee, remote) => {
  const toBackend = Math.round(peak * (1 - h));
  const capacity = Math.max(1, Math.ceil((toBackend + writes) / 100)) * 30;
  const transfer = (remote ? h * reads * bytes / 1e9 : 0) * ZONE_PRICE;
  console.log(fmt3([set, label, (h * 100).toFixed(1), (ms + (1 - h) * DATA_MS).toFixed(2),
    cacheFee, capacity, transfer.toFixed(0), (cacheFee + capacity + transfer).toFixed(0)]));
};
console.log("\n" + fmt3(["set", "layout", "hit(%)", "latency(ms)", "cache", "capacity",
  "transfer", "total(unit)"]));
const FULLSET = (label, h, ms, cacheFee, remote) =>
  row("full set", label, h, ms, 400 * MONTH, AVG_BYTES, PEAK, WRITES, cacheFee, remote);
FULLSET("no cache", 0, 0, 0, false);
FULLSET("in-process 12 x 256 MB", fill(256e6).hitRate, 0.02, 0, false);
FULLSET("managed 2 GB same zone", BASE.hitRate, 0.8, 2 * GB_PRICE, false);
FULLSET("managed 2 GB other zone", BASE.hitRate, 1.9, 2 * GB_PRICE, true);
// Rate table: 20 thousand keys x 2000 bytes = 40 MB, 320 million reads a month, peak 900/s.
const RATETABLE = (label, h, ms, cacheFee, remote) =>
  row("rate table", label, h, ms, 320e6, 2000, 900, 0, cacheFee, remote);
RATETABLE("in-process 12 x 40 MB", 1, 0.02, 0, false);
RATETABLE("managed 1 GB same zone", 1, 0.8, GB_PRICE, false);
RATETABLE("managed 1 GB other zone", 1, 1.9, GB_PRICE, true);
node x GB        GB          usable       keys(k)     hit(%)   to backend(req/s)  capacity    node    total(unit)
no cache          0            0.00             0        0.0                3000      1020       0           1020
1 x 1             1            0.75          1476       63.8                1085       450      60            510
1 x 2             2            1.50          3370       87.0                 390       240     120            360
2 x 1             2            1.50          3370       87.0                 390       240     120            360
1 x 4             4            3.00          4880       93.6                 193       180     240            420
1 x 8             8            6.00          7000      100.0                   0       120     480            600

shards                   lost keys     lost access   to backend(req/s)  overage(%)  pending records
1                          3370000            87.0                3400         325             2000
2                          1685000            43.5                2095         162             1000
4                           842500            21.8                1442          80              500
8                           421250            10.9                1116          40              250
16                          210625             5.4                 953          19              125
4 (replicated)                  49             0.0                 790          -1               25
warm-up, independent of shard count: 50% 539 s, 90% 3514 s

set         layout                         hit(%)   latency(ms)      cache   capacity  transfer    total(unit)
full set    no cache                          0.0          6.00          0       1020         0           1020
full set    in-process 12 x 256 MB           34.7          3.94          0        720         0            720
full set    managed 2 GB same zone           87.0          1.58        120        240         0            360
full set    managed 2 GB other zone          87.0          2.68        120        240       722           1082
rate table  in-process 12 x 40 MB           100.0          0.02          0         30         0             30
rate table  managed 1 GB same zone          100.0          0.80         60         30         0             90
rate table  managed 1 GB other zone         100.0          1.90         60         30       640            730

Two Axes, One Curve

The managed cache service offers two scaling levers: growing the node and adding nodes. The first table puts them side by side, and the first reading is this: no difference. Two GB is reached with one node just as well as with two; both ways the cache holds 3,370,000 keys, the hit rate is 87%, the bill is 360 units. The scaling-unit decision cannot be made on hit rate and price alone.

The bill itself is the second reading. The no-cache layout runs 1,020 units; one GB lifts the hit rate to 63.8% and drops the bill to 510, two GB to 360. Then the curve turns: 420 at four GB, 600 at eight GB. The 100%-hit-rate row is 67% pricier than the two-GB row; the remaining 120 units goes to the four hundred requests/s of writes that never touch the cache at all, and there is no cache-side way to bring that down.

The usable column is the third reading: buy eight GB and six GB gets used, the remaining quarter goes into the provider’s ledger. Since the working set is 4.272 GB, the node needed to hold all of it in memory is not four GB, it is eight.

When a Node Changes

A node change is a routine event — a version upgrade, a patch window, or a failed node being replaced — and the cache is not durable: a changed node’s memory comes back empty.

In a single-shard layout, all 3,370,000 keys are lost. Requests hitting the backend rise from 790 to 3,400, landing 325% over the purchased 800 requests/s capacity. This is not a crash, it is throttling: the data layer turns away the excess, and the dashboard comes back empty.

Shard count brings down the wave’s height: overage is 80% at four shards, 19% at sixteen. The number falls, but it never zeroes out at any shard count. The last row shows the one fix: add a replica to every shard and a node change turns into a failover, and lost keys drop to forty-nine — the keys written within the quarter-second replication lag. The cost is read off the first table: the node fee rises from 120 to 240, the total from 360 to 480.

The last column is this lesson’s irreversible item. The collector accumulates its minute-by-minute total in the cache and writes it out every five seconds; at any moment two thousand records exist only in memory, and when a node is lost in a four-shard layout, five hundred of them go with it. A cached copy can be brought back; a cached original cannot.

The warm-up row gives the wave’s length: half the access share comes back in five hundred thirty-nine seconds, 90% in 3,514. This time is independent of shard count: because the hash spreads key names, every shard draws the same share from every popularity tier, and one shard’s warm-up curve is the same as the whole’s. Splitting brings down the wave’s height, not its length. In a four-shard layout, the data layer carries above-purchased load for close to an hour; when that window opens is not your decision.

Where the Cache Sits

The third table’s first four rows meet the same read load under four layouts. The managed cache is both cheapest and fastest when it sits in the same zone: 360 units, 1.58 ms.

The in-process cache row shows what being a service brings. Each of twelve application instances allocates two hundred fifty-six MB — three GB in total — and the hit rate sits at 34.7%; the managed cache reaches 87% using one and a half GB. The difference is not the amount of memory, it is the singleness of the pool: twelve separate caches hold twelve copies of the same keys, and each one only warms up on the requests it personally sees.

The fourth row is this lesson’s transfer item. Putting the cache in another zone does not change the hit rate, and writes a new 722-unit line onto the bill: 87% of a monthly 1,036,800,000 reads, an average 800-byte response, one unit per GB. The cache node itself costs 120 units; putting it in the wrong zone costs six times the node and brings the total to 1,082 units — pricier than running no cache at all.

Where It Gains Nothing

The last three rows measure the zonal rate table: forty MB, three hundred twenty million reads a month, a set that fits entirely into every layout. The in-process cache costs 30 units and 0.02 ms; a managed cache in the same zone costs 90 units and 0.80 ms — three times the price, forty times slower. In another zone it is 730 units — twenty-four times.

The managed cache gains nothing here: since the set already fits in every process’s memory, a shared pool is unnecessary, and access over the network is pure cost. The rule is this: a managed cache pays off when the working set does not fit in a single process’s memory. The in-process layout’s cost is not in the table: twelve copies, twelve separate freshness windows.

The Delegated Decision

In a rented cluster, decisions split up as follows:

Group Yours Provider’s Fixed at creation
version engine version, patch window when the patch becomes mandatory —
maintenance whether backups are on change timing, node refresh, backup location —
scaling node size, shard count, replica count usable memory ratio shard count
data eviction policy, key naming, time to live data lost on a change —
network the cluster’s zone which node a client lands on the cluster’s zone

Ten of seventeen items are yours, seven are the provider’s, two are fixed at creation. Three of the maintenance group’s four items are delegated; the second table’s hour-long warm-up window ties back to this — you are not the one deciding when that window opens.

You choose the engine version, but the choice is one-way: an upgraded cluster does not go back to the old version. The item fixed in scaling is the shard count; change it and every key gets redistributed, with the hit rate dropping through the redistribution. That is why the first table’s “same hit rate, same price” result is misleading: the two levers land in the same place, but one is reversible and the other is not. The cluster’s zone is also fixed, and the 722-unit item ties directly back to it.

Summary

  • Whichever path reaches two GB, the hit rate is 87% and the bill is 360 units: the scaling-unit decision cannot be read off hit rate and price.
  • The bill’s curve turns: 1,020 units with no cache, 510 at one GB, 360 at two, 420 at four, 600 at eight. 100% hit rate is the most expensive row.
  • A single-shard node change pushes backend-bound requests 325% over purchased capacity; at sixteen shards this falls to 19% but never zeroes out. Warm-up is 3,514 seconds under every layout. The irreversible item is the records pending in write-behind mode.
  • Putting the cache in another zone writes a 722-unit transfer item and pushes the total above the no-cache layout. On the 40 MB rate table, the in-process cache costs 30 units and the managed cache 90: when the working set fits in a single process, the managed cache gains nothing.

Next Step

In this lesson, transfer was a single column with a single price: cross-zone, one unit per GB. That one column became the biggest item on the bill once the placement decision went wrong.

Across the rest of the measurement network, that same column gets written over and over: the collector writes to the data layer, the data layer replicates to the read replica, backups go out, logs stream, dashboard responses go out to the open network. Each flow prices differently depending on where its source and destination sit: the same zone, a neighboring zone, a distant region, or the internet.

The next lesson is this course’s lesson on money: the same architecture is run across three placements and the egress fee is worked out item by item, and then the effect of replica count, log and backup shipping, edge use, and compression decisions on that fee is counted.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close