Кратко. Amber может работать как библиотека внутри другого процесса, а это предъявляет особые требования к обработке сбоев: любую ошибку ввода-вывода, которую нельзя безопасно обработать, система должна чисто, локально и явно остановить. Нельзя продолжать работу незаметно, повреждать данные и оставлять систему в неопределённом состоянии. Это важно. Очень.
1. Коротко о mmap
mmap отображает байты файла как обычный срез в адресном пространстве. После этого «чтение файла» выглядит как обращение к памяти, а ядро по мере необходимости лениво загружает страницы и хранит их в файловом кэше страниц.
import "syscall"
f, _ := os.Open("segment.fidx")
defer f.Close()
fi, _ := f.Stat()
// data — []byte размером с файл, но сами данные ещё не обязательно в RAM:
data, err := syscall.Mmap(int(f.Fd()), 0, int(fi.Size()),
syscall.PROT_READ, syscall.MAP_SHARED)
if err != nil { /* ... */ }
defer syscall.Munmap(data)
// Вот и «чтение». Выглядит как доступ к памяти за наносекунды:
v := binary.BigEndian.Uint64(data[off : off+8])То же самое с явным вводом-выводом. pread (в Go — ReadAt) читает данные по указанному смещению: один системный вызов, N байт начиная с off, без изменения общего смещения файла, результат — (n, err). Последняя часть — именно то, что нам нужно: ошибка представлена значением и видна вызывающему коду.
var buf [8]byte
if _, err := f.ReadAt(buf[:], off); err != nil {
return err // ошибка — значение, её можно обработать
}
v := binary.BigEndian.Uint64(buf[:])Соблазн понятен: бинарный поиск по срезу mmap неотличим от бинарного поиска по обычному срезу. Нет системных вызовов в горячем цикле, не нужно управлять буферами, файловый кэш ОС доступен бесплатно.
Что на самом деле происходит при data[off]
Строка v := data[off] скрывает стоимость. Если страницы со смещением off ещё нет в памяти:
- CPU вызывает исключение page fault.
- Управление переходит ядру, которое проверяет наличие страницы в файловом кэше.
- Если страницы там нет, ядро синхронно читает её с диска, а поток ждёт.
- Страница отображается, инструкция запускается повторно, и
data[off]«возвращает значение».
В исходном коде ничего этого не видно. Доступ, похожий на чтение памяти, может превратиться в блокирующий ввод-вывод с диска на миллисекунды. Мы не контролируем момент: он зависит от состояния кэша страниц, которым управляет ядро.
Спасибо, планировщик
При вызове file.ReadAt горутина входит в системный вызов через entersyscall и переходит в состояние _Gsyscall, а её P — в _Psyscall. P не отсоединяется сразу: среда выполнения оптимистично оставляет его на случай быстрого завершения вызова. Но если чтение действительно ждёт диск, sysmon замечает P в системном вызове и передаёт его другому M через handoffp. Другие горутины могут продолжить работу на этом P. Передача не происходит мгновенно, но возможна, поскольку состояние горутины видно среде выполнения.
Page fault при доступе к mmap — не системный вызов Go. Это исключение CPU, которое ядро обрабатывает прозрачно. Среда выполнения об этом не знает: нет entersyscall и смены состояния горутины. С её точки зрения горутина всё ещё _Grunning на P в состоянии _Prunning, хотя M застрял в обработчике ошибки страницы. Здесь sysmon бессилен: он не может передать P, потому что тот не в _Psyscall, и не может вытеснить M — тот находится в ядре и не дойдёт до безопасной точки, пока ошибка страницы не разрешится.
Итак, mmap не просто скрывает ввод-вывод. Он скрывает его и от среды выполнения Go, лишая планировщик информации, на которой тот основан.
2. Зачем mmap понадобился amber
В FTS-индексе есть «уникальная секция»: отсортированные 64-битные хеши токенов с df==1, примерно 13 МБ на сегмент. Сегментов десятки. Если держать всё это в памяти, для редко используемых данных потребуются сотни мегабайт RAM.
mmap как будто решает задачу бесплатно: вся секция доступна по адресам, резидентными становятся только затронутые страницы, холодные страницы вытесняет ядро, а бинарный поиск по отсортированным хешам остаётся простым и не требует системных вызовов на каждом шаге.
Это классический сценарий для mmap: большой файл только для чтения, отсортированный набор, случайные точечные запросы и желание использовать кэш страниц ОС. На этом построена LMDB. Вопрос «почему не мы?» вполне честный.
Компромиссы mmap в статье CIDR 2022
У mmap есть четыре группы проблем:
- Транзакционная надёжность. mmap может сбрасывать грязные страницы в произвольный момент;
msyncне обеспечивает строгого порядка и способен нарушить гарантии WAL. В нашем случае данные только читаются, поэтому это почти не относится к делу. - Задержки ввода-вывода. Любое обращение может вызвать page fault и неявно заблокироваться. Мы теряем контроль над моментом ввода-вывода и не можем осмысленно упреждать чтение.
- Обработка ошибок. Ошибки ввода-вывода при page fault приходят как SIGBUS, а не как возвращаемые значения. Для нас это фатально.
- Производительность при вытеснении страниц. Вытеснение страниц в ядре однопоточное; на многих ядрах каждая операция может требовать TLB shootdown через межпроцессорные прерывания. Прозрачные огромные страницы способны добавлять скачки задержки.
Важно быть справедливым: mmap не плох сам по себе. Для данных, которые преимущественно читаются, помещаются в RAM и лежат на стабильном локальном хранилище одного узла, это отличный инструмент. Даже если не учитывать fail-stop, для нашей нагрузки mmap не выглядит безусловной победой. Но решающим становится наш инвариант.
3. Требование amber: fail-stop
Fail-stop — это контракт: обнаружив ошибку, которую нельзя безопасно обработать, система чисто, локально и явно останавливается, а не продолжает работу в искажённом или неоднозначном состоянии. На этом построен amber.
mmap несовместим с таким контрактом. SIGBUS — противоположность fail-stop.
Fail-stop требует обнаружить ошибку, чисто остановиться и вернуть её как значение. SIGBUS завершает процесс на произвольной инструкции; превратить это в аккуратно обработанную ошибку на практике нереально. В C можно попробовать обработчики сигналов и sigsetjmp/siglongjmp; в среде выполнения Go с горутинными стеками и собственной обработкой сигналов это нерабочий вариант. Во встроенной библиотеке всё ещё хуже: пришлось бы перехватывать сигналы в чужом процессе.
Amber работает внутри процесса пользователя. SIGBUS из-за нестабильного диска убивает не только amber, но и хост-приложение. Нельзя выпускать библиотеку, которая завершает чужой процесс из-за сбоя сетевого диска.
Поэтому контракт исключает mmap: эта абстракция делает ввод-вывод невидимым. Хранилище построено на инварианте, что операции ввода-вывода и их ошибки должны быть видны и возвращаться как значения.
К чему это привело
Горячая секция нового формата индекса читается по требованию через явный pread: каждый запрос возвращает (n, err). Повреждённый блок становится ошибкой Go, которую можно передать выше; у читателя есть счётчик ссылок; сбой локализован; процесс хоста продолжает работать. Этот формат называется AFT2.
Главное: pread не лишает нас кэша страниц. Ядро кэширует страницы файла при ReadAt так же, как и при mmap. Мы не теряем основную выгоду mmap, а платим только за системные вызовы: O(log n) обращений на поиск вместо нуля при mmap. Для секции df==1 это около 20 чтений по холодному, редкому пути — небольшая цена за явные ошибки и возможность планировщика видеть ввод-вывод.
4. AFT2 — формат нашего индекса
AFT2 заменил radix-снимок из fts-engine.
Как мы пришли к формату
- Сначала использовался radix-снимок fts-engine. Каждая запись posting хранилась десятичной строкой DocID внутри сериализованных узлов дерева. Для сегмента на 100 тысяч записей результат занимал 38 МБ на диске, 150 МБ в RAM и разбирался 400 мс. Файлы
.fidxсоставляли 92% хранилища amber и доминировали в RSS. - Распределение токенов в логах: примерно 80% имеют
df==1, то есть встречаются ровно в одной записи. Это UUID, ID трассировок и идентификаторы. Обычный словарь строк требует около 40 байт заголовков строки и среза на токен — примерно 50 МБ заголовков на сегмент в LRU исполнителя. - Разделим данные по частоте документов. Две группы — два представления.
Структура .fidx (AFT2)
[ магическое значение "AFT2" ]
[ секция df>=2: отсортированный словарь токенов + posting lists uint64 с delta-varint ]
[ секция df==1: два плоских массива 8-байтовых значений: ]
[ fnv64a(token), по возрастанию ]
[ || параллельные полные ID записей ]
[ CRC32 ]В df>=2 — десятки тысяч токенов, в основном слова из шаблонов сообщений. Секция резидентна и ищется бинарным поиском по словарю. Токенов немного, поэтому это недорого.
df==1 — оставшиеся 80%. Заголовки строк не нужны: только хеши и ID, по 8 байт каждый. Коллизии не теряют записи: одинаковые хеши стоят рядом, поиск возвращает все записи с этим хешем. 64-битная коллизия внутри сегмента (при примерно 1 млн токенов вероятность коллизии по дню рождения — около 3e-8) может добавить постороннюю запись, но не потеряет нужную; в бенчмарках это выявляют проверки равенства числа результатов.
Механика
В загруженном режиме секция df==1 не находится в памяти: поиск выполняется через pread по файлу. После Save() индекс переводится на файловое хранение: резидентные массивы хешей (около 13 МБ на сегмент) очищаются, остаются только path + offset + count. Только что запечатанный индекс занимает в кэше исполнителя те же несколько мегабайт, что и загруженный.
Бинарный поиск из lookupUnique в файловом режиме выглядит так:
h := tokenHash(token)
var buf [8]byte
readU64 := func(off int64) (uint64, bool) {
if _, err := file.ReadAt(buf[:], off); err != nil {
return 0, false // ошибка блока — значение, а не SIGBUS
}
return binary.BigEndian.Uint64(buf[:]), true
}
lo, hi := 0, n
for lo < hi {
mid := (lo + hi) / 2
v, ok := readU64(uniqOff + int64(mid)*8)
if !ok { return nil } // fail-stop в миниатюре
if v < h { lo = mid + 1 } else { hi = mid }
}Всё дело в строке if !ok. С mmap здесь было бы data[mid*8] и не было бы проверки !ok; единственный способ отказа — завершение процесса. pread превращает «процесс погиб» в «функция вернула ошибку».