Chapter 10 · Key-Value Databases and Data-Structure Stores

Values as Bytes, Documents, or Data Structures: Modeling Beyond Simple Strings

Compare opaque byte values with structured documents and server-side maps, sets, sorted structures, lists, counters, and streams while exposing serialization, schema evolution, partial-update, and lost-update tradeoffs.

Intermediate95–115 minutesValue-shape + atomic-update labPython 3.13+ · standard libraryRedis/Valkey only as referencesLast reviewed: August 2026

Learning outcomes

AtlasMart initially stores every value as a JSON blob. That is simple until two checkout workers decrement stock concurrently: both deserialize stock=10, both write stock=9, and one decrement disappears. A richer server-side operation can move the critical read-modify-write boundary into the store. This lesson separates value representation from operation semantics.

01

Distinguish opaque bytes, structured documents, maps/hashes, sets, sorted sets, lists, counters, and append-oriented streams.

02

Explain serialization format, schema/version envelopes, partial update, atomic server-side operation, and compatibility boundaries.

03

Produce a lost update with two client-side JSON modifications and repair it with an atomic field/counter operation.

04

Choose a data structure from the required operation—not from fashion or superficial similarity to an in-memory language type.

05

Record value-size, mutation frequency, schema-evolution, hot-key, durability, and replication implications.

Tooling and version snapshot · checked 29 August 2026

The mandatory labs use Python 3.13+ standard library only in one deterministic process with a logical clock; there is no database server, client driver, container, cloud account, network fault injection, or paid feature. Optional implementation references were rechecked against Redis Open Source 8.10.0 (GA July 2026; Redis 8+ is offered under the user's choice of AGPLv3, RSALv2, or SSPLv1) and Valkey 9.1.1 (released 21 July 2026; predominantly BSD-3-Clause). Product commands/defaults are examples only; later Stage 03 product courses teach product-specific operations in depth.

1. Opaque bytes maximize freedom—and move responsibility to the application

An opaque value may be raw bytes, UTF-8 text, MessagePack, Protocol Buffers, Avro, JSON, or a custom encoding. The store can often fetch/replace it without understanding its fields. This makes the storage contract simple but pushes schema evolution, validation, partial updates, compatibility, and race prevention into application code.

An explicit envelope such as {"schema":2,...} gives readers a deterministic upgrade path. Without a version or self-describing format, a deploy can write data that an older rollback binary cannot parse. “Flexible schema” therefore does not mean “no schema”; the schema simply lives in application/serialization rules instead of a rigid table definition.

2. Structured values let operations move closer to the state

A map/hash can update one field without transferring/replacing an entire document. A set can enforce unique membership. A sorted set associates unique members with scores for rank/range operations. A list represents an ordered sequence. A counter offers atomic numeric change. A stream or append-oriented sequence records ordered entries for later consumption. These structures are useful when the server can perform the exact state transition atomically at the required boundary.

Representation Good fit Main caveat
Opaque bytes/string Application-owned encoding, immutable blobs, cached fragments Whole-value read/modify/write races and schema compatibility.
Document/map/hash Named fields, partial updates, aggregate-like state Large/ever-growing values and cross-field invariants need careful semantics.
Set Unique membership Cardinality/memory can grow without bound.
Sorted set Rank/range by score Score semantics and hot global leaderboards can concentrate load.
List Bounded queues/recent items Not automatically a durable exactly-once work queue.
Counter Atomic increments/decrements One hot counter can serialize load; distributed exactness varies.
Stream/log structure Append/read ordered entries Retention, consumer acknowledgement, replay, and idempotency still matter.

3. Failure case: client-side read-modify-write loses an update

Two clients fetch the same JSON inventory blob. Each decrements from 10 to 9 and replaces the entire value. The second replacement is valid in isolation, but the combined history should have produced 8. This is the classic lost-update mechanism: the client assumed that read + local computation + set was one atomic operation when it was actually three separate steps.

Possible repairs include a native atomic decrement, a server-side function/transaction, compare-and-set (CAS) on a version, or redesigning the invariant. The right repair depends on whether negative inventory is forbidden, whether multiple warehouses are independent, and whether stronger coordination is acceptable.

4. Schema evolution is an operational compatibility problem

When a byte/document value evolves, record what old readers do with new fields, what new readers do with missing fields, whether field types can change, how backfills happen, and whether rollback is possible. A version envelope can support lazy migration on read or explicit migration jobs. The migration must remain idempotent because retries and partial progress are normal distributed-system conditions.

5. AtlasMart lab: bytes, structures, and server-side atomicity

The lab implements a toy in-process data-structure store. It deliberately loses an inventory decrement with JSON bytes, then performs two atomic field decrements correctly. It also demonstrates a set, scored structure, list, stream-like append, and a schema-v1-to-v2 conversion.

python · value_shapes_and_atomic_ops.py
import json
from copy import deepcopy

class ToyDataStructureStore:
    def __init__(self):
        self.bytes = {}
        self.hashes = {}
        self.sets = {}
        self.sorted_sets = {}
        self.lists = {}
        self.streams = {}

    def set_bytes(self, key, value: bytes): self.bytes[key] = bytes(value)
    def get_bytes(self, key): return self.bytes.get(key)
    def hset(self, key, **fields): self.hashes.setdefault(key, {}).update(fields)
    def hincrby(self, key, field, delta):
        h = self.hashes.setdefault(key, {})
        h[field] = int(h.get(field, 0)) + delta
        return h[field]
    def sadd(self, key, *members): self.sets.setdefault(key, set()).update(members)
    def zadd(self, key, member, score): self.sorted_sets.setdefault(key, {})[member] = score
    def lpush(self, key, value): self.lists.setdefault(key, []).insert(0, value)
    def xadd(self, key, fields):
        stream = self.streams.setdefault(key, [])
        entry_id = len(stream) + 1
        stream.append((entry_id, dict(fields)))
        return entry_id

store = ToyDataStructureStore()

print("OPAQUE BYTES + LOST UPDATE")
store.set_bytes("inventory:sku-7", json.dumps({"schema": 1, "stock": 10}).encode())
a = json.loads(store.get_bytes("inventory:sku-7"))
b = json.loads(store.get_bytes("inventory:sku-7"))
a["stock"] -= 1
b["stock"] -= 1
store.set_bytes("inventory:sku-7", json.dumps(a).encode())
store.set_bytes("inventory:sku-7", json.dumps(b).encode())
print("expected stock 8, actual", json.loads(store.get_bytes("inventory:sku-7"))["stock"])

print("\nSERVER-SIDE FIELD/COUNTER OPERATION")
store.hset("inventory-hash:sku-7", schema=2, stock=10, warehouse="W1")
print("after decrement 1 ->", store.hincrby("inventory-hash:sku-7", "stock", -1))
print("after decrement 2 ->", store.hincrby("inventory-hash:sku-7", "stock", -1))
print("hash state ->", store.hashes["inventory-hash:sku-7"])

print("\nRICH STRUCTURES")
store.sadd("product:sku-7:tags", "sale", "blue", "summer")
store.zadd("product:popularity", "sku-7", 98.5)
store.zadd("product:popularity", "sku-8", 76.0)
store.lpush("user:42:recent", "sku-7")
store.lpush("user:42:recent", "sku-8")
eid = store.xadd("inventory:events", {"sku": "sku-7", "delta": -1})
print("tags", sorted(store.sets["product:sku-7:tags"]))
print("leaderboard", sorted(store.sorted_sets["product:popularity"].items(), key=lambda x: -x[1]))
print("recent", store.lists["user:42:recent"])
print("stream entry", eid, store.streams["inventory:events"][-1])

print("\nSCHEMA EVOLUTION ENVELOPE")
v1 = {"schema": 1, "name": "Ada", "locale": "en"}
blob = json.dumps(v1).encode()
loaded = json.loads(blob)
if loaded["schema"] == 1:
    loaded = {"schema": 2, "display_name": loaded["name"], "locale": loaded["locale"], "marketing_opt_in": False}
print("migrated document", loaded)
text · expected output
OPAQUE BYTES + LOST UPDATE
expected stock 8, actual 9

SERVER-SIDE FIELD/COUNTER OPERATION
after decrement 1 -> 9
after decrement 2 -> 8
hash state -> {'schema': 2, 'stock': 8, 'warehouse': 'W1'}

RICH STRUCTURES
tags ['blue', 'sale', 'summer']
leaderboard [('sku-7', 98.5), ('sku-8', 76.0)]
recent ['sku-8', 'sku-7']
stream entry 1 (1, {'sku': 'sku-7', 'delta': -1})

SCHEMA EVOLUTION ENVELOPE
migrated document {'schema': 2, 'display_name': 'Ada', 'locale': 'en', 'marketing_opt_in': False}

Interpretation

The result does not prove that every hash/counter/stream product has identical atomicity or replication semantics. It proves a narrower mechanism: when the operation executes atomically where the state lives, the application can avoid a client-side race that whole-value replacement creates.

Check your understanding

  1. Why can opaque bytes still be a good design?
  2. What caused the inventory lost update?
  3. Why can a server-side counter reduce races?
  4. Does a map/hash automatically solve cross-key transactions?
  5. Why include a schema version?
Review the answers

1. They keep the storage contract simple and allow application-specific encodings, especially for immutable/small objects where whole-value replacement is acceptable.

2. Two clients read the same old value and independently replaced it; the read-modify-write sequence was not atomic.

3. The state transition executes atomically inside the store boundary instead of exposing an old value to competing clients.

4. No. It can improve one-key/one-structure updates, but invariants spanning multiple keys may still require transactions, compensation, or redesign.

5. It makes compatibility and migration behavior explicit for mixed-version deploys, backfills, and rollback.

6. Production judgment

Production dimension Questions to record before using the pattern
Correctness / atomicity What is the atomic boundary: one key, one structure, one shard, one transaction, or a broader invariant? Which races remain possible around reads, retries, expiration, failover, and multiple keys?
Consistency / topology Which node owns or coordinates the key, how are replicas acknowledged, what stale reads are allowed, and what changes during partition/failover?
Durability / recovery Is the state disposable, reconstructable, or authoritative? What persistence, replication, backup, restore, and reconciliation evidence supports that claim?
Latency / hot keys Measure p50/p95/p99, queueing, value size, key distribution, per-key operation rate, serialization cost, fan-in/fan-out, and hot-key concentration.
TTL / memory Differentiate logical expiration from physical reclamation and memory-pressure eviction. Define refresh rules, jitter, capacity headroom, and acceptable disappearance.
Security / tenancy Namespace tenant data, authorize keys/operations, protect secrets, prevent cross-tenant scans/collisions, limit abusive large values/structures, and audit privileged coordination actions.
Operations / testing Test ambiguous retries, duplicate requests, eviction, restart, replica lag, failover, clock shifts where relevant, hot keys, schema evolution, restore, and cache rebuild.
Version / license / cost Pin product/client versions when used; verify license/edition/feature status; price memory, replicas, persistence I/O, network egress, backup retention, and operational skill.

Bridge to Lesson 3: Rich operations become especially useful for ephemeral state: TTL, counters, conditional creation, and CAS. But TTL and eviction have different semantics, and memory pressure can remove data before its TTL.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.