Статьи

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

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

1
2
3
4
5
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 найденный в Guava — но распространяется. Однако с большой силой приходит большое разочарование, особенно когда вы не понимаете особенностей API и внутренней сложности распределенной системы.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
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:

1
2
3
4
5
6
7
8
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 или выдает исключение:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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 никогда не выполняется из клиентского потока, всегда из отдельного пула потоков:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
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()
}

Этот тест проходит, потому что текущий поток является "main" а загрузка происходит из чего-то вроде "hz.Cluster.partition-operation.thread-10" . Это важное наблюдение, и на самом деле оно совершенно очевидно, если вспомнить, что когда многие потоки пытаются получить доступ к одному и тому же отсутствующему ключу, загрузчик вызывается только один раз. Но больше нужно объяснить здесь. Почти каждая операция в IMap инкапсулируется в один из объектов операции (см. Также: Шаблон команды ). Позднее эта операция отправляется одному или всем членам кластера и выполняется удаленно в отдельном пуле потоков или даже на другом компьютере. Таким образом, не ожидайте, что загрузка произойдет в том же потоке или даже в той же JVM / сервере (!)

Это приводит к интересной ситуации, когда вы запрашиваете заданный ключ на одном компьютере, но фактическая загрузка происходит на другом. Или даже более эпично — машины A, B и C запрашивают заданный ключ, тогда как машина D физически загружает значение для этого ключа. Решение о том, какая машина отвечает за загрузку, принимается на основе согласованного алгоритма хеширования .

Последнее замечание — конечно, вы можете настроить размер пулов потоков, выполняющих эти операции, см. « Дополнительные параметры конфигурации» .

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

Это совершенно удивительно, и определенно стоит ожидать, если подумать:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
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()
}

Смотри внимательно! Все, что мы делаем, это удаляем отсутствующий ключ с карты. Ничего больше. Тем не менее, loaderMock.load() был выполнен. Это проблема, особенно когда ваш пользовательский загрузчик работает особенно медленно или дорого. Почему это было выполнено здесь? Найдите API `java.util.Map # remove () :

V remove(Object key)

[…]

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

Может быть, это спорно, но можно было бы утверждать, что Hazelcast делает правильную вещь. Если вы рассматриваете нашу карту с подключенным MapLoader как вид внешнего хранилища, это имеет смысл. При удалении отсутствующего ключа Hazelcast фактически спрашивает наш MapLoader : что могло быть предыдущим значением? Он притворяется, будто карта содержит каждое значение, возвращенное из MapLoader , но загружается лениво. Это не ошибка, поскольку существует специальный метод IMap.delete() который работает так же, как и remove() , но не загружает «предыдущее» значение:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
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() значения remove() вначале является подозрительным, как насчет явного метода put() сначала загружающего значение для данного ключа? В конце концов, мы явно помещаем что-то в карту по ключу, почему Hazelcast сначала загружает это значение через MapLoader ?

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
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()
}

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

V положить (ключ K, значение V)

[…]

Возвращает:

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
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 умолчанию IMap . Но это также означает, что вы можете назначить произвольный TTL для каждой записи карты, не только глобально для всей карты — полезно.

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
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 . Опять же, это не проблема, если ваш загрузчик работает быстро, без побочных эффектов и надежен. Если это не так, это убьет вас:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
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()
}

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


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

Ссылка: Подводные камни MapLoader от Hazelcast от нашего партнера по JCG Томаша Нуркевича в блоге, посвященном Java и соседству .