Если вы когда-нибудь задумывались, почему ваше Go-приложение потребляет память, как на буфете с неограниченным количеством еды, скорее всего, вы не оптимизировали использование Protocol Buffers. Я сам был в такой ситуации, с ужасом глядя на профили кучи, обычно испытывая те же чувства, что и при проверке банковского счёта после ночной прогулки. Но вот хорошая новость: Protocol Buffers в Go могут быть невероятно быстрыми и эффективными по использованию памяти, если знать приёмы оптимизации.
Позвольте мне рассказать о методах оптимизации, которые превратили мои сервисы из прожорливых монстров в компактные и эффективные машины сериализации. Мы говорим о реальных улучшениях производительности — не о тех, что едва заметны, а о тех, благодаря которым использование памяти сократилось вдвое.
Проблема выделения памяти
Прежде чем углубиться в решения, давайте разберёмся, что происходит, когда вы создаёте тысячи сообщений protobuf. Каждый раз, когда вы вызываете &pb.MyMessage{}
или используете proto.Unmarshal()
, в действие вступает распределитель памяти Go. Для небольшого количества сообщений это нормально. Но когда вы обрабатываете тысячи запросов в секунду, каждый из которых создаёт несколько сообщений protobuf, распределитель памяти становится узким местом.
Вот как выглядит типичный поток запросов:
Проблема быстро усугубляется. Каждое выделение памяти создаёт нагрузку на сборщик мусора, который в конечном итоге вынужден приостановить ваше приложение для очистки. Эти паузы GC — тихие убийцы задержек.
Повторное использование сообщений: ваша первая линия обороны
Начнём с самой простой оптимизации: повторное использование объектов сообщений. Сообщения Protocol Buffers «умные» — они сохраняют выделенную память даже после того, как вы их очищаете. Это означает, что вы можете повторно использовать один и тот же объект сообщения без необходимости обращаться к распределителю каждый раз.
Вот наивная реализация, которая создаёт новое сообщение для каждого запроса:
func ProcessRequests(requests []*pb.Request) []*pb.Response {
responses := make([]*pb.Response, 0, len(requests))
for _, req := range requests {
// Плохо: новое выделение памяти на каждой итерации
resp := &pb.Response{
Id: req.Id,
Status: pb.Status_OK,
Message: "Обработано",
}
responses = append(responses, resp)
}
return responses
}
Теперь посмотрим, что произойдёт, если мы будем повторно использовать сообщение:
func ProcessRequestsOptimized(requests []*pb.Request) []*pb.Response {
responses := make([]*pb.Response, 0, len(requests))
resp := &pb.Response{} // Одно выделение памяти
for _, req := range requests {
// Очистка предыдущих данных
resp.Reset()
// Заполнение новыми данными
resp.Id = req.Id
resp.Status = pb.Status_OK
resp.Message = "Обработано"
// Клонирование для среза (всё равно лучше, чем свежее выделение)
responses = append(responses, proto.Clone(resp).(*pb.Response))
}
return responses
}
Но есть нюанс — и здесь становится интересно. Сообщения со временем могут раздуваться, особенно если вы периодически обрабатываете большие сообщения, а затем возвращаетесь к сообщениям обычного размера. Сообщение удерживает эту память. Поэтому вам нужно отслеживать и сбрасывать размер, когда он становится слишком большим:
type MessagePool struct {
resp *pb.Response
maxSpaceUsed int
}
func NewMessagePool() *MessagePool {
return &MessagePool{
resp: &pb.Response{},
maxSpaceUsed: 1024 * 1024, // Порог 1MB
}
}
func (p *MessagePool) GetResponse() *pb.Response {
// Проверка, не стало ли сообщение слишком большим
if proto.Size(p.resp) > p.maxSpaceUsed {
// Время для нового начала
p.resp = &pb.Response{}
} else {
p.resp.Reset()
}
return p.resp
}
Проектирование схемы: основа производительности
Вот что меня удивило, когда я только начинал работать с Protocol Buffers: номера полей на самом деле важны для производительности. Не только для обратной совместимости, но и для сырой скорости.
Меньшие номера полей требуют меньше байтов для кодирования. Поле с номером от 1 до 15 занимает один байт для тега, а поле от 16 до 2047 — два байта. Это может показаться тривиальным, но когда вы сериализуете миллионы сообщений, эти байты складываются.
// До оптимизации
message UserProfile {
string биография = 1; // Редко используется
repeated string интересы = 2; // Редко используется
string user_id = 15; // Часто используется
string email = 16; // Часто используется
int64 created_at = 17; // Часто используется
}
// После оптимизации
message UserProfile {
string user_id = 1; // Наиболее часто используемые сначала
string email = 2;
int64 created_at = 3;
string биография = 15;
repeated string интересы = 16;
}
Ещё один нюанс: избегайте вложенности, если она не нужна. Каждый уровень вложенности усложняет сериализацию и десериализацию. Иногда плоская структура бывает быстрее и проще в работе:
// Избегайте этого, если возможно
message DeepNesting {
message Level1 {
message Level2 {
message Level3 {
string data = 1;
}
Level3 level3 = 1;
}
Level2 level2 = 1;
}
Level1 level1 = 1;
}
// Предпочитайте это
message FlatStructure {
string data = 1;
string context_level1 = 2;
string context_level2 = 3;
}
И вот совет от профессионала: используйте числовые типы вместо строк, когда это возможно. Строковое представление числа занимает больше места и обрабатывается медленнее:
message Transaction {
// Плохо: 8–20+ байтов в зависимости от значения
string amount = 1;
// Хорошо: фиксированные 8 байтов
int64 amount_cents = 2;
}
Сжатое кодирование: бесплатная производительность
Для повторяющихся полей, содержащих примитивные типы, сжатое кодирование — ваш друг. Оно включено по умолчанию в proto3, но если вы всё ещё используете proto2, нужно указать это явно:
// proto2
message Analytics {
repeated int32 user_ids = 1 [packed=true];
repeated double metrics = 2 [packed=true];
}
// proto3 (сжато по умолчанию)
message Analytics {
repeated int32 user_ids = 1;
repeated double metrics = 2;
}
Сжатое кодирование хранит все значения в одной записи с ограничением длины, вместо того чтобы кодировать каждое значение отдельно. Для поля типа repeated int32
с 100 значениями это может уменьшить размер сообщения более чем на 50%.
Сила oneof
Когда у вас есть поля, которые взаимоисключающие, oneof
меняет правила игры. Он уменьшает размер полезной нагрузки и ускоряет десериализацию, потому что парсер знает, что будет установлено только одно поле:
// Без oneof
message Notification {
string email_content = 1;
string sms_content = 2;
string push_content = 3;
}
// С oneof
message Notification {
oneof content {
string email_content = 1;
string sms_content = 2;
string push_content = 3;
}
}
В Go это генерирует более чистый код:
func ProcessNotification(n *pb.Notification) error {
switch content := n.Content.(type) {
case *pb.Notification_EmailContent:
return sendEmail(content.EmailContent)
case *pb.Notification_SmsContent:
return sendSMS(content.SmsContent)
case *pb.Notification_PushContent:
return sendPush(content.PushContent)
default:
return errors.New("неизвестный тип