Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,74 @@ Reads walk 50 SSTables. Each index fits in CPU cache so map lookups are fast and

Fewer SSTables so reads are faster. Writes are slower since each flush is much larger. Index maps no longer fit in CPU cache, so bloom's compact bitset beats a plain map lookup on misses.

## go test benchmarks

Run on Apple M4, `-benchtime=5s`. Numbers are per-operation.

### Sequential writes

| Config | ns/op | B/op |
|--------|------:|-----:|
| bloom=true, lock=true | 58,165 | 28,180 |
| bloom=true, lock=false | 73,167 | 36,454 |
| bloom=false, lock=true | 70,526 | 35,228 |
| bloom=false, lock=false | 80,792 | 39,588 |

### Sequential reads — hits (10k keys pre-loaded)

| Config | ns/op |
|--------|------:|
| bloom=true, lock=true | 5,445 |
| bloom=true, lock=false | 5,364 |
| bloom=false, lock=true | 5,351 |
| bloom=false, lock=false | 5,333 |

### Sequential reads — misses (10k keys pre-loaded, reading non-existent keys)

| Config | ns/op |
|--------|------:|
| bloom=true, lock=true | 65.4 |
| bloom=true, lock=false | 65.1 |
| bloom=false, lock=true | 67.5 |
| bloom=false, lock=false | 67.4 |

### Reads — large dataset (100k keys, hits vs misses)

| Config | ns/op |
|--------|------:|
| hit, bloom=true | 5,418 |
| hit, bloom=false | 5,378 |
| miss, bloom=true | 97.5 |
| miss, bloom=false | 110.4 |

### Concurrent writes (`b.RunParallel`, 10 goroutines)

| Config | ns/op |
|--------|------:|
| bloom=true, lock=true | 18,639 |
| bloom=false, lock=true | 18,889 |

### Concurrent reads (`b.RunParallel`, 10 goroutines)

| Config | ns/op |
|--------|------:|
| bloom=true, lock=true | 4,696 |
| bloom=false, lock=true | 4,754 |

### What the numbers tell us

**Bloom filter only helps misses.** On a hit, the bloom filter says "maybe" and you still have to read the index — so you pay the hash cost for nothing. On a miss, bloom can reject a key outright without touching the index at all, which is where the speedup comes from.

**The bloom advantage scales with index size.** With 10k keys (small SSTables, index fits in cache), bloom saves ~3% on misses (65ns vs 67ns). With 100k keys (larger indices after compaction), the saving grows to ~12% (97ns vs 110ns). The index maps are too large for CPU cache at that point, so each lookup becomes a RAM access; bloom's compact bitset stays cache-hot and wins.

**Bloom never helps read hits.** The hit numbers across small and large datasets are nearly identical with and without bloom (~5.3–5.4µs). The SSTable data file read dominates, and bloom adds a small hash overhead before it.

**An uncontended lock is basically free.** For sequential writes, `lock=true` is actually faster than `lock=false` (58µs vs 73µs). There's no contention so the mutex costs nothing, and the two code paths end up with different allocation patterns — `lock=false` allocates about 30% more memory per op, which is where the slowdown comes from.

**Concurrent writes scale to about 3x, not 10x.** Sequential puts cost ~58µs; parallel drops to ~18µs across 10 goroutines. The ceiling is the memtable write lock — WAL appends and B-tree inserts both serialize, so adding more goroutines doesn't help past a point.

**Concurrent reads barely move the needle** (~5.4µs → 4.7µs). Reads take a shared `RLock` so they can technically run in parallel, but the bottleneck is SSTable file I/O which doesn't fan out well on a single drive.

## Running

```bash
Expand Down
23 changes: 22 additions & 1 deletion db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"lorem-lsm/sstable"
"lorem-lsm/wal"
"os"
"sync"
"time"
)

Expand All @@ -16,10 +17,12 @@ type LoremDB struct {
ssTables []*sstable.SSTable
ssTablePath string
useBloom bool
useLock bool
ssTableLimit int
lock sync.RWMutex
}

func NewLoremDB(useBloom bool) *LoremDB {
func NewLoremDB(useBloom bool, supportConcurrency bool) *LoremDB {

os.MkdirAll("sstable", 0755)
writeAheadLog, _ := wal.NewWal("wal.log")
Expand All @@ -43,11 +46,17 @@ func NewLoremDB(useBloom bool) *LoremDB {
ssTablePath: ssTablePath,
useBloom: useBloom,
ssTableLimit: 5,
useLock: supportConcurrency,
}
}

func (db *LoremDB) Put(key string, value string) error {
// write wal first to maximize data recovery chances
if db.useLock {
db.lock.Lock()
defer db.lock.Unlock()
}

db.wal.Append(wal.WalRow{
Key: key,
Value: value,
Expand Down Expand Up @@ -79,6 +88,12 @@ func (db *LoremDB) Put(key string, value string) error {
}

func (db *LoremDB) Delete(key string) error {

if db.useLock {
db.lock.Lock()
defer db.lock.Unlock()
}

// write wal first to maximize data recovery chances
db.wal.Append(wal.WalRow{
Key: key,
Expand Down Expand Up @@ -106,6 +121,12 @@ func (db *LoremDB) Delete(key string) error {
}

func (db *LoremDB) Get(key string) (string, bool) {

if db.useLock {
db.lock.RLock()
defer db.lock.RUnlock()
}

// check in memtable
val, ok := db.memTable.Get(key)
if ok {
Expand Down
176 changes: 176 additions & 0 deletions db/db_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package db

import (
"fmt"
"os"
"testing"
)

func setupDB(b *testing.B, useBloom bool, useLock bool) *LoremDB {
os.RemoveAll("sstable")
os.Remove("wal.log")
os.MkdirAll("sstable", 0755)
return NewLoremDB(useBloom, useLock)
}

func BenchmarkPut(b *testing.B) {
configs := []struct {
useBloom bool
useLock bool
}{
{true, true},
{true, false},
{false, true},
{false, false},
}
for _, cfg := range configs {
name := fmt.Sprintf("bloom=%v,lock=%v", cfg.useBloom, cfg.useLock)
b.Run(name, func(b *testing.B) {
store := setupDB(b, cfg.useBloom, cfg.useLock)
b.ResetTimer()
for i := 0; i < b.N; i++ {
store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i))
}
})
}
}

func BenchmarkGet(b *testing.B) {
configs := []struct {
useBloom bool
useLock bool
}{
{true, true},
{true, false},
{false, true},
{false, false},
}
for _, cfg := range configs {
name := fmt.Sprintf("bloom=%v,lock=%v", cfg.useBloom, cfg.useLock)
b.Run(name, func(b *testing.B) {
store := setupDB(b, cfg.useBloom, cfg.useLock)
for i := 0; i < 10000; i++ {
store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i))
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
store.Get(fmt.Sprintf("key-%d", i%10000))
}
})
}
}

// BenchmarkGetMiss reads keys that don't exist. Bloom filter can short-circuit
// every SSTable lookup (no index access needed), so the gap vs no-bloom is largest here.
func BenchmarkGetMiss(b *testing.B) {
configs := []struct {
useBloom bool
useLock bool
}{
{true, true},
{true, false},
{false, true},
{false, false},
}
for _, cfg := range configs {
name := fmt.Sprintf("bloom=%v,lock=%v", cfg.useBloom, cfg.useLock)
b.Run(name, func(b *testing.B) {
store := setupDB(b, cfg.useBloom, cfg.useLock)
for i := 0; i < 10000; i++ {
store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i))
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
store.Get(fmt.Sprintf("miss-%d", i))
}
})
}
}

// BenchmarkGetLargeDataset loads enough keys to trigger compaction and produce
// large SSTables whose index maps no longer fit in CPU cache. At this scale
// bloom's compact bitset should beat plain map lookups for both hits and misses.
func BenchmarkGetLargeDataset(b *testing.B) {
const preload = 100000
configs := []struct {
useBloom bool
label string
}{
{true, "hit,bloom=true"},
{false, "hit,bloom=false"},
{true, "miss,bloom=true"},
{false, "miss,bloom=false"},
}
_ = configs
for _, hit := range []bool{true, false} {
for _, useBloom := range []bool{true, false} {
hit, useBloom := hit, useBloom
label := fmt.Sprintf("hit=%v,bloom=%v", hit, useBloom)
b.Run(label, func(b *testing.B) {
store := setupDB(b, useBloom, false)
for i := 0; i < preload; i++ {
store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i))
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if hit {
store.Get(fmt.Sprintf("key-%d", i%preload))
} else {
store.Get(fmt.Sprintf("miss-%d", i))
}
}
})
}
}
}

func BenchmarkPutConcurrent(b *testing.B) {
configs := []struct {
useBloom bool
useLock bool
}{
{true, true},
{false, true},
}
for _, cfg := range configs {
name := fmt.Sprintf("bloom=%v,lock=%v", cfg.useBloom, cfg.useLock)
b.Run(name, func(b *testing.B) {
store := setupDB(b, cfg.useBloom, cfg.useLock)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i))
i++
}
})
})
}
}

func BenchmarkGetConcurrent(b *testing.B) {
configs := []struct {
useBloom bool
useLock bool
}{
{true, true},
{false, true},
}
for _, cfg := range configs {
name := fmt.Sprintf("bloom=%v,lock=%v", cfg.useBloom, cfg.useLock)
b.Run(name, func(b *testing.B) {
store := setupDB(b, cfg.useBloom, cfg.useLock)
for i := 0; i < 10000; i++ {
store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i))
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
store.Get(fmt.Sprintf("key-%d", i%10000))
i++
}
})
})
}
}
4 changes: 2 additions & 2 deletions db/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ func TestCrashRecovery(t *testing.T) {
os.MkdirAll("sstable", 0755)

// write some keys
store := NewLoremDB(true)
store := NewLoremDB(true, true)
store.Put("name", "sakar")
store.Put("lang", "go")

// simulate crash — don't flush, just create a new instance
store2 := NewLoremDB(true)
store2 := NewLoremDB(true, true)

val, ok := store2.Get("name")
if !ok || val != "sakar" {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ module lorem-lsm

go 1.26.2

require github.com/google/btree v1.1.3 // indirect
require github.com/google/btree v1.1.3
10 changes: 6 additions & 4 deletions sstable/sstable.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ func (table *SSTable) Close() {
table.indexFile.Close()
table.dataFile.Close()
os.RemoveAll(table.BasePath)
println("Cleaned")
}

func CreateSSTable(basePath string, useBloom bool) (*SSTable, error) {
Expand Down Expand Up @@ -120,14 +119,17 @@ func (ssTable *SSTable) Get(key string) (string, bool) {
if !yes {
return "", false
}
handle, err := os.Open(ssTable.dataFile.Name())

_, err := ssTable.dataFile.Seek(seekPoint, 0)
defer handle.Close()

if err != nil {
_, err1 := handle.Seek(seekPoint, 0)

if err1 != nil || err != nil {
return "", false
}

reader := bufio.NewReader(ssTable.dataFile)
reader := bufio.NewReader(handle)

row, err := reader.ReadString('\n')
row = strings.TrimSuffix(row, "\n")
Expand Down
Loading