Загрузка
Все статьи

Redis: стратегии кэширования в Kotlin-микросервисах — от Cache-Aside до CQRS

Redis — не просто key-value хранилище. Pub/Sub, Streams, Sorted Sets, Lua-скрипты — Redis позволяет реализовать сложные паттерны кэширования и event sourcing. Разбираю Cache-Aside, Write-Through, Write-Behind, паттерн Cache Stampede и реальный опыт оптимизации задержки с 80 мс до 5 мс.

Каждый раз, когда я слышу «добавим 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. Кэширование — только верхушка айсберга.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Micronaut vs Spring Boot — лёгкий фреймворк для Kotlin-микросервисов

Сравниваю Micronaut и Spring Boot для Kotlin-микросервисов: стартовое время, потребление памяти, холодный старт и реальный опыт миграции одного из сервисов.

12 июня 2026 11 мин

Kotlin: sealed interfaces и when-выражения — паттерн-матчинг как в Rust

Как sealed interfaces из Kotlin 1.5+ вместе с when-выражениями дают мощный паттерн-матчинг, похожий на Rust enum+match, и почему мы перешли на ADT для доменных событий.

11 июня 2026 9 мин

Kotlinx Serialization + Protobuf — быстрая сериализация для микросервисов

Разбираюсь с накладными расходами JSON-сериализации при высоких нагрузках и показываю, как интегрировать Protobuf с kotlinx-serialization в микросервисной архитектуре на Kotlin.

10 июня 2026 10 мин