Пишем свою in-memory базу на Go, ускоряем поиск отелей в десятки раз
- пятница, 19 декабря 2025 г. в 00:00:07

Если вы когда-либо строили высоконагруженные системы поиска, то знаете, что в какой-то момент узким местом становится не код, а сама архитектура. Поиск доступных отелей — как раз тот случай: миллиарды «ночей», десятки тысяч RPS, постоянные обновления календарей, строгая консистентность и высокая цена любой ошибки. Старый стек на Python + Postgres + Redis долго тянул, но однажды стал «тормозить» настолько, что оптимизировать дальше было невозможно — SQL-запросы разрастались, реплики множились, latency прыгала до 60 секунд, а кэширование превращалось в источник инцидентов.
Так мы пришли к идее построить собственную in-memory базу данных на Go — заточенную под наш домен. Быструю, безопасную и синхронизированную с Postgres.
Под катом — история того, как мы её спроектировали, какие архитектурные решения приняли, как победили холодный старт, справились с миллиардами значений. И почему в итоге смогли полностью отказаться от кэша доступности, переведя поиск в real‑time.
Привет, Хабр! Я — Иван Коломбет, работаю в Островке уже больше 11 лет, в разработке — суммарно 17. Писал на разных языках программирования (Delphi, C++, PHP, Java, Python, Go). В компании много времени потратил на оптимизацию разных компонентов, а сегодня расскажу, как мы улучшили один из основных сервисов — поиск доступности отелей.

Начну с базы: что вообще представляет собой поисковый запрос?
{
"arrive_at": "2025-04-12",
"depart_at": "2025-04-15",
"guest_groups": [
{
"adults": 2,
"children": [8, 10]
}
],
"payment_model": "postpay",
"hotel_ids": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
}Здесь есть дата заезда-выезда, информация о гостях (группа, в которой двое взрослых и двое детей в возрасте восьми и десяти лет), опциональный фильтр (пользователь уточнил, что хочет оплатить услуги на стойке — postpay) и массив id отелей, по которым мы ведём поиск.
Теперь посмотрим на ответ — урезанную версию; на самом деле полей намного больше:
{
"rates": [
{
"hotel_id": 1,
"room_name": "Standard room",
"postpay": true,
"price_per_day": [1000, 1000, 1000],
"currency": "RUB",
"meal_plan_included": true,
"meal_plan_type": "SCANDINAVIAN_BREAKFAST",
"id": "1:2:3:4"
// и ещё ~50 полей
}
]
}Ответ содержит название номера, информацию об оплате (сколько, в какой валюте, когда платить), сведения о завтраке (включён ли он в стоимость, какой именно завтрак), а также id. Всё это представляет собой сущность — rate или предложение, доступное для бронирования.
Рассмотрим, как на основе этой информации собираются rates. В отеле «Пример» у нас есть стандартный номер с тарифом «С завтраком», тариф «Раннее бронирование» и «Невозвратный тариф» для улучшенного номера.
Отель «Пример»:
Стандартный номер
Тариф «С завтраком»
Тариф «Раннее бронирование»
Улучшенный номер
Тариф «Невозвратный»
То есть у нас есть сущность «отель», к которой привязаны номера, а к номерам — тарифы.
Всего в нашей системе:
130 тыс. отелей
700 тыс. категорий номеров
2,5 млн тарифов
Также есть инструмент — календарь цен и доступности, с которым работают отельеры. По каждой дате они могут предоставить информацию о свободных номерах в разных категориях:

В календаре:
на уровне номеров — 500 млн ночей
на уровне тарифов — 1 млрд ночей
на уровне цен — 2 млрд ночей
Суммарный объём сырых данных (без учёта индексов) — 400 ГБ. Обновления происходят интенсивно: примерно 1000 ночей в секунду меняется в календаре.
В какой-то момент мы пришли к стабильному росту трафика: если раньше было 170 поисков в секунду, то за пару месяцев эта цифра удвоилась. При этом технически старый поиск работал неэффективно: задержка даже в спокойное время составляла 10 секунд, иногда доходила до 60. Применялась классическая связка Python, Postgres и Redis.

Было несколько веб-интерфейсов на Python, но основным узким местом стал Postgres из-за сложных SQL-запросов. Изначально использовалась одна реплика, со временем их количество выросло до 10. Проблема заключалась в самом SQL-запросе — он был тяжёлым, содержал 12 джойнов (ниже приведена лишь его малая часть) и работал медленно.
LEFT JOIN hotels_allotment pa ON (
pa.hotel_id = a.hotel_id
AND pa.parent_id = rp.parent_id
AND pa.occupancy_id = a.occupancy_id
)
INNER JOIN hotels_roomallotmentplan AS rcap ON (
rcap.room_category_id = a.room_category_id
AND rcap.plan_date BETWEEN %(plan_date_start)s::date AND %(plan_date_end)s::date
AND rcap.flexible_count > 0 -- hint to match search_rcap_idx_v2 index
)
LEFT JOIN hotels_rateallotmentplan AS rpap ON (
rpap.rate_plan_id = a.rate_plan_id
AND rpap.room_category_id = rcap.room_category_id
AND rpap.plan_date = rcap.plan_date
AND rpap.plan_date >= '2023-04-04'::date -- hint to match search_rpap_idx index
AND rpap.advance IS NOT NULL
AND rpap.last_minute IS NOT NULL
AND rpap.min_stay_arrival IS NOT NULL
AND rpap.max_stay_arrival IS NOT NULL
AND rpap.min_stay_through IS NOT NULL
AND rpap.max_stay_through IS NOT NULL
AND rpap.disable_flexible
OR rpap.closed_on_arrival
OR rpap.closed_on_departure
)
LEFT JOIN hotels_occupancyallotmentplan AS oap ON (
oap.allotment_id = a.id
AND oap.plan_date = rcap.plan_date
AND oap.plan_date >= '2023-04-04'::date -- hint to match search_oap_idx_v2 index
AND oap.bar_price > 0
)
LEFT JOIN hotels_occupancyallotmentplan AS poap ON (
poap.allotment_id = pa.id
AND poap.plan_date = rcap.plan_date
AND poap.plan_date >= '2023-04-04'::date -- hint to match search_oap_idx_v2 index
AND poap.bar_price > 0
)Мы постоянно пытались его оптимизировать: строили и пересоздавали индексы, чтобы сбрасывать bloat, и так далее. Однако из-за постоянных апдейтов механизм MVCC в Postgres приводил к накоплению bloat в таблицах и индексах, что ухудшало производительность.
Приходилось искать баланс: как разработчики, мы предпочитаем писать бизнес-логику на Python или Go, а не на SQL. Это означает, что чаще мы загружаем данные из базы и обрабатываем их в приложении. Однако для лучшей производительности желательно фильтровать данные сразу на уровне SQL, что требует переносить часть логики в базу — а этого нам делать не хочется.
Представим, что в одной из таблиц календаря хранятся счётчики доступности номеров на каждую дату.

Есть компонент поиска, задача которого — определить, доступен ли номер для выбранных пользователем дат. Это сводится к простому SELECT-запросу.
SELECT flexible_count FROM av_table
WHERE room_category_id = 1
AND plan_date >= '2024-11-18'
AND plan_date <= '2024-11-22';SELECT будет работать эффективно, если для него создан индекс, тюплы в индексе упорядочены и располагаются локально на диске в одной странице, то есть всё компактно.

Но на практике, из-за постоянных обновлений и перезаписей, данные постепенно размазываются по диску. Тогда сфетчить условные пять ночей займёт больше времени. На первый взгляд это несущественно, но в масштабе системы становится заметной проблемой.
Как разработчики, мы хотим писать код более императивно — то есть проверять какое-то условие. Если оно не удовлетворяется, мы сразу дропаем rate, тем самым экономим ресурсы, можем что-то затрекать, вывести метрики и т. д.
if !condition {
dropRate(reason)
continue
}Однако SQL не даёт гибкости для подобных оптимизаций. Планировщик запросов сам определяет порядок выполнения JOIN'ов и условий WHERE, и напрямую на это повлиять сложно.
Мы достигли максимальной производительности на SQL с 10 репликами — 700 поисков в секунду. Конечно, это не весь RPS Островка: на входе было около 10 тысяч запросов в секунду, но перед нами стоял кэш, который снимал основную нагрузку. Тем не менее, даже при относительно небольшом RPS у нас оставалась посредственная задержка: мы смогли довести её до 2 секунд (99%), что всё равно далеко от идеала. Дополнительно возникали проблемы со спайками трафика, например, во время маркетинговых акций: если резко возрастала нагрузка, система её не выдерживала — не было запаса по масштабированию.
По сути, мы упёрлись в предел масштабирования через реплики: поддерживать 10 экземпляров Postgres уже сложно и дорого.
Приведу пример: кэш показывает, что номер доступен для бронирования, а на самом деле в базе данных он уже продан. В такой ситуации, если клиент попытается его забронировать, скорее всего, гость не сможет заселиться, и Островку придётся компенсировать расходы или искать альтернативный вариант размещения.
Возможна и обратная ситуация: в кэше номер отмечен как проданный, а фактически он доступен. В этом случае мы не показываем его в поиске и теряем потенциальные продажи.
С учётом описанных проблем мы сформулировали требования к новому поисковому сервису:
поддержка десятков тысяч RPS при latency < 100 мс;
отсутствие проблем, связанных с TTL кэша;
лёгкая масштабируемость;
надёжное хранение данных.
Как достичь этих 10 тыс. RPS — ускорить поиск по календарю.
При этом у поиска есть свои особенности:
не участвуют данные за прошедшие даты;
не участвуют данные из далёкого будущего.
Хотя календарь и большой, основная его часть — исторические данные, которые уже нельзя забронировать. Кроме того, есть верхний лимит, например, на сайте нельзя бронировать более чем на два года вперёд. Таким образом, остаётся рабочий диапазон примерно в два года — без пропусков и с упорядоченными данными.
Логика поиска следующая. Когда мы проверяем диапазон (например, пользователь хочет заехать с 1 по 5 число), в календаре должны быть данные по всем этим датам — без пропусков. Если хотя бы по одной дате информации нет, предложение полностью исключается из выдачи.
Всё это было бы неплохо сложить в массивы в памяти — они как раз локальные, компактные, упорядоченные и дают все необходимые свойства.
Возможный подход к хранению календаря — использование массивов в памяти. В этом случае логика поиска упрощается: поиск — это просто получение слайса массива.

Например, индекс 0 — сегодняшний день, индекс n — n-ная ночь. Для корректной работы нужно предусмотреть запас по времени (например, 734 ночи — чуть больше двух лет), чтобы учесть таймзоны и високосные года.
Поиск — это превращение даты в индексы и взятие слайса. Нужно превратить даты в индексы и взять слайс.
Но поскольку массив лежит в памяти, необходимо синхронизироваться с Postgres. Кроме того, мы хотим хранить данные надёжно — значит, в Postgres, поскольку память — это ненадёжное хранилище.
С развитием этой идеи появляется такой нюанс как ежесуточный сдвиг. Мы засинкали данные в память, но прошли сутки — теперь под индексом 0 уже то, что забронировать нельзя. С другой стороны, то, что вчера было недоступно, сегодня вышло в поисковый диапазон и это можно забронировать. Итог — надо сдвинуть все массивы.
Чтобы действовать по этой схеме, нужно решить ряд проблем.
Холодный старт
Предположим, мы задеплоились: память пустая, все данные лежат в базе. Нужно заполнить память.
Целостность/корректность синхронизации
Есть два варианта: синхронизация через потоковую репликацию Postgres и самописный cache write-through. Синхронизация между базой и памятью должна быть корректной: ошибка чревата закорапченным отравленным кэшем и, как следствие, большими инцидентами.
Рассмотрим, как выглядит архитектура такого сервера:

Есть четыре простых слоя:
- Memory — кэш, который используется только для поиска.
- Postgres — персистентное хранилище.
- Engine — слой бизнес-логики, который синхронизирует кэш с базой данных.
- Сервер, который реализует некое api.proto. Под капотом он вызывает Engine — то есть реализует API поиска и API календаря.
Теперь — о том, как выглядит запись данных в нашем сервисе.
Чтобы память и база данных оставались консистентными, мы придерживаемся чёткого алгоритма. Поскольку любая операция с БД потенциально может завершиться ошибкой, порядок действий такой:
Сначала лочим нужные объекты в памяти на запись.
Пробуем применить изменения в БД.
Если на этом этапе происходит ошибка, мы просто снимаем все локи и выходим — в базе транзакция откатится атомарно, а в память мы ничего не успели записать. Таким образом данные остаются корректными.
Если же база успешно закоммитила изменения, значит, основная часть операции уже прошла. Теперь остаётся обновить кэш. Память, в отличие от БД, не делает I/O, поэтому ошибок здесь мы не ожидаем. На этом этапе:
Лочим соответствующие объекты в памяти, но уже на чтение.
Применяем изменения в кэшевые структуры.
Снимаем локи и завершаем операцию.
Такой подход называется cache write-through: данные синхронно записываются и в долговременное хранилище (Postgres), и в кэш, который на нём основан.
В коде это выглядит так:
// Write path: Memory
func (m *Memory) UpdateRNA(changes []rna.Change, onLock OnLockFunc) error {
updateCtx := updateContext{}
defer updateCtx.UnlockAll()
for i := 0; i < len(changes); i++ {
lockWrite(&updateCtx, &changes[i])
}
if onLock != nil {
if err := onLock(); err != nil {
return fmt.Errorf("onLock: %w", err)
}
}
for i := 0; i < len(changes); i++ {
lockRead(&updateCtx, &changes[i])
}
executePendingUpdates(&updateCtx)
return nil
}Предположим, есть функция апдейта календаря. Она получает контекст, в котором мы фиксируем, какие локи уже удерживаются. Далее мы перебираем все изменения и для каждого элемента заранее лочим соответствующую ячейку календаря в памяти.
После этого вызываем callback commit, который применяет обновление в базе. Если функция возвращает ошибку — чаще всего это ошибка записи в Postgres — мы немедленно выходим: срабатывает defer, который освобождает все локи. Если всё успешно, переходим к следующему шагу: лочим объекты на запись, обновляем данные в памяти и завершаем выполнение.
Первое, что делает поиск, — конвертирует запрошенные даты в индексы массивов (offset’ы). Затем мы начинаем обход календаря. На этом этапе важно учитывать, что в памяти может не хватать части данных. Например, пользователь впервые запрашивает отель, сведения о котором ещё не прогружены в кэш. В такой ситуации мы подгружаем недостающую информацию из базы через механизм синхронизации и запускаем поиск повторно.
Ответ возвращается только тогда, когда поиск достигает «чистого» состояния — то есть когда все отели и нужные даты полностью присутствуют в памяти, и алгоритм больше не вынужден обращаться к базе.
Пример кода:
// Search path: Engine
func (e *Engine) Search(ctx context.Context, sp *rna.SearchParams) (SearchResult, error) {
low, high := e.calculateOffsets(sp.ArriveAt(), sp.DepartAt())
for {
select {
case <-ctx.Done():
return SearchResult{}, ctx.Err()
default:
res := e.memory.Search(ctx, sp, low, high)
if res.Clean() {
return SearchResult{Rates: res}, nil
}
err := e.sync(ctx, res.MissingData)
if err != nil {
return SearchResult{}, fmt.Errorf("e.sync: %w", err)
}
}
}
}Сначала мы переводим даты поиска в офсеты, после чего запускаем цикл, который продолжается до тех пор, пока поиск не выполнится в «чистом» состоянии. Внутри цикла первым делом проверяем контекст — из-за синхронизации могут возникать операции ввода-вывода, поэтому даём системе возможность корректно обработать тайм-ауты и отмену запроса. Если всё в порядке, запускаем сам поиск.
Если бы Postgres без проблем выдерживал необходимые нам нагрузки, никакой in-memory движок мы бы и не писали. Но база — самый дорогой и медленный компонент системы, поэтому количество обращений к ней нужно минимизировать.
Для этого мы используем три механизма наполнения кэша.
При запуске сервиса заранее подгружаем данные для top-N отелей на ближайшие 180 дней.
Почему 180? По статистике, 95% всех поисков укладываются в этот диапазон.
Календарь, который хранится в памяти, мы логически разбили на блоки по 16 ночей.
Когда приходит первый же запрос, который затрагивает блок, мы подгружаем весь блок целиком, а не только нужный диапазон.
Например: пользователь ищет даты 5–7, но мы грузим блок 1–16.
Это снижает нагрузку на Postgres. Скорее всего, следующий запрос попадёт в соседние даты (например, 8–9), и вместо двух SQL-запросов мы используем один — чуть «шире», но намного дешевле.
On-demand. Этот механизм включается в последнюю очередь — когда данных нет ни в Prefetch, ни в Block.
Мы подгружаем конкретный rate и только те даты, которых не хватает. Это самая медленная стратегия, и мы стремимся использовать её как можно реже — по идее, абсолютное большинство запросов должно покрываться первыми двумя уровнями.
Каждые сутки календарь «стареет» — под индексом 0 оказывается дата, которую уже нельзя забронировать. Поэтому нужно сдвигать все массивы.
Для этого мы берём эксклюзивный лок: в этот момент поиск и запись ставятся на паузу, а сами массивы сдвигаются через copy.
Операция занимает около 5 секунд, что ощутимо, и мы планируем заменить этот механизм на ring buffer, чтобы двигать не сами данные, а лишь указатель.
Пример кода:
func shiftRoomRow(row *rnaRoomRow) {
copy(row.cells[:], row.cells[1:])
row.cells[len(row.cells)-1] = rna.RoomCell{}
}При сдвиге последний элемент массива сбрасывается в нулевое состояние. Если затем придёт поиск на дальние даты (например, почти через два года), он увидит «дырку» и подгрузит недостающее из базы через on-demand.
Перенос календаря в память и раздача данных напрямую из RAM дают огромный прирост. Но мы пошли ещё дальше и использовали дополнительные техники оптимизации:
Arenas (экспериментальная фича Go для снижения нагрузки на GC);
Flatbuffers — для сверхбыстрой сериализации
Немного unsafe там, где это оправдано
Высокопроизводительная decimal-библиотека fixed вместо популярной, но тяжёлой shopspring/decimal
Широко используемая в Go shopspring/decimal генерирует много аллокаций и плохо подходит под высоконагруженные вычисления. fixed оказалась куда быстрее.
Без SQL мы можем писать бизнес-логику максимально императивно и эффективно.
func searchRoomRow(
sctx *searchContext,
row *rnaRoomRow,
) {
ok, oc := matchRoomLevel(sctx, row)
if !ok {
return
}
row.rateMap.Range(func(_ rna.RatePlanID, value *rnaRateRow) bool {
searchRatePlanRow(sctx, row, value, oc)
return true
})
}Если условие не выполняется — мы просто выходим из обработки rate и не тратим ресурсы на дальнейшие проверки. Это даёт ощутимую экономию и упрощает трассировку.
Арены — это механизм, который позволяет размещать множество объектов в одном большом непрерывном регионе памяти. Вместо того чтобы делать миллионы отдельных аллокаций, мы выполняем одну — крупную — и размещаем всё внутри неё.
Такой подход снижает нагрузку на Garbage Collector: он знает, что объекты, размещённые в арене, ему «не принадлежат», и не обходит их при работе. Это сильно ускоряет код, особенно в горячих участках.
Но есть нюанс: будущее арен в Go туманно — их могут в какой-то момент удалить или изменить API. Поэтому мы используем их аккуратно: прячем реализацию за интерфейсом аллокатора. Если арены исчезнут, мы сможем переключиться на стандартный аллокатор без переписывания бизнес-логики.
Для примера — у нас есть простой интерфейс аллокатора, который нужен в поиске.
type Allocator interface {
Free()
MakeSearchParams() *rna.SearchParams
MakeRateCandidates(l, c int) []RateCandidate
MakeRates(l, c int) []Rate
MakeBedAllocationsList(l, c int) [][]rna.BedAllocation
MakeBedAllocations(l int) []rna.BedAllocation
MakePrices(l, c int) []price.Price
MakeCancellationPenalties(l, c int) []CancellationPenalty
MakeECLC(l, c int) []ECLCPoilcy
MakeDropReasonStat() DropReasonStat
MakeFlatbuffersOffsets(l, c int) []flatbuffers.UOffsetT
}Допустим, нам требуется выделить массив цен.
У нас есть аллокатор арены, который размещает данные внутри арены. И есть стандартный аллокатор, который просто делает make.
type ArenaAllocator struct {
a *arena.Arena
}
func (a *ArenaAllocator) MakePrices(l, c int) []price.Price {
return arena.MakeSlice[price.Price](a.a, l, c)
}
type StdAllocator struct{}
func (a *StdAllocator) MakePrices(l, c int) []price.Price {
return make([]price.Price, l, c)
}Оба реализуют один и тот же интерфейс, поэтому их можно использовать взаимозаменяемо.
func (s *Server) Search(
ctx context.Context,
request *fb.SearchRequest,
) (*flatbuffers.Builder, error) {
var alloc search.Allocator
if a := s.engine.TryAcquireArenaAllocator(); a != nil {
defer s.engine.FreeArenaAllocator(a)
alloc = a
} else {
alloc = &search.StdAllocator{}
}
// ... use alloc
prices := alloc.MakePrices(0, 12)Как это выглядит на практике: внутри ручки поиска мы пытаемся получить арену. Это может получиться, а может и нет — всё зависит от лимитов и текущей загрузки. Если арена доступна — работаем с ней и обязательно освобождаем в defer. Если нет — используем стандартный аллокатор.
В итоге весь код поиска работает с абстракцией аллокатора, а конкретный механизм выделения памяти может меняться под капотом без изменения логики.
Каждый поисковый запрос по возможности пытается получить арену. Если арена доступна, все временные объекты (кроме map) аллоцируются внутри неё. Но арену мы даём не всегда:
Глобальный лимит арен — 10 000
Арена весит довольно много, поэтому мы установили глобальный лимит. Это защитный механизм: если внезапно прилетит всплеск нагрузки (например, до миллионов RPS), сервис не съест всю память системой арен.
Недоступны во время сдвига
Во время ежедневного сдвига календаря (операция copy, ~5 секунд) арены мы отключаем.
Причина проста: при 20k RPS за 5 секунд набегает ~100k запросов, и каждый из них попытался бы взять арену и занять память, которая в момент сдвига особенно чувствительна.
По завершении поиска арена освобождается — всё работает через интерфейс аллокатора, поэтому логика остаётся чистой.
В итоге использование арен дало нам примерно +20% RPS, то есть заметный прирост пропускной способности.
Для сериализации мы используем Flatbuffers — протокол, похожий на Protobuf, но заточенный под максимальную производительность и минимальное количество аллокаций.
Среди плюсов Flatbuffers:
Чёткая схема и кодогенерация (как в Protobuf).
Обратная и прямая совместимость.
Поддержка Google и интеграция с gRPC.
Полный контроль над процессом (де)маршализации.
Дедупликация данных и минимум копирований.
Есть и минусы:
Код низкоуровневый и довольно «шумный».
Ошибки при сборке структуры могут приводить к panic.
Глубокие вложенности описывать неудобно.
Как работает Flatbuffers:
Flatbuffers собирает объект в один общий байтовый буфер. Всё пишется последовательно, и каждый элемент возвращает свой Offset — позицию в этом буфере.
Из-за этого упаковка идёт «снизу вверх»:
сначала создаются вложенные объекты,
массивы пишутся в обратном порядке,
затем собирается конечная структура.
Вот небольшой пример работы с Flatbuffers. Видно, насколько код вербозен:
fb.RateStart(b)
fb.RateAddTotalPrice(b, packPrice(b, totalPrice))
fb.RateAddNoShowRate(b, packPrice(b, rp.NoShowRate))
fb.RateAddDiscount(b, packPrice(b, rp.Discount))
fb.RateAddRates(b, offsetRates)
fb.RateAddEarlyCheckin(b, offsetEC)
fb.RateAddLateCheckout(b, offsetLC)
fb.RateAddCancellationPenalties(b, offsetCP)
fb.RateAddCurrency(b, offsetCurrency)
fb.RateAddRoomName(b, offsetRoomName)
fb.RateAddAcquisitionType(b, adaptAcquisitionType(rp.AcquisitionType))
fb.RateAddBathroomType(b, adaptBathroomType(rp.BathroomType))
fb.RateAddBalconyType(b, adaptBalconyType(rp.BalconyType))
fb.RateAddLegalEntityType(b, adaptLegalEntityType(mo.LegalEntityType))В этом фрагменте приведены постоянные Offsets. Например, здесь мы говорим, что у rate есть валюта. Это строка, но мы положили её раньше и запомнили её Offset.
Всё это того стоило! Вот пример бенчмарка:

Мы начинали с Protobuf, но столкнулись с тем, что он генерирует слишком много аллокаций, и это стало узким местом. Был промежуточный «костыльный» вариант: Protobuf с одним бинарным полем, внутри которого лежал Msgpack. Это давало прирост производительности, но выглядело неаккуратно.
Flatbuffers же дал ощутимый буст без этих компромиссов.
Вывод простой: если для ручки критична производительность — используйте Flatbuffers, но только там, где оправдана цена сложности.
У нас Flatbuffers применяются только в Search API, а остальные сервисы продолжают жить на стандартном Protobuf — он проще и безопаснее.
Всё, что описано выше, — это логика одного шарда. На практике мы используем четыре независимых шарда, каждый со своей собственной базой данных.
Перед ними стоит лёгкий сервис-роутер: он реализует те же Flatbuffers/Protobuf API и маршрутизирует запрос в нужный шард. Поиск распараллеливается сразу на все шарды, а proxy затем собирает ответы в единый результат.

Это позволяет масштабироваться горизонтально и повысить отказоустойчивость системы.
Чтобы быть уверенными, что новый движок работает корректно, мы провели довольно серьёзный комплекс тестов.
1. Юнит-тесты
Около 1900 тестов и 80% покрытия.
2. Тестирование целостности
Мы должны были убедиться, что синхронизация между Postgres и памятью корректна. Для этого сделали специальный CI-тест, который:
поднимает сервис,
генерирует поток read/write-операций и апдейтов календаря в течение 30 секунд,
затем снимает снимки состояния памяти и таблиц в базе,
сравнивает, что они идентичны.
запускается в каждом пайплайне.

Этот тест запускается в каждом Merge Request и удерживает нас от регрессий в консистентности.
3. Регресс-тестирование (BDD)
Поскольку мы переписывали бизнес-логику поиска с Python на Go, важно было убедиться, что «поведение» осталось корректным для бизнеса.
QA заранее подготовили много BDD-сценариев на Cucumber — человекочитаемых спецификаций. Мы перенесли их, написали небольшой glue-код на Go и использовали библиотеку godog.
Около 500 зелёных BDD-тестов дали хороший confidence, что новый движок повторяет ожидаемое поведение.
Пример теста:

4. Сравнение старого и нового поисков
Мы сделали инструмент на Go для реплея поисковых логов:
брали search_replay.log — он содержал ~20% запросов с продакшена;
для каждого запроса фиксировались параметры и request_timestamp, чтобы воспроизведение было детерминированным;
старый поиск выдавал JSON, новый — Flatbuffers;
оба результата приводились к общему виду и сравнивались.
На 100 000 запросов мы получили расхождение лишь в 15 результатах — и почти все они оказались багами старого движка.
На этом этапе мы достигли точности 99,985% и посчитали задачу закрытой.
Переезд был постепенным:
Старый поиск под капотом делал HTTP-запросы в новый, но только для части отелей.
Результаты склеивались: часть отелей приходила из старого поиска, часть — из нового.
Мы постепенно увеличивали долю отелей, обслуживаемых новым движком.
Когда достигли 100%, старый поиск выключили.
Когда мы постепенно увеличивали долю отелей, обслуживаемых новым поиском, нам нужно было внимательно следить за тем, как это влияет на реальные бронирования и технические метрики. Для этого мы использовали систему фича-флагов, которые позволяли гибко и безопасно управлять rollout’ом.
Каждое бронирование, которое проходило через новый движок, помечалось специальным флагом is_new_engine. Это давало нам возможность:
отслеживать, как новый поиск влияет на конверсию и качество результатов;
быстро находить и разбирать подозрительные кейсы;
сравнивать показатели старого и нового поведения в реальном трафике.
Если что-то шло не так — например, в логах появлялись аномалии или начинала падать конверсия — мы могли моментально откатиться, просто переключив фича-флаг в livesetting.
Благодаря длительной подготовке и объёмным тестам мы смогли перейти безболезненно.
Память + оптимизация бизнес-логики дали настолько высокий per-request performance, что мы полностью отказались от кеширования доступности — поиск стал реал-таймовым.
Результат:
меньше инцидентов у пользователей,
меньше компенсаций отеля и потерянных броней,
больше реальных продаж,
и намного более предсказуемая работа системы.
Субъективно — поддерживать сервис на Go оказалось куда приятнее, чем пытаться дорабатывать сложный SQL.
Серверные затраты остались примерно такими же: раньше у нас было 10 реплик Postgres, теперь — 4 шарда (в первую очередь ради отказоустойчивости).
Но метрики — другой уровень:
30 000 RPS (было 700)
60 ms 99% latency (было 2000 ms)
5 ms mean latency (было 150 ms)
99,99 availability (было 99,8)
200 000 rate/сек — отдаём
4 000 000 rate/сек — отсекаем по условиям поиска
Запас по производительности: ещё около 80% при текущих 4 шардах — можно добавлять 5-й и масштабироваться дальше
Как это выглядит на графиках:
Трафик — 30K req/s:

Тайминги — 50 ms:

Cache-hit — 99,992%:
Процент «чистых» запросов, про которые говорилось ранее. Эти запросы полностью попали в кэш и им не потребовалась информация из БД.

Крайне мало запросов доходят до Postgres — а значит, идея in-memory движка работает.
Drop-rate — 4 млн rate/s:

Без них, конечно, не обошлось.
1. Фактически сервис стал базой данных
Память и Postgres должны быть строго синхронизированы. Это накладывает два ограничения:
в пределах одного шарда должен работать только один инстанс сервиса,
деплой приходится делать аккуратно — классические стратегии Kubernetes вроде rolling update здесь неприменимы.
Иначе возможны расхождения между кэшем и базой.
2. Холодный старт
Самое слабое место. После рестарта память пуста, и наполнение её данными может ударить по Postgres. Мы продолжаем улучшать Prefetch и блоковую инициализацию, но всё ещё есть куда расти.
3. Масштабирование и отказоустойчивость слабее, чем у специализированных хранилищ
Решения вроде Aerospike или Elasticsearch заточены под горизонтальное масштабирование.
Наш подход — кастомный, нишевый, и в первую очередь делает ставку именно на максимальный перформанс, это осознанный трейд-офф.
Проект был полностью инженерной инициативой — без запроса от бизнеса.
От первого коммита до полного переключения прошло три года.
В современном мире быстрых MVP это, скорее, исключение, но в нашем случае ставка на качество и тщательную проверку себя оправдала.
К каким выводам мы пришли? Во-первых, производительность — это важно. Во-вторых, не надо бояться писать кастомные решения, если понимаете, что делаете и полностью отдаёте себе отчёт о плюсах и минусах. Ну и в третьих, не всегда нужен MVP-подход и постоянные итерационные улучшения. Иногда лучше отполировать как следует и выкатить хорошо.
Материал «Пишем свою in-memory базу на Go» доступен также в видео-формате (доклад).
Если хотите больше контента о разработке и программировании, следите за обновлениями в нашем аккаунте на Хабре и на сайте конференции GolangConf — ближайшая состоится уже в апреле!
Узнавать о новых ИТ-материалах и событиях Островка можно в тг-канале Ostrovok Tech.