Я хочу поделиться своим опытом использования ProtoBuf для оптимизации размера передаваемых/кэшируемых объектов. Мы в Ликероводочном заводе столкнулись с проблемой, что размер объектов Memcached огромен. Мы приблизились к превышению лимита хранилища сервера Memcached, что может привести к вытеснению объектов, увеличивая нагрузку на базы данных. Цель здесь — уменьшить трафик от IIS к серверам Memcached за счет уменьшения размера объектов Memcached.

По умолчанию мы используем BeIT.Memcached (клиент Memcached на C#), который использует BinaryFormatter для преобразования наших объектов в массив байтов, которые затем сжимаются (используя встроенный DeflateStream сжатие) и помещается на сервер Memcached.

Чтобы уменьшить размер объектов, хранящихся на серверах Memcached, клиент реализует собственную схему сериализации. Объекты сериализуются следующим образом:

  • bool, byte, short, ushort, int, uint, long, ulong, float, double сериализуются в их собственное байтовое представление;
  • DateTime сериализуется в long, содержащее значение Ticks;
  • строка закодирована как UTF8;
  • byte[] сохраняется без преобразования;
  • все остальные объекты проходят через обычный сериализатор среды выполнения BinaryFormatter.

Поскольку BinaryFormatter неоптимален, мы выбрали ProtoBuf (двоичная сериализация для .NET с использованием протокольных буферов) для сериализации этих других объектов, что должно уменьшить трафик из-за высокой степени сжатия. Основная идея состоит в том, чтобы добавить сериализатор ProtoBuf до того, как мы поместим данные на сервер Memcached. Мы предполагали, что сжатие ProtoBuf будет намного лучше, чем BinaryFormatter (теперь мы знаем).

Протобуф

ProtoBuf (протокольные буферы) — это название формата двоичной сериализации, используемого Google для большей части обмена данными. Он предназначен для:

  • небольшой размер (эффективное хранение данных, намного меньше, чем XML);
  • дешевый в обработке (на клиенте и сервере);
  • независимость от платформы (переносимость между различными архитектурами программирования);
  • расширяемый (возможность добавлять новые данные к старым сообщениям)

ProtoBuf может быть объявлен двумя способами:

  1. Использование таких атрибутов, как [ProtoContract] для каждого класса, который следует кэшировать. Пример:
[ProtoContract] 
public class ProtoBuffClass 
{    
  [ProtoMember(1)]    
  public int ProtoMember1 { get; set; }     
  
  [ProtoMember(2)]    
  public string ProtoMember2 { get; set; }     
  
  [ProtoMember(3)]    
  public bool ProtoMember3 { get; set; } 
}

2. Использование мета-реализации (на основе Reflection). Пример:

var model = ProtoBuf.Meta.RuntimeTypeModel.Default; 
var serializableTypes = Assembly.GetExecutingAssembly().GetTypes();

Каждый тип из текущей сборки будет проходить через несколько методов, чтобы подготовить правильную модель для ProtoBuf. Мы не стали заниматься этим дальше, так как считали, что использование отражения для всех наших объектов Memcache может оказаться слишком дорогим.

Дизайн

Базовые/производные классы

Каждый производный класс должен иметь базовый класс, отмеченный [ProtoInclude(‹num›, typeof(ProtoBuff-Derived-Class))]. Если нет, все значения будут NULL.

[ProtoContract]
[ProtoInclude(100, typeof(HomeFolders))]
[ProtoInclude(200, typeof(PublicFolders))]
public class Folders
{
   [ProtoMember(1)]
   public int ProtoMember1 { get; set; }
   [ProtoMember(2)]
   public int ProtoMember2 { get; set; }
}
[ProtoContract]
public class HomeFolders : Folders
{
   [ProtoMember(1)]
   public int ProtoMember4 { get; set; }
}
[ProtoContract]
public class PublicFolders : Folders
{
   [ProtoMember(1)]
   public int ProtoMember5 { get; set; }
}

Избегайте повторяющихся тегов свойств

Использование одного и того же номера для ProtoInclude и ProtoMember вызовет ошибку о повторяющихся тегах свойств. Пример ниже НЕ правильный.

[ProtoContract]
[ProtoInclude(1, typeof(PublicFolders))]
public class Folders
{
   [ProtoMember(1)]
   public int ProtoMember1 { get; set; }
}

Поэтому вам нужно использовать другой номер для ProtoInclude. Исправленный пример:

[ProtoContract]
[ProtoInclude(100, typeof(PublicFolders))]
public class Folders
{
   [ProtoMember(1)]
   public int ProtoMember1 { get; set; }
}

Нулевые и пустые коллекции

ProtoBuf не понимает разницы между нулевой коллекцией (List, IEnumerable и т. д.) и пустой (нулевое количество). Например, если вы поместите эти объекты в кеш,

List<int> list1 = new List<int>();
List<int> list2 = null;

после десериализации оба списка будут иметь одинаковое значение — NULL. Есть два способа решить эту проблему:

  1. Используя приватное поле (мы используем это):
[ProtoMember(12, OverwriteList = true)] 
private List _publicFolders; 
public List publicFolders 
{    
   get    
   {        
        if (_publicFolders == null)        
        {            
            _publicFolders = new List();        
        }        
        return _publicFolders;    
   }    
   set    
   {        
        _publicFolders = value;    
   } 
}

2. Используя атрибут OnDeserialized:

[ProtoMember(2, OverwriteList = true)] 
private PublicFolder[] publicFolders; 
[ProtoMember(3, OverwriteList = true)] 
private PrivateFolder[] privateFolder; 
[ProtoMember(4, OverwriteList = true)] 
private SecureFolder[] secureFolder;   
[OnDeserialized] 
private void HandleSerializationMismatch(StreamingContext context) 
{    
   publicFolders = publicFolders ?? new PublicFolders[0];      
   privateFolder = privateFolder ?? new PrivateFolder[0];    
   secureFolder = secureFolder ?? new SecureFolder[0]; 
}

То, что нужно запомнить

ProtoBuf игнорирует свойства, если класс наследуется от коллекции, а свойство Items для этой коллекции равно null. Пример:

public class Folders : List
{
   public int value1 { get; set; }
 
   public int value2 { get; set; }
}
Folders folders = new Folders() { value1 = 5; value2 = 6; };

После десериализации значение объекта Folders будет равно NULL, поскольку количество элементов равно 0.

Классы, наследуемые от специальных коллекций, также не поддерживаются.

public class Folders : ReadOnlyCollection
{
   public int value1 { get; set; }
 
   public int value2 { get; set; }
}
 
Folders folders = new Folders() { value1 = 5; value2 = 6; };

Алловпарсеаблетипес

AllowParseableTypes — это глобальный переключатель, который определяет, должны ли типы с методами «.ToString()» и «Parse(string)» сериализоваться как строки. Мы можем использовать этот параметр для типов, которые нельзя пометить в ProtoContract, но которые можно анализировать.

static ProtoBufClient()
{
   RuntimeTypeModel.Default.AllowParseableTypes = true;
}

Например, чтобы решить проблему сериализации с типом Version:

[Serializable]
[ProtoContract(SkipConstructor = true)]
[ProtoInclude(100, typeof(PrivateFolder))]
[ProtoInclude(200, typeof(PublicFolder))]
[ProtoInclude(300, typeof(SecureFolder))]
public abstract class FolderBase : Folder
{
   ...
 
   [ProtoMember(3)]
   private string name;
   [ProtoMember(4)]
   private Owner owner;
 
   ...
}

Тестирование

Настраивать:

  1. 1605 виртуальных пользователей (устройств), используемых для создания нагрузки.
  2. Протестировано в среде с 5 веб-серверами и 1 сервером базы данных.
  3. Распределение крупных объектов.
  4. Пропускная способность ~200 тыс. об/мин

Метрики базы данных

Оригинал

Протобуф

Использование Memcached

Оригинал

Протобуф

Оригинал

Протобуф

ЦП, память и сеть

Оригинал

Протобуф

Оригинал

Протобуф

Вывод

После того, как мы применили оптимизацию ProtoBuf для объектов, мы увидели огромное влияние на производительность системы. RPM нашего продукта увеличился на 25%, а средний размер элементов данных уменьшился с 1 МБ до 200 КБ (в 5 раз). Скорость сетевого ввода-вывода снизилась с 1 Гбит/с до 500 Мбит/с.