Skip to content
academia.sh

Lesson 01 / 22

Key-Value Basics

The contract and the cost of an in-memory store: building set, get, and expiration on a hand-written store, counting bytes held per entry as key, value, and overhead, comparing no-expiration, fixed-expiration, and sliding-expiration policies on peak memory and re-entry count, the effect of key and value shape on bytes held, and how overhead scales with entry count.

Contents

The Non-Relational Data Models course closed on a habit shared by every store it covered: data was written to disk sooner or later, and durability was a given part of the model. This course takes up the stores that drop that habit. In an in-memory store, all of the data sits in process memory, disk is an optional setting, and the real constraint is not capacity but the memory budget.

Every lesson in this course asks the same three questions: how many bytes does a structure hold in memory, what does that buy in return, and what does an alternative that does the same job with less memory give up. “It is fast” answers none of these questions; a fast in-memory store is this course’s assumption, not its subject. This lesson opens with the contract itself: a key is set, read, and given a lifetime.

The Contract and Bytes per Entry

The store defines three operations. Set binds a value to a key and gives it an optional lifetime. Get takes the key and returns the value; it treats an expired entry as if it were absent. Renewal moves the expiration timestamp forward. The basic key-value contract still holds here: the store does not look inside the value. The only thing that changes is where the record sits — and that single change rewrites the cost calculation, because space on disk is a capacity line item while space in memory is a budget.

The measurement uses the library’s session record. DS1: the catalog carries 20,000 members, the day is 12 hours (43,200 seconds), each member touches the system 1 to 5 times during the day, and the gap between touches is 60 to 2,459 seconds; touch times come from a linear congruential generator with a visible seed. DS2: overhead per entry is counted as 56 bytes — 8 for the hash bucket pointer, 16 for the entry structure’s pointers, 16 for length fields, 8 for the expiration timestamp, 8 for alignment. Held bytes are always computed as key + value + 56. DS3: a live entry is sampled every 300 seconds, and the peak is the largest of these samples; when an expired entry is actually removed from memory is a separate question — only unexpired entries are counted here.

// memory/store.mjs — a hand-written in-memory store: key space, expiration, and
// counting the bytes held. Overhead per entry is reported in DS2; a live entry is
// counted once every 300 s.
const OVERHEAD = 56;

class InMemoryStore {
  #table = new Map();
  reads = 0; misses = 0;
  set(key, value, now, lifetime) {
    const s = JSON.stringify(value);
    this.#table.set(key, { s, expiresAt: lifetime === null ? null : now + lifetime });
  }
  get(key, now) {                              // an expired entry is treated as absent
    this.reads += 1;
    const entry = this.#table.get(key);
    if (entry === undefined || (entry.expiresAt !== null && entry.expiresAt <= now)) { this.misses += 1; return undefined; }
    return JSON.parse(entry.s);
  }
  renew(key, now, lifetime) { const entry = this.#table.get(key); if (entry) entry.expiresAt = now + lifetime; }
  live(now) {                                  // per entry: key + value + overhead
    let n = 0, b = 0;
    for (const [key, entry] of this.#table)
      if (entry.expiresAt === null || entry.expiresAt > now) { n += 1; b += Buffer.byteLength(key) + Buffer.byteLength(entry.s) + OVERHEAD; }
    return { n, b }; }
}

// --- event generation: a 12-hour day, 20,000 members, touches from a seeded generator ---
const MEMBERS = 20_000, DAY = 43_200, LIFETIME = 1800, SAMPLE = 300, SEED = 20240115;
let state = SEED;
const rand = () => (state = (state * 1103515245 + 12345) % 2147483648) / 2147483648;
const BRANCH = ["central", "shore", "university", "children", "archive"];
const events = [];
for (let m = 1; m <= MEMBERS; m += 1) {
  let t = Math.floor(rand() * (DAY - 7200));
  const touches = 1 + Math.floor(rand() * 5);
  for (let k = 0; k < touches && t < DAY; k += 1) { events.push([t, m]); t += 60 + Math.floor(rand() * 2400); }
}
events.sort((x, y) => x[0] - y[0] || x[1] - y[1]);
const sessionKey = (m) => `session:${m}:${(m * 2654435761) % 4294967296}`;
const session = (m) => ({ member: m, branch: BRANCH[m % 5], role: m % 97 === 0 ? "clerk" : "reader" });

function run(name, lifetime, sliding) {
  const store = new InMemoryStore();
  let peakEntries = 0, peakBytes = 0, newEntries = 0, nextSample = SAMPLE;
  for (const [t, m] of events) {
    while (t >= nextSample) { const snapshot = store.live(nextSample); if (snapshot.n > peakEntries) { peakEntries = snapshot.n; peakBytes = snapshot.b; } nextSample += SAMPLE; }
    const key = sessionKey(m);
    if (store.get(key, t) === undefined) { store.set(key, session(m), t, lifetime); newEntries += 1; }
    else if (sliding) store.renew(key, t, lifetime);
  }
  const final = store.live(DAY);
  if (final.n > peakEntries) { peakEntries = final.n; peakBytes = final.b; }
  return [name, peakEntries, peakBytes, final.n, final.b, store.misses, newEntries];
}

console.log(`seed ${SEED}; ${MEMBERS} members, ${events.length} touches, day ${DAY} s, lifetime ${LIFETIME} s`);
console.log(`sample session value ${Buffer.byteLength(JSON.stringify(session(4242)))} bytes, ` +
  `key ${Buffer.byteLength(sessionKey(4242))} bytes, overhead ${OVERHEAD} bytes`);
const rows = [run("no expiration", null, false), run("fixed expiration", LIFETIME, false),
  run("sliding expiration", LIFETIME, true)];
console.log(`\n${"policy".padEnd(16)}${"peak entries".padStart(12)}${"peak bytes".padStart(11)}` +
  `${"end-of-day entries".padStart(19)}${"end-of-day bytes".padStart(17)}${"misses".padStart(7)}${"extra re-entries".padStart(18)}`);
for (const [name, pe, pb, ee, eb, ms] of rows)
  console.log(name.padEnd(16) + String(pe).padStart(12) + String(pb).padStart(11) +
    String(ee).padStart(19) + String(eb).padStart(17) + String(ms).padStart(7) + String(ms - MEMBERS).padStart(18));
seed 20240115; 20000 members, 59337 touches, day 43200 s, lifetime 1800 s
sample session value 53 bytes, key 23 bytes, overhead 56 bytes

policy          peak entries peak bytes end-of-day entries end-of-day bytes misses  extra re-entries
no expiration          20000    2600406              20000          2600406  20000                 0
fixed expiration        2193     285185                 28             3644  36951             16951
sliding expiration        2658     345617                 29             3769  31182             11182

Expiration Is a Memory Policy

All three runs process the same 59,337 touches; the only thing that differs is the lifetime setting. With no expiration given, the store carries 20,000 entries and 2,600,406 bytes by the end of the day, and that number never shrinks: once a member logs in, the record sits there for as long as the process lives. In exchange, every touch finds its record in place; the 20,000 misses are only the day’s first touches and are unavoidable — nobody finds a session without logging in for the first time. Extra re-entries are zero.

Fixed expiration does the same job with a peak of 2,193 entries and 285,185 bytes: roughly nine times less memory. What that buys is plain to see — memory now scales with the count of concurrently active members, not the member count, and only 28 entries remain at the end of the day. What it gives up is just as countable: 16,951 extra re-entries. A member whose record has dropped has to re-authenticate.

Sliding expiration sits between the two and makes the purchase rate visible. The peak climbs to 345,617 bytes, 60,432 bytes more than the fixed policy; in exchange, extra re-entries drop from 16,951 to 11,182. Each of the 5,769 saved entries costs 10.5 bytes of peak memory. The lesson foregrounds this number: policy choice is not a matter of style, it is a purchase priced in bytes. The same arithmetic looks completely different in end-of-day memory — the no-expiration policy holds onto 2.6 MB permanently, while the two expiring policies close out around 3.6-3.8 KB; the difference is roughly 700-fold.

The Shape of the Key and the Value

Entry count is not the only lever. The same session can be held in different key and value shapes, and each shape directly changes the bytes held. The second setup counts the same member in six shapes and, at the end, compares the structural count against an environment-dependent measurement.

// memory/shape.mjs — key and value representations of the same session: bytes held
// per format. OVERHEAD is the same constant; the structural count is run-independent.
const OVERHEAD = 56, ENTRIES = 20_000, BRANCH = ["central", "shore", "university", "children", "archive"];
const session = (m) => ({ member: m, branch: BRANCH[m % 5], role: m % 97 === 0 ? "clerk" : "reader" });
const member = 4242, token = (member * 2654435761) % 4294967296, rec = session(member);

const formats = [
  ["session:token:4242:...", `session:token:${member}:${token}`],
  ["session:4242:...", `session:${member}:${token}`],
  ["s:...", `s:${token}`],
];
const values = [["self-describing", JSON.stringify(rec)], ["positional", `${rec.member}|${rec.branch}|${rec.role}`]];

console.log(`${"key format".padEnd(23)}${"key".padStart(8)}${"value format".padStart(20)}` +
  `${"value".padStart(7)}${"entry".padStart(7)}${`at ${ENTRIES}`.padStart(14)}`);
for (const [label, keyStr] of formats)
  for (const [valueLabel, valueStr] of values) {
    const entryBytes = Buffer.byteLength(keyStr) + Buffer.byteLength(valueStr) + OVERHEAD;
    console.log(label.padEnd(23) + String(Buffer.byteLength(keyStr)).padStart(8) + valueLabel.padStart(20) +
      String(Buffer.byteLength(valueStr)).padStart(7) + String(entryBytes).padStart(7) + String(entryBytes * ENTRIES).padStart(14));
  }

// a new field (branch_code) is inserted in second position — how OLD entries are read
const parsed = JSON.parse(values[0][1]), [, k1, k2, k3] = values[1][1].split("|");
console.log(`\nold entry, read with new order (member|branch_code|branch|role):`);
console.log(`  self-describing -> branch=${parsed.branch} role=${parsed.role} branch_code=${parsed.branch_code}`);
console.log(`  positional      -> branch=${k2} role=${k3} branch_code=${k1}`);

const before = process.memoryUsage().heapUsed;                  // ENVIRONMENT-DEPENDENT
const table = new Map();
for (let i = 1; i <= ENTRIES; i += 1)
  table.set(`session:${i}:${(i * 2654435761) % 4294967296}`, { s: JSON.stringify(session(i)), expiresAt: 1800 });
const after = process.memoryUsage().heapUsed;
const structural = [...table].reduce((t, [key, entry]) => t + Buffer.byteLength(key) + Buffer.byteLength(entry.s) + OVERHEAD, 0);
console.log(`\nstructural count of ${ENTRIES} entries is ${structural} bytes (run-independent); ` +
  `the real heap difference is ${((after - before) / structural).toFixed(1)} times that (ENVIRONMENT-DEPENDENT, varies from run to run)`);
key format                  key        value format  value  entry      at 20000
session:token:4242:...       29     self-describing     53    138       2760000
session:token:4242:...       29          positional     22    107       2140000
session:4242:...             23     self-describing     53    132       2640000
session:4242:...             23          positional     22    101       2020000
s:...                        12     self-describing     53    121       2420000
s:...                        12          positional     22     90       1800000

old entry, read with new order (member|branch_code|branch|role):
  self-describing -> branch=university role=reader branch_code=undefined
  positional      -> branch=reader role=undefined branch_code=university

structural count of 20000 entries is 2600406 bytes (run-independent); the real heap difference is 2.5 times that (ENVIRONMENT-DEPENDENT, varies from run to run)

At 20,000 entries, there are 960,000 bytes between the priciest shape and the cheapest — a 34.8 percent difference from a one-line code change. Shrinking the key from 29 bytes to 12 bytes saves 340,000 bytes; stripping the value of its field names saves 620,000 bytes.

Both losses are concrete. The short key removes the key namespace’s readability: without the session: prefix, the same namespace cannot distinguish which entry is a session and which is a cache record, and two different jobs that produce the same name collide silently. The loss from the positional value is harsher. The three middle lines of the output show it: once a branch_code field is added in second position, the self-describing value still resolves old entries correctly (branch=university, role=reader) and only finds the new field empty. The positional value misreads the same entry: reader lands in the branch field, university lands in the branch code. The 620,000 bytes gained are paid for with the data’s ability to describe itself.

The last line marks the limit of the measurement. The structural count is 2,600,406 bytes, identical to the first setup’s end-of-day count; the real heap usage, in this run, is 2.5 times that. This second number is environment-dependent and varies from run to run — it only says that the structural count is a lower bound; the measure that drives the decision throughout the course is the first one. The ratio between the two also says something else: the 56-byte overhead takes up 62.2 percent of the entry in the cheapest shape. As entries shrink, the payoff from shaping them better shrinks with them; the real lever is not the shape of the entry, it is the number of entries — which is to say, the expiration policy.

Summary

  • An in-memory store defines three operations: set, get, renew. The value is opaque to the store; the only thing that changes is that the record sits in memory instead of on disk, and that turns space from a capacity line item into a budget.
  • Held bytes are counted as key + value + 56. 20,000 session records hold 2,600,406 bytes — an average of 130.0 bytes per entry.
  • Expiration is a memory policy: fixed expiration drops the peak from 20,000 entries to 2,193 and from 2,600,406 bytes to 285,185, at the cost of 16,951 extra re-entries.
  • Sliding expiration holds 60,432 bytes more and prevents 5,769 re-entries — 10.5 bytes per prevented re-entry. Policy choice is a purchase priced in bytes.
  • Key and value shape make a 960,000-byte difference at 20,000 entries; the positional value earns that gain by keeping field order outside the store, and it silently misreads old entries once the schema changes.
  • The structural count is run-independent and is the measure that drives the decision; the real heap usage (2.5 times the structural count) is environment-dependent and only shows that the structural count is a lower bound.

Next Step

Every write in this lesson overwrote the entire value: the session record was read, changed, and put back. The things the library needs to count do not fit this pattern. How many times a book has been borrowed, how many loans a branch handed out during the day, how many times a member has been late — each of these is a single number that goes up by one on every request. What happens when two requests read and write back the same counter at the same time was never asked in this lesson. The next lesson measures exactly that: the difference between read-modify-write and the store’s own increment under concurrent requests, the number of lost updates, and how many bytes a single counter costs across a 200,000-book catalog.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close