
Привет, Хабр! Меня зовут Дмитрий Гурьянов, я руковожу командой Manuls в VK HR Tek (система ведения кадрового электронного документооборота). Сегодня расскажу о нюансах оптимизации на разных уровнях исполнения кода на Go.
Я хотел бы рассмотреть оптимизацию большого количества не самого сложного кода — веб-приложений, информационных систем и так далее. Часто по требованиям бизнеса приходится писать много нового кода, и редко бывает возможность вернуться назад и улучшить старые функции, поскольку постоянно появляются новые задачи.
Предлагаю разделить все возможные действия по оптимизации на группы по двум критериям: что мы оптимизируем и каким образом оптимизируем.

Макровзаимодействия
Я придумал два термина: макровзаимодействия и микровзаимодействия, чтобы иметь возможность различать «величину» той или иной операции в коде.
Макровзаимодействия начинаются с чистого листа: в самом начале у нас нет никакой информации о том, кто начинает работу, а по окончанию такой функции вся собранная информация, весь контекст выполнения забывается или пропадает.
К макровзаимодействиям можно отнести:
HTTP-запросы;
gRPC-запрос;
SQL-запрос;
запуск процессов в системе (выполнение команды).
Самым понятным примером будут HTTP-запросы: в самом начале должна отработать аутентификация, чтобы понять, кто именно подключился; далее выполняется авторизация — разбираемся, имеет ли доступ этот пользователь к запрашиваемым данным. Затем необходимо собрать некоторый контекст выполнения — объекты, с которыми будем работать, почти всегда они поступают из других сервисов (например, из базы данных), и сразу же после отправки результата работы всё это будет потеряно.
GRPC-запросы чаще всего выполняются гораздо быстрее, чем HTTP, в силу того, что они используются для межсервисного взаимодействия, так что не требуется в самом начале выполнять тяжёлые проверки. Однако всё равно необходимо собрать всю информацию, необходимую для выполнения действия.
SQL-запросы в базу данных тоже выполняются крайне медленно из-за того, что нужно, во-первых, распарсить сам запрос, во-вторых, должен отработать планировщик, чтобы составить схему выполнения запроса, и далее практически всегда у нас будет чтение информации с диска. Как только SQL-запрос полностью завершён, план запроса нигде не остаётся в памяти, и следующий точно такой же запрос потребует повторного выполнения тех же самых действий.
Ну и напоследок — про выполнение команд: абсолютно похожая ситуация. Для начала надо прочитать исполняемый файл и динамические библиотеки с диска, запустить процесс, собрать информацию (прочитать с диска, например), возможно, прочитать и распарсить конфигурационный файл — только для того, чтобы выполнить одно действие и потом всё забыть.
Подытожим, основные недостатки:
На другой стороне выполняется огромное количество кода.
Часто этот код написан другими людьми, вы его не контролируете.
Накладные расходы на выполнение огромны.
Время выполнения таких операций — миллисекунды, обычно десятки или сотни миллисекунд.
Микровзаимодействия и системные вызовы
К таким операциям относятся обработка сообщений в рамках уже установленного соединения и контекста выполнения:
Обработка сообщения из WebSocket'а;
Получение порции данных SQL-запроса, выполненного с CURSOR;
Любое другое взаимодействие по сети в рамках установленного соединения.
И системные вызовы:
Взаимодействие с оборудованием (чтение с диска);
Управление ресурсами системы: выделение памяти, управление процессами;
Межпроцессное взаимодействие на одной машине.
Особенность микровзаимодействий и системных вызовов заключается в том, что они подразумевают переключение контекста, скорость их выполнения зависит от многих факторов, которые сложно контролировать.
Время выполнения таких операций — микросекунды.
Инструкции процессора
К инструкциям процессора относятся:
копирование памяти, арифметические вычисления;
вызовы функций;
работа с локальными структурами данных: создание, запись, чтение;
почти всегда — выделение памяти, потому что Go очень неохотно расстается с памятью и практически всегда под новые структуры память берется из уже выделенной этому процессу.
Это самые приятные для анализа операции, потому что длительность их выполнения максимально предсказуема. Например, если вы один раз отсортировали массив и это заняло, допустим, 10 микросекунд, то при повторной сортировке это займет примерно столько же — плюс-минус погрешность около 1–2%. При этом накладные расходы практически отсутствуют, потому что всё находится в памяти, и сколько бы раз вы ни выполняли эту операцию, если она работает с одними и теми же данными, её производительность останется стабильной.
Время выполнения операций, состоящих исключительно из инструкций процессора, начинается от единиц наносекунд.
Пример
Рассмотрим такой код:
dbUsers := db.GetUsers()
authUsers := httpClient.GetUsers(dbUsers.GetIDs())
var users []User
for _, dbUser := range dbUsers {
user := User{
Name: dbUser.Name
...
}
for _, authUser := range authUsers {
if dbUser.ID != authUser.ID {
continue
}
user.AuthMethod = authUser.AuthMethod
}
}
Сначала получаем из базы и другого сервиса списки пользователей, а потом переводим их в доменные структуры. Стажёр написал кривой код, в котором, во-первых, нет предварительной аллокации памяти, а во-вторых, сопоставление структур сделано циклом со сложностью O(N) вместо использования map со сложностью O(1).
Будет ли проблема с этим кодом? Скорее всего, нет, потому что сначала делаются два запроса, каждый из которых занимает порядка 30 миллисекунд, а далее, даже если речь идёт о тысяче пользователей, миллион операций во вложенном цикле будет выполнен менее чем за одну миллисекунду.
Количество объектов в веб-приложениях обычно ограничено возможностями браузеров — больше тысячи элементов одновременно отображать невозможно. Например, если нужно вывести список пользователей в виде таблицы, их число вряд ли превысит сотню.
Таким образом, в первую очередь стоит обратить внимание на крупные операции, а инструкцию процессора учитывать лишь постольку-поскольку. Разумеется, намеренно писать плохой код не рекомендуется, ведь рано или поздно это станет серьёзной проблемой.
Однако я убеждён, что если взять крупный проект и удалить из него все случаи предаллокации памяти, то скорость выполнения программы практически не изменится.
Теперь перейдем к рассмотрению того, что можно сделать с количеством самих операций.
Первый способ — не запускать медленный код
Звучит странно, но в больших проектах очень часто можно найти фрагменты, которые очень сильно замедляют исполнение и на самом деле не нужны. Сильно упрощенный пример – взяли из базы список отсортированных данных, а потом сделали из него map.
SELECT * FROM users ORDER BY name;
...
usersMap := map[int64]User{}
for _, u := range users {
usersMap[u.ID] = u
}
Второй пример, наверняка, многим встречался. Есть веб-приложение, на странице — таблица и пять фильтров с выпадающими списками. Как можно вычислить значения этих выпадающих списков? Либо в момент загрузки страницы, либо в тот момент, когда пользователь кликает по выпадающему списку.
Если посмотреть на статистику, то из тех, кто заходит на страницу, большинство на эти списки не смотрят, потому что фильтрами пользоваться не будут. А те, кто воспользуются фильтрами, откроют один или два выпадающих списка. Получается, что нет смысла заранее вычислять все пять выпадающих списков. Можно отложить вычисление данных до тех пор, пока они не окажутся нужны. И если вы запускаете своё приложение в облаке и платите за минуты процессорного времени, то, отказавшись от предварительного вычисления списков, вы не только повысите скорость загрузки страницы, но и меньше заплатите за пользование ресурсами.
Следующий способ оптимизации — уменьшение сложности
Пусть у нас есть таблица в базе данных. Со временем она стала очень большой, и выборка одной строки из этой таблицы стала работать медленно. Тогда вы создаёте индекс по нужной колонке и всё начинает работать просто великолепно. Причём настолько, что эта проблема в будущем вообще никогда не проявится. А почему создание индекса настолько хорошо работает? Потому что у вас уменьшилась сложность. Раньше страницы с диска читались за O(N), а теперь — O(log N). То есть количество работы не просто сократилось во много раз, а с увеличением числа строк в таблица разница с последовательным будет все больше и больше.
Уменьшение сложности — это единственный способ что-то реально улучшить при большом количестве объектов которое еще и увеличивается со временем. Никакие другие способы вроде распараллеливания, скорее всего, хорошо не сработают. Однако при этом код может стать немножко сложнее. Кроме того, другие разработчики, не разобравшись в ваших оптимизациях, могут всё испортить. И наконец, могут потребоваться дополнительные ресурсы. Например, когда вы создаёте индекс по таблице в базе данных, то затрачивается дополнительное место на диске. Бездумно создавать индексы по всем колонкам, конечно же, не стоит, потому что тогда таблица будет занимать в два раза больше места. Точно так же, когда вы какой-то алгоритм реализуете в памяти, то обычно ускорение может достигаться за счёт увеличения потребления памяти, которая не бесконечна. И может случиться так, что придётся выбирать между скоростью работы и потребляемой памятью.
Представим, что у нас есть таблица в базе данных. Со временем она стала очень большой, и выборка одной строки из этой таблицы стала работать медленно. В этом случае создаём индекс по нужной колонке — и всё начинает работать просто великолепно. Причём настолько, что эта проблема в будущем вообще никогда не проявится. А почему создание индекса настолько хорошо работает? Потому, что уменьшилась сложность. Раньше страницы с диска читались за O(N), а теперь — O(log N). То есть количество работы не просто сократилось во много раз, а с увеличением числа строк в таблице разница с последовательным чтением будет всё больше и больше.
Уменьшение сложности — это единственный способ что-то реально улучшить при большом количестве объектов, которое ещё и увеличивается со временем. Никакие другие способы вроде распараллеливания, скорее всего, хорошо не сработают. Однако при этом код может стать немного сложнее. Кроме того, другие разработчики, не разобравшись в ваших оптимизациях, могут всё испортить. И наконец, могут потребоваться дополнительные ресурсы. Например, при создании индекса по таблице в базе данных, затрачивается дополнительное место на диске. Бездумно создавать индексы по всем колонкам, конечно же, не стоит, потому что тогда таблица будет занимать в два раза больше места. Точно так же, когда вы какой-то алгоритм реализуете в памяти, то обычно ускорение достигается за счёт увеличения потребления памяти, которая не бесконечна. И может случиться так, что придётся выбирать между скоростью работы и потребляемой памятью.
И последний способ оптимизации — распараллеливание
В повседневной работе нам не доступны все методы распараллеливания, поэтому рассмотрим, какие вообще есть варианты:
Шардирование. Можно взять данные, поделить на несколько машин и после этого обращаться только туда, где лежат конкретные данные. Такая оптимизация работает хорошо, но, скорее всего, это вне ваших повседневных обязанностей.
Горизонтальное масштабирование. Один раз написали бэкенд так, чтобы он нормально масштабировался горизонтально, и всё. Потом это уже станет ответственностью других людей: они будут решать, сколько экземпляров надо поднять.
Распараллеливание внутри запроса. Это как раз тот метод распараллеливания, который можно точечно применить, попробовав ускорить работу какой-то конкретной функции. Но я считаю это не слишком хорошим методом оптимизации, особенно применительно к крупным приложениям. Ведь, во-первых, количество работы не сокращается. Во-вторых, увеличивается потребление ресурсов на одного клиента — это сложно анализировать. В-третьих, код всегда усложняется, и в нём почти наверняка появятся ошибки синхронизации. Наконец, у нас много функций, тысячи API, и если все их реализовать с использованием параллельных запросов, то разработка такого кода обойдётся очень дорого.
Итак, распараллеливание внутри запроса — это последний шанс хоть что-то ускорить, когда всё остальное уже перепробовано. Но не стоит с него начинать.
Оптимизируя код, действуйте в следующей последовательности: сначала оптимизируйте крупные операции, затем переходите к более мелким. Начните с удаления каких-то операций, потом уменьшайте сложность, и только в конце — распараллеливайте. Так вы потратите меньше времени на увеличение скорости работы вашего приложения. Не придется запускать профилировщик на каждый чих, потому что можно просто посмотреть и сразу понять, что надо делать.
В завершение напомню о том, что регистрация на онлайн-формат VK Go Meetup 2025 ещё открыта. Успейте занять последние места по ссылке.