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
未配 redis 段会直接启动失败 Setup 用 env.Redis() 逗号-ok 取值,取不到就返回错误。 这是刻意的:这个示例的全部能力都依赖 Redis,静默降级只会让问题更晚暴露。

它演示了什么

缓存: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: 拿到锁
				
Lock 的生命周期。等待者排进 FIFO 队列,只有队头能抢;看门狗续租时同步 刷新队列 TTL;持有者最后一层 Release 只删锁、保留队列,队头接替。

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

相关