Статьи

Подводные камни MapLoader Hazelcast

Одна из структур данных ядра , предоставляемых  Hazelcast  является  IMap<K, V> расширение java.util.concurrent.ConcurrentMap — которая в основном распределяется карта, часто используется в качестве кэш — памяти. Вы можете настроить такую ​​карту для использования пользовательского  MapLoader<K, V> кода Java, который будет запрашиваться каждый раз, когда вы пытаетесь .get() что-то из этой карты (по ключу), которой еще нет. Это особенно полезно, когда вы используете  IMap в качестве распределенного кэша в памяти — если клиентский код запрашивает что-то, что еще не было кэшировано, Hazelcast прозрачно выполнит ваш  MapLoader.load(key):

public interface MapLoader<K, V> {
V load(K key);
Map<K, V> loadAll(Collection<K> keys);
Set<K> loadAllKeys();
}

Оставшиеся два метода используются во время запуска для необязательного прогрева кэша путем загрузки предварительно определенного набора ключей. Ваш пользователь  MapLoader может связаться с (Нет) базой данных SQL, веб-службой, файловой системой, вы называете это. Работать с таким кешем гораздо удобнее, потому что вам не нужно реализовывать утомительный цикл « если не в кеш загружать и помещать в кеш ». Более того,  MapLoader имеет фантастическую особенность — если много клиентов одновременно запрашивают один и тот же ключ (из разных потоков или даже из разных элементов кластера — то есть машин),  MapLoader выполняется только один раз. Это значительно снижает нагрузку на внешние зависимости, не внося никакой сложности.

По сути  IMap с  MapLoader похож на  LoadingCache найденный в  Гуаве — но распространяется. Однако с большой силой приходит большое разочарование, особенно когда вы не понимаете особенностей API и внутренней сложности распределенной системы.

Сначала посмотрим, как настроить кастом  MapLoader. Вы можете использовать  hazelcast.xml для этого ( <map-store/> элемента), но тогда у вас нет контроля над жизненным циклом вашего загрузчика (например, вы не можете использовать Spring bean). Лучшая идея — настроить Hazelcast непосредственно из кода и передать экземпляр  MapLoader:

class HazelcastTest extends Specification {
public static final int ANY_KEY = 42
public static final String ANY_VALUE = "Forty two"




def 'should use custom loader'() {
given:
MapLoader loaderMock = Mock()
loaderMock.load(ANY_KEY) >> ANY_VALUE
def hz = build(loaderMock)
IMap<Integer, String> emptyCache = hz.getMap("cache")




when:
def value = emptyCache.get(ANY_KEY)




then:
value == ANY_VALUE




cleanup:
hz?.shutdown()
}

Обратите внимание, как мы получаем пустую карту, но когда нас просят  ANY_KEY, мы получаем  ANY_VALUE взамен. Это не удивительно, это то, что мы  loaderMock должны были сделать. Я оставил конфигурацию Hazelcast:

def HazelcastInstance build(MapLoader<Integer, String> loader) {
final Config config = new Config("Cluster")
final MapConfig mapConfig = config.getMapConfig("default")
final MapStoreConfig mapStoreConfig = new MapStoreConfig()
mapStoreConfig.factoryImplementation = {name, props -> loader } as MapStoreFactory
mapConfig.mapStoreConfig = mapStoreConfig
return Hazelcast.getOrCreateHazelcastInstance(config)
}

Любой  IMap (идентифицируемый по имени) может иметь другую конфигурацию. Однако специальная  "default" карта определяет конфигурацию по умолчанию для всех карт. Давайте немного поиграем с пользовательскими загрузчиками и посмотрим, как они ведут себя, когда  MapLoader возвращает  null или выдает исключение:

def 'should return null when custom loader returns it'() {
given:
MapLoader loaderMock = Mock()
def hz = build(loaderMock)
IMap<Integer, String> cache = hz.getMap("cache")




when:
def value = cache.get(ANY_KEY)




then:
value == null
!cache.containsKey(ANY_KEY)




cleanup:
hz?.shutdown()
}




public static final String SOME_ERR_MSG = "Don't panic!"




def 'should propagate exceptions from loader'() {
given:
MapLoader loaderMock = Mock()
loaderMock.load(ANY_KEY) >> {throw new UnsupportedOperationException(SOME_ERR_MSG)}
def hz = build(loaderMock)
IMap<Integer, String> cache = hz.getMap("cache")




when:
cache.get(ANY_KEY)




then:
UnsupportedOperationException e = thrown()
e.message.contains(SOME_ERR_MSG)




cleanup:
hz?.shutdown()
}

MapLoader выполняется в отдельном потоке

Пока ничего удивительного. Первая ловушка, с которой вы можете столкнуться — это взаимодействие потоков. MapLoader никогда не выполняется из клиентского потока, всегда из отдельного пула потоков: 

def 'loader works in a different thread'() {
given:
MapLoader loader = Mock()
loader.load(ANY_KEY) >> {key -> "$key: ${Thread.currentThread().name}"}
def hz = build(loader)
IMap<Integer, String> cache = hz.getMap("cache")




when:
def value = cache.get(ANY_KEY)




then:
value != "$ANY_KEY: ${Thread.currentThread().name}"




cleanup:
hz?.shutdown()
}

This test passes because current thread is "main" while loading occurs from within something like"hz.Cluster.partition-operation.thread-10". This is an important observation and is actually quite obvious if you remember that when many threads try to access the same absent key, loader is called only once. But more needs to be explained here. Almost every operation on IMap is encapsulated into one of operation objects (see also: Command pattern). This operation is later dispatched to one or all cluster members and executed remotely in a separate thread pool, or even on a different machine. Thus, don’t expect loading to occur in the same thread, or even same JVM/server (!) 

This leads to an interesting situation where you request given key on one machine, but actual loading happens on the other. Or even more epic — machines A, B and C request given key whereas machine D physically loads value for that key. The decision which machine is responsible for loading is made based on consistent hashing algorithm.

One final remark — of course you can customize the size of thread pools running these operations, see Advanced Configuration Properties.

IMap.remove() calls MapLoader

This one is totally surprising and definitely to be expected once you think about it:

def 'IMap.remove() on non-existing key still calls loader (!)'() {
given:
MapLoader loaderMock = Mock()
def hz = build(loaderMock)
IMap<Integer, String> emptyCache = hz.getMap("cache")




when:
emptyCache.remove(ANY_KEY)




then:
1 * loaderMock.load(ANY_KEY)




cleanup:
hz?.shutdown()
}

Look carefully! All we do is removing absent key from a map. Nothing else. Yet, loaderMock.load() was executed. This is a problem especially when your custom loader is particularly slow or expensive. Why was it executed here? Look up the API of `java.util.Map#remove():

V remove(Object key)

[…]

Returns the value to which this map previously associated the key, or null if the map contained no mapping for the key.

Maybe it’s controversial but one might argue that Hazelcast is doing the right thing. If you consider our map withMapLoader attached as sort of like a view to external storage, it makes sense. When removing absent key, Hazelcast actually asks our MapLoader: what could have been a previous value? It pretends as if the map contained every single value returned from MapLoader, but loaded lazily. This is not a bug since there is a special method IMap.delete() that works just like remove(), but doesn’t load «previous» value:

@Issue("https://github.com/hazelcast/hazelcast/issues/3178")
def "IMap.delete() doesn't call loader"() {
given:
MapLoader loaderMock = Mock()
def hz = build(loaderMock)
IMap<Integer, String> cache = hz.getMap("cache")




when:
cache.delete(ANY_KEY)




then:
0 * loaderMock.load(ANY_KEY)




cleanup:
hz?.shutdown()
}

Собственно, произошла ошибка: 
IMap.delete() не должен вызывать MapLoader.load() , исправленный в 3.2.6 и 3.3. Если вы еще не обновились, даже 
IMap.delete() перейдете на 
MapLoader. Если вы считаете 
IMap.remove() удивительным, посмотрите, как это
put() работает!

IMap.put() вызывает MapLoader

Если вы думали, что 
remove() загрузка значения вначале является подозрительной, как насчет явной 
put() загрузки значения для данного ключа в первую очередь? В конце концов, мы 
явно  помещаем что-то в карту по ключу, почему Hazelcast сначала загружает это значение через 
MapLoader?

def 'IMap.put() on non-existing key still calls loader (!)'() {
given:
MapLoader loaderMock = Mock()
def hz = build(loaderMock)
IMap<Integer, String> emptyCache = hz.getMap("cache")




when:
emptyCache.put(ANY_KEY, ANY_VALUE)




then:
1 * loaderMock.load(ANY_KEY)




cleanup:
hz?.shutdown()
}

Опять же, давайте восстановим 
java.util.Map.put() JavaDoc:


V put (ключ K, значение V)

[…]

Возвращает:

предыдущее значение, связанное с ключом, или ноль, если не было сопоставления для ключа.

Hazelcast притворяется, что IMap это просто ленивый взгляд на некоторый внешний источник, поэтому, когда мы  помещаем что-  put() то в  IMapто, чего раньше не было, оно сначала загружает «предыдущее» значение, чтобы оно могло его вернуть. Опять же, это большая проблема, когда  MapLoaderмедленно или дорого — если мы можем явно поместить что-то на карту, зачем загружать это первым? К счастью, существует простой обходной путь  putTransient():

def "IMap.putTransient() doesn't call loader"() {
given:
MapLoader loaderMock = Mock()
def hz = build(loaderMock)
IMap<Integer, String> cache = hz.getMap("cache")




when:
cache.putTransient(ANY_KEY, ANY_VALUE, 1, TimeUnit.HOURS)




then:
0 * loaderMock.load(ANY_KEY)




cleanup:
hz?.shutdown()
}

Одним из предостережений является то, что вы должны предоставить TTL явно, а не полагаться на настроенные IMap значения по 
умолчанию. Но это также означает, что вы можете назначить произвольный TTL для каждой записи карты, не только глобально для всей карты — полезно.

IMap.containsKey() вовлекает  MapLoader, может быть медленным или блокировать

Вспомните нашу аналогию: 
IMap с поддержкой 
MapLoader ведет себя как взгляд на внешний источник данных. Поэтому неудивительно, что 
containsKey() на пустой карте будет вызываться 
MapLoader:

def "IMap.containsKey() calls loader"() {
given:
MapLoader loaderMock = Mock()
def hz = build(loaderMock)
IMap<Integer, String> emptyMap = hz.getMap("cache")




when:
emptyMap.containsKey(ANY_KEY)




then:
1 * loaderMock.load(ANY_KEY)




cleanup:
hz?.shutdown()
}

Каждый раз, когда мы просим ключ, которого нет на карте, Hazelcast будет спрашивать 
MapLoader. Опять же, это не проблема, если ваш загрузчик работает быстро, без побочных эффектов и надежен. Если это не так, это убьет вас:

def "IMap.get() after IMap.containsKey() calls loader twice"() {
given:
MapLoader loaderMock = Mock()
def hz = build(loaderMock)
IMap<Integer, String> cache = hz.getMap("cache")




when:
cache.containsKey(ANY_KEY)
cache.get(ANY_KEY)




then:
2 * loaderMock.load(ANY_KEY)




cleanup:
hz?.shutdown()
}

Несмотря на 
containsKey() вызов 
MapLoader, он не «кэширует» загруженное значение, чтобы использовать его позже. Вот почему 
containsKey()следуют 
get() звонки 
MapLoader два раза, довольно расточительно. К счастью, если вы вызываете 
containsKey() существующий ключ, он запускается практически сразу, хотя, скорее всего, потребуется сетевой переход. Что не так повезло , это поведение 
keySet(),
values()
entrySet() и несколько других методов до версии 3.3 Hazelcast. Все они будут блокироваться в случае 
 загрузки
какого-либо ключа за раз. Так что если у вас есть карта с тысячами ключей и вы запрашиваете 
keySet(), один медленный
MapLoader.load() вызов заблокирует весь кластер. К счастью, это было исправлено в 3.3, так что 
IMap.keySet(),
IMap.values()и т. Д. Не блокируются, даже когда некоторые ключи вычисляются в данный момент.


Как вы можете видеть, 
IMap + 
MapLoader комбо мощно, но также заполнено ловушками. Некоторые из них продиктованы API, а в некоторых случаях — распределенной природой Hazelcast, наконец, некоторые зависят от конкретной реализации. Убедитесь, что вы понимаете их, прежде чем использовать функцию загрузки кеша