Каждый раз, когда я слышу «добавим Redis для кэширования», я спрашиваю: «какую стратегию?» И в половине случаев ответ — «ну, просто кэш». Это как сказать «просто база данных» — технически верно, но бесполезно. Стратегия кэширования определяет, когда данные попадают в кэш, когда обновляются и когда инвалидируются. Неправильный выбор — это устаревшие данные, cache stampede, thundering herd и P0-инциденты в 3 часа ночи.
На нашем маркетплейсе Redis обрабатывает 50 000 операций в секунду. Рассказываю, какие стратегии мы используем и почему.
Базовая настройка: Redis + Ktor
Для Kotlin есть несколько Redis-клиентов: lettuce (асинхронный, Netty-based), jedis (синхронный), redis4cats (Cats Effect, для функционального программирования). Мы используем lettuce с обёрткой на корутинах:
class RedisCache(
private val redisClient: RedisClient,
private val objectMapper: ObjectMapper
) {
private val connection = redisClient.connect()
private val commands = connection.sync()
suspend fun <T> get(key: String, clazz: KClass<T>): T? = withContext(Dispatchers.IO) {
val value = commands.get(key) ?: return@withContext null
objectMapper.readValue(value, clazz.java)
}
suspend fun <T> set(key: String, value: T, ttl: Duration) = withContext(Dispatchers.IO) {
val json = objectMapper.writeValueAsString(value)
commands.setex(key, ttl.seconds.toInt(), json)
}
suspend fun delete(key: String) = withContext(Dispatchers.IO) {
commands.del(key)
}
suspend fun <T> getOrSet(key: String, ttl: Duration, clazz: KClass<T>, loader: suspend () -> T): T {
val cached = get(key, clazz)
if (cached != null) return cached
val value = loader()
set(key, value, ttl)
return value
}
}
// DI (Koin)
single {
RedisClient.create(RedisURI.create(System.getenv("REDIS_URL")))
}
single {
RedisCache(get(), jacksonObjectMapper())
}
Обёртка с getOrSet — это базовая реализация паттерна Cache-Aside. Проверяем кэш, если пусто — загружаем из БД и сохраняем в кэш. Простой, но с нюансами, которые мы разберём ниже.
Cache-Aside: самая распространённая стратегия
Cache-Aside (ленивая загрузка) — приложение отвечает за загрузку данных в кэш. Redis не знает о схеме данных, TTL или инвалидации. Это просто key-value хранилище, а вся логика — в коде приложения.
class ProductService(
private val productRepository: ProductRepository,
private val cache: RedisCache
) {
companion object {
private val CACHE_TTL = Duration.ofMinutes(10)
private const val CACHE_PREFIX = "product:"
}
suspend fun findById(productId: Long): ProductDto {
val cacheKey = "$CACHE_PREFIX$productId"
// 1. Пытаемся достать из кэша
return cache.get(cacheKey, ProductDto::class) ?: run {
// 2. Cache miss — загружаем из БД
val product = productRepository.findById(productId)
?: throw ProductNotFoundException(productId)
val dto = ProductDto.fromDomain(product)
// 3. Сохраняем в кэш с TTL
cache.set(cacheKey, dto, CACHE_TTL)
dto
}
}
suspend fun update(productId: Long, request: UpdateProductRequest): ProductDto {
val product = productRepository.update(productId, request)
val dto = ProductDto.fromDomain(product)
// 4. Инвалидируем кэш после обновления
cache.delete("$CACHE_PREFIX$productId")
return dto
}
}
Cache-Aside прост и предсказуем. При cache miss данные загружаются из БД и кэшируются. При обновлении данных кэш инвалидируется. TTL защищает от устаревших данных в случае, если инвалидация не сработала.
Проблема Cache Stampede
Когда популярный ключ истекает, десятки параллельных запросов одновременно обнаруживают cache miss и идут в БД. База данных получает лавину запросов вместо одного. Это Cache Stampede (он же dogpile effect, thundering herd).
// ❌ Cache Stampede: 100 запросов одновременно идут в БД
suspend fun findById(productId: Long): ProductDto {
val cacheKey = "product:$productId"
return cache.get(cacheKey, ProductDto::class) ?: run {
// Все 100 запросов доходят сюда
val product = productRepository.findById(productId) // 100 SELECT запросов!
cache.set(cacheKey, ProductDto.fromDomain(product), CACHE_TTL)
ProductDto.fromDomain(product)
}
}
// ✅ Решение: SETNX (SET if Not eXists) — взаимное исключение
class StampedeSafeProductService(
private val productRepository: ProductRepository,
private val commands: RedisCommands<String, String>
) {
suspend fun findById(productId: Long): ProductDto {
val cacheKey = "product:$productId"
val lockKey = "lock:product:$productId"
// 1. Проверяем кэш
val cached = commands.get(cacheKey)
if (cached != null) return deserialize(cached)
// 2. Пробуем захватить блокировку (SETNX с TTL — защита от взаимоблокировки)
val acquired = commands.setnx(lockKey, "locked")
if (acquired) {
commands.expire(lockKey, 10) // TTL для блокировки
try {
// Double-check: вдруг кто-то уже загрузил
val doubleCheck = commands.get(cacheKey)
if (doubleCheck != null) return deserialize(doubleCheck)
// 3. Загружаем из БД (один запрос вместо 100)
val product = productRepository.findById(productId)
?: throw ProductNotFoundException(productId)
val dto = ProductDto.fromDomain(product)
commands.setex(cacheKey, 600, serialize(dto))
return dto
} finally {
commands.del(lockKey)
}
} else {
// 4. Кто-то уже загружает — ждём и пробуем снова
delay(50)
return findById(productId) // Повтор после задержки
}
}
}
Блокировка через SETNX решает проблему Stampede: только один запрос загружает данные в кэш, остальные ждут и забирают готовый результат. На продакшене с популярными товарами (5000 rps на один товар) это снизило нагрузку на PostgreSQL с 5000 QPS до 5 QPS.
Write-Through: синхронная запись в кэш
В Write-Through приложение пишет данные одновременно в БД и в кэш. Чтение — всегда из кэша (cache miss невозможен, если нет вытеснения). Это синхронная стратегия: запись считается успешной только когда и БД, и кэш обновлены.
class WriteThroughProductService(
private val productRepository: ProductRepository,
private val cache: RedisCache
) {
suspend fun update(productId: Long, request: UpdateProductRequest): ProductDto {
val product = productRepository.findById(productId)!!
val updated = product.copy(
name = request.name ?: product.name,
price = request.price ?: product.price
)
// 1. Пишем в БД
productRepository.save(updated)
// 2. Синхронно обновляем кэш
val dto = ProductDto.fromDomain(updated)
cache.set("product:$productId", dto, Duration.ofMinutes(30))
return dto
}
suspend fun findById(productId: Long): ProductDto {
// В идеальном Write-Through — читаем ТОЛЬКО из кэша
return cache.get("product:$productId", ProductDto::class)
?: throw IllegalStateException("Write-Through: в кэше всегда должны быть данные")
}
}
Write-Through гарантирует консистентность: кэш всегда содержит актуальные данные. Но цена — задержка: каждая операция записи ждёт и БД, и Redis. Если Redis недоступен — запись блокируется. Для обновления профилей пользователей, конфигураций и справочных данных — отлично подходит.
Write-Behind (Write-Back): асинхронная запись
В Write-Behind приложение пишет в кэш немедленно, а в БД — асинхронно (пакетно). Это максимально быстро для записи, но есть риск потери данных при сбое.
class WriteBehindOrderCounter(
private val commands: RedisCommands<String, String>,
private val database: Database
) {
// 1. Считаем просмотры в Redis (INCR — атомарная операция)
suspend fun incrementViewCount(productId: Long) {
commands.incr("views:$productId")
commands.expire("views:$productId", 3600) // TTL 1 час
}
// 2. Пакетная запись в БД (каждые 60 секунд через корутину)
fun startFlushJob(): Job = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
delay(60_000) // Каждую минуту
flushViewCountsToDatabase()
}
}
private suspend fun flushViewCountsToDatabase() {
val keys = commands.keys("views:*")
if (keys.isEmpty()) return
transaction(database) {
keys.forEach { key ->
val productId = key.removePrefix("views:").toLong()
val views = commands.get(key)?.toLong() ?: return@forEach
// UPSERT в БД
exec(
"INSERT INTO product_views (product_id, view_count) VALUES (?, ?) " +
"ON CONFLICT (product_id) DO UPDATE SET view_count = product_views.view_count + ?",
args = listOf(productId, views, views)
)
commands.del(key) // Очищаем после сброса
}
}
}
}
Write-Behind идеально подходит для счётчиков, аналитики и метрик. INCR в Redis — атомарный и быстрый (O(1)). Пакетная запись в БД раз в минуту — минимум нагрузки. Да, при падении сервиса до сброса вы потеряете до 60 секунд данных, но для счётчика просмотров это приемлемо.
Pub/Sub: инвалидация кэша в распределённой системе
Когда у вас 10 экземпляров одного микросервиса, и один обновил товар — остальные 9 должны инвалидировать локальный кэш. Redis Pub/Sub решает это:
class CacheInvalidationService(
private val commands: RedisCommands<String, String>
) {
companion object {
private const val CHANNEL = "cache:invalidation"
}
// Публикуем событие инвалидации
fun invalidate(keyPattern: String) {
commands.publish(CHANNEL, keyPattern)
}
// Подписываемся на инвалидации
fun subscribe(onInvalidate: (String) -> Unit): Job {
return CoroutineScope(Dispatchers.IO).launch {
val pubsub = commands.pubsubChannels()
commands.subscribe(CHANNEL)
pubsub.use {
it.addEventListener { message ->
if (message.channel() == CHANNEL) {
val key = message.message()
launch { onInvalidate(key) }
}
}
// Держим подписку активной
while (isActive) delay(1000)
}
}
}
}
// В микросервисе
class ProductService(
private val productRepository: ProductRepository,
private val cache: RedisCache,
private val invalidation: CacheInvalidationService
) {
init {
// Подписка на инвалидации (все экземпляры сервиса)
invalidation.subscribe { keyPattern ->
cache.deleteByPattern(keyPattern)
}
}
suspend fun update(productId: Long, request: UpdateProductRequest): ProductDto {
// ... обновляем в БД ...
// Уведомляем все экземпляры об инвалидации
invalidation.invalidate("product:$productId")
// ...
}
}
Результаты на продакшене
Метрика | Без кэша | Cache-Aside | Cache-Aside + защита от Stampede
-------------------------------|-------------|---------------|----------------------------------
Средняя задержка (GET /products/:id) | 80 мс | 5 мс | 5 мс
P99 задержка | 200 мс | 12 мс | 8 мс
Нагрузка на БД (пиковая) | 50 000 QPS | 5 000 QPS | 50 QPS
Попаданий в кэш | 0% | 94% | 96%
Операций Redis в секунду | 0 | 50 000 | 50 500
Инцидентов с устаревшими данными (в месяц) | 0 | 3 | 0
Cache-Aside + защита от Stampede — наша стратегия по умолчанию. 94% попаданий в кэш, задержка снизилась с 80 мс до 5 мс, нагрузка на БД упала в 1000 раз. Защита от Stampede устранила пиковые нагрузки на PostgreSQL.
Мой вердикт
Не начинайте с «просто кэш». Определите, какие данные кэшировать (преобладает чтение или запись?), допустимое устаревание данных (секунды? минуты?) и какая производительность нужна. Для данных с преобладанием чтения — Cache-Aside с TTL. Для критичных данных — Write-Through. Для счётчиков и аналитики — Write-Behind. И всегда добавляйте защиту от Stampede, если ожидаете горячие ключи.
Redis — мощнее, чем кажется. Sorted Sets для рейтингов, Streams для event sourcing, Lua-скрипты для атомарных операций, Bitmaps для feature flags. Кэширование — только верхушка айсберга.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье