Как сервер принимает решение, обращаться к базе данных или к кешу?

Ответ

Серверы используют кеширование для значительного ускорения ответа на запросы, уменьшения нагрузки на базу данных и улучшения общей производительности. Решение о том, обращаться ли к базе данных или к кешу, обычно принимается по следующему алгоритму, известному как паттерн Cache-Aside (Кеш-со-стороны):

  1. Проверка кеша: При получении запроса на данные сервер сначала пытается найти эти данные в кеше (например, Redis, Memcached, in-memory кеш).
  2. Проверка актуальности (TTL/валидность): Если данные найдены в кеше, сервер проверяет их срок жизни (Time-To-Live, TTL) или другие механизмы валидации (например, версию данных, ETag). Если данные устарели или невалидны, они считаются отсутствующими.
  3. Чтение из базы данных: Если данные не найдены в кеше (кеш-промах) или они устарели, сервер обращается к основной базе данных для получения актуальных данных.
  4. Обновление кеша: После успешного получения данных из базы данных, сервер сохраняет эти данные в кеше, устанавливая соответствующий TTL. Это гарантирует, что последующие запросы на те же данные будут обслуживаться из кеша.
  5. Возврат данных: Данные возвращаются клиенту.

Пример реализации на Go с Redis (паттерн Cache-Aside):

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "time"

    "github.com/go-redis/redis/v8"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

// User представляет модель пользователя
type User struct {
    ID   string `json:"id" gorm:"primaryKey"`
    Name string `json:"name"`
    Email string `json:"email"`
}

var ( // Глобальные переменные для примера
    db          *gorm.DB
    redisClient *redis.Client
    ctx         = context.Background()
)

func init() {
    // Инициализация базы данных (PostgreSQL для примера)
    // Замените на свои данные подключения
    dsn := "host=localhost user=gorm password=gorm dbname=gorm port=5432 sslmode=disable TimeZone=Asia/Shanghai"
    var err error
    db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatalf("Не удалось подключиться к базе данных: %v", err)
    }
    // Автоматическая миграция схемы
    db.AutoMigrate(&User{})

    // Инициализация Redis клиента
    redisClient = redis.NewClient(&redis.Options{
        Addr:     "localhost:6379", // Адрес Redis сервера
        Password: "",             // Пароль (если есть)
        DB:       0,              // База данных по умолчанию
    })

    // Проверка соединения с Redis
    _, err = redisClient.Ping(ctx).Result()
    if err != nil {
        log.Fatalf("Не удалось подключиться к Redis: %v", err)
    }

    fmt.Println("Подключение к БД и Redis успешно установлено.")
}

// GetUser получает пользователя по ID, используя кеш.
func GetUser(id string) (User, error) {
    var user User
    cacheKey := "user:" + id

    // 1. Пытаемся получить данные из кеша
    cachedUserJSON, err := redisClient.Get(ctx, cacheKey).Result()
    if err == nil { // Данные найдены в кеше
        err = json.Unmarshal([]byte(cachedUserJSON), &user)
        if err == nil {
            fmt.Printf("Получено из кеша: %+vn", user)
            return user, nil
        }
        // Если десериализация не удалась, возможно, кеш поврежден, идем в БД
        log.Printf("Ошибка десериализации кеша для %s: %v. Идем в БД.n", cacheKey, err)
    }

    // 2. Если нет в кеше или ошибка десериализации - идем в БД
    fmt.Printf("Получение пользователя %s из БД...n", id)
    err = db.First(&user, "id = ?", id).Error // GORM: SELECT * FROM users WHERE id = '...' LIMIT 1
    if err != nil { // Пользователь не найден или другая ошибка БД
        if err == gorm.ErrRecordNotFound {
            fmt.Printf("Пользователь %s не найден в БД.n", id)
            // Опционально: можно кешировать отсутствие пользователя (например, с очень коротким TTL)
            // redisClient.Set(ctx, cacheKey, "NOT_FOUND", 5*time.Minute)
        }
        return User{}, fmt.Errorf("ошибка при получении пользователя из БД: %w", err)
    }

    // 3. Обновляем кеш после получения из БД
    userJSON, err := json.Marshal(user)
    if err != nil {
        log.Printf("Ошибка сериализации пользователя для кеша: %v", err)
        // Продолжаем, так как данные из БД уже получены
    } else {
        // Кешируем на 1 час
        redisClient.Set(ctx, cacheKey, userJSON, time.Hour)
        fmt.Printf("Пользователь %s кеширован.n", id)
    }

    fmt.Printf("Получено из БД: %+vn", user)
    return user, nil
}

func main() {
    // Пример использования
    // Создадим тестового пользователя, если его нет
    initialUser := User{ID: "123", Name: "Alice", Email: "alice@example.com"}
    db.FirstOrCreate(&initialUser, initialUser)

    // Первый запрос - из БД, затем кешируется
    _, err := GetUser("123")
    if err != nil {
        log.Printf("Ошибка: %vn", err)
    }

    // Второй запрос - из кеша
    _, err = GetUser("123")
    if err != nil {
        log.Printf("Ошибка: %vn", err)
    }

    // Запрос несуществующего пользователя
    _, err = GetUser("999")
    if err != nil {
        log.Printf("Ошибка: %vn", err)
    }
}

Дополнительные стратегии кеширования (кратко):

  • Write-Through (Запись-сквозь): Данные записываются одновременно в кеш и в БД. Обеспечивает согласованность, но может быть медленнее при записи.
  • Write-Back (Запись-обратно): Данные сначала записываются в кеш, а затем асинхронно сбрасываются в БД. Высокая производительность записи, но риск потери данных при сбое кеша.
  • Cache-Aside (Кеш-со-стороны): (Показано выше) Приложение управляет кешем, явно читая из него и записывая в него. Требует ручной инвалидации кеша при изменении данных в БД.