Bitmap, ribbon filter, posting list
When I added indexes to amber, trace_id landed in a bitmap by default.
I knew that putting a bitmap on a high-cardinality field is generally a bad idea,
but I wanted to know exactly how bad. Spoiler: very bad.
Bitmap on trace_id
Bitmap indexes work well for fields with a small number of unique values.
For level with five possible values:
INFO -> [1, 1, 0, 1, 0, ...]
ERROR -> [0, 0, 1, 0, 1, ...]
Five compact bitmaps, good RLE compression, intersection of two conditions is a single AND. Cheap.
For trace_id the picture is different. It is a unique field - almost
one value per record:
a1b2c3d4... -> [1, 0, 0, 0, ...] // one bit set per 100K
e5f6a7b8... -> [0, 1, 0, 0, ...]
f9c0d1e2... -> [0, 0, 1, 0, ...]
... (99 997 more)
Instead of a few compact bitmaps: hundreds of thousands of nearly-empty ones. The index ballooned, compressed badly, and was expensive to rebuild at segment sealing time.
Ribbon filter
A ribbon filter is a probabilistic structure (~2 bits per key). It answers "does this segment contain value X?" with no false negatives, in a few kilobytes for a segment of 100K records.
For a trace_id query the executor became:
if ribbon, ok := e.logRibbon(seg.FileName); ok {
if !ribbon.Contains(q.TraceID[:]) {
return 0, nil // segment definitely does not contain this trace_id
}
}
// scan the segment
Ribbon says "no" - skip the segment. Ribbon says "maybe yes" - scan.
Indexes for 10M records went from hundreds of MB down to 340 KiB.
Most segments were skipped on trace_id queries.
What broke
Before ribbon filter, service=api AND trace_id=X worked through
bitmap intersection:
allowedIDs = bitmap(service=api) AND bitmap(trace_id=X) // 1 record
After ribbon filter there is no bitmap(trace_id=X), so intersection
is impossible:
allowedIDs = bitmap(service=api) only // ~20K candidates out of 100K
20K records scanned instead of 1. Pure trace_id=X queries were
fine - ribbon + linear scan works. But combined queries with trace_id
lost the intersection entirely.
Posting list
A posting list is an inverted index for high-cardinality fields:
trace_id -> []record_id.
If a segment has 2000 unique trace IDs with an average of 50 records each: 2000 x 50 x 8 bytes = 800 KB. Much smaller than the bloated bitmap, and it gives exact record IDs.
The lookup for trace_id now has three steps:
ribbon filter -> skip 99 of 101 segments
posting list -> get the exact record ID list for this trace_id
roaring64.And(bitmap(service=api), posting_list(trace_id=X)) -> intersection
Combined queries work correctly again. The posting list lives in a
.pidx sidecar next to the segment, built at sealing time,
loaded lazily through an LRU cache on the first query to the segment.
Implementation detail
The first builder used map[string][]uint64. For 100K unique trace IDs:
map overhead (~12 bytes/entry) + string keys (~32 bytes) + slice headers (~32 bytes)
~= 7.6 MB per segment during build. Multiply by the number of parallel sealings - GC
says hello.
Switched to a flat sorted slice of pairs instead:
type rawPair struct {
key [16]byte
id uint64
}
Summary
Bitmap and posting list answer the same question - "which records match this value?" - but through opposite tradeoffs. Bitmap uses a bit vector of length N: cheap at low cardinality, expensive at high cardinality. Posting list uses an explicit list: cheap at high cardinality, expensive at low cardinality.
Ribbon filter answers a different question - "could this segment contain this value?" - so it is not a competitor to either. All three coexist in amber and each does the job the other two cannot.