В данной статье хочу поделиться своим опытом работы с in-memory database hazelcast. Была задача, нужно было разобраться с кэшированием разноразмерных данных в hazelcast. В hazelcast есть много распределенных сущностей с которыми вы можете работать это queue, set, multimap и т.д. В ПО с которым я имел дело использовалсь сущность Map с настроенным вытеснением в конфиге.

Конфиг для версии hazelcast 4.2.1

<map name="default">
        <eviction eviction-policy="LRU" max-size-policy="USED_HEAP_PERCENTAGE" size="80"/>
</map>

Всё просто и ясно по достижению передела памяти 80% выделеного для jvm начинается освобождение данных Map. При дальнейшем исследовании, выяснилось что при попытки достичь предела памяти кэша с одним и тем же размером данных всё отлично. Но если вы заполните память до предела очень маленькими значениями — например 3 байта а потом начнете писать туда большие объекты например размером 1 мегабайт, то вытеснение не срабатывает и hazelcast падает с ошибкой Exception: OutOfMemory.

ПРЕДИСТОРИЯ: Вначале у моего ПО была версия 3.8.6, первое что пришло в голову — нужно обновиться. Не поленясь немного поправил api для новой версии 4.2.1 запустил всё и опять провел эксперимент, каково же было мое удивление когда hazelcast опять упал с OutOfMemory.

Причиной такого поведения было то что алгоритм вытеснения(eviction) hazelcast по достижению предела памяти выбирает 15 записей из карты (sampling) из них ищет самое позднее по доступу в соответствии с LRU или LFU и освобождает один объект и так до следущей записи. Что же получается по достижению предела памяти на операции записи, мы освобождаем 3 байта памяти на каждый записываемый 1МБ. Нетрудно догодаться что запись в память дальше выходит за пределы лимита 80% который мы выставили и упирается в потолок heap, что приводит hazelcast к exception OutOfMemory.

Количество освобождаемых элементов за раз можно настроить hazelcast.map.eviction.batch.size и количество сэмплируемых тоже hazelcast.map.eviction.sample.count. Но это не решает проблему с кэшем разноразмерных данных. Я написал issue на github разработчикам на ломанном английском, и они потвердили мои догадки.

ahmetmircik commented 16 days ago
Right we remove 1 entry by default. More or less identical values in length is better fit. But the branch i referenced above tries to remove entries till there is available memory. It is something auto tuned version of hazelcast.map.eviction.batch.size. Did you have chance to try it?

РАЗРЕШЕНИЕ: Оказывается дело было в том что в open source community версии hazelcast используется приведенный алгоритм вытеснения, который подходит только для одно размерных данных, а в enterprise версии используется более усовершенствованный алгоритм “Forced Eviction” c типом памяти NATIVE который доступен только в enterprise версии. И всё это лишь маркетинговая стратегия.

Hazelcast IMDG Enterprise

Hazelcast may use forced eviction in the cases when the eviction explained in Understanding Map Eviction is not enough to free up your memory. Note that this is valid if you are using Hazelcast IMDG Enterprise and you set your in-memory format to NATIVE.
The forced eviction mechanism is explained below as steps in the given order:
When the normal eviction is not enough, forced eviction is triggered and first it tries to evict approx. 20% of the entries from the current partition. It retries this five times.
If the result of above step is still not enough, forced eviction applies the above step to all maps. This time it might perform eviction from some other partitions too, provided that they are owned by the same thread.
If that is still not enough to free up your memory, it evicts not the 20% but all the entries from the current partition.
If that is not enough, it will evict all the entries from the other data structures; from the partitions owned by the local thread.
Finally, when all the above steps are not enough, Hazelcast throws a native OutOfMemoryException.
When you have an evictable cache/map, you should safely put entries to it without facing with any memory shortages. Forced eviction helps to achieve this. Regular eviction removes one entry at a time while forced eviction can remove multiple entries, which can even be owned by another caches/maps.

Но также разработчики предложили некую ветка с патчем github: oomeEviction которая реализует вытеснение пока память не освободиться для объекта. Если посмотреть там всего один коммит.

    public void evict(RecordStore recordStore, Data excludedKey) {
        assertRunningOnPartitionThread();

 -      for (int i = 0; i < batchSize; i++) {
 -          EntryView evictableEntry = selectEvictableEntry(recordStore, excludedKey);
 -          if (evictableEntry == null) {
 -              return;
 +       do {
 +          for (int i = 0; i < batchSize; i++) {
 +              EntryView evictableEntry = selectEvictableEntry(recordStore, excludedKey);
 +              if (evictableEntry == null) {
 +                  return;
 +              }
 +              evictEntry(recordStore, evictableEntry);
 +          }
 -          evictEntry(recordStore, evictableEntry);
 -      }
 +      } while (recordStore.shouldEvict());

    }

Суть тут в том что while (recordStore.shouldEvict()) проверяет есть ли свободная память и повторяет цикл сэмплирование с вытеснение объекта до тех пор пока память не будет доступна. Вродебы проблема решена но я сделал другой тест, в heap 500МБ я оставил записываться данные по 3байта, это примерно 500.000.000/3=166.000.000 записей. На самом деле конечно же намного меньше т.к надо учитывать метаданные.

В итоге hazelcast упал после 3.000.000 записей, т.к либо нехватило памяти для метаданных, либо garbage collector не успел отработать, кстате нужно быть уверененным что памяти jvm хватает и она не свопиться иначе вас жду очередные падаения. И я решил добавить ещё один фикс для ограничения максимального количества элементов равным 100000.

diff -r ./hazelcast-4.2.1/hazelcast/src/main/java/com/hazelcast/map/impl/eviction/EvictionChecker.java ./hazelcast-4.2.1-modified/hazelcast/src/main/java/com/hazelcast/map/impl/eviction/EvictionChecker.java
90a91,95
>       // limit objects in node
>       if ( recordStore.size() > toPerPartitionMaxSize(100000, mapName) ) {
>               return true;
>       }
>

Приведенные патчи это конечно решения костыли, т.к нарушают всю логику конфигурации, но добавить свою опцию в eviction для maxSizePolicy у меня вызвало затруднее т.к после добавления начинает ругаться hazelcast managment center что он не знает такого enum.