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:
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.
Когда я добавил индексы в amber, trace_id по умолчанию попал в bitmap. Я знал, что bitmap для поля высокой кардинальности обычно плохая идея, но хотел понять насколько. Спойлер: очень плохая.
Bitmap для trace_id
Bitmap-индексы хорошо работают для полей с небольшим числом уникальных значений. У level всего пять вариантов:
Вместо нескольких компактных bitmap получаются сотни тысяч почти пустых. Индекс раздувался, плохо сжимался, а его перестроение при запечатывании сегмента обходилось дорого.
Ribbon-фильтр
Ribbon-фильтр — вероятностная структура (около 2 бит на ключ). Он отвечает на вопрос «есть ли в сегменте значение X?» без ложноотрицательных ответов. Для сегмента со 100 тысячами записей он занимает несколько килобайт.
Для запроса по trace_id исполнитель стал работать так:
if ribbon, ok := e.logRibbon(seg.FileName); ok {
if !ribbon.Contains(q.TraceID[:]) {
return 0, nil // в сегменте точно нет этого trace_id
}
}
// сканирование сегмента
Ribbon ответил «нет» — пропускаем сегмент. Ответил «возможно» — сканируем. Для 10 млн записей размер индексов упал с сотен мегабайт до 340 КиБ. При запросах по trace_id большинство сегментов удавалось пропустить.
Что сломалось
До Ribbon запрос service=api AND trace_id=X выполнялся пересечением bitmap:
allowedIDs = bitmap(service=api) AND bitmap(trace_id=X) // 1 запись
После замены bitmap для trace_id пересекать стало нечего:
allowedIDs = bitmap(service=api) only // около 20 тысяч кандидатов из 100 тысяч
Вместо одной записи приходилось просматривать 20 тысяч. Запросы только с trace_id=X работали нормально: Ribbon плюс линейное сканирование. Но в составных запросах пересечение пропало.
Posting list
Для полей высокой кардинальности подходит posting list — инвертированный индекс вида trace_id -> []record_id.
Если в сегменте 2 000 уникальных trace ID со средним числом 50 записей на каждый, список занимает 2000 × 50 × 8 байт, то есть 800 КиБ. Это намного меньше раздутого bitmap и при этом даёт точные ID записей.
Теперь поиск по trace_id состоит из трёх шагов:
Ribbon-фильтр -> пропустить 99 из 101 сегмента
posting list -> получить точный список записей для trace_id
roaring64.And(bitmap(service=api), posting_list(trace_id=X)) -> пересечь результаты
Составные запросы снова работают корректно. Posting list хранится в sidecar-файле .pidx рядом с сегментом. Он строится при запечатывании и лениво загружается через LRU-кэш при первом запросе к сегменту.
Деталь реализации
Первый вариант builder-а использовал map[string][]uint64. Для 100 тысяч уникальных trace ID накладные расходы map (около 12 байт на запись), строковых ключей (32 байта) и заголовков срезов (32 байта) составляли примерно 7,6 МБ на сегмент во время построения. Умножаем на число параллельно запечатываемых сегментов — и сборщик мусора уже здесь.
Вместо этого я перешёл на плоский отсортированный срез пар:
type rawPair struct {
key [16]byte
id uint64
}
Итог
Bitmap и posting list отвечают на один вопрос — «какие записи соответствуют этому значению?» — но делают это с противоположными компромиссами. Bitmap использует битовый вектор длины N: он дёшев при низкой кардинальности и дорог при высокой. Posting list хранит явный список: он дёшев при высокой кардинальности и дорог при низкой.
Ribbon-фильтр отвечает на другой вопрос: «может ли это значение вообще быть в сегменте?» Поэтому он не заменяет ни bitmap, ни posting list. В amber используются все три: каждый решает задачу, с которой два других не справляются.