Write-Ahead Log¶
Role in the engine¶
Every write is appended to the log before it is inserted into the
memtable. If the process or machine crashes, the memtable is gone, but
reopening the database replays the log and rebuilds it. Once a
memtable has been flushed to an SSTable, its log file is deleted. The
general theory is in WAL;
this page builds the concrete pieces in Go: the Batch format, the log
record format, the writer, the reader, and group commit.
Batch: the unit of atomicity¶
A Batch collects puts and deletes that must be applied atomically —
all or none survive a crash. A single Put is a batch of one. The
batch is serialized into one byte string, and that string is what the
log stores:
+----------------+-------------+-----------------------------------+
| sequence (8 B) | count (4 B) | records... |
+----------------+-------------+-----------------------------------+
record := 0x01 uvarint(len(key)) key uvarint(len(value)) value (put)
| 0x00 uvarint(len(key)) key (delete)
type Batch struct{ rep []byte }
func (b *Batch) Put(key, val []byte) {
b.init() // 12-byte header on first use
b.setCount(b.Count() + 1)
b.rep = append(b.rep, byte(TypeValue))
b.rep = appendBytes(b.rep, key)
b.rep = appendBytes(b.rep, val)
}
func appendBytes(dst, b []byte) []byte {
dst = binary.AppendUvarint(dst, uint64(len(b)))
return append(dst, b...)
}
Entry i of a batch with base sequence s gets sequence s + i.
Iterate decodes the records with bounds-checked reads and returns
ErrCorrupt if the bytes are malformed or the number of records does
not match the count — a batch read back from disk is untrusted
input:
func readBytes(p []byte) (b, rest []byte, ok bool) {
n, w := binary.Uvarint(p)
if w <= 0 || uint64(len(p)-w) < n { // varint invalid, or length beyond the buffer
return nil, nil, false
}
p = p[w:]
return p[:n:n], p[n:], true // full slice expression: callers can't append into p
}
The log file format¶
The log does not know about batches; it stores opaque records and guarantees each is read back exactly or detected as damaged. The format is LevelDB's, and worth copying:
- The file is divided into 32 KB blocks.
- A record is written as one or more fragments, each with a 7-byte header, so records can span blocks:
fragment := crc32c (4 B) | length (2 B) | type (1 B) | data (length B)
type := FULL | FIRST | MIDDLE | LAST
- If fewer than 7 bytes remain in a block, they are zero-filled and the next fragment starts in the next block.
- The CRC covers the type byte and the data.
Why blocks? A reader that hits a corrupted fragment can resynchronize at the next 32 KB boundary instead of losing the rest of the file, and a torn write can only damage the final block's tail.
The writer¶
const (
blockSize = 32 << 10
headerSize = 7
)
type LogWriter struct {
w io.Writer
blockOffset int
buf []byte
}
func (lw *LogWriter) AddRecord(rec []byte) error {
lw.buf = lw.buf[:0]
first := true
for {
left := blockSize - lw.blockOffset
if left < headerSize { // no room for a header: pad the block
lw.buf = append(lw.buf, make([]byte, left)...)
lw.blockOffset, left = 0, blockSize
}
frag := min(len(rec), left-headerSize)
last := frag == len(rec)
var t recType
switch {
case first && last:
t = recFull
case first:
t = recFirst
case last:
t = recLast
default:
t = recMiddle
}
lw.buf = appendFragment(lw.buf, t, rec[:frag])
lw.blockOffset += headerSize + frag
rec, first = rec[frag:], false
if last {
break
}
}
_, err := lw.w.Write(lw.buf) // one write syscall per record
return err
}
func appendFragment(dst []byte, t recType, data []byte) []byte {
var h [headerSize]byte
c := crc32.Update(0, castagnoli, []byte{byte(t)})
c = crc32.Update(c, castagnoli, data)
binary.LittleEndian.PutUint32(h[0:], c)
binary.LittleEndian.PutUint16(h[4:], uint16(len(data)))
h[6] = byte(t)
dst = append(dst, h[:]...)
return append(dst, data...)
}
castagnoli is crc32.MakeTable(crc32.Castagnoli): CRC32-C has
hardware instructions on both amd64 (SSE4.2) and arm64, and Go's
hash/crc32 uses them, so checksumming runs at many GB/s. The whole
record is assembled in a reused buffer and written with one
Write, so a record is never interleaved with another and costs one
syscall.
The reader: torn writes vs. corruption¶
The reader must classify every way a log can end:
| Situation | Meaning | Reader returns |
|---|---|---|
| Clean end of file after a complete record | Normal | io.EOF |
| File ends in the middle of a fragment or record | The last write was torn by a crash; it was never acknowledged | ErrTornWrite |
| Checksum mismatch, impossible length, bad fragment sequence | The file was damaged | ErrCorrupt |
func (lr *LogReader) Next() ([]byte, error) {
var rec []byte
inRecord := false
for {
if len(lr.block) < headerSize {
if lr.eof {
if inRecord || len(lr.block) > 0 {
return nil, ErrTornWrite
}
return nil, io.EOF
}
// ... read the next 32 KB block (short read → lr.eof = true)
continue
}
h := lr.block[:headerSize]
length := int(binary.LittleEndian.Uint16(h[4:]))
t := recType(h[6])
if t == 0 && length == 0 { // zero padding at the end of a block
lr.block = nil
continue
}
if headerSize+length > len(lr.block) {
if lr.eof {
return nil, ErrTornWrite // fragment cut off by end of file
}
return nil, ErrCorrupt // a length that can't fit in a block
}
data := lr.block[headerSize : headerSize+length]
want := binary.LittleEndian.Uint32(h[0:])
if crc32.Update(crc32.Update(0, castagnoli, h[6:7]), castagnoli, data) != want {
return nil, ErrCorrupt
}
lr.block = lr.block[headerSize+length:]
switch t {
case recFull:
return append([]byte(nil), data...), nil
case recFirst:
rec, inRecord = append(rec[:0], data...), true
case recMiddle:
rec = append(rec, data...)
case recLast:
return append(rec, data...), nil
}
// (fragment-sequence validation omitted here; it returns ErrCorrupt)
}
}
Recovery treats ErrTornWrite at the end of the last log as the
normal result of a crash — stop replaying, the torn record was never
acknowledged to its writer — and ErrCorrupt as a real error that
fails Open. A production engine offers recovery modes (LevelDB's
paranoid_checks, RocksDB's WALRecoveryMode) to choose between
failing, skipping corrupted records, or truncating at the first error.
The tests write records of sizes 0, 1, 100, exactly one block, and
several blocks, then truncate the file and flip a bit to check each
classification.
Group commit¶
fsync is expensive (Files and Durability in Go
measured 4.4 ms per f.Sync() on this laptop). If each writer synced
its own record, the engine would be limited to ~220 durable writes per
second regardless of core count. Group commit lets writers that
arrive while a commit is in progress share the next one:
func (db *DB) commitLoop() {
defer db.wg.Done()
var group []*writeReq
var merged Batch
for {
select {
case w := <-db.writes: // block until at least one writer
group = append(group[:0], w)
case <-db.closed:
return
}
drain: // take everyone else who is already waiting, without blocking
for len(group) < maxGroupSize {
select {
case w := <-db.writes:
group = append(group, w)
default:
break drain
}
}
err := db.commit(group, &merged) // one WAL record, at most one fsync
for _, w := range group {
w.done <- err
}
}
}
func (db *DB) commit(group []*writeReq, merged *Batch) error {
if err := db.makeRoomForWrite(); err != nil { // may rotate the memtable or stall
return err
}
merged.Reset()
merged.init()
sync := false
for _, w := range group {
merged.rep = append(merged.rep, w.b.rep[batchHeader:]...)
merged.setCount(merged.Count() + w.b.Count())
sync = sync || w.sync
}
first := db.lastSeq.Load() + 1
merged.setSeq(first)
if err := db.logw.AddRecord(merged.rep); err != nil {
return err
}
if sync {
if err := db.log.Sync(); err != nil {
return err
}
}
seq := first
merged.Iterate(func(t ValueType, k, v []byte) {
db.mem.Add(seq, t, k, v)
seq++
})
db.lastSeq.Store(seq - 1) // readers see the whole group at once, or none of it
return nil
}
The group forms naturally: while the commit goroutine is inside an fsync, new writers pile up in the channel, and the next iteration takes all of them. Under light load groups are size 1 and latency is minimal; under heavy load groups grow and the fsync cost is amortized. No timers, no tuning.
Measured (100-byte values, Sync: true means the write is durable
before Put returns):
| Benchmark | Time per Put |
Durable writes/s |
|---|---|---|
Sync: false, one goroutine |
5.2 µs | ~190,000 (not durable) |
Sync: true, one goroutine |
3,936 µs | 254 |
Sync: true, 128 concurrent goroutines |
76 µs | ~13,100 |
Concurrent durable writes are 51x faster per operation than sequential ones, with the same disk, because each fsync now covers dozens of writes. This is how every serious database gets durable throughput, and why a database benchmark with one client thread says little about its throughput.
Durability options, honestly¶
| Setting | A crash can lose | Typical use |
|---|---|---|
Sync: true on every write |
Nothing acknowledged | Financial records, metadata |
Sync: false |
Writes since the last OS flush (seconds); a process crash alone loses nothing — the data is in the page cache — only an OS crash or power loss does | Caches, rebuildable indexes, most analytics |
| Periodic sync (every N ms in the background) | Up to N ms of writes | A common middle ground |
Whatever the setting, the log format guarantees that recovery sees a prefix of the acknowledged writes, never a reordered or corrupted set — because records are checksummed and replay stops at the first torn record.
Common pitfall¶
Acknowledging a write before its group's fsync. An optimization
attempt moves the memtable insert and the reply to writers before
db.log.Sync(), "because the data is already in the log". Throughput
improves, all tests pass — tests don't cut power. After a power loss,
writes that clients were told were durable are missing, and some
clients may have already acted on them (e.g. read their own write from
the memtable before the crash). The order is the contract: append,
fsync, then make visible and acknowledge. A crash test that drops
unsynced data (copy only the synced prefix of the log, as the
test suite does) catches the reordering
immediately.
Review questions¶
- Why is the log divided into 32 KB blocks instead of being a plain sequence of length-prefixed records?
- How does the reader distinguish a torn write from corruption, and why is one normal and the other fatal?
- Why does group commit need no timer to form groups?
- Why were concurrent synced writes 51x faster per operation than sequential ones on the same disk?
- With
Sync: false, which kinds of crash lose data, and which do not?