下载工作台
Gin Web 开发

Redis 缓存与会话

试读上半部分 · 解锁后可读全文

第 9 章 · Redis 缓存与会话

本章目标:使用 go-redis/v9api-go-demo 实现商品详情 Cache-Aside 缓存;配置 TTL 与写后失效;理解缓存击穿、穿透、雪崩概念与应对;可选 Redis 存储登录失败计数;保证 JSON 中 slug + is_published + price 分 与 API 一致;对照 fastapi-web ch11 Redis 缓存。

学时建议:4~5 小时(含 2 小时跟练)

前置:完成 gin-web ch08;本地 Redis 或 Docker;复习 ch04 商品模型字段。


9.1 场景说明:热点 slug 读优化

GET /api/v1/products/go-handbookuser-demo SPA 的高频读。每次穿透到 MySQL 成本高;Redis 内存读可将 P99 从数十毫秒降到亚毫秒级。

Client ──► Handler ──► ProductService ──► Redis (hit?)
                              │ miss
                              └──► Repository ──► MySQL
                              └──► Set JSON + TTL
Key 模式TTL
product:slug:{slug}Product JSON5~15 分钟
product:slug:{slug}:nil占位 "nil"1~3 分钟(防穿透)
login:fail:{email}失败次数 int15 分钟
虚构项目 api-go-demo、域名 api.example.com严禁写入真实 Redis 集群密码或内网地址。

与 ch16 验收的关系:Redis 缓存 slug 详情占 15 分;答辩需演示第二次 GET 命中缓存。


9.2 Redis 本地环境

Docker Compose(推荐)

在项目根目录 docker-compose.dev.yml 追加或独立:

services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  redis_data:
docker compose -f docker-compose.dev.yml up -d redis
redis-cli ping   # PONG

配置项

configs/config.yaml.env.example

redis:
  addr: "127.0.0.1:6379"
  password: ""
  db: 0
  product_ttl: 600        # 秒,商品详情 10 分钟
  nil_ttl: 120            # 空值占位 2 分钟
  login_fail_ttl: 900     # 15 分钟
环境addr 示例
本地127.0.0.1:6379
composeredis:6379
paas ch17 K8sredis.data.svc.cluster.local:6379

9.3 连接 Redis 与启动检查

go get github.com/redis/go-redis/v9@v9.7.0
// internal/cache/redis.go
package cache

import (
    "context"
    "fmt"
    "time"

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

type RedisConfig struct {
    Addr     string
    Password string
    DB       int
    PoolSize int
}

func NewRedis(cfg RedisConfig) *redis.Client {
    if cfg.PoolSize == 0 {
        cfg.PoolSize = 20
    }
    return redis.NewClient(&redis.Options{
        Addr:         cfg.Addr,
        Password:     cfg.Password,
        DB:           cfg.DB,
        PoolSize:     cfg.PoolSize,
        MinIdleConns: 5,
        DialTimeout:  3 * time.Second,
        ReadTimeout:  2 * time.Second,
        WriteTimeout: 2 * time.Second,
    })
}

func Ping(ctx context.Context, rdb *redis.Client) error {
    if rdb == nil {
        return fmt.Errorf("redis client is nil")
    }
    return rdb.Ping(ctx).Err()
}

cmd/server/main.go 启动时:

rdb := cache.NewRedis(cache.RedisConfig{Addr: cfg.RedisAddr})
if err := cache.Ping(ctx, rdb); err != nil {
    log.Fatal("redis ping failed", zap.Error(err))
}
productSvc := service.NewProductService(repo, rdb, cfg, log)

降级策略:Redis 不可用时 rdbnil,Service 跳过缓存直查 MySQL(见 9.4 if s.rdb != nil)。


9.4 Cache-Aside 完整读路径

// internal/service/product_cache.go
package service

import (
    "context"
    "encoding/json"
    "errors"
    "time"

    "example.com/api-go-demo/internal/model"
    "github.com/redis/go-redis/v9"
)

const (
    productKeyPrefix    = "product:slug:"
    productNilKeySuffix = ":nil"
)

type cacheMetrics interface {
    IncCacheHit()
    IncCacheMiss()
}

func productCacheKey(slug string) string {
    return productKeyPrefix + slug
}

func productNilKey(slug string) string {
    return productKeyPrefix + slug + productNilKeySuffix
}

// GetPublishedCached Cache-Aside:先 Redis,miss 再 MySQL,回写缓存
func (s *ProductService) GetPublishedCached(ctx context.Context, slug string) (*model.Product, error) {
    if s.rdb != nil {
        key := productCacheKey(slug)
        val, err := s.rdb.Get(ctx, key).Result()
        if err == nil {
            var p model.Product
            if json.Unmarshal([]byte(val), &p) == nil {
                if s.metrics != nil {
                    s.metrics.IncCacheHit()
                }
                return &p, nil
            }
        } else if !errors.Is(err, redis.Nil) {
            s.log.Warn("redis get error", zap.String("slug", slug), zap.Error(err))
        }
        // 空值占位:防穿透
        if _, err := s.rdb.Get(ctx, productNilKey(slug)).Result(); err == nil {
            if s.metrics != nil {
                s.metrics.IncCacheHit()
            }
            return nil, ErrProductNotFound
        }
    }

    if s.metrics != nil {
        s.metrics.IncCacheMiss()
    }
    p, err := s.GetPublished(ctx, slug)
    if err != nil {
        if errors.Is(err, ErrProductNotFound) && s.rdb != nil {
            _ = s.rdb.Set(ctx, productNilKey(slug), "nil", s.cfg.NilTTL).Err()
        }
        return nil, err
    }
    s.setProductCache(ctx, p)
    return p, nil
}

func (s *ProductService) setProductCache(ctx context.Context, p *model.Product) {
    if s.rdb == nil || p == nil {
        return
    }
    b, err := json.Marshal(p)
    if err != nil {
        return
    }
    ttl := s.cfg.ProductTTL
    if ttl <= 0 {
        ttl = 10 * time.Minute
    }
    // TTL jitter:避免雪崩(±10%)
    jitter := time.Duration(float64(ttl) * 0.1 * (0.5 - randFloat()))
    _ = s.rdb.Set(ctx, productCacheKey(p.Slug), b, ttl+jitter).Err()
}

以下内容需解锁后阅读

试读已结束。解锁本章 ¥5.00,或开通年度会员畅读全部教程。
年度会员 ¥199.00/年; 小紫 AI 工作台有效会员 ¥99.00/年

正文仅在服务端鉴权后下发,未付费无法获取下半部分内容。