1 of 200

Превратности

кэша

Как Redis спасает latency и ломает корректность в сервисах

Евгений Сулейманов

СТО | ProzyTech

1

2 of 200

2

Дисклеймер

​

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

​

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

3 of 200

3

4 of 200

План расследования

​

4

5 of 200

План расследования

​

Система без кэша: честно, но медленно

​

​

5

6 of 200

План расследования

​

Redis как спасение: p95 падает

​

​

Система без кэша: честно, но медленно

​

​

6

7 of 200

План расследования

​

Redis как спасение: p95 падает

​

​

Неочевидные проблемы кэширования

​

​

Система без кэша: честно, но медленно

​

​

7

8 of 200

План расследования

​

Redis как спасение: p95 падает

​

​

Неочевидные проблемы кэширования

​

​

Кэш скрывает деградацию БД

​

​

Система без кэша: честно, но медленно

​

​

8

9 of 200

План расследования

​

Redis как спасение: p95 падает

​

​

Неочевидные проблемы кэширования

​

​

Кэш скрывает деградацию БД

​

​

Как кэшировать безопаснее

​

​

Система без кэша: честно, но медленно

​

​

9

10 of 200

План расследования

​

Redis как спасение: p95 падает

​

​

Неочевидные проблемы кэширования

​

​

Кэш скрывает деградацию БД

​

​

Как кэшировать безопаснее

​

​

Система без кэша: честно, но медленно

​

​

Сделаем выводы

10

11 of 200

11

Как устроен доклад?

​

​

​

12 of 200

12

Как устроен доклад?

​

​

​

видим боль в latency и БД

13 of 200

13

Как устроен доклад?

​

​

​

видим боль в latency и БД

добавляем Redis и радуемся

14 of 200

14

Как устроен доклад?

​

​

​

видим боль в latency и БД

добавляем Redis и радуемся

ловим новую проблему

15 of 200

15

Как устроен доклад?

​

​

​

видим боль в latency и БД

добавляем Redis и радуемся

ловим новую проблему

разбираем код и метрики

16 of 200

16

Как устроен доклад?

​

​

​

видим боль в latency и БД

добавляем Redis и радуемся

ловим новую проблему

разбираем код и метрики

голосуем: что делать?

17 of 200

17

Как устроен доклад?

​

​

​

видим боль в latency и БД

добавляем Redis и радуемся

ловим новую проблему

разбираем код и метрики

голосуем: что делать?

фиксируем компромиссы

18 of 200

18

Как устроен доклад?

​

​

​

Не лекция про Redis, а инцидент, где каждое “исправление” порождает новый вопрос.

видим боль в latency и БД

добавляем Redis и радуемся

ловим новую проблему

разбираем код и метрики

голосуем: что делать?

фиксируем компромиссы

19 of 200

Откройте

голосование

19

ГОЛОСОВАНИЕ

20 of 200

Дополнительные

ссылки

20

21 of 200

Дополнительные

ссылки

21

GitHub repo

22 of 200

Дополнительные

ссылки

22

Презентация

GitHub repo

23 of 200

Дополнительные

ссылки

23

Software Engineering

Презентация

GitHub repo

24 of 200

О себе

24

25 of 200

О себе

25

~15 лет опыта в разработке

26 of 200

О себе

26

~15 лет опыта в разработке

Прошу других писать код

27 of 200

О себе

27

~15 лет опыта в разработке

Прошу других писать код

Говорю слова

28 of 200

Акт 1. Без кэша все честно, но становится медленно

28

Один ендпоинт, одна БД, один понятный источник истины.

29 of 200

29

Типовой Spring-сервис до кэша

​

​

​

Client

30 of 200

30

Типовой Spring-сервис до кэша

​

​

​

Client

Spring MVC Controller

31 of 200

31

Типовой Spring-сервис до кэша

​

​

​

Client

Spring MVC Controller

Service

32 of 200

32

Типовой Spring-сервис до кэша

​

​

​

Client

Spring MVC Controller

Service

PostgreSQL�SourceOfTruth

33 of 200

33

Типовой Spring-сервис до кэша

​

​

​

Client

Spring MVC Controller

Service

HikariCP

PostgreSQL�SourceOfTruth

34 of 200

34

Типовой Spring-сервис до кэша

​

​

​

Источник истины один. Проблема: дорогие чтения и высокий p95/p99.

Client

Spring MVC Controller

Service

HikariCP

PostgreSQL�SourceOfTruth

35 of 200

35

@GetMapping("/api/products/{id}/card")

public ProductCardDto getProductCard(@PathVariable UUID id) {

return productCardService.getProductCard(id);

}

​

public ProductCardDto getProductCard(UUID id) {

return productRepository.loadProductCard(id)

.orElseThrow(ProductNotFoundException::new);

}

​

​

Код, который выглядит нормально

​

​

36 of 200

37 of 200

38 of 200

38

Инцидент начинается скучно

​

​

39 of 200

39

Инцидент начинается скучно

​

​

  • Пользователи жалуются: “иногда долго открывается карточка товара”.

40 of 200

40

Инцидент начинается скучно

​

​

  • Пользователи жалуются: “иногда долго открывается карточка товара”.
  • База не падает.

41 of 200

41

Инцидент начинается скучно

​

​

  • Пользователи жалуются: “иногда долго открывается карточка товара”.
  • База не падает.
  • SQL не выглядит катастрофой.

42 of 200

42

Инцидент начинается скучно

​

​

  • Пользователи жалуются: “иногда долго открывается карточка товара”.
  • База не падает.
  • SQL не выглядит катастрофой.
  • Но p95/p99 уже съедают UX.

43 of 200

43

Инцидент начинается скучно

​

​

  • Пользователи жалуются: “иногда долго открывается карточка товара”.
  • База не падает.
  • SQL не выглядит катастрофой.
  • Но p95/p99 уже съедают UX.
  • И вот здесь появляется первый соблазн.

44 of 200

Интерактив #1: Что вы сделаете первым с медленным ендпоинтом для чтения?

​

44

A. Уводим чтение на реплику

C. Сначала фиксируем профиль чтения и допустимое устаревание

B. Кэшируем горячие ключи в Redis

D. Строим отдельную модель для чтения

45 of 200

45

Первое решение должно начинаться не с Redis

​

​

​

​

​

​

46 of 200

46

Первое решение должно начинаться не с Redis

​

​

​

​

​

​

Кэш можно добавить, но сначала нужно понять: что именно и как мы кэшируем

Практичный ответ

47 of 200

47

Первое решение должно начинаться не с Redis

​

​

​

​

​

​

Кэш можно добавить, но сначала нужно понять: что именно и как мы кэшируем

Практичный ответ

Почему

Redis отлично снижает latency. Но существует:

  • свежесть данных
  • инвалидация
  • возможен отказ Redis.

48 of 200

Что мы имеем?

48

Что получили

Система показывает фактическую стоимость чтения: DB QPS, Hikari, p95/p99.

49 of 200

Что мы имеем?

49

Что получили

Система показывает фактическую стоимость чтения: DB QPS, Hikari, p95/p99.

Что решили

Поняли, почему команда тянется к кэшу: latency действительно болит.

50 of 200

Что мы имеем?

50

Что получили

Система показывает фактическую стоимость чтения: DB QPS, Hikari, p95/p99.

Что решили

Что осталось

Поняли, почему команда тянется к кэшу: latency действительно болит.

Добавить Redis и увидеть, почему самая опасная фаза - когда он помог.

51 of 200

Акт 2. Redis как спасение

51

Сначала кэш действительно делает систему лучше.

52 of 200

52

Почему не просто добавить много реплик?

​

декабрь

Критерий

​

Реплика на чтение

Redis кэш

Запрос на каждый вызов

​

да, просто на другой БД

​

​

нет при cache hit

​

​

Устаревание

лаг репликации

TTL / инвалидация / политика устаревания

Лучший сценарий

разнообразные SQL‑чтения

повторяющиеся “горячие” чтения

Главный риск

лаг, конфликты, стоимость БД

устаревшие данные, холодный кэш, инвалидация

53 of 200

53

@GetMapping("/api/products/{id}/card")

public ProductCardDto getProductCard(@PathVariable UUID id) {

return productCardService.getProductCard(id);

}

​

​

Изначальный код

​

​

54 of 200

54

@GetMapping("/api/products/{id}/card")

public ProductCardDto getProductCard(@PathVariable UUID id) {

return productCardService.getProductCard(id);

}

​

​

public ProductCardDto getProductCard(UUID id) {

return productRepository.loadProductCard(id)

.orElseThrow(ProductNotFoundException::new);

}

​

​

Изначальный код

​

​

55 of 200

55

@GetMapping("/api/products/{id}/card")

public ProductCardDto getProductCard(@PathVariable UUID id) {

return productCardService.getProductCard(id);

}

​

@Cacheable(cacheNames = "product-card", key = "#productId")

public ProductCardDto getProductCard(UUID id) {

return productRepository.loadProductCard(id)

.orElseThrow(ProductNotFoundException::new);

}

​

​

Изначальный код

​

​

Внедряем кэширование

56 of 200

56

@Cacheable

​

​

  • Одна аннотация - и поток чтения перестал быть обычным CRUD.
  • На “cache hit” мы “не заходим” в метод.

57 of 200

57

С этого момента появляется вторая модель данных

​

​

​

58 of 200

58

С этого момента появляется вторая модель данных

​

​

​

Client

59 of 200

59

С этого момента появляется вторая модель данных

​

​

​

Client

Spring MVC Controller

60 of 200

60

С этого момента появляется вторая модель данных

​

​

​

Client

Spring MVC Controller

Service

61 of 200

61

С этого момента появляется вторая модель данных

​

​

​

Client

Spring MVC Controller

Service

PostgreSQL�SoT

62 of 200

62

С этого момента появляется вторая модель данных

​

​

​

Client

Spring MVC Controller

Service

Redis

cache

PostgreSQL�SoT

63 of 200

63

С этого момента появляется вторая модель данных

​

​

​

Кэш - не деталь реализации. Это изменение модели консистентности.

Client

Spring MVC Controller

Service

Redis

cache

PostgreSQL�SoT

64 of 200

65 of 200

65

66 of 200

66

Самая опасная фаза: �когда все стало быстрее

  • Latency упала.
  • DB QPS упал.
  • Именно сейчас легко не заметить, что мы изменили модель данных.

67 of 200

Что мы имеем?

67

Что получили

Увидели фактическую пользу Redis: latency падает, БД дышит легче.

68 of 200

Что мы имеем?

68

Что получили

Увидели фактическую пользу Redis: latency падает, БД дышит легче.

Что решили

Зафиксировали: кэш - не враг, а мощный инструмент.

69 of 200

Что мы имеем?

69

Что получили

Увидели фактическую пользу Redis: latency падает, БД дышит легче.

Что решили

Что осталось

Зафиксировали: кэш - не враг, а мощный инструмент.

Показать, где он начинает врать и почему hit ratio не спасает корректность.

70 of 200

Акт 3. Неочевидные проблемы кэширования

70

Про что мы можем “забыть” при кэшировании

​

71 of 200

Основные классы проблем

​

71

72 of 200

Основные классы проблем

​

Устаревшие данные

​

​

72

73 of 200

Основные классы проблем

​

Проблемы негативного кэширования

​

​

Устаревшие данные

​

​

73

74 of 200

Основные классы проблем

​

Проблемы негативного кэширования

​

​

Права доступа

​

​

Устаревшие данные

​

​

74

75 of 200

Основные классы проблем

​

Проблемы негативного кэширования

​

​

Права доступа

​

​

Изменение нагрузочного профиля

​

​

Устаревшие данные

​

​

75

76 of 200

Основные классы проблем

​

Проблемы негативного кэширования

​

​

Права доступа

​

​

Изменение нагрузочного профиля

​

​

Кэш усложняет релизы

​

​

Устаревшие данные

​

​

76

77 of 200

Основные классы проблем

​

Проблемы негативного кэширования

​

​

Права доступа

​

​

Изменение нагрузочного профиля

​

​

Кэш усложняет релизы

​

​

Устаревшие данные

​

​

Local cache + Redis

77

78 of 200

Устаревшие данные

78

79 of 200

79

Устаревшие данные:

профиль изменился, кэш старый

12:00 read card Redis version=1

80 of 200

80

Устаревшие данные:

профиль изменился, кэш старый

12:00 read card Redis version=1

12:01 DB update version=2

81 of 200

81

Устаревшие данные:

профиль изменился, кэш старый

12:00 read card Redis version=1

12:01 DB update version=2

12:01 Redis old value version=1

82 of 200

82

Устаревшие данные:

профиль изменился, кэш старый

12:00 read card Redis version=1

12:01 DB update version=2

12:01 Redis old value version=1

12:02 read old card version=1

83 of 200

83

Устаревшие данные:

профиль изменился, кэш старый

Источников истины два. Проблема: в кэше устаревшие данные

12:00 read card Redis version=1

12:01 DB update version=2

12:01 Redis old value version=1

12:02 read old card version=1

84 of 200

84

@Transactional

public void updateProduct(UUID productId, UpdateProductRequest request) {

productRepository.updateProduct(productId, request);

}

​

​

UPDATE без инвалидации

​

​

85 of 200

85

@Transactional

public void updateProduct(UUID productId, UpdateProductRequest request) {

productRepository.updateProduct(productId, request);

}

​

// Redis все еще содержит product-card::{productId}

// с предыдущей версией DTO

​

​

UPDATE без инвалидации

​

​

86 of 200

86

@Transactional

public void updateProduct(UUID productId, UpdateProductRequest request) {

productRepository.updateProduct(productId, request);

}

​

// Redis все еще содержит product-card::{productId}

// с предыдущей версией DTO

​

​

UPDATE без инвалидации

​

​

Запись прошла.

Кэш не знает, что мир изменился.

​

​

87 of 200

Интерактив #2: после PATCH приходит старый DTO. Как чинить путь записи?

87

A. `@CachePut` после апдейта

С. Проверка версии перед чтением из кэша

B. Evict после коммита + доставка инвалидации

D. Двойное удаление (из БД и кэша) с задержкой

88 of 200

88

​

​

​

​

​

@Transactional

public void updateProduct(

UUID productId,

UpdateProductRequest request

) {

productRepository.updateProduct(productId, request);

}

​

Добавим @CacheEvict - и это еще не конец

​

​

89 of 200

89

@CacheEvict(

cacheNames = "product-card",

key = "#productId",

beforeInvocation = false

)

@Transactional

public void updateProduct(

UUID productId,

UpdateProductRequest request

) {

productRepository.updateProduct(productId, request);

}

​

Добавим @CacheEvict - и это еще не конец

​

​

90 of 200

90

@CacheEvict(

cacheNames = "product-card",

key = "#productId",

beforeInvocation = false

)

@Transactional

public void updateProduct(

UUID productId,

UpdateProductRequest request

) {

productRepository.updateProduct(productId, request);

}

​

Добавим @CacheEvict - и это еще не конец

​

​

Теперь вопрос не “есть eviction?”, а “когда и кто гарантирует его корректность?”.

91 of 200

91

Cache-aside ломается не в “happy path”

Надежность cache-aside определяется не чтением из Redis, а тем, как система переживает update, commit, eviction и события инвалидации.

PATCH update

DB commit OK

Redis evict timeout

HTTP 200 OK

next GET returns old DTO

92 of 200

92

@Bean

@Primary

CacheManager cacheManager(

@Qualifier("redisCacheManagerDelegate")

RedisCacheManager delegate

) {

return new TransactionAwareCacheManagerProxy(delegate);

}

​

Transaction-aware cache manager

​

​

93 of 200

93

@Transactional

public void updateProduct(

UUID productId,

UpdateProductRequest request

) {

long newVersion =

productRepository.updateProduct(productId, request);

​

cacheInvalidationOutboxRepository.save(

CacheInvalidationEvent.productCard(

productId,

newVersion

)

);

}

​

@Transactional outbox для гарантии

​

​

94 of 200

Негативное кэширование

94

95 of 200

95

Негативное кэширование

12:00

GET /products/ABC

Redis MISS

96 of 200

96

Негативное кэширование

12:00

GET /products/ABC

Redis MISS

DB: NOT_FOUND

Redis SET

ABC = NOT_FOUND

97 of 200

97

Негативное кэширование

12:00

GET /products/ABC

Redis MISS

DB: NOT_FOUND

Redis SET

ABC = NOT_FOUND

12:02

Redis HIT

ABC = NOT_FOUND

98 of 200

98

Негативное кэширование

  • Отсутствие данных - это тоже данные.

12:00

GET /products/ABC

Redis MISS

DB: NOT_FOUND

Redis SET

ABC = NOT_FOUND

12:02

Redis HIT

ABC = NOT_FOUND

99 of 200

99

Негативное кэширование

  • Отсутствие данных - это тоже данные.
  • Нерационально постоянно “ходить” в БД, если данных с высокой вероятностью нет.

12:00

GET /products/ABC

Redis MISS

DB: NOT_FOUND

Redis SET

ABC = NOT_FOUND

12:02

Redis HIT

ABC = NOT_FOUND

100 of 200

100

Негативное кэширование

  • Отсутствие данных - это тоже данные.
  • Нерационально постоянно “ходить” в БД, если данных с высокой вероятностью нет.
  • Мы хотим защитить БД от избыточных дорогих поисков.

12:00

GET /products/ABC

Redis MISS

DB: NOT_FOUND

Redis SET

ABC = NOT_FOUND

12:02

Redis HIT

ABC = NOT_FOUND

101 of 200

101

Негативное кэширование: отсутствие данных устаревает

12:00

GET /products/ABC

Redis MISS

102 of 200

102

Негативное кэширование: отсутствие данных устаревает

12:00

GET /products/ABC

Redis MISS

DB: NOT_FOUND

Redis SET

ABC = NOT_FOUND

103 of 200

103

Негативное кэширование: отсутствие данных устаревает

12:00

GET /products/ABC

Redis MISS

DB: NOT_FOUND

Redis SET

ABC = NOT_FOUND

12:01

POST /products/ABC

DB: created

104 of 200

104

Негативное кэширование: отсутствие данных устаревает

12:00

GET /products/ABC

Redis MISS

DB: NOT_FOUND

Redis SET

ABC = NOT_FOUND

12:01

POST /products/ABC

DB: created

12:02

Redis HIT

ABC = NOT_FOUND

105 of 200

105

Негативное кэширование: отсутствие данных устаревает

  • Мы закэшировали не просто результат запроса, а утверждение:
  • Этого объекта не существует в течение следующих N секунд

12:00

GET /products/ABC

Redis MISS

DB: NOT_FOUND

Redis SET

ABC = NOT_FOUND

12:01

POST /products/ABC

DB: created

12:02

Redis HIT

ABC = NOT_FOUND

106 of 200

106

Что делать с NOT_FOUND?

​

декабрь

Вариант

​

Что получаем

Где платим

Не кэшировать отсутствие

​

Всегда актуальный ответ

​

​

Каждый MISS идет в БД

​

​

Короткий TTL

Снижает нагрузку и ограничивает stale-окно

После создания еще возможен временный 404

Инвалидировать при создании

Новый объект становится видим почти сразу

Логика записи должна знать ключ негативного кэширования, возможны ошибки при инвалидации

TTL + инвалидация

Защита от повторных MISS + быстрое появление объекта

Больше логики и сценариев отказа

107 of 200

Кэширование прав доступа

107

108 of 200

108

Крайне неприятный баг: права доступа

  • Первый пользователь прогрел кэш.

109 of 200

109

Крайне неприятный баг: права доступа

  • Первый пользователь прогрел кэш.
  • Второй получил ответ по тому же accountId.

110 of 200

110

​

public AccountDto getAccount(UUID accountId) {

authorizationService.checkAccess(accountId);

return accountRepository.findAccount(accountId);

}

​

​

Ключ кэширования не учитывает контекст доступа

​

​

111 of 200

111

@Cacheable(cacheNames = "account-view", key = "#accountId")

public AccountDto getAccount(UUID accountId) {

authorizationService.checkAccess(accountId);

return accountRepository.findAccount(accountId);

}

​

​

Ключ кэширования не учитывает контекст доступа

​

​

112 of 200

112

@Cacheable(cacheNames = "account-view", key = "#accountId")

public AccountDto getAccount(UUID userId, UUID tenantId, UUID accountId) {

authorizationService.checkAccess(userId, tenantId, accountId);

return accountRepository.findAccount(accountId);

}

​

​

Ключ кэширования не учитывает контекст доступа

​

​

113 of 200

113

@Cacheable(cacheNames = "account-view", key = "#accountId")

public AccountDto getAccount(UUID userId, UUID tenantId, UUID accountId) {

authorizationService.checkAccess(userId, tenantId, accountId);

return accountRepository.findAccount(accountId);

}

​

​

Ключ кэширования не учитывает контекст доступа

​

​

Если Redis вернул HIT, метод не вызван. Значит checkAccess(...) тоже не вызван.

114 of 200

Интерактив #3: права доступа. Где должна проходить граница безопасности?

​

114

C. Кэшировать разрешения отдельно

D. Не кэшировать защищенные данные совсем.

A. Добавить user/tenant в ключ кэширования

B. Проверять доступ до входа в кэшированный метод

115 of 200

115

@Cacheable(

cacheNames = "account-view",

key = "#tenantId + ':' + #userId + ':' + #accountId"

)

public AccountDto getAccount(UUID userId, UUID tenantId, UUID accountId) { ... }

​

​

Безопасный ключ дороже по кардинальности

​

​

116 of 200

116

@Cacheable(

cacheNames = "account-view",

key = "#tenantId + ':' + #userId + ':' + #accountId"

)

public AccountDto getAccount(UUID userId, UUID tenantId, UUID accountId) { ... }

​

​

Безопасный ключ дороже по кардинальности

​

​

Иногда лучше кэшировать данные отдельно, а авторизацию проверять вне метода.

117 of 200

117

Безопасный ключ дороже по кардинальности

​

​

​

​

​

​

  • ключ учитывает контекст доступа

Плюс

118 of 200

118

Безопасный ключ дороже по кардинальности

​

​

​

​

​

​

  • ключ учитывает контекст доступа

Плюс

Минус

  • растет количество ключей и память Redis

119 of 200

Что мы имеем?

119

Что получили

Поняли: быстрый кэшированный ответ может быть неправильным.

120 of 200

Что мы имеем?

120

Что получили

Поняли: быстрый кэшированный ответ может быть неправильным.

Что решили

Разобрали устаревшие данные, negative caching и ошибки ключей для прав доступа.

121 of 200

Что мы имеем?

121

Что получили

Поняли: быстрый кэшированный ответ может быть неправильным.

Что решили

Что осталось

Разобрали устаревшие данные, negative caching и ошибки ключей для прав доступа.

Показать, как кэш меняет не только данные, но и нагрузку на систему.

122 of 200

Кэш меняет нагрузочный профиль

122

TTL, miss storm и отказ Redis способны ударить по БД сильнее обычного трафика.

123 of 200

123

TTL - это не просто срок жизни ключа

Один популярный ключ TTL = 5 минут

124 of 200

124

TTL - это не просто срок жизни ключа

Один популярный ключ TTL = 5 минут

Через 5 минут 1000 cache miss

125 of 200

125

TTL - это не просто срок жизни ключа

Один популярный ключ TTL = 5 минут

Через 5 минут 1000 cache miss

1000 одинаковых запросов в БД

126 of 200

126

TTL - это не просто срок жизни ключа

TTL задает момент, когда система может синхронно переложить чтение с Redis обратно на PostgreSQL.

Один популярный ключ TTL = 5 минут

Через 5 минут 1000 cache miss

1000 одинаковых запросов в БД

127 of 200

128 of 200

128

129 of 200

129

​

public CatalogPageDto getCatalogPage(int page) {

return catalogRepository.loadPage(page);

}

​

​

Примитивный TTL для горячего ключа

​

​

130 of 200

130

@Cacheable(cacheNames = "catalog-page", key = "#page")

public CatalogPageDto getCatalogPage(int page) {

return catalogRepository.loadPage(page);

}

​

​

Примитивный TTL для горячего ключа

​

​

131 of 200

131

@Cacheable(cacheNames = "catalog-page", key = "#page")

public CatalogPageDto getCatalogPage(int page) {

return catalogRepository.loadPage(page);

}

​

// catalog-page TTL = 30 seconds

// один популярный ключ истекает

// сразу для всех

​

​

Примитивный TTL для горячего ключа

​

​

132 of 200

132

@Cacheable(cacheNames = "catalog-page", key = "#page")

public CatalogPageDto getCatalogPage(int page) {

return catalogRepository.loadPage(page);

}

​

// catalog-page TTL = 30 seconds

// один популярный ключ истекает

// сразу для всех

​

​

Примитивный TTL для горячего ключа

​

​

Проблема не в самом TTL.

Проблема в синхронном восстановлении популярного значения.

​

​

133 of 200

Интерактив #4: У нас cache stampede. Что первым уменьшит лавину одинаковых MISS?

​

133

A. Увеличить размер Hikari pool

C. Single-flight + лимит загрузчиков кэша

B. Добавить TTL jitter

D. Фоновое обновление + прогрев кэша

134 of 200

134

135 of 200

135

136 of 200

136

137 of 200

137

138 of 200

138

139 of 200

139

140 of 200

140

141 of 200

141

142 of 200

Что мы имеем?

142

Что получили

Кэш меняет не только latency, но и профиль нагрузки на PostgreSQL.

143 of 200

Что мы имеем?

143

Что получили

Кэш меняет не только latency, но и профиль нагрузки на PostgreSQL.

Что решили

Разобрали TTL, stampede, cold cache и защиту от miss storm.

144 of 200

Что мы имеем?

144

Что получили

Кэш меняет не только latency, но и профиль нагрузки на PostgreSQL.

Что решили

Что осталось

Разобрали TTL, stampede, cold cache и защиту от miss storm.

Показать, что кэшированные данные становятся частью релиза.

145 of 200

Кэш усложняет релизы

145

Формат значения кэша - это прод-контракт.

146 of 200

146

// v1

public record ProductCardCacheDto(

UUID id,

String name,

BigDecimal price

) {}

​

​

Кэш хранит не объект. Кэш хранит байты.

​

​

147 of 200

147

// v1

public record ProductCardCacheDto(

UUID id,

String name,

BigDecimal price

) {}

​

// v2

public record ProductCardCacheDto(

UUID id,

String name,

Money price,

ProductStatus status

) {}

​

​

Кэш хранит не объект. Кэш хранит байты.

​

​

148 of 200

148

// v1

public record ProductCardCacheDto(

UUID id,

String name,

BigDecimal price

) {}

​

// v2

public record ProductCardCacheDto(

UUID id,

String name,

Money price,

ProductStatus status

) {}

​

​

Кэш хранит не объект. Кэш хранит байты.

​

​

Redis все еще содержит значения v1. Новый код уже читает как v2.

149 of 200

149

150 of 200

150

cache-lab::v1::product-card::{id}

cache-lab::v2::product-card::{id}

​

ProductCardCacheDto {

UUID id;

String name;

long schemaVersion;

}

​

​

Версионирование кэша: скучно, но спасает релизы

​

​

151 of 200

151

Версионирование кэша:

скучно, но спасает релизы

  • не кэшировать JPA сущности

152 of 200

152

Версионирование кэша:

скучно, но спасает релизы

  • не кэшировать JPA сущности
  • использовать отдельные DTO для кэша

153 of 200

153

Версионирование кэша:

скучно, но спасает релизы

  • не кэшировать JPA сущности
  • использовать отдельные DTO для кэша
  • обрабатывать ошибки десериализации контролируемо

154 of 200

Что мы имеем?

154

Что получили

Формат значения кэша оказался частью релизного контракта.

155 of 200

Что мы имеем?

155

Что получили

Формат значения кэша оказался частью релизного контракта.

​

Что решили

Разобрали эволюцию DTO, blue-green и БД fallback шторм.

156 of 200

Что мы имеем?

156

Что получили

Формат cache value оказался частью релизного контракта.

Что решили

Что осталось

Разобрали эволюцию DTO, blue-green и БД fallback шторм.

Показать, почему local cache + Redis усложняет получение свежих данных еще сильнее.

157 of 200

Локальный кэш + Redis

157

Два уровня скорости - несколько уровней рассинхронизации.

158 of 200

158

Caffeine перед Redis: быстрее, но не проще

PostgreSQL

version=2

159 of 200

159

Caffeine перед Redis: быстрее, но не проще

Redis

version=2

PostgreSQL

version=2

160 of 200

160

Caffeine перед Redis: быстрее, но не проще

app-b

Caffeine: v2

Redis

version=2

PostgreSQL

version=2

161 of 200

161

Caffeine перед Redis: быстрее, но не проще

Client

app-b

Caffeine: v2

Redis

version=2

PostgreSQL

version=2

162 of 200

162

Caffeine перед Redis: быстрее, но не проще

Client

app-a

Caffeine: v1

app-b

Caffeine: v2

Redis

version=2

PostgreSQL

version=2

163 of 200

163

Caffeine перед Redis: быстрее, но не проще

Client

app-a

Caffeine: v1

app-b

Caffeine: v2

Redis

version=2

PostgreSQL

version=2

Один Redis и одна БД, но несколько локальных состояний. После операции UPDATE один под может продолжать отдавать старое значение.

164 of 200

164

Что делать с многоуровневым кэшем

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

Короткий локальный TTL

снижает окно устаревания

Pub/Sub инвалидация

полезно, но не бесплатно

Версионированные значения

помогают обнаружить старое

Никакого локального кэша

для чувствительных данных

Иммутабельные данные

лучший кандидат

Метрики на каждый слой

local hit ≠ Redis hit

165 of 200

165

Что делать с многоуровневым кэшем

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

Короткий локальный TTL

снижает окно устаревания

Pub/Sub инвалидация

полезно, но не бесплатно

Версионированные значения

помогают обнаружить старое

Никакого локального кэша

для чувствительных данных

Иммутабельные данные

лучший кандидат

Метрики на каждый слой

local hit ≠ Redis hit

166 of 200

166

Что делать с многоуровневым кэшем

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

Короткий локальный TTL

снижает окно устаревания

Pub/Sub инвалидация

полезно, но не бесплатно

Версионированные значения

помогают обнаружить старое

Никакого локального кэша

для чувствительных данных

Иммутабельные данные

лучший кандидат

Метрики на каждый слой

local hit ≠ Redis hit

167 of 200

167

Что делать с многоуровневым кэшем

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

Короткий локальный TTL

снижает окно устаревания

Pub/Sub инвалидация

полезно, но не бесплатно

Версионированные значения

помогают обнаружить старое

Никакого локального кэша

для чувствительных данных

Иммутабельные данные

лучший кандидат

Метрики на каждый слой

local hit ≠ Redis hit

168 of 200

168

Что делать с многоуровневым кэшем

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

Короткий локальный TTL

снижает окно устаревания

Pub/Sub инвалидация

полезно, но не бесплатно

Версионированные значения

помогают обнаружить старое

Никакого локального кэша

для чувствительных данных

Иммутабельные данные

лучший кандидат

Метрики на каждый слой

local hit ≠ Redis hit

169 of 200

169

Что делать с многоуровневым кэшем

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

Короткий локальный TTL

снижает окно устаревания

Pub/Sub инвалидация

полезно, но не бесплатно

Версионированные значения

помогают обнаружить старое

Никакого локального кэша

для чувствительных данных

Иммутабельные данные

лучший кандидат

Метрики на каждый слой

local hit ≠ Redis hit

170 of 200

Что мы имеем?

170

Что получили

Поняли, что Redis может быть актуален, а локальный кэш - нет.

171 of 200

Что мы имеем?

171

Что получили

Поняли, что Redis может быть актуален, а локальный кэш - нет.

Что решили

Определили: не все данные можно держать в нескольких слоях.

172 of 200

Что мы имеем?

172

Что получили

Поняли, что Redis может быть актуален, а локальный кэш - нет.

Что решили

Что осталось

Определили: не все данные можно держать в нескольких слоях.

Показать кульминацию: кэш долго скрывал деградацию базы.

173 of 200

Акт 4. Кэш скрывает деградацию БД

​

173

Пока hit ratio высокий, база может болеть почти незаметно.

174 of 200

175 of 200

175

176 of 200

177 of 200

177

178 of 200

Интерактив #5: Redis очистили, БД легла. Что сломано архитектурно?

​

178

C. Нет управляемого режима холодного кэша.

D. Redis стал единой точкой отказа

A. БД должна держать весь трафик на чтение.

B. Не было прогрева кэша

179 of 200

179

Холодный кэш - это отдельный режим работы системы

Правильный разбор начинается с вопроса: выдерживает ли PostgreSQL трафик при падении hit ratio и есть ли лимиты на cache loader.

Практичный ответ

180 of 200

180

Холодный кэш - это отдельный режим работы системы

Правильный разбор начинается с вопроса: выдерживает ли PostgreSQL трафик при падении hit ratio и есть ли лимиты на cache loader.

Практичный ответ

Почему

Высокий hit ratio может скрывать деградацию БД. Планирование пропускной способности должен включать:

  • холодный кэш
  • перезапуск Redis
  • очистку кэша
  • массовую инвалидацию.

181 of 200

Что мы имеем?

181

Что получили

Увидели, как кэш маскирует проблемы БД до момента резкого провала hit ratio.

182 of 200

Что мы имеем?

182

Что получили

Увидели, как кэш маскирует проблемы БД до момента резкого провала hit ratio.

Что решили

Зафиксировали: Redis outage - это сценарий нагрузки на БД, а не только проблема Redis.

183 of 200

Что мы имеем?

183

Что получили

Увидели, как кэш маскирует проблемы БД до момента резкого провала hit ratio.

Что решили

Что осталось

Зафиксировали: Redis outage - это сценарий нагрузки на БД, а не только проблема Redis.

Собрать практический чеклист безопасного кэширования.

184 of 200

Акт 5. Как кэшировать безопаснее

184

Не “не используйте кэш”, а проектируйте его как часть архитектуры.

185 of 200

185

Чеклист перед @Cacheable

Redis не лечит БД. Он уменьшает количество случаев, когда ее болезнь видна пользователю.

Что кэшируем?

DTO, не Entity

Свежесть:

сколько старого можно показать?

Инвалидация

кто и когда удаляет?

Авария

что при отказе Redis?

Холодный кэш

выдержит ли БД?

Безопасность

есть ли user/tenant/role?

Релиз

формат совместим?

Метрики/тесты

как поймем, что сломали?

186 of 200

186

Чеклист перед @Cacheable

Redis не лечит БД. Он уменьшает количество случаев, когда ее болезнь видна пользователю.

Что кэшируем?

DTO, не Entity

Свежесть:

сколько старого можно показать?

Инвалидация

кто и когда удаляет?

Авария

что при отказе Redis?

Холодный кэш

выдержит ли БД?

Безопасность

есть ли user/tenant/role?

Релиз

формат совместим?

Метрики/тесты

как поймем, что сломали?

187 of 200

187

Чеклист перед @Cacheable

Redis не лечит БД. Он уменьшает количество случаев, когда ее болезнь видна пользователю.

Что кэшируем?

DTO, не Entity

Свежесть:

сколько старого можно показать?

Инвалидация

кто и когда удаляет?

Авария

что при отказе Redis?

Холодный кэш

выдержит ли БД?

Безопасность

есть ли user/tenant/role?

Релиз

формат совместим?

Метрики/тесты

как поймем, что сломали?

188 of 200

188

Чеклист перед @Cacheable

Redis не лечит БД. Он уменьшает количество случаев, когда ее болезнь видна пользователю.

Что кэшируем?

DTO, не Entity

Свежесть:

сколько старого можно показать?

Инвалидация

кто и когда удаляет?

Авария

что при отказе Redis?

Холодный кэш

выдержит ли БД?

Безопасность

есть ли user/tenant/role?

Релиз

формат совместим?

Метрики/тесты

как поймем, что сломали?

189 of 200

189

Чеклист перед @Cacheable

Redis не лечит БД. Он уменьшает количество случаев, когда ее болезнь видна пользователю.

Что кэшируем?

DTO, не Entity

Свежесть:

сколько старого можно показать?

Инвалидация

кто и когда удаляет?

Авария

что при отказе Redis?

Холодный кэш

выдержит ли БД?

Безопасность

есть ли user/tenant/role?

Релиз

формат совместим?

Метрики/тесты

как поймем, что сломали?

190 of 200

190

Чеклист перед @Cacheable

Redis не лечит БД. Он уменьшает количество случаев, когда ее болезнь видна пользователю.

Что кэшируем?

DTO, не Entity

Свежесть:

сколько старого можно показать?

Инвалидация

кто и когда удаляет?

Авария

что при отказе Redis?

Холодный кэш

выдержит ли БД?

Безопасность

есть ли user/tenant/role?

Релиз

формат совместим?

Метрики/тесты

как поймем, что сломали?

191 of 200

191

Чеклист перед @Cacheable

Redis не лечит БД. Он уменьшает количество случаев, когда ее болезнь видна пользователю.

Что кэшируем?

DTO, не Entity

Свежесть:

сколько старого можно показать?

Инвалидация

кто и когда удаляет?

Авария

что при отказе Redis?

Холодный кэш

выдержит ли БД?

Безопасность

есть ли user/tenant/role?

Релиз

формат совместим?

Метрики/тесты

как поймем, что сломали?

192 of 200

192

Чеклист перед @Cacheable

Redis не лечит БД. Он уменьшает количество случаев, когда ее болезнь видна пользователю.

Что кэшируем?

DTO, не Entity

Свежесть:

сколько старого можно показать?

Инвалидация

кто и когда удаляет?

Авария

что при отказе Redis?

Холодный кэш

выдержит ли БД?

Безопасность

есть ли user/tenant/role?

Релиз

формат совместим?

Метрики/тесты

как поймем, что сломали?

193 of 200

193

cache.hit / cache.miss / cache.put / cache.eviction

cache.load.duration / cache.load.failures

cache.stale.served / cache.refresh.failures

cache.deserialization.errors

cache.db.fallback

hikaricp.connections.pending

http.server.requests p95 / p99

redis evicted_keys / expired_keys

​

​

Метрики и тесты: hit/miss недостаточно

​

​

194 of 200

194

Метрики и тесты: hit/miss недостаточно

  • Redis недоступен или медленный
  • массовый cache miss
  • истечение TTL популярного ключа
  • изменение прав доступа
  • изменение DTO между версиями
  • blue-green deployment

195 of 200

196 of 200

196

Выводы

  • Кэш - это не ускоритель.

197 of 200

197

Выводы

  • Кэш - это не ускоритель.
  • Кэш - это договор с бизнесом о том, насколько старые данные мы имеем право показать ради скорости.

198 of 200

198

Выводы

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

199 of 200

199

Исходный код проекта

  • Запуск через make up
  • Ветки/теги показывают сценарии доклада
  • Grafana дашборды “подтягиваются” автоматически
  • k6 сценарии лежат в load-tests

200 of 200

Превратности

кэша

200

Евгений Сулейманов

СТО | ProzyTech

​

Software Engineering

Презентация

Github repo