Reading LevelDB: The MemTable
The Arena piece showed where MemTable’s nodes come from in memory; the SkipList piece showed the index structure they are woven into. This article sits at the top of that stack: MemTable (db/memtable.h, db/memtable.cc) — the LSM tree’s in-memory write buffer, the structure that actually encodes the K/V pairs, orders them, and hides deletes behind tombstones.
If you haven’t read the two prerequisite pieces, they are the natural lead-in: → Reading LevelDB: The Arena Region Allocator → Reading LevelDB: The SkipList Index
1. What MemTable is
A MemTable is the LSM tree’s in-memory component. Writes (Put/Delete) land here first; once it fills (write_buffer_size) it is frozen and flushed to an SSTable. Internally it is just three things:
- an
Arenathat owns all node memory (bump-allocated, freed wholesale); - a
SkipList<const char*, KeyComparator>that indexes entries by internal key; - a
KeyComparatorthat knows how two encoded entries compare.
The skip-list node key is nothing more than a const char* pointer to the head of an entry buffer. So everything MemTable does — encoding, ordering, lookup — reduces to how that buffer is laid out and how two buffers are compared.
2. The memory entry format
From db/memtable.cc:78-99, Add packs one K/V pair into a single contiguous arena buffer:
// Format of an entry is concatenation of:
// key_size : varint32 of internal_key.size()
// key bytes : char[internal_key.size()]
// tag : uint64((sequence << 8) | type)
// value_size : varint32 of value.size()
// value bytes : char[value.size()]
...
char* buf = arena_.Allocate(encoded_len);
char* p = EncodeVarint32(buf, internal_key_size);
std::memcpy(p, key.data(), key_size);
p += key_size;
EncodeFixed64(p, (s << 8) | type);
p += 8;
p = EncodeVarint32(p, val_size);
std::memcpy(p, value.data(), val_size);
table_.Insert(buf);
One entry = key and value packed in the same contiguous span of arena memory (fully owned by the arena):
┌──────────┬──────────────────────────┬────────┬───────────┬──────────┐
│ key_size │ userkey + tag (8B) │ tag │ value_size│ value │
│ varint32 │ char[internal_key_size-8] │ uint64 │ varint32 │ char[] │
└──────────┴──────────────────────────┴────────┴───────────┴──────────┘
└──── together = internal_key (the sort key) ────┘
internal_key = userkey + 8-byte tag, wheretag = (sequence << 8) | type.- Note that
userkeyandtagare written by separatememcpy/EncodeFixed64calls, but they are semantically contiguous — together they form the sort key,internal_key. table_.Insert(buf)inserts the start pointer of this buffer as the skip-list node’s “key”. The node itself is just aconst char*pointing at the entry.- The sort order only ever looks at
internal_key; it never touchesvalue(see §4).
3. Variable-length encoding (varint) — byte-compatible with Protobuf
EncodeVarint32 (util/coding.cc:21-47):
char* EncodeVarint32(char* dst, uint32_t v) {
uint8_t* ptr = reinterpret_cast<uint8_t*>(dst);
static const int B = 128;
if (v < (1 << 7)) { *(ptr++) = v; }
else if (v < (1 << 14)){ *(ptr++) = v | B; *(ptr++) = v >> 7; }
...
}
- Rule: each byte stores 7 bits of data in its low 7 bits; the high bit (
0x80) is the continuation flag. Multi-byte values are laid out little-endian (low-order byte first). - Decode (
GetVarint32PtrFallback,util/coding.cc:86-102): loop over bytes; ifbyte & 128there is more,result |= (byte & 127) << shift,shiftincrements by 7 each round. VarintLength(:77-84) pre-computes how many bytes a value needs (while (v >= 128) { v >>= 7; len++; }).
Relation to Protobuf
LevelDB’s varint is byte-level compatible with Protobuf’s varint encoding. The only difference is that Protobuf additionally offers zigzag (mapping signed integers to unsigned), whereas LevelDB here only encodes unsigned lengths / sequence numbers and never applies signed zigzag.
4. KeyComparator — compares only internal_key, never value
int MemTable::KeyComparator::operator()(const char* aptr,
const char* bptr) const {
Slice a = GetLengthPrefixedSlice(aptr); // skip key_size, take internal_key
Slice b = GetLengthPrefixedSlice(bptr);
return comparator.Compare(a, b);
}
- The comparator receives the entry start pointer
aptr. GetLengthPrefixedSlice(:14-19) first readskey_sizeviaGetVarint32Ptr, then returns the slice[after key_size, length = key_size]— i.e. it cuts out exactly theinternal_key(userkey + tag) and thevalueis skipped entirely.- So the sort comparison only ever looks at
internal_key; the value’s byte order and size have zero effect on ordering.
The Get lookup flow and tombstones
bool MemTable::Get(const LookupKey& key, std::string* value, Status* s) {
Slice memkey = key.memtable_key();
Table::Iterator iter(&table_);
iter.Seek(memkey.data()); // skip-list: first entry >= memkey
if (iter.Valid()) {
const char* entry = iter.key();
const char* key_ptr = GetVarint32Ptr(entry, entry + 5, &key_length);
if (user_comparator()->Compare(
Slice(key_ptr, key_length - 8), key.user_key()) == 0) {
const uint64_t tag = DecodeFixed64(key_ptr + key_length - 8);
switch (static_cast<ValueType>(tag & 0xff)) {
case kTypeValue: /* read value, return it */;
case kTypeDeletion: *s = Status::NotFound(...); return true;
}
}
}
return false;
}
Flow: encode memkey → skip-list Seek to target → verify user_key equality (the tag’s low 8 bits tag & 0xff is the type; tag >> 8 is the sequence) → branch on type:
kTypeValue: useGetLengthPrefixedSlice(key_ptr + key_length)to skip theinternal_keyand return the value.kTypeDeletion: returnNotFound— that is the tombstone.
Tombstone semantics (correcting the “it deletes” misconception)
- The MemTable layer never physically deletes any entry; a delete is just inserting a
kTypeDeletiontombstone record into the skip list. - Real cleanup happens during compaction, when an SSTable merge meets a tombstone against an older value and discards both.
- So “inserted data is never deleted, only marked invalid” is precise in the local sense: nothing is removed here; global convergence relies on background compaction.
5. The Add path — how a write becomes an entry
Add (memtable.h:53-57, implemented in memtable.cc) is the landing point of the write path:
void Add(SequenceNumber s, ValueType type, const Slice& key,
const Slice& value);
- Parameters:
sis the sequence number (the MVCC version);typeiskTypeValueorkTypeDeletion(value is usually empty for deletes);keyis the user key (the internal key is synthesized here);valueis the value. - What it does (tied to §2’s format): first synthesize the internal key from the user key (
userkey + 8B tag,tag = (s << 8) | type), then packinternal_key + valueinto one arena buffer, thentable_.Insert(buf). Three design points:- Tag packing
(s << 8) | type: sequence occupies the high 56 bits, type the low 8. The skip list orders internal keys descending, so under the same user key the largest sequence comes first — meaningGet’sSeekhits the newest version first. That is how multi-version reads are implemented. - Key + value in one arena buffer: the skip-list node stores just one
const char*; after packing it is a single allocation, cache-friendly; the comparator reads only theinternal_keyprefix, and the value is fetched on demand at offsetkey_ptr + key_length(see §4). - Uses
arena_.Allocate(unaligned), notAllocateAligned: the buffer is pure byte data needing no 8-byte alignment; node alignment is handled separately inside SkipList viaAllocateAligned(echoing the Arena notes).
- Tag packing
6. Class design — non-copyable and reference-counted
The first three sections are about memtable.cc (encoding & lookup). This section is about memtable.h — the ownership design at the class level.
6.1 Disable copying (memtable.h:26-27)
MemTable(const MemTable&) = delete;
MemTable& operator=(const MemTable&) = delete;
- Line 26 is the copy constructor (
const MemTable¶meter); line 27 is the copy assignment operator (returnsMemTable&).= deletedisables any value copy at compile time. - Why non-copyable:
- member
arena_owns raw memory (std::vector<char*> blocks_, etc.) and itself= deletes copying; - member
table_is aSkipListwhose nodes are all allocated inarena_and never freed individually (a node lives until the MemTable is destroyed);SkipListitself also= deletes copying. - Even if copying were possible, a shallow copy is fatal: skip-list nodes store pointers into arena memory, so two MemTables would share the same nodes; when one side
Unref()s to 0 and doesdelete this, it frees the other side’s node memory too → dangling pointers. A deep copy is hugely expensive and unnecessary.
- member
- The correct semantics is “shared ownership + reference counting”, not value copying — this matches the LSM in-memory component’s “freeze / hand-off” model: when
mem_fills it is swapped wholesale intoimm_, not copied.
6.2 Reference counting Ref / Unref (memtable.h:29-39)
void Ref() { ++refs_; }
void Unref() {
--refs_;
assert(refs_ >= 0);
if (refs_ <= 0) delete this;
}
- Why needed: a
MemTableis shared by the same instance in many places (it cannot be copied, yet cannot have a single owner). Typical holders:DBImpl::mem_— the active memtable (holds 1 ref, written by the write path);DBImpl::imm_— the frozen memtable pending flush (holds 1 ref;Unref()after background flush);- user iterators (
NewIteratorcallsRef(), guaranteeing the memtable survives the traversal even if a background flush already happened); - background compaction tasks (hold a ref while operating on
imm_).
- whoever
Unref()s last torefs_ <= 0is responsible fordelete this.
- Relation to
shared_ptr: same idea (shared ownership + destroy on count zero), but implemented as a hand-written intrusive reference count, notstd::shared_ptr. Four key differences:- Intrusive vs external control block:
shared_ptrkeeps its count in a separate control block, invisible to the managed type; hererefs_is a member inside the object (memtable.h:80), the object carries its own count. - Initial count is 0 (
memtable.h:22-23comment: “initial reference count is zero and the caller must call Ref() at least once”): anewed object is “owned by nobody yet” and the caller must immediatelyRef()to take it over — more explicit, more discipline-bound thanshared_ptr’s default count of 1. - Not thread-safe:
refs_is a plainint, notstd::atomic; allRef/Unrefmust run underDBImpl::mutex_(external lock guarantees safety, saving atomic cost). - Purely manual, no smart-pointer wrapper: there is no
shared_ptr<MemTable>in the code; callers hold a raw pointer and manually pairRef/Unref; the destructor is even declaredprivate(memtable.h:77), so onlyUnref()can delete it — the type system forces “destruction only via the reference-count protocol”.
- Intrusive vs external control block:
- Why not just use
shared_ptr: avoids the control block’s extra heap allocation, avoids atomic RMW overhead, fits the “hand-off” semantics (mem_ → imm_transfers ownership wholesale), and reflects the early (C++03-era) style that also disables exceptions/RTTI. - Tie to DDIA: the LSM in-memory component is shared / handed off in many places; reference counting is exactly the “multiple owners, last releaser destroys” model landed in C++ — the same problem
shared_ptrsolves, just LevelDB’s lighter, more controllable hand-written variant.
7. Connection to Arena & SkipList
- MemTable composes an
Arena(arena_) and aSkipList(table_) built on it. The entry buffer in §2 isarena_.Allocate’d; the skip-list node storing its pointer isarena_.AllocateAligned’d. - The “nodes never individually freed” invariant (SkipList) and the “freed wholesale” model (Arena) are why MemTable can
= deletecopying and rely on reference counting instead — the three pieces are mutually consistent. Addproduces the bytes,KeyComparatorinterprets them,SkipListorders them,Arenaowns them. MemTable is the glue that turns aPut/Deleteinto a sorted, versioned, immortal-until-flush record.
8. Connecting to DDIA (Designing Data-Intensive Applications)
- Every record carries a sequence number = MVCC / timestamp (enabling snapshot isolation and multi-version reads); deletes use a tombstone rather than a true delete, with cleanup deferred to background compaction — echoing the §4 tombstone semantics and the §5
Adddesign. - MemTable is the canonical LSM in-memory component: writes accumulate here, then spill to SSTable; the “freeze and hand off” model (§6.1) is exactly the ownership-transfer pattern behind compaction.
9. Where to read next
MemTable is the top of this stack, and you have already seen the two pieces underneath it:
- Reading LevelDB: The Arena Region Allocator — the bump allocator that owns every byte;
- Reading LevelDB: The SkipList Index — the lock-free index over those bytes.
The natural continuation past MemTable is the main LSM line: MemTable → WAL (log) → SSTable (table/block) → Compaction (version_set).
The very next step — what happens when mem_ fills and how the SSTable on disk is laid out — is here: Reading LevelDB: Flushing MemTables and the SSTable Format.
Quick reference
| Topic | Key point |
|---|---|
| Entry format | varint(key_size) | userkey | tag | varint(val_size) | value, KV same span |
| internal_key | userkey + 8B tag = (sequence<<8)|type, is the sort key |
| varint | 7-bit payload + high continuation bit + little-endian; byte-compatible with Protobuf |
| KeyComparator | cuts internal_key only, never touches value |
| Get / tombstone | Seek → check user_key → by type return value or NotFound |
| Add | seq+type+userkey+value; synthesize internal key, pack into arena, insert; delete = tombstone |
| Non-copyable | memtable.h:26-27; arena/skiplist uncopyable, shallow copy fatal, deep copy wasteful |
| Ref counting | memtable.h:29-39; intrusive Ref/Unref, initial 0, plain int under mutex, not shared_ptr |