第 9 章 · Redis 缓存与会话
本章目标:使用 go-redis/v9 为 api-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-handbook 是 user-demo SPA 的高频读。每次穿透到 MySQL 成本高;Redis 内存读可将 P99 从数十毫秒降到亚毫秒级。
Client ──► Handler ──► ProductService ──► Redis (hit?)
│ miss
└──► Repository ──► MySQL
└──► Set JSON + TTL
| Key 模式 | 值 | TTL |
|---|---|---|
product:slug:{slug} | Product JSON | 5~15 分钟 |
product:slug:{slug}:nil | 占位 "nil" | 1~3 分钟(防穿透) |
login:fail:{email} | 失败次数 int | 15 分钟 |
虚构项目 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 |
| compose | redis:6379 |
| paas ch17 K8s | redis.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 不可用时 rdb 传 nil,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()
}