
Если вы когда-либо строили высоконагруженные системы поиска, то знаете, что в какой-то момент узким местом становится не код, а сама архитектура. Поиск доступных отелей — как раз тот случай: миллиарды «ночей», десятки тысяч 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 ночей в секунду меняется в календаре.
Ситуация до переписывания на Go
В какой-то момент мы пришли к стабильному росту трафика: если раньше было 170 поисков в секунду, то за пару месяцев эта цифра удвоилась. При этом технически старый поиск работал неэффективно: задержка даже в спокойное время составляла 10 секунд, иногда доходила до 60. Применялась классическая связка Python, Postgres и Redis.

Было несколько веб-интерфейсов на Python, но основным узким местом стал Postgres из-за сложных SQL-запросов. Изначально использовалась одна реплика, со временем их количество выросло до 10. Проблема заключалась в самом SQL-запросе — он был тяжёлым, содержал 12 джойнов (ниже приведена лишь его малая часть) и работал медленно.
SQL-запрос
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 будет работать эффективно, если для него создан индекс, тюплы в индексе упорядочены и располагаются локально на диске в одной странице, то есть всё компактно.

Но на практике, из-за постоянных обновлений и перезаписей, данные постепенно размазываются по диску. Тогда сфетчить условные пять ночей займёт больше времени. На первый взгляд это несущественно, но в масштабе системы становится заметной проблемой.
SQL: отсутствие императивности и вопрос производительности
Как разработчики, мы хотим писать код более императивно — то есть проверять какое-то условие. Если оно не удовлетворяется, мы сразу дропаем 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) } } } }
Сначала мы переводим даты поиска в офсеты, после чего запускаем цикл, который продолжается до тех пор, пока поиск не выполнится в «чистом» состоянии. Внутри цикла первым делом проверяем контекст — из-за синхронизации могут возникать операции ввода-вывода, поэтому даём системе возможность корректно обработать тайм-ауты и отмену запроса. Если всё в порядке, запускаем сам поиск.
Минимизация SQL-запросов
Если бы 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
Для сериализации мы используем 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.
