golang

Пишем свою in-memory базу на Go, ускоряем поиск отелей в десятки раз

  • пятница, 19 декабря 2025 г. в 00:00:07
https://habr.com/ru/companies/ostrovok/articles/972084/

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

Теперь — о том, как выглядит запись данных в нашем сервисе.

Чтобы память и база данных оставались консистентными, мы придерживаемся чёткого алгоритма. Поскольку любая операция с БД потенциально может завершиться ошибкой, порядок действий такой:

  1. Сначала лочим нужные объекты в памяти на запись.

  2. Пробуем применить изменения в БД.

Если на этом этапе происходит ошибка, мы просто снимаем все локи и выходим — в базе транзакция откатится атомарно, а в память мы ничего не успели записать. Таким образом данные остаются корректными.

Если же база успешно закоммитила изменения, значит, основная часть операции уже прошла. Теперь остаётся обновить кэш. Память, в отличие от БД, не делает I/O, поэтому ошибок здесь мы не ожидаем. На этом этапе:

  1. Лочим соответствующие объекты в памяти, но уже на чтение.

  2. Применяем изменения в кэшевые структуры.

  3. Снимаем локи и завершаем операцию.

Такой подход называется 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% и посчитали задачу закрытой.

Релиз

Переезд был постепенным:

  1. Старый поиск под капотом делал HTTP-запросы в новый, но только для части отелей.

  2. Результаты склеивались: часть отелей приходила из старого поиска, часть — из нового.

  3. Мы постепенно увеличивали долю отелей, обслуживаемых новым движком.

Когда достигли 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.