redis — 缓存读写与分布式锁
pkg/core/redis 包住 go-redis:Open 先 ping 一次,
连不上就在启动时失败;业务代码拿到的是两个 JSON 读写入口、一把阻塞可重入的
Lock,和一次性 TryLock,不需要 import go-redis。
本示例把缓存、阻塞锁、一次性锁各做成一个 HTTP 端点。
运行
需要一个真实的 Redis。make dev 会起它(以及其它中间件)。
HTTP 默认监听 :8080,配置文件是目录里的
config.yaml(-conf 默认指向它)。
make dev # 起 redis
go run ./examples/redis # 等价于 make example NAME=redis
Setup 用 env.Redis() 逗号-ok 取值,取不到就返回错误。
这是刻意的:这个示例的全部能力都依赖 Redis,静默降级只会让问题更晚暴露。
它演示了什么
- cache-aside 读:先查缓存,miss 就计算并回填,带 TTL 一起写回。
- 命中与未命中的区分:
GetJSON用ok报告 miss, 不是一个错误。 - 写后失效:
DELETE删 key,下一次读会重新回填。 - 阻塞获取:
Lock忙时排进 FIFO 队列,WithWaitTimeout到时返回ErrLockBusy,映射成409。 - 可重入:同一个客户端对同一个 key 再
Lock立即返回同一把锁, 计数 +1,而不是等自己释放。 - 自动续租:
WithWatchdog每watchdog/3续租, 临界区可比LockTTL长;续租失败关闭Lost()。 - 一次性 TryLock:带 TTL 的 SETNX,
ok=false即 409, 适合"宁可跳过也不排队"。
缓存:cache-aside 回填
下面这段来自 examples/redis/main.go 的 cachedValue。
注意 GetJSON 是包级泛型函数(redis.GetJSON[entry](ctx, rc, key)),
而 SetJSON 是 *Client 的方法。
func (s *redisService) cachedValue(ctx context.Context, key string) (value string, cached bool, err error) {
full := s.cfg.Prefix + key
e, ok, err := redis.GetJSON[entry](ctx, s.rc, full)
if err != nil {
return "", false, err
}
if ok {
return e.Value, true, nil
}
e = entry{Value: "computed:" + key, ComputedAt: time.Now().UTC()}
if err := s.rc.SetJSON(ctx, full, e, s.cfg.TTL); err != nil {
return "", false, err
}
return e.Value, false, nil
}
锁:阻塞可重入的 Lock
来自同一文件的 lock 处理器。它用 Lock 阻塞等待取锁,
打开看门狗续租,并在临界区内再调用一次 Lock 展示重入——
第二次拿到的是同一把锁,计数 +1,而不是等自己释放。
l, err := s.rc.Lock(ctx, key,
redis.WithLockTTL(s.cfg.LockTTL),
redis.WithWaitTimeout(s.cfg.WaitTimeout),
redis.WithWatchdog(s.cfg.Watchdog),
)
if errors.Is(err, redis.ErrLockBusy) {
return nil, errs.Conflict(40901, "lock %q is held by someone else", name)
}
defer func() { _ = l.Release(ctx) }()
// 重入:同一客户端再 Lock 同一 key,立即返回同一个 l
inner, _ := s.rc.Lock(ctx, key) // 计数 +1
defer func() { _ = inner.Release(ctx) }() // 每层 Lock 对应一层 Release
select {
case <-ctx.Done():
case <-l.Lost(): // 看门狗发现租约丢失
case <-time.After(s.cfg.Hold): // 临界区(示例 sleep)
}
sequenceDiagram
participant A as 持有者 A
participant B as 等待者 B
participant R as redis
A->>R: Lock: SET lock:k <tokenA>\n1
R-->>A: 拿到锁
B->>R: Lock: 入队 lock:k:queue(ticket B)
opt 看门狗开启
loop 每 watchdog/3
A->>R: Renew(PEXPIRE 锁与队列)
end
end
A->>A: 重入 Lock(计数 2)
A->>A: Release(计数 1,key 仍在)
A->>R: Release(最后一层,DEL 锁,保留队列)
B->>R: 队头轮询:取锁、出队、续队列
R-->>B: 拿到锁
TryLock:一次性尝试
来自同一文件的 tryLock 处理器。TryLock 只做一次尝试,
返回的 release 用 Lua 比对 token 再删 key——这样即使锁已经过期、
别人拿到了它,也不会误删。
release, ok, err := s.rc.TryLock(ctx, s.cfg.Prefix+"lock:"+name, s.cfg.LockTTL)
if err != nil {
return nil, err
}
if !ok {
// Let the caller decide: retry, queue, or give up.
return nil, errs.Conflict(40901, "lock %q is held by someone else", name)
}
defer func() { _ = release(ctx) }()
验证
curl -i localhost:8080/api/v1/cache/world # cached=false(miss,回填)
curl -i localhost:8080/api/v1/cache/world # cached=true
curl -i -X DELETE localhost:8080/api/v1/cache/world
# Lock:等待后接替,响应里 held=2(重入计数)
curl -i -X POST localhost:8080/api/v1/lock/job1 & curl -i -X POST localhost:8080/api/v1/lock/job1
# TryLock:一次性,忙则立即 409
curl -i -X POST localhost:8080/api/v1/trylock/job1 & curl -i -X POST localhost:8080/api/v1/trylock/job1
命中与未命中以 {"code":0,"msg":"ok","data":{"cached":true,"key":"world","value":"computed:world"}}
的封套返回;DELETE 返回 {"key":"world","deleted":true}。
Lock 拿到锁时 data.held=2;等待超时或
TryLock 遇到占用,都是
409 {"code":40901,"msg":"lock \"job1\" is held by someone else"}。
测试
main_test.go 用 redistest.Start(t) 拿到一个由进程内
miniredis 支撑的真实 *redis.Client(也可以用
REDIS_TEST_ADDR 指向真服务器),因此测试不需要 Docker。
它钉住:第一次读必然 miss 且值是 computed:k、第二次读必然命中;
删除后重新 miss;Lock 重入计数为 2 且释放两次后 key 消失;阻塞
等待者在持有者释放后拿到锁;开着看门狗时租约越过 TTL,外人仍拿不到;以及
同一把锁第二次 TryLock 必然 ok=false。
go test ./examples/redis
相关
- 缓存模式:cache-aside、防击穿、防雪崩。
- pkg/core 速查:各包入口一览。
- examples/cache:在 redis 之上补并发合并与 TTL 抖动。
- examples/memcache:另一种缓存后端(无 ping、无 context)。
- Skill:
skills/matex-setup-redis/SKILL.md。 - 示例索引。