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.
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.
Distinguish opaque bytes, structured documents, maps/hashes, sets, sorted sets, lists, counters, and append-oriented streams.
Explain serialization format, schema/version envelopes, partial update, atomic server-side operation, and compatibility boundaries.
Produce a lost update with two client-side JSON modifications and repair it with an atomic field/counter operation.
Choose a data structure from the required operation—not from fashion or superficial similarity to an in-memory language type.
Record value-size, mutation frequency, schema-evolution, hot-key, durability, and replication implications.
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.
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)
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
- Why can opaque bytes still be a good design?
- What caused the inventory lost update?
- Why can a server-side counter reduce races?
- Does a map/hash automatically solve cross-key transactions?
- 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
- Redis data types — current concrete example of bytes and rich server-side data structures
- Redis — Compare data types — operation-oriented selection guidance
- Redis strings — example of byte/string values and atomic counter operations
- Redis transactions / WATCH — current concrete example of optimistic check-and-set behavior