Files
gh-stirlingmarketinggroup-c…/references/caching-guide.md
2025-11-30 08:58:35 +08:00

17 KiB

Caching Guide

Complete guide to caching strategies and configuration in cool-mysql.

Table of Contents

  1. Caching Overview
  2. Cache Types
  3. Cache Configuration
  4. TTL Selection
  5. Multi-Level Caching
  6. Cache Invalidation
  7. Distributed Locking
  8. Performance Optimization
  9. Best Practices

Caching Overview

cool-mysql supports pluggable caching for SELECT queries to reduce database load and improve response times.

How Caching Works

  1. Cache Key Generation: Automatically generated from query + parameters
  2. Cache Check: Before executing query, check cache for existing result
  3. Cache Miss: Execute query and store result with TTL
  4. Cache Hit: Return cached result without database query

What Gets Cached

  • Cached: All SELECT queries with cacheTTL > 0
  • Not Cached: INSERT, UPDATE, DELETE, EXEC operations
  • Not Cached: SELECT queries with cacheTTL = 0

Cache Behavior

// No caching (TTL = 0)
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)

// Cache for 5 minutes
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 5*time.Minute)

// Cache for 1 hour
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = 1", time.Hour)

Cache Types

1. In-Memory Weak Cache

Type: Local, process-specific, GC-managed Use Case: Single-server applications, development, testing

Characteristics:

  • Fastest access (no network)
  • Memory managed by Go GC
  • Weak pointers - automatically freed when under memory pressure
  • Not shared across processes
  • Lost on restart

Setup:

db.UseCache(mysql.NewWeakCache())

Pros:

  • Zero configuration
  • No external dependencies
  • Automatic memory management
  • Extremely fast

Cons:

  • Not shared across servers
  • No distributed locking
  • Cache lost on restart
  • Memory limited

Best For:

  • Development
  • Testing
  • Single-server deployments
  • Applications with low cache requirements

2. Redis Cache

Type: Distributed, persistent Use Case: Multi-server deployments, high-traffic applications

Characteristics:

  • Shared across all application instances
  • Distributed locking to prevent cache stampedes
  • Configurable persistence
  • Network latency overhead
  • Requires Redis server

Setup:

import "github.com/redis/go-redis/v9"

redisClient := redis.NewClient(&redis.Options{
    Addr:     "localhost:6379",
    Password: "", // no password set
    DB:       0,  // use default DB
})

db.EnableRedis(redisClient)

Pros:

  • Shared cache across servers
  • Distributed locking
  • Persistent (optional)
  • High capacity
  • Cache stampede prevention

Cons:

  • Network latency
  • Requires Redis infrastructure
  • More complex setup

Best For:

  • Production multi-server deployments
  • High-traffic applications
  • Applications requiring cache consistency
  • Preventing thundering herd problems

3. Memcached Cache

Type: Distributed, volatile Use Case: Multi-server deployments, simple caching needs

Characteristics:

  • Shared across all application instances
  • No persistence
  • Simple protocol
  • No distributed locking
  • Requires Memcached server

Setup:

import "github.com/bradfitz/gomemcache/memcache"

memcacheClient := memcache.New("localhost:11211")
db.EnableMemcache(memcacheClient)

Pros:

  • Shared cache across servers
  • Simple and fast
  • Mature technology
  • Good performance

Cons:

  • No distributed locking
  • No persistence
  • Cache lost on restart
  • No cache stampede prevention

Best For:

  • Legacy infrastructure with Memcached
  • Simple caching needs
  • When distributed locking not required

Cache Configuration

Basic Setup

// In-memory cache
db := mysql.New(...)
db.UseCache(mysql.NewWeakCache())

// Redis cache
redisClient := redis.NewClient(&redis.Options{
    Addr: "localhost:6379",
})
db.EnableRedis(redisClient)

// Memcached
memcacheClient := memcache.New("localhost:11211")
db.EnableMemcache(memcacheClient)

Redis Advanced Configuration

redisClient := redis.NewClient(&redis.Options{
    Addr:         "localhost:6379",
    Password:     "secret",
    DB:           0,
    DialTimeout:  5 * time.Second,
    ReadTimeout:  3 * time.Second,
    WriteTimeout: 3 * time.Second,
    PoolSize:     10,
    MinIdleConns: 5,
})

db.EnableRedis(redisClient)

Redis Cluster

redisClient := redis.NewClusterClient(&redis.ClusterOptions{
    Addrs: []string{
        "localhost:7000",
        "localhost:7001",
        "localhost:7002",
    },
})

db.EnableRedis(redisClient)

Environment Configuration

Configure cache behavior via environment variables:

# Redis lock retry delay (default: 0.020 seconds)
export COOL_REDIS_LOCK_RETRY_DELAY=0.050

# Max query execution time (default: 27 seconds)
export COOL_MAX_EXECUTION_TIME_TIME=30s

# Max retry attempts (default: unlimited)
export COOL_MAX_ATTEMPTS=5

TTL Selection

TTL Guidelines

Choose TTL based on data volatility and access patterns:

Data Type Recommended TTL Rationale
User sessions 5-15 minutes Frequently changing
Reference data 1-24 hours Rarely changing
Analytics/Reports 15-60 minutes Tolerates staleness
Real-time data 0 (no cache) Must be fresh
Configuration 5-60 minutes Infrequent changes
Search results 1-5 minutes Balance freshness/load
Product catalogs 10-30 minutes Moderate change rate

Dynamic TTL Selection

// Choose TTL based on query type
func getCacheTTL(queryType string) time.Duration {
    switch queryType {
    case "user_profile":
        return 10 * time.Minute
    case "product_catalog":
        return 30 * time.Minute
    case "analytics":
        return time.Hour
    case "real_time":
        return 0 // No caching
    default:
        return 5 * time.Minute
    }
}

var users []User
err := db.Select(&users,
    "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = 1",
    getCacheTTL("user_profile"))

Conditional TTL

// Cache differently based on result size
var users []User
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `status` = @@status", 0,
    status)

ttl := 5 * time.Minute
if len(users) > 1000 {
    // Large result set - cache longer to reduce load
    ttl = 30 * time.Minute
}

// Re-query with caching
err = db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `status` = @@status", ttl,
    status)

Multi-Level Caching

Layered Cache Strategy

Combine fast local cache with shared distributed cache:

db.UseCache(mysql.NewMultiCache(
    mysql.NewWeakCache(),           // L1: Fast local cache
    mysql.NewRedisCache(redisClient), // L2: Shared distributed cache
))

How It Works:

  1. Check L1 (local weak cache) - fastest
  2. If miss, check L2 (Redis) - shared
  3. If miss, query database
  4. Store result in both L1 and L2

Benefits:

  • Extremely fast for repeated queries in same process
  • Shared cache prevents duplicate work across servers
  • Best of both worlds: speed + consistency

Custom Multi-Level Configuration

// Create custom cache layers
type CustomCache struct {
    layers []mysql.Cache
}

func (c *CustomCache) Get(key string) ([]byte, bool) {
    for _, layer := range c.layers {
        if val, ok := layer.Get(key); ok {
            // Backfill previous layers
            for _, prevLayer := range c.layers {
                if prevLayer == layer {
                    break
                }
                prevLayer.Set(key, val, 0)
            }
            return val, true
        }
    }
    return nil, false
}

func (c *CustomCache) Set(key string, val []byte, ttl time.Duration) {
    for _, layer := range c.layers {
        layer.Set(key, val, ttl)
    }
}

// Use custom cache
cache := &CustomCache{
    layers: []mysql.Cache{
        mysql.NewWeakCache(),
        mysql.NewRedisCache(redis1),
        mysql.NewRedisCache(redis2), // Backup Redis
    },
}
db.UseCache(cache)

Cache Invalidation

Automatic Invalidation

cool-mysql doesn't auto-invalidate on writes. You must handle invalidation explicitly.

Handling Cache After Writes

Note: Cache keys are generated internally by cool-mysql using SHA256 hashing and are not exposed as a public API. You cannot manually invalidate specific cache entries.

// Write to database
err := db.Insert("users", user)
if err != nil {
    return err
}

// Read from write pool for immediate consistency
err = db.SelectWrites(&user,
    "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id",
    0, // Don't cache write-pool reads
    user.ID)

3. Tag-Based Invalidation

// Tag queries with invalidation keys
const userCacheTag = "users:all"

// Set cache with tag
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 10*time.Minute)
redisClient.SAdd(ctx, userCacheTag, cacheKey)

// Invalidate all user queries
keys, _ := redisClient.SMembers(ctx, userCacheTag).Result()
redisClient.Del(ctx, keys...)
redisClient.Del(ctx, userCacheTag)

4. TTL-Based Invalidation

// Rely on short TTL for eventual consistency
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`",
    30*time.Second) // Short TTL = frequent refresh

Cache Invalidation Strategies

Strategy Pros Cons Best For
Manual invalidation Precise control Complex to implement Critical data
SelectWrites Simple, consistent Bypasses read pool Read-after-write
Short TTL Simple, automatic Higher DB load Frequently changing data
Tag-based Bulk invalidation Requires Redis Related queries

Distributed Locking

Cache Stampede Problem

When cache expires on high-traffic query:

  1. Multiple requests see cache miss
  2. All execute same expensive query simultaneously
  3. Database overload

Redis Distributed Locking Solution

cool-mysql's Redis cache includes distributed locking:

db.EnableRedis(redisClient)

// Automatic distributed locking
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = 1", 10*time.Minute)

How It Works:

  1. First request gets lock, executes query
  2. Subsequent requests wait for lock
  3. First request populates cache
  4. Waiting requests get result from cache
  5. Lock automatically released

Lock Configuration

# Configure lock retry delay
export COOL_REDIS_LOCK_RETRY_DELAY=0.020  # 20ms between retries

Without Distributed Locking (Memcached)

Memcached doesn't support distributed locking. Mitigate stampedes with:

  1. Probabilistic Early Expiration
// Refresh cache before expiration
func shouldRefresh(ttl time.Duration) bool {
    // Refresh 10% of requests in last 10% of TTL
    return rand.Float64() < 0.1
}
  1. Stale-While-Revalidate
// Serve stale data while refreshing
// (Requires custom cache implementation)

Performance Optimization

Cache Hit Rate Monitoring

type CacheStats struct {
    Hits   int64
    Misses int64
}

var stats CacheStats

// Wrap cache to track stats
type StatsCache struct {
    underlying mysql.Cache
    stats      *CacheStats
}

func (c *StatsCache) Get(key string) ([]byte, bool) {
    val, ok := c.underlying.Get(key)
    if ok {
        atomic.AddInt64(&c.stats.Hits, 1)
    } else {
        atomic.AddInt64(&c.stats.Misses, 1)
    }
    return val, ok
}

// Use stats cache
statsCache := &StatsCache{
    underlying: mysql.NewRedisCache(redisClient),
    stats:      &stats,
}
db.UseCache(statsCache)

// Check cache performance
hitRate := float64(stats.Hits) / float64(stats.Hits + stats.Misses)
fmt.Printf("Cache hit rate: %.2f%%\n", hitRate*100)

Optimizing Cache Keys

cool-mysql generates cache keys from query + parameters. Optimize by:

  1. Normalizing Queries
// BAD: Different queries, same intent
db.Select(&users, "SELECT   *   FROM `users` WHERE `id` = @@id", ttl, params)
db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id", ttl, params)
// ^ Different cache keys due to whitespace

// GOOD: Consistent formatting
const userByIDQuery = "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id"
db.Select(&users, userByIDQuery, ttl, params)
  1. Parameter Ordering
// Parameter order doesn't matter - they're normalized
db.Select(&users, query, ttl,
    mysql.Params{"status": "active", "age": 18})
db.Select(&users, query, ttl,
    mysql.Params{"age": 18, "status": "active"})
// ^ Same cache key

Memory Usage Optimization

// For memory-constrained environments
// Use shorter TTLs to reduce memory usage
db.UseCache(mysql.NewWeakCache())
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`",
    1*time.Minute) // Short TTL = less memory

// Or use Redis with maxmemory policy
// redis.conf:
// maxmemory 100mb
// maxmemory-policy allkeys-lru

Network Latency Optimization

// Minimize Redis roundtrips with pipelining
// (Requires custom cache implementation)

// Or use MultiCache for local-first
db.UseCache(mysql.NewMultiCache(
    mysql.NewWeakCache(),           // Fast local first
    mysql.NewRedisCache(redisClient), // Fallback to Redis
))

Best Practices

1. Match TTL to Data Volatility

// Frequently changing - short TTL or no cache
db.Select(&liveData, query, 0)

// Rarely changing - long TTL
db.Select(&refData, query, 24*time.Hour)

2. Use SelectWrites After Writes

// Write
db.Insert("users", user)

// Read with consistency
db.SelectWrites(&user, query, 0, params)

3. Cache High-Traffic Queries

// Identify expensive queries
// Use longer TTLs for high-traffic, expensive queries
db.Select(&results, expensiveQuery, 30*time.Minute)

4. Don't Over-Cache

// Don't cache everything - adds complexity
// Only cache queries that benefit from caching:
// - Expensive to compute
// - Frequently accessed
// - Tolerates staleness

5. Monitor Cache Performance

// Track hit rates
// Tune TTLs based on metrics
// Remove caching from low-hit-rate queries

6. Use MultiCache for Best Performance

// Production setup
db.UseCache(mysql.NewMultiCache(
    mysql.NewWeakCache(),           // L1: Fast
    mysql.NewRedisCache(redisClient), // L2: Shared
))

7. Handle Cache Failures Gracefully

// Cache failures should fallback to database
// cool-mysql handles this automatically
// Even if Redis is down, queries still work

8. Consider Cache Warming

// Pre-populate cache for known hot queries
func warmCache(db *mysql.Database) {
    db.Select(&refData, "SELECT `id`, `name`, `code` FROM `countries`", 24*time.Hour)
    db.Select(&config, "SELECT `key`, `value` FROM `config`", time.Hour)
}

9. Use Appropriate Cache for Environment

// Development
db.UseCache(mysql.NewWeakCache())

// Production
db.EnableRedis(redisClient) // Distributed locking + sharing

10. Document Cache TTLs

const (
    // Cache TTLs
    UserProfileTTL      = 10 * time.Minute // User data changes moderately
    ProductCatalogTTL   = 30 * time.Minute // Products updated infrequently
    AnalyticsTTL        = time.Hour        // Analytics can be stale
    NoCache             = 0                // Real-time data
)

db.Select(&user, query, UserProfileTTL, params)

Troubleshooting

High Cache Miss Rate

Symptoms: Low hit rate, high database load

Solutions:

  • Increase TTL
  • Check if queries are identical (whitespace, parameter names)
  • Verify cache is configured correctly
  • Check if queries are actually repeated

Cache Stampede

Symptoms: Periodic database spikes, slow response during cache expiration

Solutions:

  • Use Redis with distributed locking
  • Implement probabilistic early refresh
  • Increase TTL to reduce expiration frequency

Memory Issues

Symptoms: High memory usage, OOM errors

Solutions:

  • Reduce TTLs
  • Use Redis instead of in-memory
  • Configure Redis maxmemory policy
  • Cache fewer queries

Stale Data

Symptoms: Users see outdated information

Solutions:

  • Reduce TTL
  • Use SelectWrites after modifications
  • Implement cache invalidation
  • Consider if data should be cached at all