Статьи

Как работает Hibernate READ_WRITE CacheConcurrencyStrategy

Вступление

В моем предыдущем посте я представил механизм параллелизма кэша второго уровня NONSTRICT_READ_WRITE . В этой статье я собираюсь продолжить эту тему со стратегией READ_WRITE .

Сквозное кэширование

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

Поскольку база данных представляет собой систему записей, а операции базы данных заключены в физические транзакции, кэш может обновляться либо синхронно (как в случае стратегии параллелизма кэш-памяти TRANSACTIONAL ), либо асинхронно (сразу после фиксации транзакции базы данных).

Стратегия READ_WRITE представляет собой механизм одновременного асинхронного кэширования, и для предотвращения проблем целостности данных (например, устаревших записей кэша) она использует механизм блокировки, который обеспечивает гарантии изоляции на единицу работы .

Вставка данных

Поскольку постоянные сущности однозначно идентифицируются (каждая сущность присваивается отдельной строке базы данных), вновь созданные сущности кэшируются сразу после фиксации транзакции базы данных:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public boolean afterInsert(
    Object key, Object value, Object version)
        throws CacheException {
    region().writeLock( key );
    try {
        final Lockable item =
            (Lockable) region().get( key );
        if ( item == null ) {
            region().put( key,
                new Item( value, version,
                    region().nextTimestamp()
                )
            );
            return true;
        }
        else {
            return false;
        }
    }
    finally {
        region().writeUnlock( key );
    }
}

Для того чтобы объект был кэширован при вставке, он должен использовать генератор SEQUENCE , кэш заполняется EntityInsertAction :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
@Override
public void doAfterTransactionCompletion(boolean success,
    SessionImplementor session)
    throws HibernateException {
 
    final EntityPersister persister = getPersister();
    if ( success && isCachePutEnabled( persister,
        getSession() ) ) {
            final CacheKey ck = getSession()
               .generateCacheKey(
                    getId(),
                    persister.getIdentifierType(),
                    persister.getRootEntityName() );
                 
            final boolean put = cacheAfterInsert(
                persister, ck );
        }
    }
    postCommitInsert( success );
}

Генератор IDENTITY не очень хорошо работает с дизайном кэша первого уровня с транзакционной записью , поэтому связанная с ним EntityIdentityInsertAction не кэширует вновь вставленные записи (по крайней мере, пока HHH-7964 не будет исправлен).

Теоретически, между фиксацией транзакции базы данных и вставкой в ​​кэш второго уровня одна параллельная транзакция может загрузить вновь созданную сущность, вызывая вставку в кэш. Хотя это возможно, задержка синхронизации кеша очень мала, и если параллельная транзакция перемежается, она только заставляет другую транзакцию попадать в базу данных вместо загрузки объекта из кеша.

Обновление данных

Хотя вставка сущностей является довольно простой операцией, для обновлений нам нужно синхронизировать как базу данных, так и запись в кэше. Стратегия параллелизма READ_WRITE использует механизм блокировки для обеспечения целостности данных:

readwritecacheconcurrencystrategy_update4

  1. Процедура принятия Hibernate Transaction вызывает сброс сеанса
  2. EntityUpdateAction заменяет текущую запись в кэше объектом Lock
  3. Метод обновления используется для синхронных обновлений кэша, поэтому он ничего не делает при использовании асинхронной стратегии параллельного кэширования, такой как READ_WRITE.
  4. После фиксации транзакции базы данных вызываются обратные вызовы после завершения транзакции
  5. EntityUpdateAction вызывает метод afterUpdate объекта EntityRegionAccessStrategy.
  6. ReadWriteEhcacheEntityRegionAccessStrategy заменяет запись Lock фактическим элементом , инкапсулируя состояние рассеянного объекта

Удаление данных

Удаление объектов аналогично процессу обновления, как мы можем видеть из следующей диаграммы последовательности:

readwritecacheconcurrencystrategy_delete3

  • Процедура принятия Hibernate Transaction вызывает сброс сеанса
  • EntityDeleteAction заменяет текущую запись в кэше объектом Lock
  • Вызов метода remove ничего не делает, поскольку READ_WRITE является стратегией асинхронного параллельного кэширования.
  • После фиксации транзакции базы данных вызываются обратные вызовы после завершения транзакции
  • EntityDeleteAction вызывает метод unlockItem объекта EntityRegionAccessStrategy
  • ReadWriteEhcacheEntityRegionAccessStrategy заменяет запись Lock другим объектом Lock , период ожидания которого увеличен

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

Блокирующие конструкции

Классы Item и Lock наследуются от типа Lockable, и у каждого из них есть особая политика, позволяющая читать или записывать запись в кэше.

Объект READ_WRITE Lock

Класс Lock определяет следующие методы:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public boolean isReadable(long txTimestamp) {
    return false;
}
 
@Override
public boolean isWriteable(long txTimestamp,
    Object newVersion, Comparator versionComparator) {
    if ( txTimestamp > timeout ) {
        // if timedout then allow write
        return true;
    }
    if ( multiplicity > 0 ) {
        // if still locked then disallow write
        return false;
    }
    return version == null
        ? txTimestamp > unlockTimestamp
        : versionComparator.compare( version,
            newVersion ) < 0;
}
  • Объект Lock не позволяет читать записи в кэше, поэтому любой последующий запрос должен идти в базу данных
  • Если текущая временная метка создания сеанса превышает пороговое значение тайм-аута блокировки, запись в кэше разрешается записывать
  • Если хотя бы одному сеансу удалось заблокировать эту запись, любая операция записи запрещена.
  • Запись Lock позволяет писать, если состояние входящего объекта увеличило его версию или текущая временная метка создания сеанса больше, чем текущая временная метка разблокировки записи

Объект READ_WRITE Item

Класс Item определяет следующую политику доступа для чтения / записи:

01
02
03
04
05
06
07
08
09
10
11
@Override
public boolean isReadable(long txTimestamp) {
    return txTimestamp > timestamp;
}
 
@Override
public boolean isWriteable(long txTimestamp,
    Object newVersion, Comparator versionComparator) {
    return version != null && versionComparator
        .compare( version, newVersion ) < 0;
}
  • Элемент доступен для чтения только из сеанса, который был запущен после времени создания записи в кэш.
  • Запись Item позволяет писать, только если состояние входящего объекта увеличило свою версию

Контроль параллельного входа в кэш

Этот механизм управления параллелизмом вызывается при сохранении и чтении записей основного кэша.

Запись в кэш читается при вызове метода get ReadWriteEhcacheEntityRegionAccessStrategy :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public final Object get(Object key, long txTimestamp)
    throws CacheException {
    readLockIfNeeded( key );
    try {
        final Lockable item =
            (Lockable) region().get( key );
 
        final boolean readable =
            item != null &&
            item.isReadable( txTimestamp );
             
        if ( readable ) {
            return item.getValue();
        }
        else {
            return null;
        }
    }
    finally {
        readUnlockIfNeeded( key );
    }
}

Запись в кэш записывается методом ReadWriteEhcacheEntityRegionAccessStrategy putFromLoad :

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
37
38
public final boolean putFromLoad(
        Object key,
        Object value,
        long txTimestamp,
        Object version,
        boolean minimalPutOverride)
        throws CacheException {
    region().writeLock( key );
    try {
        final Lockable item =
            (Lockable) region().get( key );
             
        final boolean writeable =
            item == null ||
            item.isWriteable(
                txTimestamp,
                version,
                versionComparator );
                 
        if ( writeable ) {
            region().put(
                key,
                new Item(
                    value,
                    version,
                    region().nextTimestamp()
                )
            );
            return true;
        }
        else {
            return false;
        }
    }
    finally {
        region().writeUnlock( key );
    }
}

Сроки

Если операция базы данных завершается неудачно, текущая запись в кэше содержит объект Lock и не может выполнить откат к своему предыдущему состоянию Item . По этой причине блокировка должна прерваться, чтобы позволить записи в кэше заменить действительный объект Item . EhcacheDataRegion определяет следующее свойство времени ожидания:

1
2
3
private static final String CACHE_LOCK_TIMEOUT_PROPERTY =
    "net.sf.ehcache.hibernate.cache_lock_timeout";
private static final int DEFAULT_CACHE_LOCK_TIMEOUT = 60000;

Если мы не переопределим свойство net.sf.ehcache.hibernate.cache_lock_timeout , тайм-аут по умолчанию составляет 60 секунд:

1
2
3
4
final String timeout = properties.getProperty(
    CACHE_LOCK_TIMEOUT_PROPERTY,
    Integer.toString( DEFAULT_CACHE_LOCK_TIMEOUT )
);

Следующий тест будет эмулировать неудачную транзакцию базы данных, поэтому мы можем наблюдать, как кэш READ_WRITE разрешает запись только после истечения порога времени ожидания. Сначала мы собираемся уменьшить значение тайм-аута, чтобы уменьшить период зависания кэша:

1
2
3
properties.put(
    "net.sf.ehcache.hibernate.cache_lock_timeout",
    String.valueOf(250));

Мы будем использовать пользовательский перехватчик для ручного отката текущей запущенной транзакции:

01
02
03
04
05
06
07
08
09
10
11
12
@Override
protected Interceptor interceptor() {
    return new EmptyInterceptor() {
        @Override
        public void beforeTransactionCompletion(
            Transaction tx) {
            if(applyInterceptor.get()) {
                tx.rollback();
            }
        }
    };
}

Следующая процедура протестирует поведение тайм-аута блокировки:

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
37
38
39
40
41
42
43
44
try {
    doInTransaction(session -> {
        Repository repository = (Repository)
            session.get(Repository.class, 1L);
        repository.setName("High-Performance Hibernate");
        applyInterceptor.set(true);
    });
} catch (Exception e) {
    LOGGER.info("Expected", e);
}
applyInterceptor.set(false);
 
AtomicReference<Object> previousCacheEntryReference =
        new AtomicReference<>();
AtomicBoolean cacheEntryChanged = new AtomicBoolean();
 
while (!cacheEntryChanged.get()) {
    doInTransaction(session -> {
        boolean entryChange;
        session.get(Repository.class, 1L);
         
        try {
            Object previousCacheEntry =
                previousCacheEntryReference.get();
            Object cacheEntry =
                getCacheEntry(Repository.class, 1L);
             
            entryChange = previousCacheEntry != null &&
                previousCacheEntry != cacheEntry;
            previousCacheEntryReference.set(cacheEntry);
            LOGGER.info("Cache entry {}",
                ToStringBuilder.reflectionToString(
                    cacheEntry));
                     
            if(!entryChange) {
                sleep(100);
            } else {
                cacheEntryChanged.set(true);
            }
        } catch (IllegalAccessException e) {
            LOGGER.error("Error accessing Cache", e);
        }
    });
}

Запуск этого теста приводит к следующему выводу:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
select
   readwritec0_.id as id1_0_0_,
   readwritec0_.name as name2_0_0_,
   readwritec0_.version as version3_0_0_
from
   repository readwritec0_
where
   readwritec0_.id=1
    
update
   repository
set
   name='High-Performance Hibernate',
   version=1
where
   id=1
   and version=0
 
JdbcTransaction - rolled JDBC Connection
 
select
   readwritec0_.id as id1_0_0_,
   readwritec0_.name as name2_0_0_,
   readwritec0_.version as version3_0_0_
from
   repository readwritec0_
where
   readwritec0_.id = 1
 
Cache entry net.sf.ehcache.Element@3f9a0805[
    key=ReadWriteCacheConcurrencyStrategyWithLockTimeoutTest$Repository#1,
    value=Lock Source-UUID:ac775350-3930-4042-84b8-362b64c47e4b Lock-ID:0,
        version=1,
        hitCount=3,
        timeToLive=120,
        timeToIdle=120,
        lastUpdateTime=1432280657865,
        cacheDefaultLifespan=true,id=0
]
Wait 100 ms!
JdbcTransaction - committed JDBC Connection
 
select
   readwritec0_.id as id1_0_0_,
   readwritec0_.name as name2_0_0_,
   readwritec0_.version as version3_0_0_
from
   repository readwritec0_
where
   readwritec0_.id = 1
    
Cache entry net.sf.ehcache.Element@3f9a0805[
    key=ReadWriteCacheConcurrencyStrategyWithLockTimeoutTest$Repository#1,
    value=Lock Source-UUID:ac775350-3930-4042-84b8-362b64c47e4b Lock-ID:0,
        version=1,
        hitCount=3,
        timeToLive=120,
        timeToIdle=120,
        lastUpdateTime=1432280657865,
        cacheDefaultLifespan=true,
        id=0
]
Wait 100 ms!
JdbcTransaction - committed JDBC Connection
 
select
   readwritec0_.id as id1_0_0_,
   readwritec0_.name as name2_0_0_,
   readwritec0_.version as version3_0_0_
from
   repository readwritec0_
where
   readwritec0_.id = 1
Cache entry net.sf.ehcache.Element@305f031[
    key=ReadWriteCacheConcurrencyStrategyWithLockTimeoutTest$Repository#1,
    value=org.hibernate.cache.ehcache.internal.strategy.AbstractReadWriteEhcacheAccessStrategy$Item@592e843a,
        version=1,
        hitCount=1,
        timeToLive=120,
        timeToIdle=120,
        lastUpdateTime=1432280658322,
        cacheDefaultLifespan=true,
        id=0
]
JdbcTransaction - committed JDBC Connection
  • Первая транзакция пытается обновить сущность, поэтому соответствующая запись кэша второго уровня блокируется до совершения транзакции.
  • Первая транзакция завершается неудачно и откатывается
  • Блокировка удерживается, поэтому следующие две последовательные транзакции отправляются в базу данных, не заменяя запись Lock текущим состоянием загруженной сущности базы данных.
  • После истечения времени ожидания блокировки третья транзакция может, наконец, заменить блокировку записью в кэше элементов (удерживая разобранное гидратированное состояние объекта )

Вывод

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

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

  • Код доступен на GitHub .