Lab 04 — SSTable (Sorted String Table)
- What this lab adds
- Concept: Immutable files and log-structured storage
- Concept: Varint encoding
- SSTable file layout
Builder: writing an SSTable- Byte-level example: building a 2-record SSTable
Reader: opening and searching an SSTableSSTIter: sequential scan- Read amplification: why a single SSTable isn’t enough
- Running the tests
- Running the demo
- FoundationDB parallel
What this lab adds
Labs 01–03 built a durable, crash-safe in-memory engine. The MemTable lives in RAM; when the process exits, all data is gone (only the WAL remains, and the WAL is replayed on reopen). This has a hard limit: the machine’s RAM.
Lab 04 introduces the SSTable: an on-disk file format that stores a sorted, immutable snapshot of a MemTable. Once an SSTable is written:
- All the data it contains is permanent — no WAL replay needed.
- The in-memory MemTable can be discarded.
- Reads can go directly to the SSTable file.
Lab 04 provides two types:
Builder— writes an SSTable from a sorted stream of key-value records.Reader— reads an existing SSTable, supporting point lookup (Get) and sequential scan (NewIterator).
Concept: Immutable files and log-structured storage
Traditional storage engines (e.g. B-trees) update data in place: writing a new value for a key overwrites the old value’s disk location. This causes two structural problems:
- Random writes — each update seeks to a specific offset in the file. An HDD performs ~100 random writes/s; sequential writes run at GB/s.
- Complex crash recovery — a crash during an in-place overwrite leaves the page half-written. B-trees solve this with page-level write-ahead logs or shadow-paging, adding implementation complexity.
A log-structured approach eliminates both:
B-tree (in-place): LevelDB (log-structured):
Page 7: "apple"→"v1" MemTable → WAL (append)
update: "apple"→"v2" ← random MemTable full → SSTable (sequential write)
seek + overwrite Old MemTable discarded
crash risk! SSTable is immutable: no crash risk
Every write is sequential, and files are never mutated after creation. Immutability also means files can be safely read by multiple goroutines without locking — the OS page cache handles caching automatically.
Concept: Varint encoding
Fixed-width integers waste space when values are small. A 4-byte uint32 uses
4 bytes even for the value 1. SSTable records use varint encoding:
Each byte: 7 bits of data + 1 MSB continuation flag
MSB = 1: more bytes follow
MSB = 0: last byte
Encoding examples:
Value 1: binary 0000001 → 0x01 (1 byte)
Value 127: binary 1111111 → 0x7F (1 byte)
Value 128: binary 10000000 → 0x80 0x01 (2 bytes)
┌────────────────────────────────────────┐
│ byte 0: 0x80 = 1_0000000 │
│ MSB=1 (more bytes), 7 data bits = 0 │
│ byte 1: 0x01 = 0_0000001 │
│ MSB=0 (last), 7 data bits = 1 │
│ value = (1 << 7) | 0 = 128 │
└────────────────────────────────────────┘
Value 1000: 1000 = 0b1111101000
low 7 bits: 1101000 = 104 → 0xE8 (with MSB=1: 0b11101000)
high 3 bits: 111 = 7 → 0x07 (with MSB=0: 0b00000111)
→ 0xE8 0x07 (2 bytes)
Value 100,000: → 0xA0 0x8D 0x06 (3 bytes)
For key and value lengths (almost always < 128 bytes in practice), varint saves
3 bytes per record versus a fixed uint32. A 4 MiB MemTable with 40,000 keys
averaging 20-byte keys and 80-byte values saves 40,000 × 3 × 2 = 240 KB —
about 6% of the file size.
appendVarint / readVarint annotated
// appendVarint appends the varint encoding of v to buf and returns the result.
func appendVarint(buf []byte, v uint64) []byte {
for v >= 0x80 { // while more than 7 bits remain
buf = append(buf, byte(v)|0x80) // emit low 7 bits with MSB=1
v >>= 7 // shift out the 7 bits we just emitted
}
return append(buf, byte(v)) // emit final byte with MSB=0
}
// readVarint reads one varint from r.
func readVarint(r io.Reader) (uint64, error) {
var v uint64
var shift uint
for {
var b [1]byte
if _, err := io.ReadFull(r, b[:]); err != nil {
return 0, err
}
v |= uint64(b[0]&0x7f) << shift // extract 7 data bits, place at shift
if b[0]&0x80 == 0 { // MSB=0: last byte
return v, nil
}
shift += 7
if shift >= 64 { // overflow guard (varint > 9 bytes)
return 0, errors.New("sstable: varint overflow")
}
}
}
Byte-level trace: decode 0xE8 0x07 (= 1000)
byte 0: 0xE8 = 0b11101000
MSB=1 (more bytes)
7 data bits: 0b1101000 = 104
v = 104 << 0 = 104
shift → 7
byte 1: 0x07 = 0b00000111
MSB=0 (last byte)
7 data bits: 0b0000111 = 7
v |= 7 << 7 = 7 × 128 = 896
v = 104 + 896 = 1000 ✓
SSTable file layout
┌──────────────────────────────────────────────────────────────────┐
│ DATA SECTION │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ record 0: varint(kLen) │ ikey │ varint(vLen) │ value │ │
│ │ record 1: varint(kLen) │ ikey │ varint(vLen) │ value │ │
│ │ … │ │
│ └────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────┤
│ INDEX SECTION (one entry per data record) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ entry 0: varint(kLen) │ ikey │ 8B LE file-offset-of-rec0 │ │
│ │ entry 1: varint(kLen) │ ikey │ 8B LE file-offset-of-rec1 │ │
│ │ … │ │
│ └────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────┤
│ FOOTER (exactly 16 bytes) │
│ ┌────────────────────────┬──────────────────────────────────┐ │
│ │ indexOffset (8B LE) │ magic = 0x000000001edb4b4f (8B) │ │
│ └────────────────────────┴──────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
Keys are internal keys from lab02: userKey || uint64(seqNum<<8 | keyType).
Why store an index at all? Without an index, finding a key requires scanning
every record from offset 0. For a 2 MiB SSTable with 100-byte average record
size, that is up to 20,000 reads. The in-memory index (one entry per record,
~24 bytes each for a 20-byte key) costs ~480 KB of RAM but reduces Get to a
binary search O(log N) + short linear scan.
Why is the index at the end, not the beginning? The Builder writes records
in one sequential pass. If the index were first, the builder would need to
either pre-allocate space for it (unknown size before writing) or do two passes.
Appending the index after all data records allows a single forward pass. The
16-byte footer at the very end tells the reader where the index starts.
The magic number 0x1edb4b4f is checked before parsing. A truncated or
corrupt file returns a descriptive error rather than silently returning wrong
data.
Builder: writing an SSTable
type indexEntry struct {
lastKey []byte
offset uint64 // byte offset of this record in the data section
}
type Builder struct {
f *os.File
index []indexEntry
dataOffset int64 // current write position in the file
}
Add(ikey, value []byte) annotated
func (b *Builder) Add(ikey, value []byte) error {
offset := b.dataOffset // save current position = offset of this record
var rec []byte
rec = appendVarint(rec, uint64(len(ikey))) // 1-3 bytes for key length
rec = append(rec, ikey...) // key bytes
rec = appendVarint(rec, uint64(len(value))) // 1-3 bytes for value length
rec = append(rec, value...) // value bytes
n, err := b.f.Write(rec) // single write syscall per record
if err != nil {
return err
}
b.dataOffset += int64(n)
// Register this record in the index:
keyCopy := make([]byte, len(ikey))
copy(keyCopy, ikey) // own the key bytes — ikey may be reused by caller
b.index = append(b.index, indexEntry{lastKey: keyCopy, offset: uint64(offset)})
return nil
}
Keys must be added in CompareInternal ascending order — the caller
(MemTable flush) iterates the skip list which already yields keys in that order.
Finish() annotated
func (b *Builder) Finish() error {
indexOffset := b.dataOffset // index section begins immediately after data
// Write each index entry:
for _, e := range b.index {
var rec []byte
rec = appendVarint(rec, uint64(len(e.lastKey)))
rec = append(rec, e.lastKey...)
var offBuf [8]byte
binary.LittleEndian.PutUint64(offBuf[:], e.offset)
rec = append(rec, offBuf[:]...) // 8-byte LE file offset
if _, err := b.f.Write(rec); err != nil {
return err
}
}
// Write 16-byte footer:
var footer [16]byte
binary.LittleEndian.PutUint64(footer[0:8], uint64(indexOffset)) // index start
binary.LittleEndian.PutUint64(footer[8:16], magic) // 0x1edb4b4f
if _, err := b.f.Write(footer[:]); err != nil {
return err
}
return b.f.Sync() // fdatasync: ensure file is durable before returning
}
b.f.Sync() calls fdatasync — the same guarantee as the WAL. A crash after
Finish returns leaves a fully intact SSTable.
Byte-level example: building a 2-record SSTable
Records (in CompareInternal order):
ikey0 = EncodeInternalKey("apple", 3, TypeValue) = [61 70 70 6c 65 01 03 00 00 00 00 00 00] (13 bytes)
val0 = "green" = [67 72 65 65 6e] (5 bytes)
ikey1 = EncodeInternalKey("name", 1, TypeValue) = [6e 61 6d 65 01 01 00 00 00 00 00 00] (12 bytes)
val1 = "Alice" = [41 6c 69 63 65] (5 bytes)
Data section (after Builder.Add × 2):
offset 0: 0d ← varint(13): kLen=13
offset 1: 61 70 70 6c 65 01 03 00 00 00 00 00 00 ← ikey0 "apple" seq=3
offset 14: 05 ← varint(5): vLen=5
offset 15: 67 72 65 65 6e ← val0 "green"
offset 20: 0c ← varint(12): kLen=12
offset 21: 6e 61 6d 65 01 01 00 00 00 00 00 00 ← ikey1 "name" seq=1
offset 33: 05 ← varint(5): vLen=5
offset 34: 41 6c 69 63 65 ← val1 "Alice"
dataOffset = 39
Index section (starting at offset 39):
entry 0 (points to data offset 0):
0d ← varint(13)
61 70 70 6c 65 01 03 00 00 00 00 00 00 ← key copy
00 00 00 00 00 00 00 00 ← offset=0 (LE uint64)
entry 1 (points to data offset 20):
0c ← varint(12)
6e 61 6d 65 01 01 00 00 00 00 00 00 ← key copy
14 00 00 00 00 00 00 00 ← offset=20 (0x14)
Footer (last 16 bytes):
27 00 00 00 00 00 00 00 ← indexOffset = 39 (0x27)
4f 4b db 1e 00 00 00 00 ← magic = 0x1edb4b4f
Reader: opening and searching an SSTable
Open(path) annotated
func Open(path string) (*Reader, error) {
f, err := os.Open(path)
// ...
// 1. Read 16-byte footer from the end of the file:
var footer [16]byte
f.ReadAt(footer[:], info.Size()-16)
idxOffset := int64(binary.LittleEndian.Uint64(footer[0:8]))
if binary.LittleEndian.Uint64(footer[8:16]) != magic {
return nil, fmt.Errorf("sstable %s: bad magic", path) // corrupt file
}
// 2. Seek to index section and read all entries into memory:
f.Seek(idxOffset, io.SeekStart)
r := &Reader{f: f, indexOffset: idxOffset}
for pos < info.Size()-16 { // stop at footer
klen, _ := readVarint(f)
key := make([]byte, klen)
io.ReadFull(f, key)
var offBuf [8]byte
io.ReadFull(f, offBuf[:])
offset := binary.LittleEndian.Uint64(offBuf[:])
r.index = append(r.index, indexEntry{lastKey: key, offset: offset})
}
return r, nil
}
Why load the entire index into memory? The index is small relative to the
data section. For a 2 MiB SSTable with 100-byte average records, there are
~20,000 records. Each index entry is varint(kLen) + key + 8B ≈ 30 bytes for
a 20-byte key. Total index size: 20,000 × 30 = 600 KB. At most a few percent
of RAM even with many SSTables open simultaneously. The payoff: all subsequent
Get calls are binary searches in RAM with no index-section I/O.
Get(ikey) annotated
func (r *Reader) Get(ikey []byte) ([]byte, bool) {
// Phase 1: binary search the in-memory index.
// Goal: find the first index entry whose key >= ikey.
lo, hi := 0, len(r.index)-1
for lo < hi {
mid := (lo + hi) / 2
if lab02.CompareInternal(r.index[mid].lastKey, ikey) < 0 {
lo = mid + 1 // index[mid] < ikey: answer must be to the right
} else {
hi = mid // index[mid] >= ikey: answer is here or to the left
}
}
// Phase 2: seek to data[lo].offset and linear-scan forward.
r.f.Seek(int64(r.index[lo].offset), io.SeekStart)
for pos < r.indexOffset { // don't read past data section
klen, _ := readVarint(r.f)
k := make([]byte, klen)
io.ReadFull(r.f, k)
vlen, _ := readVarint(r.f)
v := make([]byte, vlen)
io.ReadFull(r.f, v)
cmp := lab02.CompareInternal(k, ikey)
if cmp == 0 {
return v, true // exact hit
}
if cmp > 0 {
// k > ikey: check if same userKey (MVCC case — see below)
uk, _, _ := lab02.DecodeInternalKey(k)
searchUK, _, _ := lab02.DecodeInternalKey(ikey)
if bytes.Equal(uk, searchUK) {
return v, true // same userKey, lower seqNum → valid MVCC match
}
break // past the target userKey
}
}
return nil, false
}
Why the binary search works here
CompareInternal sorts by (userKey ASC, seqNum DESC). All versions of a
user key are contiguous in the data section and come before all versions of the
next user key. The binary search finds the first index entry whose key ≥ the
lookup key, which is the entry closest to — but not past — the target. The
linear scan from there reaches the target within a few records.
MVCC-aware lookup
A lookup at readSeq=R builds the lookup key as
EncodeInternalKey(uk, R, TypeValue). The file contains entries at various
seqNums. Due to the descending seqNum sort, the first matching user key entry
in the file is the most recent version ≤ R — exactly the MVCC-correct answer.
File contents for key "apple" (seqNum descending):
("apple", seq=10, TypeValue, "green")
("apple", seq=5, TypeValue, "red")
("apple", seq=2, TypeValue, "blue")
Lookup ikey = EncodeInternalKey("apple", 7, TypeValue):
Binary search → points to entry for seq=10 (first "apple" entry ≥ our key)
Linear scan:
rec: ("apple", seq=10) → CompareInternal returns < 0 (10 > 7 in the tag)
Wait: seqNum DESC means lower tag = higher seqNum.
Actually CompareInternal(stored, ikey) > 0 means stored seqNum < ikey seqNum.
The first stored entry with seqNum ≤ 7 is seq=5.
rec: ("apple", seq=5) → userKeys match, seqNum 5 ≤ 7 → return "red" ✓
Callers must also handle TypeDelete results: if the returned record is a
tombstone, the key does not exist at readSeq and the caller must not fall
through to older SSTable levels.
SSTIter: sequential scan
func (r *Reader) NewIterator() *SSTIter {
it := &SSTIter{r: r}
r.f.Seek(0, io.SeekStart) // start at beginning of data section
it.advance() // prime the first record
return it
}
func (it *SSTIter) advance() {
pos, _ := it.r.f.Seek(0, io.SeekCurrent)
if pos >= it.r.indexOffset { // past data section → done
it.done = true
return
}
klen, _ := readVarint(it.r.f)
k := make([]byte, klen); io.ReadFull(it.r.f, k)
vlen, _ := readVarint(it.r.f)
v := make([]byte, vlen); io.ReadFull(it.r.f, v)
it.key = k; it.val = v
}
func (it *SSTIter) Valid() bool { return !it.done }
func (it *SSTIter) Key() []byte { return it.key }
func (it *SSTIter) Value() []byte { return it.val }
func (it *SSTIter) Next() { it.advance() }
The iterator maintains a file cursor — each call to Next() advances one
record. This is the interface used by:
- Lab 06 compaction (
MergedIteratormerges multipleSSTIters). - Lab 08 full-database scan (
NewIterator).
Read amplification: why a single SSTable isn’t enough
After a MemTable flush, Get("k") checks:
- Active MemTable (RAM)
- Immutable MemTable, if flushing (RAM)
- L0 SSTable (disk)
But after 4 flushes, there are 4 L0 files, each potentially containing the key.
The read path must check all of them (newest first) — this is read
amplification: one logical Get becomes up to K file reads.
Lab 06 compaction solves this by merging L0 files into L1 with non-overlapping key ranges, bounding L1 reads to exactly 1 file per key lookup.
Running the tests
cd leveldb
go test ./lab04/... -v -count=1
Expected output:
=== RUN TestBuildAndRead
--- PASS: TestBuildAndRead (0.00s)
=== RUN TestGetMissing
--- PASS: TestGetMissing (0.00s)
=== RUN TestIteratorOrder
--- PASS: TestIteratorOrder (0.00s)
=== RUN TestCorruptFooter
--- PASS: TestCorruptFooter (0.00s)
PASS
ok github.com/10xdev/leveldb/lab04
TestBuildAndRead walkthrough
Writes N records with Builder.Add, calls Finish, then opens a Reader and
calls Get for each key. Also calls NewIterator and verifies keys come back
in ascending order. This exercises both the binary-search path and the
sequential scan.
TestCorruptFooter
Writes a valid SSTable, then overwrites the last 16 bytes with garbage. Asserts
that Open returns a "bad magic" error. This verifies the corruption
detection before any data is parsed.
Running the demo
go run ./lab04/demo
Expected output:
built SSTable: 3 records, size=NNN bytes
get apple → green
get name → Alice
get lang → Go
iterate (sorted order):
apple = green
lang = Go
name = Alice
FoundationDB parallel
In FDB, the equivalent persistent unit is a B-tree page inside the storage
server. Pages are copy-on-write (immutable once written), and the storage server
keeps an in-memory page cache indexing page IDs to disk offsets — exactly our
[]indexEntry. Our SSTable is a radically simplified single-level B-tree where
the entire file is one “page” and the index covers every record.
Real LevelDB and RocksDB split SSTables into 4 KiB data blocks with one index entry per block — rather than one per record. This reduces index RAM usage by ~100× for large files while only increasing the scan length from 1 record to at most one block (40–80 records). They also add a Bloom filter per SSTable: a 10-bit/key probabilistic bit-array answering “is key X definitely NOT in this file?” in O(1) without any disk I/O. With a 1% false-positive rate this eliminates ~99% of unnecessary file reads for missing keys.