Pull to refresh

SoftReference Read-Write Cache для Hibernate — с «хвостами» для реализации кластера

Reading time17 min
Views4.4K
В этом топике попробую рассказать о реализации системы кеширования данных в CMS ArpSite. Эта подсистема уже не раз переписывалась, но она является именно тем, что позволяет системе вообще работать с приемлимым временем отклика.

Использование SoftReference даёт возможность оставить поведение кеширования «на самотёк» — за пореблением памяти следит сама JVM, она же очищает старые элементы.

«Cluster» означает, что кеш самостоятельно отслеживает события инвалидации (устаревания, обновления) значений и следит за тем, чтобы на других серверах в кластере устаревшие элементы выкидывались из кеша. Есть и другие определения кластерного кеша. Например, основной режим работы JBoss Cache может даже «вытягивать» элементы с других узлов кластера, если их нет на текущей машине. Но для нашей системы, где скорость получения элемента с другой машины ненамного меньше скорости генерации элемента, достаточно инвалидации.

Read-Write означает, что мы не используем блокировку элементов кеша и вообще не думаем (почти) о том, что в нашей системе есть какие-то независимые транзакции. Для CMS это нормально, но, разумеется, для какого-нибудь enterprise application это было бы недопустимо.

В настоящий момент кеширование в ArpSite разделяется на три уровня:
  • Кеширование сгенерированных страниц
  • Остальные кеши
  • Кеширование данных

Остальные кеши не случайно находятся по середине. Сгененрированные страницы это то, что будет отдано пользователю немедленно — если совпадёт URL и некоторые другие параметры. Обращений к базе для этого не нужно вообще. Кеширование данных — это то, что сейчас называется кешем второго уровня (second level cache) в Hibernate — кешируются отдельные строки базы данных (по ключу) и некоторые популярные запросы к базе данных (по аргументам). Под остальными кешами понимаются различные механизмы кеширования промежуточных результатов генерирования страниц, кеширования таблиц стилей XSLT, кусочков XML и прочих.

В случае, если в системе происходит какое-либо изменение, нам нужно очищать кеши. Для этого система при каждом изменении данных генерирует одно или несколько событий (event'ов). Эти события асинхронно рассылаются по остальным нодам кластера, после чего кеши реагируют на эти события полной очисткой или иинвалидацией отдельных элементов.

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

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

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

Новый Cache API для Hibernate


В терминологии Hibernate один Cache — это отдельный кусок кеша, содержащий набор одинаково типизированных данных. Например, данных из одной таблицы, либо данных, например, сразу по всем закешированным запросам к базе данных. Для того, чтобы управлять поведением кеширования в Hibernate, программист указывает какой CacheProvider использовать. Это может быть, например, EhCacheProvider или тривиальные HashtableCacheProvider или NoCacheProvider.

В версии 3.3 Hibernate ввёл новый API для кеширования, разделив один старый интерфейс Cache на несколько:
Region
** General Data Region
**** Query Results Region
**** Timestamps Region
** Transactional Data Region
**** Collection Region
**** Entity Region

Также каждый Region теперь предоставляет методы для работы вне контекста (destroy, getSize, getName()), General Data Region — стандартные методы get / put, а transaction data region организует работу с данными через паттерн Strategy — CollectionRegionAccessStrategy для Collection Region и EntityRegionAccessStrategy для Entity Region. Эти интерфейсы содержат, в числе прочего, команды для управления блокировками данных в рамках транзакций.

У нас уже был раньше кеш для Hibernate, основанный на старом API. Но от него пришлось отказаться, так как старый API не позволял отличить добавление данных в кеш из базы данных от их обновления (так как они устарели). Поэтому первым шагом была реализация нового API, но уже на основе Region / Strategy. Большую помощь оказало то, что Hibernate имеет реализации адаптеров из нового API в старый. Можно просто подсмотреть реализацию, например, EntityRegionAccessStrategy на основе non-transaction cache. Что и было сделано.

Основу API составляет Region:
  1. abstract class SoftRegion implements ArpSiteRegion, Cache, SoftRegionMBean {
  2.  
  3.   /**
  4.    * Default no-operation implementation of {@link ArpSiteRegionListener}.
  5.    * Will be replaced by cluster listener if cluster extension is installed
  6.    *
  7.    * @author vlsergey {at} gmail {dot} com
  8.    */
  9.   private static final class NoOpListener implements ArpSiteRegionListener {
  10.     @Override
  11.     public void onClear() {
  12.       // noop
  13.     }
  14.  
  15.     @Override
  16.     public void onRemove(Object key) {
  17.       // noop
  18.     }
  19.   }
  20.  
  21.   private AtomicLong clears = new AtomicLong(0);
  22.  
  23.   private AtomicLong hits = new AtomicLong(0);
  24.  
  25.   protected final AtomicLong lastTimestamp = new AtomicLong();
  26.  
  27.   protected volatile ArpSiteRegionListener listener = new NoOpListener();
  28.  
  29.   private final Map<Object, Object> map;
  30.  
  31.   private AtomicLong miss = new AtomicLong(0);
  32.  
  33.   private final String regionName;
  34.  
  35.   /**
  36.    * @param regionName
  37.    *      The name of the cache region.
  38.    * @param useHardReferences
  39.    *      <code>true</code> if cache must use hard references instead of
  40.    *      {@link java.lang.ref.SoftReference}s. <code>false</code>
  41.    *      otherwise.
  42.    */
  43.   @SuppressWarnings("unchecked")
  44.   SoftRegion(String regionName, boolean useHardReferences) {
  45.     this.regionName = regionName;
  46.  
  47.     if (useHardReferences) {
  48.       map = new ConcurrentHashMap<Object, Object>();
  49.     } else {
  50.       map = Collections
  51.           .<Object, Object> synchronizedMap(new ReferenceMap(
  52.               ReferenceMap.SOFT, ReferenceMap.SOFT, true));
  53.     }
  54.   }
  55.  
  56.   @Override
  57.   public void clear() {
  58.     map.clear();
  59.     clears.incrementAndGet();
  60.     listener.onClear();
  61.   }
  62.  
  63.   @Override
  64.   public boolean contains(Object key) {
  65.     Object value = map.get(key);
  66.     if (value != null) {
  67.       // to let JVM know it shall not GC it because of timeout
  68.       value.hashCode();
  69.       return true;
  70.     }
  71.     return false;
  72.   }
  73.  
  74.   @Override
  75.   public void destroy() throws CacheException {
  76.     map.clear();
  77.   }
  78.  
  79.   protected Object get(Object key) {
  80.     final Object result = map.get(key);
  81.  
  82.     if (result == null) {
  83.       miss.incrementAndGet();
  84.     } else {
  85.       hits.incrementAndGet();
  86.     }
  87.  
  88.     return result;
  89.   }
  90.  
  91.   @Override
  92.   public long getBuildCalls() {
  93.     return miss.get();
  94.   }
  95.  
  96.   @Override
  97.   public int getCacheSize() {
  98.     return map.size();
  99.   }
  100.  
  101.   @Override
  102.   public long getClearCalls() {
  103.     return clears.get();
  104.   }
  105.  
  106.   @Override
  107.   public long getElementCountInMemory() {
  108.     return map.size();
  109.   }
  110.  
  111.   @Override
  112.   public long getElementCountOnDisk() {
  113.     return 0;
  114.   }
  115.  
  116.   @Override
  117.   public long getGetCalls() {
  118.     return hits.get() + miss.get();
  119.   }
  120.  
  121.   @Override
  122.   public long getHits() {
  123.     return hits.get();
  124.   }
  125.  
  126.   @Override
  127.   public int getHitsPercent() {
  128.     long hits = this.hits.get();
  129.     long total = hits + miss.get();
  130.     if (total == 0) {
  131.       return 0;
  132.     }
  133.     return (int) (hits * 100 / total);
  134.   }
  135.  
  136.   @Override
  137.   public String getName() {
  138.     return regionName;
  139.   }
  140.  
  141.   @Override
  142.   public long getSizeInMemory() {
  143.     return -1;
  144.   }
  145.  
  146.   @Override
  147.   public int getTimeout() {
  148.     return Timestamper.ONE_MS * 60000; // ie. 60 seconds
  149.   }
  150.  
  151.   @Override
  152.   public long nextTimestamp() {
  153.     final long next = Timestamper.next();
  154.     lastTimestamp.set(next);
  155.     return next;
  156.   }
  157.  
  158.   protected void put(Object key, Object value) {
  159.     map.put(key, value);
  160.   }
  161.  
  162.   @Override
  163.   public void remove(Object key) {
  164.     map.remove(key);
  165.     listener.onRemove(key);
  166.   }
  167.  
  168.   @Override
  169.   public void setListener(ArpSiteRegionListener listener) {
  170.     this.listener = listener;
  171.   }
  172.  
  173.   @Override
  174.   public Map<?, ?> toMap() {
  175.     return Collections.unmodifiableMap(map);
  176.   }
  177.  
  178.   @Override
  179.   public String toString() {
  180.     return "SoftReferenceCache [" + regionName + ", "
  181.         + this.getSizeInMemory() + ']';
  182.   }
  183.  
  184. }
* This source code was highlighted with Source Code Highlighter.

Реализация основных методов довольна тривиальна. Мы реализуем интерфейс ArpSiteRegion, так как он содержит дополнительный метод setListener(), который нам позже понадобится для реализации работы в кластере. Методы nextTimestamp/getTimeout нам потребуются для TimestampRegionImpl. Дополнительные методы вроде getClearCalls/getBuildCalls/getHitsPercent/clear определены в интерфейсе Cache нашего приложения и нужны для управления кешем через JMX. Hibernate, к сожалению, подобный функционал не предоставляет. Да и откуда он знает про особенности нашего кеша ;)

SoftCollectionRegion и SoftEntityRegion являются очень похожими, поэтому приведу только второй из них:
  1. class SoftEntityRegion extends SoftTransactionalDataRegion implements
  2.     EntityRegion {
  3.  
  4.   /**
  5.    * @param regionName
  6.    *      The name of the cache region.
  7.    */
  8.   SoftEntityRegion(String regionName,
  9.       CacheDataDescription cacheDataDescription) {
  10.     super(regionName, cacheDataDescription);
  11.   }
  12.  
  13.   @Override
  14.   public EntityRegionAccessStrategy buildAccessStrategy(AccessType accessType) {
  15.     return new EntityRegionAccessStrategy() {
  16.  
  17.       @Override
  18.       public boolean afterInsert(Object key, Object value, Object version) {
  19.         return false;
  20.       }
  21.  
  22.       @Override
  23.       public boolean afterUpdate(Object key, Object value,
  24.           Object currentVersion, Object previousVersion, SoftLock lock) {
  25.         SoftEntityRegion.this.remove(key);
  26.         return false;
  27.       }
  28.  
  29.       @Override
  30.       public void evict(Object key) {
  31.         SoftEntityRegion.this.remove(key);
  32.       }
  33.  
  34.       @Override
  35.       public void evictAll() {
  36.         SoftEntityRegion.this.clear();
  37.       }
  38.  
  39.       @Override
  40.       public Object get(Object key, long txTimestamp) {
  41.         return SoftEntityRegion.this.get(key);
  42.       }
  43.  
  44.       @Override
  45.       public EntityRegion getRegion() {
  46.         return SoftEntityRegion.this;
  47.       }
  48.  
  49.       @Override
  50.       public boolean insert(Object key, Object value, Object version) {
  51.         return false;
  52.       }
  53.  
  54.       @Override
  55.       public SoftLock lockItem(Object key, Object version) {
  56.         return null;
  57.       }
  58.  
  59.       @Override
  60.       public SoftLock lockRegion() {
  61.         return null;
  62.       }
  63.  
  64.       @Override
  65.       public boolean putFromLoad(Object key, Object value,
  66.           long txTimestamp, Object version) {
  67.         SoftEntityRegion.this.put(key, value);
  68.         return true;
  69.       }
  70.  
  71.       @Override
  72.       public boolean putFromLoad(Object key, Object value,
  73.           long txTimestamp, Object version, boolean minimalPutOverride) {
  74.         if (minimalPutOverride && contains(key)) {
  75.           return false;
  76.         }
  77.  
  78.         SoftEntityRegion.this.put(key, value);
  79.         return true;
  80.       }
  81.  
  82.       @Override
  83.       public void remove(Object key) {
  84.         SoftEntityRegion.this.remove(key);
  85.       }
  86.  
  87.       @Override
  88.       public void removeAll() {
  89.         SoftEntityRegion.this.clear();
  90.       }
  91.  
  92.       @Override
  93.       public void unlockItem(Object key, SoftLock lock) {
  94.         SoftEntityRegion.this.remove(key);
  95.       }
  96.  
  97.       @Override
  98.       public void unlockRegion(SoftLock lock) {
  99.         SoftEntityRegion.this.clear();
  100.       }
  101.  
  102.       @Override
  103.       public boolean update(Object key, Object value,
  104.           Object currentVersion, Object previousVersion) {
  105.         SoftEntityRegion.this.remove(key);
  106.         return false;
  107.       }
  108.     };
  109.   }
  110.  
  111. }
* This source code was highlighted with Source Code Highlighter.


Основная работа выполняется через интерфейс стратегии. Идея использования отдельного интерфейса в том, что в транзакционной реализации каждая транзакция будет использовать отдельный экземпляр EntityRegionAccessStrategy. Однако у нас мы просто перенаправляем все запросы к SoftEntityRegion. Особенности реализации методов update(), unlockItem() и unlockRegion() были подсмотрены у класса EntityAccessStrategyAdapter. С точки зрения будущего «прикручивания» кластера важно отметить, что листенер из SoftRegion будет реагировать только на методы remove() и clear().

Кто их будет вызывать? Сам Hibernate будет их вызывать в те моменты, когда закончит работать с отдельными объектами. Например, он всегда будет вызывать evict(), или же evictAll() — если мы что-то делали с помощью запросов. Именно такая предсказуемая работа с кешем и даст нам возможность в будущем легко добавить поддержку кластера — всего лишь отследив вызовы clear / remove.

А вот с QueryCache не всё так просто. Проблема в том, что Hibernate… не удаляет старые запросы из кеша. То есть не инвалидирует значения в кеше если что-то произошло. Его поведение хитрее: рядом с QueryResultsRegion трудится TimestampsRegion, который содержит, судя по его названию, timestamp'ы последних изменений отдельных таблиц в базе данных. Поэтому если нам надо выполнить запрос к базе данных, Hibernate делает следующее:
  1. Взять timestamp для таблицы из TimestampsRegion
  2. Взять результат из QueryResultsRegion
  3. У результата взять Timestamp
  4. Сравнить с тем, что было взять из TimestampsRegion. Если значение больше — то результат в QueryResultsRegion считается более новым, чем последнее изменение, и его можно использовать.
  5. Важно — если вTimestampsRegion нет значения для определённой таблицы, Hibernate считает, что её данные не менялись
  6. Если же timestamp результата меньше, чем взятый из TimestampsRegion, то значение считается устаревшим. Однако, при этом значение не удаляется из кеша, а заменяется тем, что будет получено из базы

При этом в случае каких-либо запросов на UPDATE или INSERT в TimestampsRegion будет помещён новый timestamp для таблицы, но при этом QueryCache об этом никак оповещён не будет!

Из 5-го пункта, кстати, следует, что для нормальной работы даже без кластера ни в коему случае нельзя использовать SoftReference для хранения значений в TimestampsRegion. Даже в описании UpdateTimestampsCache сказано — "we recommend that the the underlying cache not be configured for expiry at all" — то есть не удалять значения из этого кеша вообще. Благо, он не очень-то много места занимает в памяти.

Из описания выше ясно, что простой перехват clear()/remove() ничего не даст — они просто не вызываются. Перехватывать put() у QueryCache бесполезно, да и может оказаться поздно — timestamp мог уже обновиться. Поэтому возможны два варианта:
  • Делать полноценную реализацию TimestampsRegion на уровне кластера. А именно — отслеживать выдаваемые на каждой машине timestapm'ы и корректно их синхронизировать на уровне кластера
  • Делать грязные хаки

К сожалению, первый подход для CMS Arp.Site оказался недопустим — синхронизация Timestamp предполагает некий общий счётчик вроде AtomicLong, но работающий в рамках кластера. То есть все обращения к нему должны быть синхронизированы — каждое добавления объекта в QueryCache кеш. А это очень отрицательно сказалось бы на производительности — синхронная обработка в сети намного дороже, чем асинхронная.

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

  1. class TimestampRegionImpl extends SoftRegion implements ArpSiteTimestampRegion {
  2.   /**
  3.    * Default no-operation implementation of
  4.    * {@link ArpSiteTimestampRegionListener}. Will be replaced by cluster
  5.    * listener if cluster extension is installed
  6.    *
  7.    * @author vlsergey {at} gmail {dot} com
  8.    */
  9.   private static final class NoOpListener implements
  10.       ArpSiteTimestampRegionListener {
  11.  
  12.     @Override
  13.     public void onInvalidate(Serializable space) {
  14.       // no op
  15.     }
  16.  
  17.     @Override
  18.     public void onPreInvalidate(Serializable space) {
  19.       // no op
  20.     }
  21.  
  22.   }
  23.  
  24.   private ArpSiteTimestampRegionListener listener = new NoOpListener();
  25.  
  26.   /**
  27.    * @param regionName
  28.    *      The name of the region.
  29.    */
  30.   TimestampRegionImpl(String regionName) {
  31.     super(regionName, true);
  32.   }
  33.  
  34.   @Override
  35.   public void evict(Object key) {
  36.     super.remove(key);
  37.   }
  38.  
  39.   @Override
  40.   public void evictAll() {
  41.     super.clear();
  42.   }
  43.  
  44.   @Override
  45.   public Object get(Object key) {
  46.     return super.get(key);
  47.   }
  48.  
  49.   @Override
  50.   public void invalidate(Serializable space) {
  51.     Long ts = Long.valueOf(nextTimestamp());
  52.     super.put(space, ts);
  53.   }
  54.  
  55.   @Override
  56.   public void preInvalidate(Serializable space) {
  57.     Long ts = Long.valueOf(nextTimestamp() + getTimeout());
  58.     super.put(space, ts);
  59.   }
  60.  
  61.   @Override
  62.   public void put(Object key, Object value) {
  63.     super.put(key, value);
  64.  
  65.     final Serializable space = (Serializable) key;
  66.     final Long timestamp = (Long) value;
  67.  
  68.     final boolean inFuture = timestamp.longValue() > lastTimestamp.get();
  69.     if (inFuture) {
  70.       listener.onPreInvalidate(space);
  71.     } else {
  72.       listener.onInvalidate(space);
  73.     }
  74.   }
  75.  
  76.   @Override
  77.   public void setTimstampListener(
  78.       ArpSiteTimestampRegionListener timestampListener) {
  79.     this.listener = timestampListener;
  80.   }
  81. }
* This source code was highlighted with Source Code Highlighter.


Основная работа делается в методе put(). Чтобы понять логику метода нужно остановиться на том, что именно делает Hibernate c timestamp cache. А именно делаются три операции:
  • Операция get — взять timestamp для region (скрывается за Serializable)
  • Операция invalidate — взять новый timestamp и поместить его в кеш для region
  • Операция preinvalidate — взять новый timestamp, прибавить 60 секунд и поместить в кеш

Наша реализация пытается отследить, какое именно из последних двух действий выполняет Hibernate, и, в будущем, распространить данное действие на все узлы кластера. Для этого, разумеется, нам потребуется ещё один Listener (ArpSiteTimestampRegionListener). Разницу между invalidate и preinvalidate мы отслеживаем по тому значению, которое помещается в кеш, после чего соответствующим образом уведомляется листенер. А для кластера, для уведомления с других машин, мы сделали свои два метода (доступны через интерфейс ArpSiteTimestampRegion) invalidate и preInvalidate, которые повторяют логику Hibernate. Особенностью реализации является то, что нам не нужно знать текущие значения timestamp на разных машинах для того, чтобы инвалидировать кеши в кластере. Например, метод invalidate(region) возьмёт новый timestamp на текущей машине чтобы инвалидировать QueryCache на текущей же машине. Ему не интересно, какое было значение на той машине, где Hibernate запрашивал инвалидацию.

Сама логика Hibernate, кстати, кому интересно, находится в классе UpdateTimestampsCache и StandardQueryCache. Если они поменяется, нашу реализацию TimestampRegionImpl, возможно, придётся переписывать. Это та цена, которую мы платим за «хак».

Продолжение следует


В этом топике я описал, как сделана реализация кешей с «зарубками» на то, чтобы можно было связать между собой отдельные кеши на отдельных машинах в единое кластерное решение. Зарубки состоят в следующем:
— классы SoftRegion и TimestampRegionImpl вызывают методы интерфейсов ArpSiteRegionListener и ArpSiteTimestampRegionListener, если нужно уведомить другие машины в кластере о происходящих событиях
— в свою очередь через интерфейсы ArpSiteRegion и ArpSiteTimestampRegion они предоставляют возможность уведомить их о событиях в кластере.

И хотя работа с кластером пока не описана, кеши уже рабочие.

В следующем топике — как с помощью JGroups теперь сделать кластер.
Tags:
Hubs:
+3
Comments23

Articles