Как мы делали Go-VShard-router

Привет, меня зовут Нуржан Сактаганов, я ведущий разработчик в Почте и Облаке Mail. Хочу рассказать о нашей библиотеке Go-VShard-router и поделиться трюками и выводами, которые мы сделали при разработке.

3ae46b6ffe0c5abbad188a20b4eb8f98.jpg

Введение

Если вкратце, то мы взяли open-source библиотеку VShard от авторов Tarantool, написанную на Lua, и переписали её часть на Go. 

Tarantool — промежуточное ПО для работы с данными, которое ускоряет цифровые сервисы и снижает нагрузку на core-cистемы. Сочетает в себе сервер приложений, гибридное хранилище с гибкой схемой данных и мощные средства масштабирования. Если вы не знакомы с этим продуктом, можно начать с этой статьи.

А VShard — это модуль, библиотека для Tarantool, которая автоматически шардирует и решардирует данные. Позволяет указывать различные правила: задавать веса шардам и репликам, как балансировать запросы и многое другое. Подробнее о возможностях библиотеки рассказано здесь. 

Как работает шардинг

В VShard шардинг реализован классически: данные разбиваются на большое количество кусков — бакетов, — которые раскидываются по физическим шардам.

А решардинг работает так (см. анимацию ниже). Допустим, сначала у вас было три шарда. Со временем данных стало больше и вы добавили новые шарды. После чего существующие бакеты автоматически перераспределяются с учётом новых шардов. 

738bdb4d59d8ec63a80bd6377116dc94.gif

Как пользоваться VShard

В первую очередь нужно выбрать количество бакетов: N. Это делается только один раз, потом поменять нельзя. Затем нужно выбрать ключ для шардирования данных. Например, взять поле userid или fullname. Потом вы должны задать отображение, какие данные попадают в какой бакет: key → 1…N. Допустим,   остаток от деления на количество бакетов (userid % N) + 1или хеш от fullname и остаток от деления (crc32(fullname) % N) + 1

И в завершение вам нужно bootstrap-ить кластер. 

Архитектура библиотеки VShard

VShard состоит из двух частей: VShard storage и VShard router. Storage работает на экземплярах Tarantool, на которых хранятся данные, и под капотом делает всю магию шардирования и решардирования. Router работает на отдельных экземплярах Tarantool, предназначенных для маршрутизации. Использует API storage для служебных задач, например, построения карты кластера. Также этот компонент предоставляет API для прозрачной работы с кластером, чтобы пользователю не нужно было заботиться о маршрутизации. 

Пусть у нас есть приложение на Go. Тогда схема взаимодействия выглядит так:

bfe2e4c8b77b9650ef27bd26dff819a3.png

Приложение обращается к router-у, а тот перенаправляет запросы в соответствующие Tarantool-ы, где хранятся данные. 

Зачем понадобилось переписывать библиотеку на Go

Поскольку Lua в Tarantool работает в однопоточном режиме и интерпретируется, требуется много router-ов. Например, у нас в Облаке есть большой кластер с 256 шардами и 112 router-ами (соотношение примерно 2 к 1). Кроме того, в Lua VShard-router есть некоторые нерешённые проблемы, которые мешают во время учений. Также в оригинальной библиотеке приходится писать на Lua, причём довольно разветвлённую логику. И наконец, есть лишний hop: app → router → storage.

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

К слову, на Go мы решили переписать не только библиотеку, но и ВКонтакте! А если быть точнее, мы запустили большой технологический проект по переводу ВКонтакте на сервисную архитектуру и построению единой платформы разработки. 

Приглашаем Go-разработчиков на VK Go Meetup 2025, который пройдёт уже 24 апреля. Расскажем о том, как планируем решать настолько масштабную задачу. Подробности и регистрация уже на сайте.

Как мы делали Go-VShard-router

Мы взяли из Tarantool официальный Go-коннектор, воспользовались VShard storage API и повторили на Go всё то же самое, что делает Lua VShard-router. Теперь библиотека встроена в приложение на Go и мы ходим за данными напрямую. 

14b20ac5a11a62c05bf68fb6f2c2436a.png

Как пользоваться библиотекой

vshardRouter, err := vshardrouter.NewRouter(ctx, vshardrouter.Config{
       Loggerf:          &vshardrouter.StdoutLoggerf{},
       DiscoveryTimeout: time.Minute,
       DiscoveryMode:    vshardrouter.DiscoveryModeOn,
       TopologyProvider: static.NewProvider(cfg.Storage.Topology),
       TotalBucketCount: cfg.Storage.TotalBucketCount,
       PoolOpts: tarantool.Opts{
           Timeout: time.Second,
       },
   })

   bucketID := (userID % cfg.Storage.TotalBucketCount) + 1

   resp, err := vshardRouter.CallRW(ctx, bucketID, "get_user", []interface{}{userID}, vshardrouter.CallOptions{
       Timeout: 1 * time.Second,
   })

Мы просто создаём экземпляр router-a (пропустим аргументы NewRouter). Дальше смотрим на строку с bucketID. Допустим, мы хотим получить пользователя с ID, равным userID. Тогда вычисляем, в каком bucket-е этот пользователь находится, вызываем CallRW и передаём туда bucket, имя метода и его аргументы, и получаем ответ. 

Трюки и выводы

Основной тип данных у нас называется router:

// Может использоваться из разных горутин
type Router struct {
   mutex            sync.RWMutex
   nameToReplicaset map[string]*Replicaset
   // . . .
}

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

Какие операции можно выполнять с полем nameToReplicaset map[string]*Replicaset:

  • добавлять или удалять элементы из-за изменения топологии;

  • пользователь может итерироваться по map;

  • мы сами можем по итерироваться по map.

И всё это из разных горутин. То есть существует конкурентный доступ к этой map по чтению и записи.

Например, есть метод RouteAll, который позволяет пользователю итерироваться по map. 

func (r *Router) RouteAll() map[string]*Replicaset {
   return r.nameToReplicaset
}

В этом коде что-то не так. Вспомним, что map — ссылочный тип. Допустим, пользователь получил map, и хочет обратиться по некоторому ключу:

// получили мапку
name2Rs := r.RouteAll()

// плохо, name2Rs может конкурентно меняться и получим панику
rs := name2Rs["name1"]

// ещё хуже, пользователь либы может изменить внутренние данные библиотеки
name2Rs["name2"] = rs

Как избежать всех этих возможных проблем? Просто создайте копию map и экспортируйте её. Теперь пользователь может делать с ней всё, что угодно. 

func (r *Router) RouteAll() map[string]*Replicaset {
   // это всё под мьютексом
   copy := make(map[string]*Replicaset)
   for k, v := range r.nameToReplicaset {
       copy[k] = v
   }
   return copy
}

В библиотеке есть метод MapCallRW, который итерируется по map:

func (r *Router) MapCallRW(...) error {
   // Берем референс/локи в каждом репликасете
   for _, rs := range r.nameToReplicaset {
       rs.Ref()
   }
   // Освобождаем референс/локи в каждом репликасете
   for _, rs := range r.nameToReplicaset {
       rs.Unref()
   }
   return nil
}

Сначала идём по наборам реплик, устанавливаем блокировки, делаем свои дела и снимаем блокировки. Но пока мы что-то делаем, map может измениться, и тогда, например, мы можем снять не все блокировки. Решение здесь такое же, как и в предыдущем примере: нужно зафиксировать состояние, создав копию. 

func (r *Router) MapCallRW(...) error {
   // нужно зафиксировать состояние мапы создав копию
   name2Replicaset := r.name2ReplicasetCopy()
   for _, rs := range nameToReplicaset {
       // . . .
   }
   // . . .
   return nil
}

Да, каждый раз делать копии неудобно. Чтобы упростить себе жизнь, воспользуемся свойством неизменяемости. Идея в том, что map не будет меняться после создания.

func (r *Router) AddReplicaset(name string, rs *Replicaset) {
   // всё это под локом
   newMap := make(map[string]*Replicaset)
   // копируем в newMap из r.nameToReplicaset
   //...
   newMap[name] = rs
   r.nameToReplicaset = newMap
}

То же самое делаем для RemoveReplicaset.

В результате нам больше не надо создавать копии для внутренних структур. Мы получили ссылку на map и знаем, что map не изменится: мьютекс теперь защищает не данные, а переменную r.nameToReplicaset. Можно даже заменить мьютекс на atomic. Всё это мы можем делать при каждом изменении топологии, потому что вряд ли она станет часто меняться, и это вообще недорого. 

Промежуточные выводы:

  • Будьте осторожны с экспортом внутренних данных. 

  • В подходящих случаях используйте неизменяемость, это облегчит вам жизнь. 

  • По сути, неизменяемость — это не свойство, а соглашение, то есть никто нам не запрещает менять map, мы просто так договорились. 

Вернёмся к нашему типу данных Router. В нём есть поле bucketMap — слайс указателей. С ним связано другое поле. 

type Router struct {
   // . . .
   bucketMap        []*Replicaset
   knownBucketCount int
}

BucketMap — это карта нашего кластера, информация о том, где какой бакет лежит. Допустим, мы хотим узнать, где лежит пятый бакет. Тогда мы просто обращаемся к элементу с индексом 5 этого слайса, и получаем указатель на Replicaset, где находится этот бакет. Если там лежит nil, значит информация о расположении бакета нам ещё неизвестна. 

Поле knownBucketCount — это количество бакетов, информация о расположении которых нам известна. По сути, это количество не-nil элементов этого слайса. 

Какие операции с полем возможны в нашей библиотеке

func (r *Router) bucketSet(bucketID uint64, rs *Replicaset) {
   if r.bucketMap[bucketID] == nil {
       r.knownBucketCount += 1
   }
   r.bucketMap[bucketID] = rs
}

У нас есть метод bucketSet. Он позволяет установить для такого-то bucketID такой-то Replicaset. Если был nil, то увеличиваем счётчик и устанавливаем его значение. 

Ещё есть метод bucketReset, в котором стирается информация, и в случае необходимости счётчик уменьшается. 

Есть discovering-горутина, в которой циклически с какой-то периодичностью вызывается bucketSet. 

Наконец, на каждый запрос происходит маршрутизация: обращение к некоторому элементу слайса r.bucketMap[bucketID]. То есть мы опять убедились, что есть конкурентный доступ по чтению и записи к элементам слайса. 

Как синхронизировать доступ к элементам

Слайс BucketMap может содержать тысячи, десятки или сотни тысяч элементов. Например, у нас в Облаке есть кластер, в котором 220 бакетов — 1 048 576. И ещё может быть очень много запросов, каждый из которых — это обращение к элементу слайса. 

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

Другой вариант использовать по мьютексу на каждый элемент. Но миллион мьютексов меня как-то смущает. 

Может, создадим массив мьютексов? Чтобы каждый из них защищал свой диапазон элементов? Например, N мьютексов: bucketID % N:= номер мьютекса для этого бакета.

Но есть вариант ещё проще — atomic-и!  

type Router struct {
   // . . .
   bucketMap        []atomic.Pointer[Replicaset]
   knownBucketCount atomic.Int32
}

По сути, мы превращаем наш слайс указателей в слайс atomic-указателей. И счётчик превращаем в atomic-счётчик. Тогда метод bucketSet будет выглядеть так:

func (r *Router) bucketSet(rs *Replicaset, bucketID uint64) {
   if old := r.bucketMap[bucketID].Swap(rs); old == nil {
       r.knownBucketCount.Add(1)
   }
}

func (r *Router) bucketReset(bucketID uint64) {
   if old := r.bucketMap[bucketID].Swap(nil); old != nil {
       r.knownBucketCount.Add(-1)
   }
}

Мы просто устанавливаем новые значения, а потом смотрим старые. Если там раньше был nil, значит нам надо увеличить счётчик. Аналогично и с bucketReset, только счётчик мы уменьшаем. 

Всё это возможно потому, что:

  • Элементы слайса считаются как независимые элементы — независимые atomic-и.

  • Операция сложения коммутативна и ассоциативна, проще говоря — от перестановки слагаемых, сумма не меняется.

  • Благодаря atomic-ам решение об инкрементировании и декрементировании принимается атомарно.

  • Несогласованность данных в течение наносекунд нам не страшна.

И вроде бы всё хорошо, но у нас есть операция RouteMapClean(): мы очищаем карту кластера, то есть создаём новый слайс и присваиваем 0 полю r.knownBucketCount

func (r *Router) RouteMapClean() {
   r.mutex.Lock()
   r.bucketMap = make([]atomic.Pointer[Replicaset], totalBucketCount)
   r.mutex.Unlock()
   r.knownBucketCount.Store(0) // присваивание - не коммутативная операция
}

Однако это присваивание уже не обладает свойством коммутативности и всё ломается. Что делать? Давайте подумаем. Вряд ли RouteMapClean() будет вызываться часто. А раз это редкая операция, то можно просто создать новый объект с корректным начальным значением, как в предыдущем случае с неизменяемостью. 

Давайте вынесем два поля в отдельную структуру:

type bucketMapView struct {
   bucketMap        []atomic.Pointer[Replicaset]
   knownBucketCount atomic.Int32
}

type Router struct {
   view atomic.Pointer[bucketMapView]
}

В Router-е будем хранить указатель на эту структуру. Метод bucketSet приобретает такой вид: берём актуальный указатель и делаем всё, что нам нужно, только с этой новой структурой. 

func (r *Router) bucketSet(rs *Replicaset, bucketID uint64) {
   view := r.view.Load()

   if old := view.bucketMap[bucketID].Swap(rs); old == nil {
       view.knownBucketCount.Add(1)
   }
}

func (r *Router) RouteMapClean() {
   view := &bucketMapView{
       bucketMap: make([]atomic.Pointer[Replicaset], totalBucketCount),
   }
   r.view.Store(view)
}

RouteMapClean просто создаёт новый объект с корректным начальным значением и подменяет указатель. BucketSet и RouteMapClean не приводят к некорректному состоянию. 

Выводы

  • Несогласованность данных в течение нескольких наносекунд бывает не страшна.

  • Конкурентность — это не только про блокировки, но и про:

    • atomic-и;

    • свойства используемых операций: в нашем случае сложения и присваивания;

    • неизменяемость.

  • Используя эти техники, получили wait-free библиотеку. Бенчмарки можно посмотреть в репозитории.

  • Atomic-и не являются серебряной пулей! Есть ситуации, когда решение с atomic-ом даже в 100 раз медленнее, чем без. 

Мы закончили активную разработку, библиотека теперь в open-source и готова к эксплуатации. 

А если хотите обсудить какие-то технические детали лично или больше узнать про смену архитектуры и инструменты, которые сделают разработку в разы проще, приходите на VK Go Meetup 2025, который мы уже заспойлерили выше. Будет много технических деталей. Разберём богатства Go 1.24 и недостатки новой map, тулинг мини-аппов и нашу самую масштабную задачу последних лет.

Habrahabr.ru прочитано 6834 раза