Миллионы пользователей ежедневно заходят на Яндекс Маркет. И одна из ключевых задач сервиса — показывать им точные сроки доставки на поиске и в корзине. При пиковых нагрузках это около 40 тысяч запросов в секунду. Как обеспечить столь быструю и точную обработку данных о доставке?

Привет, Хабр! Меня зовут Никита Деревянко. Я руковожу разработкой логистической платформы Яндекс Маркета. Люблю играть в шахматы, бильярд и программировать. Изучаю японский язык, чтобы тренировать мозг и смотреть аниме в оригинале. Расскажу о том, как построить логистический runtime на Go, не являясь Golang-разработчиком. Рассмотрим, как справиться с большим объёмом данных и какие преимущества может (или не может) предложить Golang для масштабной задачи.

Чем занимается наша логистическая платформа

Доменную область Яндекс Маркета можно разделить на две смысловых части:

  1. Процессинг заказа. Если коротко, это о том, как отвезти посылочки, чтобы все контрагенты (сортировочные центры, склады, пункты выдачи) могли выдать заказ пользователю. И даже если что-то пошло не так, сработали триггеры и заказ всё равно доставили. Про это мы почти не будем говорить в статье, зато поговорим про вторую часть логистической платформы.

  2. Логистический runtime. Отвечает за то, какие даты и способы доставки вы увидите у себя на экране в чек-ауте.

Наша задача кажется простой:

  • проверяем габариты, уточняем состав: горючая ли жидкость, есть ли стекло и т. д.;

  • рассчитываем варианты доставки;

  • вычисляем сроки и опции;

  • оптимизируем маршруты.

Это необходимо, чтобы обещать заказчику даты доставки, в которые мы реально уложимся и точно доставим посылку. Под капотом — большой логистический граф:

Даже простое комбинаторное перемножение даст ощутимое количество путей. А в таком большом городе, как Москва, разделённом на полигоны, в каждом из которых могут быть свои даты доставки, количество путей будто бы стремится к бесконечности…

Но миллионы пользователей заходят на Яндекс Маркет, и на выдаче (в корзине) им нужно показать верные сроки доставки. А это порядка ~40k RPS в средний день в пиковое время.

Всем этим заведует комбинатор, пока ещё не великий, но уже умеющий комбинировать маршруты. Быстро, да ещё и на Go.

Он берёт части пути и комбинирует из них итоговый маршрут. С одной стороны, быстрый, чтобы пользователь был рад, а с другой, максимально эффективный для нас, как маркетплейса, чтобы обеспечить прибыль.

Откуда взялся Go и первые впечатления 

В Маркете почти всё написано на C++ или Java, а комбинатор — на Go. Потому что из-за роста и развития маркетплейса наш CTO решил выделить доставку из-под монолита поиска C++ в отдельный сервис.

О том, что мы будем писать на Go, я узнал в отпуске. Раньше на этом языке не писал, и почти ничего о нём не знал. Поэтому начал своё знакомство, читая книгу Go programming language. Оказалось, что в этом языке много обработки ошибок, нет классов и темплейтов как в C++, и никогда не было дженериков. Зато есть удобные библиотеки, а MVP-сервисы почти всегда можно сделать на внутренних библиотеках. Бонусом идут поддержка tuple, горутины и каналы! Это прекрасно выглядит, и многое я теперь переиспользую, в том числе в «плюсах».

Для подтверждения тезиса о простоте я взял корневую библиотеку языка src/runtime/slice.go и попытался найти сложный кусок кода.

А код не такой уж и сложный! Но так, конечно, бывает не во всех библиотеках. Давайте посмотрим на библиотеку Vector —  я даже не пытался отыскать сложный кусок.

В коде на иллюстрации выше видно метод erase, несколько темплейтных параметров и много чего ещё. Если приглядеться, то между нижними подчёркиваниями можно разглядеть даже, что она делает.

Первый рабочий вариант — чудеса скорости

Первый полноценный рабочий вариант сервиса мы самостоятельно сделали за 3 недели. В это время вошло и копание в данных, и парсинг, и тесты. Напомню, что до этого на Go я никогда не писал (не хвастаюсь, просто чтобы вы понимали скорость вхождения). MVP сервиса в 3 недели — это очень немного. Самый сложный челлендж был — объяснить коллегам, что я всё это сделал за этот срок.

Единственная реальная проблема — некогда пить кофе. Ведь компиляция летает. Добавление функциональности в либу перекомпилируется за 2 минуты. Тесты «бегут» очень быстро. Всё это непривычно для C++-разработчика и в процессе я кайфовал от скорости.

Теперь расскажу подробнее, как мы это делали.

На самом деле, в Яндексе, у нас всё было готово для разработки на Go:

  • Поддержка языка во внутреннем репозитории.

  • Клиент к YTsaurus. По сути, это распределённая файловая система, над которой можно запускать MapReduce и различные вычисления. Кстати, она уже давно в опенсорсе.

  • Поддержка gRPC — всегда была в Go.

  • Маленькое и уютное комьюнити Go, которое, кстати, и написало сборку и клиент для YTsaurus.

Оставалось только подготовить данные и поднять runtime-сервис, который будет считать доставку. Для этого:

  • ходили по сети — net/http;

  • парсили json и xml — encoding;

  • писали тесты — testing;

  • сохраняли в YTsaurus — свой pkg для работы с YTsaurus уже был.

К счастью, в Go есть все нужные библиотеки, marshal/unmarshal, причём из коробки.

Источников данных у нас много, ведь нам нужно было:

  • распарсить тарифы доставки;

  • достать пункты выдачи заказов;

  • построить логистический граф;

  • обновлять данные параллельно.

В Go это сделать легко, ведь у нас есть горутины. Читаем мануал, перед функцией чтения данных пишем ключевое слово "go" и вот у вас есть параллельная обработка. Ещё нужно не забыть про триггер обновления данных — мы использовали "chan bool". А также не стоит забывать о примитивах синхронизации из библиотеки sync. Как минимум, чтобы главная горутина не закончилась после запуска остальных.

Конечно, на этом этапе возникли баги и торможения. Например, медленное чтение информации про тарифы доставки. Благо с помощью pprof мы быстро нашли проблемные места. Где-то было слишком много локаций, где-то астрономические траты CPU на ровном месте. Потом фиксы, тесты, бенчмарки и раскатка. Результат не уступал коду на «плюсах» с подобной функциональностью чтения из YTsaurus. Причина проблем была в неопытности. После применения pprof всё удалось исправить.

Борьба с указателями

Вот мы уже запустились, преодолели первые сложности и всё полетело идеально. Первые серьёзные проблемы начались спустя 9–10 месяцев после выхода в продакшн и касались они CPU usage:

Процентиль держался на уровне 80%. Для нас это дурной знак, так как запросы начинают таймаутить, а приложение не переживает ДЦ-1 (отключение датацентра). Что приводит к потере заказов и недовольству пользователей. Это не нравится ни нам, ни клиентам.

Оказалось, что проблема кроется в огромном количестве указателей, большинство которых на самом деле не нужны.

// Все optional поля - указатели
type Node {
  PointID     *int64  // optional
  LocationID  *int64  // optional
  PartnerName *string // optional
}
// Все поля optional, но нет указателей
type Node {
  PointID     int64  // optional значение 0
  LocationID  int64  // optional значение 0
  PartnerName string // optional значение ""
}

Один из ярких примеров — опциональные поля. При чтении табличек у нас их было слишком много. А ещё множество лишних указателей. При этом у string и int есть значения для пустых полей. Это наши любимые: пустая строка и нолик, соответственно. В результате мы нагружали Garbage Collector почём зря. Он, по сути, отсматривал миллионы указателей, хотя это было абсолютно бесполезно, потому что данные у нас зачитываются поколениями. В один момент времени либо текущие данные валидны, поэтому указатели тоже валидны, либо все указатели моментально инвалидируются, мы читаем уже новое поколение и работать надо на нём. Garbage Collector всё это время проверял происходящее, но на самом деле ему этого делать не надо. Поэтому мы переписали весь код так, чтобы проверять дефолтные значения. Это несколько усложнило имплементацию, но от указателей мы избавились.

Опциональные переменные оказались не единственным узким местом с большим числом указателей. Нужно было решить, что делать с данными о складах и пунктах выдачи заказа, которые полностью перечитываются раз в 5 минут.

// Указатели на Node и Outlet
type CombinatorData {
  Nodes   map[int64]*Node
  Edges   map[int64]int64
  Outlets map[int64]*Outlets
  // Ещё поля
}

Очень удобно, что у ноды в графе есть ID. Поэтому достаточно взять map, ID, указатель на node или outlet —  всё прекрасно работает. Указатели в Go — умные из коробки, поэтому никаких проблем не было. Ровно до тех пор, пока мы не упёрлись в разрастание графа. Указателей снова стало слишком много. Если быть более точным, то в первой реализации их O(N), где N — число узлов в графе.

// Времена начала и продолжительность
old := map[int64]*Node {
  1: &Node{ID: 1},
  2: &Node{ID: 2},
}

node, ok := old[2]

Чтобы победить эту проблему, мы использовали простой подход и допустимые ограничения. Так как не всем подойдёт запрет на модификацию данных после чтения, решили, что после чтения модификации и не нужны. Мы зачитывали поколение данных и потом держали их в памяти, не модифицируя. Следующая модификация происходила лишь тогда, когда мы снова зачитывали новое поколение. 

В «плюсах» частый подход при таких ограничениях — уложить всё это в памяти. То есть заремапить индексы и положить все ноды в огромный плоский список. Мы так и поступили. В результате потеряли простую модификацию — но она нам и не была нужна.

// Ремапинг индексов
indexes := map[int64]int {
  1: 0,
  2: 1,
}
// Плоское хранилище
nodeStorage := []Node{
  Node{ID: 1},
  Node{ID: 2},
}

Ограничения на модификацию стоит отразить в имплементации, например, так:

type Nodes struct {
  indexes map[int64]int
  nodeStorage []Node
}
// Создание
func NewNodes(...) *Nodes

// Доступ есть только по методу
func (ns *Nodes) GetNode(ID int64) *Node

Благодаря этому, никто не сможет поменять данные без изменения интерфейса. Так мы избавились от огромного количества лишних указателей и потенциальных «выстрелов в колено».

В результате получили:

  • существенно меньше указателей;

  • одну аллокацию при чтении;

  • исчезновение пиков CPU;

  • несколько усложнённый код чтения;

  • новые проверки на пустоту.

Несколько ступенек привели нас от 80% к 40%. Мы увеличили capacity сервиса в 2 раза, просто избавившись от лишних указателей.

Профилирование

Конечно, не стоит пытаться оптимизировать, если у вас всё и так хорошо работает. В нашем случае мы уже подбирались к 90% по CPU и получили троттлы, таймауты и потери заказов. Поэтому  у нас не было выбора, и мы запустили prof, чтобы найти узкие места.

Здесь нам пригодились все профили, особенно CPU и MEM. В CPU-профиле заметили частые срабатывания GC, их можно отследить, например, по наличию runtime.gcBgMarkWorker. А в MEM-профиле мы смотрели места с большим количеством или объёмом аллокаций. Обычно полезнее смотреть именно count, потому что количество и объёмы локации зачастую подсвечивает возможные проблемы.

Важно, что все профили нужно снимать с прода или с пода под продовой нагрузкой. Иначе локально не видны все проблемы, а сделать верные выводы без верных данных — непростая задача.

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

Если после релиза с оптимизацией не видно улучшений, то сначала проверяем, верим ли мы приборам. Ведь наличие достоверной информации с прода — одна из самых важных частей приложения. Если данным мы верим, то стоит поискать баги в коде самой оптимизации. И там всё чисто? Тогда, боюсь, стоит откатить коммит и написать, какая идея оптимизации оказалась неудачной. Помните, что усложнение кода без значимых улучшений показателей — плохая практика.

Слайсы 

В Go сложно представить программу без слайсов. Они прекрасны: удобные, простые и переиспользуемые.

Однако при всей их простоте, довольно легко сесть в лужу, сделав shallow copy структуры, внутри которой хранятся слайсы. Так мы сделали структуру со слайсом, затем её shallow copy и модифицировали. В результате, наши посылки, которые должны были ехать к пользователю неожиданно поехали в обратном направлении — туда, откуда должны отгружаться.

Но с такими проблемами вы сталкиваетесь лишь в первые недели, а потом привыкаете и начинаете ловить их на лету. Слайсы можно использовать, например, в MergeSort или как стек для поиска в глубину, потому что их можно «нарезать» на подслайсы, и это очень удобно.

В нашем коде слайсы можно встретить повсюду:

  • путь по графу — слайс;

  • время обработки на объектах — слайс;

  • для поиска в глубину нужны слайсы;

  • хорошие итоговые маршруты — тоже слайсы.

Рассмотрим на примере ServiceTime, насколько их много.

  • Различных путей — примерно 23 млн.

  • Каждый путь при поиске хранит ServiceTimeList.

  • В одном запросе — примерно 10 тысяч путей.

  • При 100 RPS на машинку получаем примерно 50 млн слайсов в минуту.

// Время начала и продолжительность
type ServiceTime struct {
  ID        int64
  duration  time.Duration
  startTime time.Time
}

type ServiceTimeList []ServiceTime

В среднем в нашем запросе обрабатывается примерно 10 тысяч путей, потому что на вход приходит большое количество складов. Нам нужно просчитать пути для всех видов доставки, так как в зависимости от способа доставки путь может различаться. А при 10 тысячах путей, которые нужно обсчитать при 100 RPS, получим порядка 50 миллионов слайсов в минуту на одном инстансе.

А когда слайсов становится слишком много, могут возникнуть проблемы. «С чем же могут быть проблемы? – спросите вы. Ведь слайсы, по сути, работают по ссылке». Проблема, конечно же, не в самих слайсах, а в необходимости делать аллокации array под капотом, чтобы всё это хранить. 50 миллионов — это много. А самое обидное, что жизненный цикл слайсов у нас всегда меньше, чем жизненный цикл конкретного запроса пользователя. То есть всё, что мы выделили, становится ненужным, как только сервис отправил клиенту ответ.

Очевидное решение — сделать memory pool.

Пулы

var serviceTimePool = sync.Pool{
  New: func() interface{} {
    // Полезно указать размер по умолчанию
    stl := make(ServiceTimeList, 0, defaultCap)
    return &stl
  },
}

func NewServiceTimeList() ServiceTimeList {
  stl := serviceTimePool.Get().(*ServiceTimeList)
  return *stl
}
// Очищаем перед возвратом
func PutServiceTimeList(stl ServiceTimeList) {
  stl = stl[:0]
  serviceTimePool.Put(&stl)
}

// Очищаем перед возвратом
func PutServiceTimeList(stl ServiceTimeList) {
  stl = stl[:0]
  serviceTimePool.Put(&stl)
}

Memory pool есть в стандартной библиотеке sync языка Go, которую мы уже упоминали. Его очень просто использовать в своём коде. Главное, не забывать очищать объекты перед возвратом.

Давайте оценим, стоит ли тратить время, чтобы переписывать код на использование пула. Посмотрим на бенчмарк без пула:

  • 10 тысяч запросов за 3 минуты.

  • Среднее время выполнения — 18 ms.

  • Порядка 10 млн аллокаций.

Теперь включаем пул.

У нас всё те же 10 тысяч запросов, но уже за 2 минуты. Среднее время — 12 ms. И всего лишь тысяча аллокаций новых объектов. Мы получили экономию на 5 порядков! Отличный результат! В тех местах, где пул используется по делу, получаются такие солидные оптимизации.

Очистка

Но могут быть и подводные камни. Например, вы можете положить в пул один и тот же объект дважды. А могут быть ситуации и похитрее…

// Очищаем перед тем как вернуть в Pool
func PutServiceTimeList(stl ServiceTimeList) {
  stl = stl[:0]
  serviceTimePool.Put(&stl)
}

Разберём одну из таких ситуаций на примере. Вот график по памяти:

Симптомы:

  • приложение стало съедать всю память;

  • белые падения — моменты релизов;

  • при повышении нагрузки — падали по OOM и тормозили.

Видно, что мы стабильно упирались в потолок по memory. Garbage Collector старается нас спасти. Но когда число пользователей растёт, например, во время акции, мы падаем по OOM, поднимаемся заново, и картина повторяется.

Релизов в это время не было, данные драматически не менялись. Но что-то точно случилось.

Давайте, подробнее посмотрим на структуру ServiceTime, слайсы с которой, мы кладём в sync.Pool. Явно выделяется лишь указатель на parentNode в графе.

// У нас есть указатель на ноду в графе
type ServiceTime struct {
  ID         int64
  duration   time.Duration
  startTime  time.Time
  parentNode *Node
}

Сами по себе эти указатели не страшны, но что будет если такие указатели будут хранится в пуле и указывать на старые версии узлов графа? Верно, они будут лежать мёртвым грузом в нашей RAM!

Мы жили с этим кодом несколько недель, но что стало триггером проблемы с памятью?

Всё дело в длине слайсов ServiceTimeList, которая примерно равна длине пути в графе умноженному на 3. Для некоторых складов и регионов у нас встречаются «пути-монстры», сильно длиннее обычных. Скажем, длина обычного пути — 7–9, а «путь-монстр» мог достигать 20, а то и 30.

Обозначим вероятность встретить «путь-монстр» за X. Изначально величина X была небольшой, поэтому проблема себя не проявляла. Но последовательные изменения графа за несколько недель, а также изменение путей из-за перекрытия одной из трасс в снегопад, привели к тому, что величина Х выросла. Из-за этого мы перешли в фазу насыщения, когда накопление старых указателей в пуле стало слишком быстрым и заметным. Именно в этот момент мы стали ловить OOM.

Самое забавное, что при Х, стремящейся к 1, количество старых указателей, наоборот, станет стремиться к 0, так как они постоянно будут перезатираться новыми значениями.

Вывод из этой неприятной ситуации простой. Необходимо правильно очищать данные перед отправкой в пул. А также стоит следить за максимальной длиной слайса в пуле, так как слишком длинные слайсы увеличивают издержки памяти.

func (st *ServiceTime) Clear() {
  st.parentNode = nil
}

// Очищаем, но лучше
func PutServiceTimeList(stl ServiceTimeList) {
  for i := 0; i < len(stl); i++ {
    stl[i].Clear()
  }
  stl = stl[:0]
  serviceTimePool.Put(&stl)
}

В данном случае после обнуления указателя parentNode, расход памяти вернулся к ожидаемым значениям. Пиковым значением стало 60% memory usage, что нас вполне устроило. 

Резюмируя, пулы – хороший, но опасный инструмент. Их стоит добавлять только при явной необходимости, так как они могут привести к «весёлым» инцидентам, в которых очень сложно докопаться до причины проблем. А деструкторы, к которым в C++ все привыкли — неплохая вещь.

Рантайм на gRPC 

Мы сразу знали, что у нас будет большая нагрузка. Поэтому наш выбор пал на gRPC. Он готов к такому и передаёт меньше данных по сети. Они передаются в сжатом proto-представлении, что гораздо экономичнее классического Json. Также у gRPC есть клиенты на разных языках Java, С++ и Go, удобный proto-формат для контрактов. Тем более, мы уже привыкли к protobuf для хранения и чтения данных на разных языках программирования.

Давайте посмотрим, что gRPC-клиент нам может сказать про официальные бенчмарки:

  • Видно, что серверы на C++ и Go сопоставимы по устойчивости к нагрузке. 

  • Go-runtime сопоставим с C++ по использованию ресурсов.

  • С точки зрения concurrency — параллельных коннектов — Go даже смотрится сильнее.

  • Есть проблемы с хвостовыми таймингами (99+ процентиль запросов).

Выглядит неплохо, а что с библиотекой?

  • Подробная документация.

  • Marshal/Unmarshal.

  • Интерсепторы для сервера.

  • Кодеки сжатия.

  • Активно развивается и дополняется.

  • Добавила гибкости в работе на разных стадиях обработки запроса.

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

// Запрос на поиск маршрутов
type DeliveryRequest struct {
  state         protoimpl.MessageState
  sizeCache     protoimpl.SizeCache
  unknownFields protoimpl.UnknownFields

  StartTime   *timestamppb.Timestamp
  Destination *PointIds
  Option      *DeliveryOption
  UserInfo    *UserInfo
}
// Вот как выглядит этот дефолтный таймстемп
type Timestamp struct {
  // Пропустим служебные поля
  Seconds int64
  Nanos   int32
}

Например, возьмём стандартный timestamp внутри Protocol Buffer. Это структура из двух полей типов int64 и int32. Добавляя её в proto-описание, вы добавляете указатель на эту структуру. И такое поведение может стать проблемой.

Бороться с указателями можно разными методами:

  • дедовскими — с помощью кэширования частых объектов;

  • более современными — с использованием pool для таких объектов;

  • кропотливым — рефакторингом и проверкой proto-структуры во избежание лишних указателей;

  • костыльным — с помощью кастомного кодека сжатия с кэшированием, чтобы лишний раз не тратить CPU на компрессию;

  • радикальным — генерировать  кастомные marshal/unmarshal функции и структуры, в которых будет меньше указателей.

По радикальному подходу есть прекрасный доклад автора библиотеки gogo/protobuf, где он рассказывает, как воевал с marshal и проиграл. 

Мы попытались это применить и получили профит порядка 15%. Но, к сожалению, из-за достаточно больших проседаний внутри инфраструктуры, поняли, что оно того не стоило. 

Проблемы с хвостами таймингов 

Во время эксплуатации мы столкнулись с ещё одной проблемой  — выбросами по таймингам для 99+% запросов из-за работы GC. У C++ подобного недостатка нет, а в Go нас спасают хеджированные запросы.

Это график таймингов по одному из ключевых хендлеров с прода:

Видно, что:

  • 95 процентиль – 50ms;

  • 99 процентиль – 80ms.

95 и 99 процентили различаются более, чем в полтора раза. Подобный разброс таймингов характерен для языков, в которых есть Garbage Collection. В C++ мы такого не наблюдаем. Для решения подобных проблем придумали Hedged Requests или хеджированные запросы. О том, что это такое можно прочесть в статье про hedged-запросы

Но если коротко, хеджирование используют, чтобы снизить вероятность тайм-аута на запрос. Базовую идею понять легко: если сделать два параллельных запроса вместо одного и жать самый быстрый из двух ответов, то вероятность тайм-аута существенно снизится (возводится в квадрат). Но мы создадим двойную нагрузку на сервис.

Давайте попробуем поступить умнее и рассмотрим новый подход. Например, нам нужно уложиться в 100 ms. Предположим, что 99-й процентиль — 100 ms, 95-й — 50 ms. А задача всё та же — нужно уменьшить количество таймаутов, не увеличив нагрузку кратно.

Минутка математики:

  • α — вероятность 1%, что мы получим тайм-аут за 100 ms (99-й процентиль),

  • β — вероятность 5%, что мы не уложимся в 50 ms (95-й процентиль).

Мы запускаем первый запрос и ждём отсечки 95-го процентиля. Если до этого времени ваш запрос не вернулся, то делаем второй параллельный запрос. На картинке первый запрос не уложился в 100 ms, ему не повезло, зато второй запрос отработал 45 ms. Поэтому мы довольны и уложились в общий тайминг.

Случайные величины хоть и зависимы, но чтобы найти вероятность, одновременного таймаута обоих запросов, нам достаточно их перемножить. Таким образом вероятность тайм-аута снизилась  в 20 раз, а нагрузка выросла лишь на 5% (подумайте при чём тут 95-й процентиль). Кажется, обалденный trade-off!

А сложно ли такое реализовать? В Go горутины делали для людей! И вся сложность многопоточности концентрируется на блокировании доступа и архитектуре приложения. В C++ часто приходится бороться с языком больше, чем со сложностями многопоточности. Например, в hedged-запросы можно заложить разное число ретраев с константным интервалом. Это очень простая реализация, а наличие select упрощает написание логики.

func MakeHedgedRequest(
  retryInterval time.Duration,
  retryCount int,
  callable func(int, chan any),
) any {
  resCh := make(chan any, retryCount)
  go callable(0, resCh)
  for i := 0; i < retryCount; i++ {
    select {
    case res := <-resCh:
      return res
    case <-time.After(retryInterval):
      go callable(i+1, resCh)
    }
  }
  return <-resCh
}

В коде выше retryCount — это переменная. Так подход можно обобщить и делать не два ретрая, а больше, в зависимости от того, какой у вас характер нагрузки. Реализация довольно простая. Мы делаем канал с буфером на количество ретраев и запускаем первую горутину. С помощью for ожидаем, сколько времени пройдёт до следующего ретрая. В результате получаем ответ от одной из горутин и возвращаем его.

Выводы

  • Мне кажется, самое большое достижение, что я по-прежнему обожаю писать на Go после 3,5 лет, как начал.

  • MVP-сервис на Go пишется на одном дыхании. И это обалденно и для бизнеса, и для команды. Разработка нового сервиса в 2 раза быстрее, чем на C++.

  • Производительность Go в среднем сопоставима с C++.

  • Бизнес-логика пишется быстрее.

  • Итоговый код — сильно проще и понятнее.

  • Скорость в Go хороша во всех смыслах. Простота кода и инструментов даёт больше времени на алгоритмы и проработку. Общая метрика time-to-market растёт, если у вас простой код. Даже новому разработчику легко в нём разобраться, у него остаётся банально больше времени на то, чтобы подумать над правильностью постановки проблемы и её решением.

  • Крутые алгоритмы дают ускорение на порядки, а не проценты. Правильно применённый алгоритм может ускорить вас в разы — в 10, в 100 раз. На самом деле, отсюда и настоящая скорость, а не от того, что мы сэкономили микросекунды на более правильно написанном Unmarshall. 

  • Команда мотивирована делать ещё лучше и быстрее. И вообще к Go легко адаптировать команды.

  • Разработчиков в команду можно нанимать с любым бэкграундом — хоть C++, хоть Java, хоть Python.Получаем скорость и гибкость в найме.

  • Можно легко подружить C++ и Java-разработку на почве Go

На самом деле, как и человеческие языки, на которых мы разговариваем, каждый язык программирования нас чему-то учит. C++ — заранее думать о том, что будешь писать. Python — экспериментировать, делать быстро и проверять гипотезы. А Go — простоте, фокусу на результате и имплементации этого результата. Поэтому рекомендую учить разные языки (не только программирования) и использовать этот опыт при решении проблем. А сами проблемы решать на Go!

Комментарии (18)


  1. Takumi
    17.01.2025 09:25

    никогда не было дженериков

    Вам нужна книжка поновее...


    1. awakair
      17.01.2025 09:25

      кажется, автор говорит о своих впечатлениях четыре года назад


      1. Takumi
        17.01.2025 09:25

        Не знаю как вы это поняли, но я только видел про то что первые проблемы появились через 9-10 месяцев после релиза. Но дженерики появились в 1.18, релиз которой был в марте 22ого года...


        1. EricShelbogashev
          17.01.2025 09:25

          Раньше на этом языке не писал, и почти ничего о нём не знал

          и

          Мне кажется, самое большое достижение, что я по-прежнему обожаю писать на Go после 3,5 лет, как начал.


    1. moon_l1ght_nd Автор
      17.01.2025 09:25

      Сейчас точно стоит сразу знакомиться с языком в новой редакции!
      Тогда поддержки generic'ов, как минимум у нас в репозитории не было)


  1. Lewigh
    17.01.2025 09:25

    Самое интересное не описали - с какой болью приходиться сталкиваться при написании программ на Go и почему для компании преимущества перевешивают эту боль.
    Без этого статья смотриться исключительно как реклама языка Go.


    1. qeeveex
      17.01.2025 09:25

      Я пишу больше пяти лет на Go. О какой боли идет речь?

      Если про обработку ошибок, то это не боль, просто надо приучить себя оборачивать в fmt.Errorf и расширять контекст ошибки (молчу про кастомные типы ошибок, это отдельная тема для большой статьи).

      По остальным аспектом, по опыту работы в трех компаниях, вся боль связана с использованием каких-то сторонних подходов, не принятых в сообществе Go (наблюдаю это у команд которые пытаются свои старые привычки по предыдущим стекам натянуть на Go, особенно из Java и PHP практик).

      Т.е. во всех случаях изобретают свой кактус и героически его едят, объясняя это как проблему Go.


  1. KaiserProger
    17.01.2025 09:25

    Optional можно легко сделать собственной обёрткой а-ля:

    type Optional[V any] struct {
    	defined bool
    	value   V
    }
    // И пара-тройка хелперов к нему


    Всё на стеке, легко проверяется на "nil", и нет путаницы с валидными нулевыми значениями если отказаться от указателей. Так делают в sql пакете, например: https://pkg.go.dev/database/sql#NullInt64



  1. avovana7
    17.01.2025 09:25

    Спасибо за интересный разбор! По опыту сколько нужно времени, чтобы свичнуться с С++ на Go для middle, senior уровня С++?

    И есть ли у вас такие переходы? Т.е. берёте, к примеру, middle C++ и ему на испытательном сроке ставятся задачи в том числе с изучением Go? Учитывается, ли что он будет нарабатывать Go опыт?


    1. moon_l1ght_nd Автор
      17.01.2025 09:25

      Да, у нас много ребят кто приходил только с опытом на C++, как раз middle и senior уровня. На деле, привыкание и адаптация довольно быстрая, порядка 1 месяца. Поэтому проблема с погружением в бизнес процессы и сущности, даже сложнее выглядит!
      Сейчас, в изучении языков очень сильно помогает LLMы, типа GPT-4 и подобных, так что скоро месяц будет парой недель!


    1. qeeveex
      17.01.2025 09:25

      Свичнутся можно за неделю.

      Для изучения всех подводных камней и понимания в целом принятых подходов в Go - где-то год. Если адекватны и готовы принять устои, например утиную типизацию, тесты рядом с реализацией, обработку ошибок и т.п. Иначе будите бороться с ветряными мельницами и пытаться натянуть свой старый стек на Go.

      А так КМК лучше посмотреть в сторону Rust.


    1. evgeniy_kudinov
      17.01.2025 09:25

      Я думал с C++ в Rust хотят свичнуться в основном ,а так с php идут.


  1. evgeniy_kudinov
    17.01.2025 09:25

    Правильно ли я понял, что в основном задачи были CPU bound?


  1. falconandy
    17.01.2025 09:25

    Ведь компиляция летает. Добавление функциональности в либу перекомпилируется за 2 минуты.

    Не опечатка? Не 2 секунды?


  1. blind_oracle
    17.01.2025 09:25

    Вот если добавят в Go что-то вроде Option<T> (самопальный вариант из варианта выше не катит, нужно что-то встроенное) и enum-ы как в Rust - цены ему не будет.

    Это то, чего мне всегда не хватало.


  1. robert_ayrapetyan
    17.01.2025 09:25

    Обожаю когда выбирают модную технологию (с непонятными подчеркиваниями в простейшем коде), тратят кучу времени на отключение основных фич типа GC, и в итоге счастливы, что избавились от С++


    1. slonopotamus
      17.01.2025 09:25

      Ну вместе с C++ люди обычно избавляются от UB. Одного этого часто достаточно чтобы склонить чашу весов.