Amber: append-only база для логов и трейсов
Почему не подошли текущие инструменты?
Loki
Хорошее хранилище логов, но у него есть очень существенное ограничение: поиск по содержимому это скан по сжатым чанкам всех совпавших потоков, индекса по телу строки нет. Так что при высококардинальных полях (тот же trace_id, user_id) — привет, полный скан, чего конечно хочется избежать. Ну а чтобы получить fts по телу, соизвольте заплтатить.
Трейсы кстати Loki не хранит, так что корреляция логов и трейсов идёт через Grafana и Jaeger — это уже вторая система и ещё один запрос.
VictoriaLogs
Уже имеется FTS что решает проблему Loki, но за трейсами увы пройдите в VictoriaTraces.
Jaeger
Это база, классно и круто, и благодаря модульности мы буквально можем пересобрать его под себя, но Jaeger про трейсы а не логи. Так что и тут мимо.
Elasticsearch
После их шизо-качелей с лицензией которые убили этот продукт. А брать форк который вырос на фоне его смерти (OpenSearch) тоже не хочется, ибо там везде под капотом тащится JVM с проблемами которые это за собой тянет — догадайтесь сами.
Что имеем?
Собственно у нас есть точечные продукты, которые хороши в X и не очень в Y. А что делать когда хочется всё в одном месте — тащим либ Elastic с его jvm мусором, либо собирайте зоопарк технологий из нескольких продуктов, что на средне-больших команды круто, но на маленьких продуктах такое разворачивать не хочется.(По крайне мере мне)
Да, есть OpenObserve — open-source, single binary, логи и трейсы вместе. Но часть нужных фич уже уехала под платную подписку, а история последних лет показывает что это только начало: Terraform, Redis, Elasticsearch,ScyllaDB — паттерн знакомый. Брать опенсоурс на который нельзя положиться в долгую не хотелось.
Вот из чего появился Amber.
Что внутри
Два отдельных хранилища: data/logs/ и data/spans/. Логи и спаны живут в одном файловом формате (.alog), но в разных директориях — у них разные схемы и разные паттерны запросов. Fulltext-поиск по телу лога не должен трогать спаны. Запрос трейса не должен сканировать все логи. Индексы, WAL, ротация сегментов — всё независимо.
LogEntry содержит TraceID и SpanID совместимые с W3C TraceContext. SpanEntry рядом — там ParentSpanID, Operation, StartTime, Duration, атрибуты.
Запрос GET /api/v1/traces/{trace_id} объединяет оба хранилища:
- Из
data/spans/достаём все спаны с этимtrace_id - Строим дерево в памяти: кладём все спаны в
map[SpanID]SpanEntry, находим корни (те у когоParentSpanIDнулевой), рекурсивно прикрепляем детей. Спаны из разных сервисов записываются независимо — порядок записи не гарантирован, поэтому дерево собирается на выдаче, а не при записи. - Из
data/logs/достаём все логи с этимtrace_id, прикрепляем к соответствующим спанам поspan_id.
{
"trace_id": "a1b2c3...",
"span_count": 3,
"tree": [
{
"span": { "operation": "POST /api/v1/orders", "duration_ms": 145 },
"logs": [
{ "level": "INFO", "message": "order created", "ts": "..." }
],
"children": [
{ "span": { "operation": "db.query orders" }, "logs": [], "children": [] },
{ "span": { "operation": "redis.get cache" }, "logs": [], "children": [] }
]
}
],
"took_ms": 2
}
Всё — за один запрос, без отдельного Jaeger и второго round-trip.
Архитектура: почему append-only
Логи никогда не изменяются — это свойство данных. Можно агрессивно сжимать без MVCC, не думать про update-in-place, не беспокоиться о фрагментации.
Данные хранятся в .alog файлах: data/logs/seg_00000001.alog, data/spans/seg_00000001.alog. Каждый файл состоит из заголовка, последовательности блоков и footer-а:
┌─────────────────────────────────┐
│ HEADER (16 bytes) │
│ magic "AMBR", version, │
│ created_at (unixnano), flags │
├─────────────────────────────────┤
│ BLOCK 0 │
│ magic "BLOK" │
│ uncompressed_size / comp_size │
│ record_count │
│ data: zstd(records...) │
├─────────────────────────────────┤
│ BLOCK 1 ... │
├─────────────────────────────────┤
│ FOOTER │
│ min_ts, max_ts │
│ record_count, block_count │
│ block_offsets[] (байт-оффсет │
│ каждого блока в файле) │
│ footer_size, magic "FOOT" │
└─────────────────────────────────┘
Один блок — это буфер до 4MB несжатых записей. Внутри него записи лежат последовательно: [4 байта длина][N байт данные][4 байта длина][N байт данные].... Одна запись — от ста до нескольких сотен байт, в один блок помещается 8–20K записей в зависимости от их размера. Когда буфер достигает 4MB, он сжимается zstd и пишется на диск целиком.
Footer — ключевая деталь. При открытии файла читаем только его: последние 4 байта проверяем на magic "FOOT", оттуда читаем footer_size, прыгаем назад на footer_size байт и читаем весь footer. В block_offsets[] лежат байтовые позиции каждого блока в файле — можно прыгнуть сразу на нужный блок без скана.
Из min_ts и max_ts сразу понятно — есть ли в этом сегменте что-то нужное для временного запроса. Большинство файлов пропускаем.
Если процесс упал посередине записи блока — footer не записан, magic "FOOT" не будет совпадать, читатель получает ErrNoFooter. Незавершённый сегмент либо игнорируется, либо восстанавливается через WAL-replay.
Write path: WAL и батчинг
Перед сегментом — WAL. Каждая запись сначала идёт в WAL с fsync, потом в сегмент. При краше — replay.
Каждая запись в WAL имеет фиксированный 12-байтовый заголовок:
[4] magic = 0xABCD1234
[4] crc32 = IEEE CRC32 от payload
[4] length = длина payload в байтах
[N] payload = [8 байт timestamp LE] + [данные записи]
Replay при старте: читаем записи последовательно, проверяем magic и CRC. Первая запись с битым magic или несовпадающим CRC — стоп. Всё до неё применяем к сегменту. Оборванная запись в хвосте это штатная ситуация при краше — WAL об этом знает.
После того как батч успешно записан в сегмент, WAL очищается: Truncate(0), seek в начало, пересоздаём буферизованный writer. Следующий батч пишет в чистый файл.
Но fsync дорогой. На NVMe ~0.1ms, при 1000 запросов/сек в одном потоке — максимум 10K записей/сек.
Так что батчим:
// N fsync:
for _, entry := range entries {
wal.Write(entry) // N × fsync
}
// 1 fsync на весь батч:
wal.WriteBatch(payloads) // 1 × fsync, N записей
Все заголовки и payload пишутся в один 64KB буферизованный writer, потом один buf.Flush(), потом один file.Sync(). Размер канала, размер батча и таймаут сброса задаются через конфиг — можно подбирать под свою нагрузку:
ingest:
queue_size: 10000 # cap входного канала
batch_size: 1000 # записей в батче
batch_timeout: 100ms # максимальное ожидание перед сбросом
BenchmarkWALWriteBatch 115 278 ops/sec
BenchmarkWALWriteSingle 7 812 ops/sec
15x на одном изменении.
Индексы: воронка фильтрации
Три уровня. Каждый отсекает данные без чтения сегментов с диска.
flowchart TD
A["Все сегменты 100%"]
B["SparseIndex\nsравнение min/max ts\n→ 3–5% сегментов"]
C["BitmapIndex\nroaring AND по полям\n→ ~1% записей"]
D["FTSIndex\nрадикс-дерево + stemming\n→ <1% при fulltext"]
E["Scan сегмента\nzstd decode + filter"]
A --> B --> C --> D --> E
SparseIndex — один файл, один диапазон времени на сегмент. Запрос “последний час” при данных за 30 дней отсекает 97% файлов за 380ns.
BitmapIndex — для каждого поля и каждого значения хранится множество entry ID в виде Roaring Bitmap. Если не знакомы с этой структурой: можно думать о ней как о сжатом битовом массиве, где бит N означает “запись с ID=N удовлетворяет условию”. level=ERROR — один bitmap с ID всех error-логов. service=api-gateway — другой bitmap. AND двух bitmap через SIMD-операции возвращает список ID, которые совпадают по обоим полям.
"level":
"ERROR" → {3, 7, 42, 891, 1204, ...}
"INFO" → {1, 2, 4, 5, 6, ...}
"service":
"api-gateway" → {2, 7, 42, 100, ...}
level=ERROR AND service=api-gateway:
{3,7,42,891,...} AND {2,7,42,100,...} = {7, 42, ...}
Строится при запечатывании сегмента, не при записи — в профилировщике addDoc занимал 39% CPU и memmove от роста trie ещё 37% при build-on-write. Убрал из write path.
FTSIndex — радикс-три (сжатое prefix-дерево) со stemming-ом (Snowball, русский + английский). В листьях дерева лежат списки document ID — для каждого слова это все записи где оно встречается. Stemming превращает слово в корневую форму перед индексированием и перед поиском: “connections” и “connected” оба ищутся через “connect”, русское “логирование” через “логир”. Это увеличивает recall — находишь документы даже если форма слова не совпадает точно. Fulltext поиск по содержимому логов — то чего у Loki нет без платного облака.
FTS как и BitmapIndex строится при запечатывании сегмента. Причина та же — в write path добавление в растущее trie слишком дорого.
За основу fts был взят движок: fts, и адаптирован для работы в amber fork. Можете глянуть кому интересно.
Полная картина работы amber

При старте — загружаем .bidx для всех sealed сегментов. Если файла нет — строим через scan.
Что дальше
Amber сейчас — хранилище логов и трейсов с HTTP API. Следующие шаги:
OTLP endpoint — POST /v1/logs и POST /v1/traces в формате OpenTelemetry Protocol. Модель уже совместима с OTel: TraceID/SpanID следуют W3C TraceContext, атрибуты — []Attr{Key, Value}. Добавить OTLP receiver означает совместимость со всей экосистемой OTel сразу — Go, Python, Java, Node.js — без отдельного Collector.
Embeddable library — закрыть ту нишу которой нет. amber.Open("./data") и приложение само хранит свои логи и трейсы без внешнего сервера. Полезно для CLI-инструментов, тестовых окружений, edge-деплоя.
Go SDK — нативная интеграция с slog, zap, zerolog с автобатчингом и локальным буфером.
• • •
