
Если вы когда-либо строили высоконагруженные системы поиска, то знаете, что в какой-то момент узким местом становится не код, а сама архитектура. Поиск доступных отелей — как раз тот случай: миллиарды «ночей», десятки тысяч 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.
Комментарии (36)

Kahelman
18.12.2025 09:45Приходилось искать баланс: как разработчики, мы предпочитаем писать бизнес-логику на Python или Go, а не на SQL. Это означает, что чаще мы загружаем данные из базы и обрабатываем их в приложении. Однако для лучшей производительности желательно фильтровать данные сразу на уровне SQL, что требует переносить часть логики в базу — а этого нам делать не хочется.
ССЗБ?
Взяли нормальную базу -PostgreSql ,
Загнали туда кучу данных, а потом стали таскать все на клиента для расчетов и удивляемся что производительность упала?
У нас тут 3 камеры делают фото с частотой 30 KHz и льют в базу данных постргреса. И это практически в режиме 24х7. Правда запросы сложные писать не приходится так как там только числа в итоге.

Kahelman
18.12.2025 09:45Что то сомнения меня берут:
Вынуждены кэшировать доступность: почему это плохо
Приведу пример: кэш показывает, что номер доступен для бронирования, а на самом деле в базе данных он уже продан. В такой ситуации, если клиент попытается его забронировать, скорее всего, гость не сможет заселиться, и Островку придётся компенсировать расходы или искать альтернативный вариант размещения
Заходим на ваш сайт и ищем одеть где -нибудь на Канарах.
Нашли номер.
Звоним в отель, бронируем номер по телефону.
Жмем кнопку забронировать на вашем сайте - забронировали номер которого уже нет.
Я не думаю что товарищи с Канар вам сразу в систему введут что номер забронирован и отправят всем агрегаторам данных…,
Шах и мат :)

Masnin
18.12.2025 09:45Работаю в той же сфере, что и автор, только компания другая)
Так вот, товарищи с Канар очень быстро увидят такую ситуацию в своей Property Management System (PMS), быстренько свяжутся с вами и попросят отменить бронирование в островке(комиссия же)
Это я к тому, что отели тоже все понимают и овербукинг по такого рода ситуациям не всегда решается в минус для поставщика услуги онлайн бронирования.
А вот когда ситуация становится стабильно неприятной (много оверов в период ажиотажа, например, черные пятницы) - вот тогда у отелей возникает вопросик уже, в том числе и финансовый.
Ну и последний момент, есть такая штука у отелей как "In-house квота" - это определенное количество номеров, которые не продается онлайн, а придерживается как раз под описанные вами ситуации (таким обычно довольно зрелые отели занимаются, где развито управление доходами через оптимизацию продаж, оно же Revenue)

funca
18.12.2025 09:45Как так получается, что искать отели удобнее через агрегаторов, а бронировать лучше напрямую? - в этом случае отели дают хорошие скидки. При этом свободных номеров там практически нет.

Masnin
18.12.2025 09:45Почему искать отели удобнее через агрегаторов - думаю ответ очевиден. А почему бронировать лучше напрямую - так просто потому, что отели агрегатору неплохо так комиссии отстегивают за каждое бронирование. И в некоторых случаях отелю выгодно сделать вам скидку по прямому бронированию вместо оплаты комиссии условному "booking.com"

Ostrovok Автор
18.12.2025 09:45Добрый день! Такой сценарий маловероятен. Если отель подтверждает доступность для OTA-канала (включая нас), он обязан отражать изменения в своей системе. Если бронь была продана в обход систем и возникнет конфликт, мы компенсируем клиенту бронь или предлагаем аналог, а дальше вопрос решается уже с отелем.
Поэтому подобная ситуация — скорее исключение, чем типичный кейс.

Dhwtj
18.12.2025 09:45Проект был полностью инженерной инициативой — без запроса от бизнеса
Редкий случай. Но, очевидно, вы продали его через бизнес метрики

Kahelman
18.12.2025 09:45Если на этом этапе происходит ошибка, мы просто снимаем все локи и выходим — в базе транзакция откатится атомарно, а в память мы ничего не успели записать. Таким образом данные остаются корректными.
In-memory database -SQLite?
Ещё есть Aerospike и иже с ними. …

Ostrovok Автор
18.12.2025 09:45Мы используем Aerospike в других задачах внутри Островка. Но под сценарий, описанный в статье, Aerospike оказался не слишком оптимальным по совокупности параметров. SQLite также рассматривали, но организация in-memory-хранилища была не самой сложной частью

Kahelman
18.12.2025 09:45Предположим, есть функция апдейта календаря. Она получает контекст, в котором мы фиксируем, какие локи уже удерживаются. Далее мы перебираем все изменения и для каждого элемента заранее лочим соответствующую ячейку календаря в памяти.
есть подозрения что вы пытались реализовать старый добрый блокировщик - как большинство ДБ работали в старые добрые времена, пока Oracle/ Interbase не завезли версионники. Теперь в 21 века народ опять блокировщики пишет на коленке…..
Кстати вопрос, вы в строгого duckdb смотрели? Нормальная аналичточеская БД для больших данных. Как раз может обрабатывать все ваши запросы. А резервацию отдать на откуп PostgreSQL. Кстати duckdb - in-process DB

funca
18.12.2025 09:45Кстати duckdb - in-process DB
DuckDB удобна на аналитических запросах, когда сами данные меняются редко. Как я понял из статьи, у них всего 130К отелей, но данные по бронированиям и доступным номерам разложены в общие таблицы. Поэтому, хоть по логике, ситуация для каждого из отелей в отдельности меняется не часто, суммарно в таблицы идёт большой постоянный поток изменений.

Kahelman
18.12.2025 09:45Так ВАК это не проблема. Меняются данные для одного отеля- нормальная задача решаемая на «калькуляторе»
Данные независимы. Единственное что надо быстро сделать это агрегировать их. Что аналитические БД эффективное могут делать.
Дальше работайте себе на здоровье.
Это не high frequency trading - номеров конечное количество и изменений мало.
Если номе разбронирован, то скорее всего в течение дня ситуация не поменяется.
У островка вроде 200 тыс «отелей» и 3 млн «размещений» в год. Т.е. можно сказать что 15 «номеров» на «отель»
15*365 =5.475 вариантов на год. (Занято/свободно) *200 тыс = ххх вариантов
Это модно упаковать в 130 МБ массив используя битовое представление: [день] [отель][номер]
130 мб это вообще не объем
Скорее всего можно ещё и кластеризовать на расположению: если вы ищите отель на Канарах, то вряд ли вас интересует отель в Тайланде.
Делим на 10 регионов:
Западная Европа, Африка, Ближний Восток, …
Грубо получаем 13 мб на регион.
Под каждый регион - по серверу. 10 серверов потянем.
допустим что у нас 1000 признаков классификации номеров/ сервисов на отель/номер.
Тогда нам надо 1000* 13 мб матриц= 13000 МБ= 1.3Гб. Все еще влезает в ОЗУ с запасом. Да и можем тупо с HHD читать все равно скорость выше чем открытие сетевого соединения. При нормальной скорости, можем передать на «клиента» за 1.7 минуты- сей час столько сайты грузятся :)
В итоге клиент может у себя в браузере запросы крутить сколько влезет, а нам только кидать данные на бронирование.
Индексы дней, строка отеля и комнаты. И список из 1000 атрибутов которые в 125 байт влезают. В общем 1 кБт данных нам выше крыши хватит чтобы за рост обработать.
В общем нет у них проблемы «больших» данных есть проблема с представлением данных. И понимания «бизнеса на земле »

funca
18.12.2025 09:45Мне тоже показалось, что задача решается кластеризацией. Интересно, почему они пошли другим путем (или я неправильно понял смысл решение, описанного в статье)?

CentariumV
18.12.2025 09:45БД вообще - то и сейчас блокировщики используют. Кто - то «почти» не использует - как PostgreSQL, а кто - то весьма активно - как SQL Server. А версионники и в 20 веке использовались, но не как механизм оптимизации, а для решения конкретных проблем конкурентного доступа

Kahelman
18.12.2025 09:45Высокопроизводительная decimal-библиотека fixed вместо популярной, но тяжёлой
shopspring/decimalпочему бы просто в integer не считать? У вас меньше 0.01 рубля все равно цены быть не может. Остальные цены либо в eur либо в usd. Поскольку вы в России, то скорее всего все придется в любом случае в рубли пересчитывать. Считаете к копейка/1000 и будет вам счастье

Ostrovok Автор
18.12.2025 09:45Во многих сценариях у нас применяются процентные скидки, комиссии, деление и другие вычисления. Поэтому нам важна точность до копеек — это уменьшает накопление погрешностей и снижает риск расхождений при финальном расчёте цены.

Kahelman
18.12.2025 09:45Считайте в копейка/1000 и будет вам счастье. Если не хватает: делите на 1 млн.

Kahelman
18.12.2025 09:45У вас на графиках идёт 30 тыс запроса в секунду на протяжении 4 часом.
Это 432 млн. Запросов за 4 часа.
Население Росси пусть будет. 200 млн.
У вас каждый житель +туристы сделали по два запроса в течение случайных 4-х часов?
Что-то не клеится….

Masnin
18.12.2025 09:45Я бы предположил, что каждый запрос - это запрос по одному отелю. Если поисковая выдача содержит 30+ отелей, например, на одну страницу выдачи, то это уже 30+ запросов в движок от одного пользователя, а потом пользователь листает до следующей страницы (подгружает данные) или меняет даты проживания...
Сюда же добавим всяких ботов-парсеров, внешних партнёров с кривой интеграцией которые на каждый чих шлют кучу запросов и картинка, в общем-то, может сложиться.
Однако, 30к RPS звучит очень солидно, конечно, с текущими вводными.

Ostrovok Автор
18.12.2025 09:45Если посмотреть на механику реальных поисков, нагрузка формируется иначе.
Например, условный «серп» по Москве может включать ~10 000 отелей. Одно открытие такой страницы генерирует около 100 поисковых запросов, сгруппированных батчами. Есть и точечные запросы по одному отелю — их много со стороны b2b-клиентов и партнёров. Это даёт тот общий объём.

Kahelman
18.12.2025 09:45Ниже написал. У вас нет столько бронирований чтобы генерить столько запросов. Если создали кривую архитектуру что каждая запись из таблицы отдельным http запросом дергается, то ССЗБ. Тут вам никакое решение не поможет, так как время http request/response будет больше чем выборка из кеша/базы.

undying
18.12.2025 09:45Думаю хорошая идея сделать цикл статей, как работает travel бизнес и откуда генерируются запросы. Если бы была единая константа отвечающая за пропорцию поисков и бронирований, то бизнес был бы сильно предсказуемее и прибыльнее

Kahelman
18.12.2025 09:45Проект был полностью инженерной инициативой — без запроса от бизнеса.
От первого коммита до полного переключения прошло три года.
Особенно порадовало: три года развлекались за счёт фирмы. Зачем это было нужно бизнесу? Не ясно…

CentariumV
18.12.2025 09:45Ну как же - у них же возникла проблема, которая ни у кого и никогда в истории человечества не возникала! Явно же, что использовать OLAP, другие кеши кроме редиса/другие OLTP не было вариантом.
не надо бояться писать кастомные решения
Да. Добавить только, что надо докупить памяти на 500к+ для экспериментов. Но для компаний уровня островка это капля в море. Ram лишней никогда не будет.
По описанному, задача не выглядит такой уж сложной. Часто сталкивался с кастомными кешами. Кроме арены еще часто sync.Pool используют. Странно по времени - 3 года. Обычно такие mvp за месяц пишут, взамен каким - нибудь готовым, но платным решениям.

Ostrovok Автор
18.12.2025 09:45По факту разработка окупилась примерно за месяц за счёт увеличения скорости поиска, улучшения конверсии и роста доли успешных бронирований.
К тому же три года — это не непрерывная full-time разработка. Пилотная версия была сделана довольно быстро, большая часть времени ушла на портирование поисковой логики, интеграции и тестирование под реальной нагрузкой.

Kahelman
18.12.2025 09:45Я не верю что увеличение скорости поиска сильно повлияло на результат. У вас уже цифры запросов не бьются с население России а тем более с количеством людей которые реально бронируют номера. Если чат гпт не врет то у вас около 3 млн бронирований в год. Это 8000 бронирований в день. Где тут 30 тыс запросов в секунду?

Ostrovok Автор
18.12.2025 09:45В суммарную нагрузку также входят b2b-клиенты, метапоисковики, различные API-интеграции. Эти каналы формируют значительную долю запросов. Поэтому итоговый RPS получается существенно выше «чистого» b2с.

Masnin
18.12.2025 09:45Могу сказать - браво! Вы проделали действительно сложную работу. Ну и CTO вашему тоже браво, что тему данную протащил и поддержал (думаю не без поддержки от него все произошло).

Kahelman
18.12.2025 09:45Эк меня тема зацепила…. Даю ещё бесплатный лайф хак. Переводим систему из «real-time” в режим пакетной обработки. Скажем с шагом. 1000msec.
Имеем замороженное состояние А. Отдаем его клиентам по запросу.
В течении 400msec собираем заявки на бронирование.
400 msec. Запускаем «аукцион» пытаемся продать номер максимально дорого. Поскольку у на экс мало шансов. Что все 30000 запросов в секунду хотят забронировать тот же самый номер, все не так уж и плохо и достаточно хорошо оптимизируется.
200 msec - обновить данные и подготовить состояние Б
Цикл повторяется. Клиентам которые шлют запросы на «забронированные номера» шлем ошибку и обновленные данные.
В результате никаких проблем с синхронизацией , блокировками и т.д.
Делаем один поток на отель = 200 000 потоков - erlang/go на одной машине легко потянет.
godzie
Какая же это база данных? Транзакции? Репликация? Снапшотинг?
Это просто кэширующий сервис.
undying
Получается MySQL на движке MyIsam не считался базой данных из-за отсутствия транзакций?
godzie
Да, отличное сравнение, MySql имеет репликацию, персестирует данные, да и еще поддерживает разные движки, как вы сами и заметили.
Тут же вообще ничего нет, вы реализуйте репликацию для своего решения, для начала, ждет много интересных открытий.
undying
Если спорить о терминах, то я тогда процитирую википедию, как источник, которому я доверяю:
"База Данных — совокупность данных, хранимых как в соответствии со схемой данных так и в произвольном порядке, манипулирование которыми выполняют в соответствии с правилами средств моделирования данных"
Под этот термин решение в статье попадает, потому что это совокупность данных, а именно доступность отелей. Но думаю вы говорили о СУБД, а не о БД, тогда копнём еще глубже:
"СУБД — комплекс программ, позволяющих создать базу данных и манипулировать данными (вставлять, обновлять, удалять и выбирать). Система обеспечивает безопасность, надёжность хранения и целостность данных, а также предоставляет средства для администрирования БД"
Но и под этот термин решение подпадает, потому что позволяет создавать и манипулировать данными. Кроме того, решение в статье поддерживает ACID.
Всё о чём вы говорите, это дополнительный функционал, к которому мы привыкли в готовых решениях СУБД. Что-то в текущем решении есть, чего-то нет, но это не отменяет того факта, что это рабочая база данных.
godzie
Если ваше решение субд/бд (блог конечно корпоративный но давайте без буквоедства) то почему оно не работает без постгри? Забавная рекурсия получается. Кстати под определение бд из википедии попадает любой контейнер из языка программирования, тоже бд получается?
Про ACID как хорошо что вы сказали слово "решение" ведь ACID в вашем решении обеспечивает postgress, а вы написали кэширующий прокси. Но видимо звучит не достаточно солидно.