学习目标
学完本章你应该能够:
- 说清单机锁(
sync.Mutex)为什么在多实例部署下失效,从而引出分布式锁要解决的问题。 - 用
SetNX实现一把最小可用的分布式锁,并讲清「过期时间」「UUID 值」「Lua 释放」三者为什么缺一不可。 - 讲清续约机制如何解决「过期时间两难」,以及手动续约 / 自动续约各自的坑。
- 解释
singleflight两段式加锁如何把 Redis 压力从「总 QPS」降到「实例数」。 - 在面试里把主从切换丢锁、Redlock、缓存一致性的两个根源讲成有逻辑的故事。
前置知识:
- Go 基础:
interface、goroutine、context、time。 - Redis 基本命令:
SETNX、EXPIRE、EVAL(Lua 脚本)。 - 对单机锁
sync.Mutex的工作方式已有了解(见上一章)。
本章你会动手做的事:
- 用 go-redis 写一把
TryLock/Unlock,本地连 Redis 跑通「加锁 + 释放」。 - 给锁加一个自动续约 goroutine,模拟一个超过过期时间的长任务,观察锁不被提前释放。
- 用
singleflight包装加锁,起 10 个 goroutine 抢同一把锁,观察只有 1 次真正打到 Redis。
类比:分布式锁就像公司茶水间唯一的一把门禁卡。以前大家都在一个房间(单机)里,谁用谁拿就行;现在公司扩成多个分公司(多实例),得把卡放在一个所有人都能访问的公共柜子(Redis)里,谁抢到谁进。难的是:有人拿着卡去开会(业务慢)忘了还、或者卡丢了(实例崩溃)——所以才需要「过期自动失效」「还卡时验证是不是本人」这些设计。
flowchart LR
A[实例 A] -->|SetNX 抢锁| R[(Redis 锁)]
B[实例 B] -->|SetNX 抢锁| R
C[实例 C] -->|SetNX 抢锁| R
R -->|只有一人成功| W[获得锁者执行业务]前言:从单机锁到分布式锁
在上一章中,我们学习了 sync.Mutex 和 sync.RWMutex——这些是单机锁,只在单个进程内有效。但真实的线上服务通常部署多个实例:
┌──────────────┐
用户请求 ──────→ │ 负载均衡器 │
└──┬───┬───┬───┘
│ │ │
┌─────┘ │ └─────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 实例 A │ │ 实例 B │ │ 实例 C │
│ Mutex │ │ Mutex │ │ Mutex │
└─────────┘ └─────────┘ └─────────┘
│ │ │
└────┬────┴────┬────┘
▼ ▼
┌────────┐ ┌────────┐
│ MySQL │ │ Redis │
└────────┘ └────────┘
问题来了:实例 A 上的 Mutex 只能保护实例 A 内部的 goroutine。如果实例 A 和实例 B 同时修改数据库中的同一条记录,Mutex 完全帮不上忙。
分布式锁就是解决这个问题的——在分布式环境下,不同实例之间抢同一把锁。和普通锁相比,本质上就是抢锁的从线程(协程)变成了实例。
分布式锁之所以难,基本上都和网络有关:网络可能延迟、可能超时、可能断开,这些在单机锁中是不存在的。
本教程将带你从零理解 Redis 分布式锁的实现、续约机制、重试策略,以及缓存一致性问题。
一、Redis 分布式锁基础
1.1 什么是分布式锁
分布式锁的核心需求:
┌───────────────────────────────────────────────────┐
│ 分布式锁的核心需求 │
├───────────────────────────────────────────────────┤
│ 1. 互斥性:同一时间只有一个客户端能持有锁 │
│ 2. 可重入性(可选):同一客户端可以多次获取锁 │
│ 3. 容错性:锁持有者崩溃后,锁能自动释放 │
│ 4. 高可用:锁服务本身不能成为单点故障 │
└───────────────────────────────────────────────────┘
1.2 用 SetNX 实现最简单的分布式锁
实现分布式锁的起点,就是利用 Redis 的 SETNX 命令(Set if Not eXists),确保可以排他地设置一个键值对。本质上 Redis 的分布式锁就是一个键值对。
package lock
import (
"context"
"errors"
"time"
"github.com/google/uuid"
redis "github.com/go-redis/redis/v9"
)
// ==================== 分布式锁结构体 ====================
// Client 是一个 Redis 分布式锁的客户端
type Client struct {
client redis.Cmdable // Redis 客户端接口
}
// NewClient 创建分布式锁客户端
// client 可以是 *redis.Client(单节点)或 *redis.ClusterClient(集群)
// 使用接口类型 Cmdable 而不是具体类型,是为了方便测试时注入 mock
func NewClient(client redis.Cmdable) *Client {
return &Client{
client: client,
}
}
// ==================== TryLock:尝试加锁 ====================
// TryLock 尝试获取一把锁
// 叫 TryLock 是因为它不能保证一定加锁成功——如果锁已被别人持有,就直接返回失败
//
// 参数:
// ctx:上下文,用于控制超时
// key:锁的名称,比如 "lock:order:123" 表示订单 123 的锁
// expiration:锁的过期时间,防止持有者崩溃后锁永远不释放
// 返回值:val 是锁的唯一标识(UUID),用于后续释放锁时验证身份
func (c *Client) TryLock(ctx context.Context, key string, expiration time.Duration) (string, error) {
// 生成一个唯一的 UUID 作为锁的值
// 为什么用 UUID?后面锁释放时会详细解释
val := uuid.New().String()
// 使用 SET key value NX PX 命令加锁
// NX:Only set the key if it does not already exist(不存在才设置)
// 这就是"排他"的核心——如果 key 已存在(锁已被持有),SET 会失败
ok, err := c.client.SetNX(ctx, key, val, expiration).Result()
if err != nil {
// 什么情况会返回 error?
// 1. Redis 服务器出错了
// 2. 网络超时了
// 3. 网络断开了
return "", err
}
if !ok {
// ok 为 false 表示 SetNX 没有设置成功
// 什么情况会返回 false?
// key 已经存在了,也就是锁已经被别人持有了
return "", ErrLockNotHold
}
// 加锁成功,返回 UUID 用于后续释放
return val, nil
}
// ErrLockNotHold 表示锁已被其他人持有
var ErrLockNotHold = errors.New("lock: not hold the lock")
1.3 为什么要设置过期时间
// ==================== 如果没有过期时间 ====================
//
// 假设不设置过期时间:
//
// 时间线:
// t1: 实例1 调用 TryLock 获取锁成功
// t2: 实例1 正在执行业务代码...
// t3: 实例1 突然崩溃了!(OOM、panic、机器宕机)
// t4: 锁永远存在于 Redis 中,没有人去释放它
// t5: 实例2 想获取锁 → SetNX 失败 → 永远拿不到锁
//
// 结果:所有其他实例永远无法获取锁,业务完全卡死
//
// 所以必须设置过期时间:
// 即使持有者崩溃了,过一段时间后锁会自动过期释放
// 其他实例就能拿到锁了
1.4 为什么用 UUID 作为值
// ==================== 为什么用 UUID 作为锁的值 ====================
//
// 假设不用 UUID,而是用固定值 "locked":
//
// 时间线:
// t1: 实例1 调用 TryLock,设置 key="mylock", value="locked", TTL=10s
// t2: 实例1 正在执行业务代码...
// t3: 10秒过去了,锁过期了(业务执行时间超过10秒)
// t4: 实例2 调用 TryLock,设置 key="mylock", value="locked", TTL=10s → 成功
// t5: 实例1 终于执行完了,准备释放锁
// t6: 实例1 调用 DEL key → 把实例2的锁删除了!
// t7: 实例3 调用 TryLock → 成功(因为实例2的锁被实例1误删了)
// t8: 实例2 和 实例3 同时持有"锁"→ 互斥性被破坏!
//
// 用 UUID 的目的:
// 释放锁的时候,先检查 Redis 里的值是不是自己的 UUID
// 如果不是自己的,说明锁已经过期被别人拿走了,不能删除
1.5 锁释放——Lua 脚本保证原子性
释放锁的时候,需要做两件事:
- 看看是不是自己加的锁(比较 Redis 里的值是不是自己的 UUID)
- 如果是,直接删除锁
关键问题:这两步必须原子执行。如果先 GET 再 DEL,中间可能有其他客户端操作。
// ==================== 为什么不能先 GET 再 DEL ====================
//
// 如果分两步做(非原子):
// t1: 实例1 调用 GET key → 返回 "uuid-1"(是自己的锁)
// t2: ← 就在这个瞬间,锁过期了 → 实例2 SetNX 成功,value 变成 "uuid-2"
// t3: 实例1 调用 DEL key → 删除了实例2的锁!
//
// 所以必须用 Lua 脚本,让"检查 + 删除"在一个原子操作中完成
// unlockScript 是释放锁的 Lua 脚本
// Lua 脚本在 Redis 中是原子执行的,不会被其他命令打断
const unlockScript = `
-- redis.call('GET', KEYS[1]):获取 key 对应的值
local val = redis.call('GET', KEYS[1])
-- 检查值是否等于我们传入的 ARGV[1](即我们的 UUID)
if val == ARGV[1] then
-- 值匹配,说明是自己的锁,可以删除
return redis.call('DEL', KEYS[1])
end
-- 值不匹配,说明锁已经被别人拿走了,不能删除
return 0
`
// Unlock 释放锁
// 参数 val 是 TryLock 时返回的 UUID
func (c *Client) Unlock(ctx context.Context, key, val string) error {
// 使用 Eval 执行 Lua 脚本
// KEYS[1] = key,ARGV[1] = val
res, err := c.client.Eval(ctx, unlockScript, []string{key}, val).Result()
if err != nil {
// 可能是 Redis 服务器出错,或者网络超时
return err
}
// res 是 Lua 脚本的返回值
// 如果返回 0,说明锁不是我们的(可能已经过期被别人拿走了)
if res.(int64) == 0 {
return ErrLockNotHold
}
return nil
}
新手理解:Lua 脚本在 Redis 中就像一个"不可打断"的操作。Redis 执行 Lua 脚本期间,不会处理任何其他客户端的命令。所以"检查值 + 删除"放在 Lua 脚本里,就能保证不会被其他客户端"插队"。
1.6 测试锁实现
package lock
import (
"context"
"testing"
"time"
redis "github.com/go-redis/redis/v9"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"your_project/mocks" // mockgen 生成的 mock 包
)
// ==================== 单元测试(使用 gomock)====================
// 严格意义上来说,单元测试不能依赖于任何第三方组件
// 所以我们用 gomock 来 mock Redis 客户端
//
// 生成 mock 文件的命令:
// mockgen -package=mocks \
// -destination=mocks/redis_cmdable.mock.go \
// github.com/go-redis/redis/v9 Cmdable
func TestTryLock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mocks.NewMockCmdable(ctrl)
client := NewClient(mockClient)
// ==================== 测试 1:加锁成功 ====================
mockClient.EXPECT().
SetNX(gomock.Any(), "lock_key", gomock.Any(), time.Second*10).
Return(redis.NewBoolResult(true, nil)).
Times(1)
val, err := client.TryLock(context.Background(), "lock_key", time.Second*10)
assert.NoError(t, err)
assert.NotEmpty(t, val) // UUID 不为空
// ==================== 测试 2:锁已被持有(加锁失败)====================
mockClient.EXPECT().
SetNX(gomock.Any(), "lock_key", gomock.Any(), time.Second*10).
Return(redis.NewBoolResult(false, nil)).
Times(1)
_, err = client.TryLock(context.Background(), "lock_key", time.Second*10)
assert.Equal(t, ErrLockNotHold, err)
}
func TestUnlock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mocks.NewMockCmdable(ctrl)
client := NewClient(mockClient)
// ==================== 测试 1:释放自己的锁(成功)====================
// 模拟 Lua 脚本返回 1(删除成功)
mockClient.EXPECT().
Eval(gomock.Any(), unlockScript, []string{"lock_key"}, "test-uuid").
Return(redis.NewCmdResult(int64(1), nil)).
Times(1)
err := client.Unlock(context.Background(), "lock_key", "test-uuid")
assert.NoError(t, err)
// ==================== 测试 2:锁不是自己的(释放失败)====================
// 模拟 Lua 脚本返回 0(值不匹配,没有删除)
mockClient.EXPECT().
Eval(gomock.Any(), unlockScript, []string{"lock_key"}, "wrong-uuid").
Return(redis.NewCmdResult(int64(0), nil)).
Times(1)
err = client.Unlock(context.Background(), "lock_key", "wrong-uuid")
assert.Equal(t, ErrLockNotHold, err)
}
// ==================== 集成测试(连接真实 Redis)====================
// 集成测试需要启动一个真实的 Redis 实例
//
// func TestLock_Integration(t *testing.T) {
// client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// lockClient := NewClient(client)
// ctx := context.Background()
//
// // 测试加锁 + 释放
// val, err := lockClient.TryLock(ctx, "test_lock", time.Second*10)
// assert.NoError(t, err)
//
// err = lockClient.Unlock(ctx, "test_lock", val)
// assert.NoError(t, err)
// }
为什么用
redis.Cmdable接口而不是*redis.Client?因为Cmdable是一个接口,测试时可以注入 mock 实现。如果用具体类型*redis.Client,就没办法 mock 了。这是一个非常重要的设计技巧——面向接口编程。
二、续约机制——解决过期时间难题
2.1 过期时间设置多长
对于锁的用户来说,他很难确定锁的过期时间应该设置多长:
// ==================== 过期时间的两难 ====================
//
// 设置短了:
// 业务还没完成,锁就过期了
// → 别人拿到锁,互斥性被破坏
//
// 设置长了:
// 万一实例崩溃了,其他实例长时间拿不到锁
// → 系统卡住很久
//
// 更严重的是:
// 不管你设置多长,极端情况下都会出现业务执行时间超过过期时间
// 不管你用 1 分钟还是 10 分钟,极端情况下都会超时
//
// 解决方案:续约(Refresh)
// 在锁还没过期的时候,再次延长过期时间
// → 过期时间不必设置得很长,自动续约会帮我们设置好
// → 如果实例崩溃了,则没有人再续约,过一段时间后自然过期
续约机制示意:
时间轴: 0s 3s 6s 9s 10s(过期)
│ │ │ │ │
加锁: SET │ │ │ │
TTL=10s│ │ │ │
│ │ │ │
续约: REFRESH REFRESH REFRESH │
TTL=10s TTL=10s TTL=10s │
│
如果实例崩溃: │
最后一次续约后崩溃 → 没人续约 → 10s后自动过期释放
2.2 手动续约
package lock
import (
"context"
"time"
)
// refreshScript 是续约的 Lua 脚本
// 逻辑:检查 key 的值是不是自己的,如果是,就延长过期时间
const refreshScript = `
-- 获取 key 当前的值
local val = redis.call('GET', KEYS[1])
-- 检查值是否匹配
if val == ARGV[1] then
-- 值匹配,是自己的锁,延长过期时间
-- EXPIRE 命令设置新的过期时间(秒)
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
end
-- 值不匹配,锁不是自己的(可能已经过期被别人拿走了)
return 0
`
// Refresh 手动续约
// 参数:
// ctx:上下文
// key:锁的名称
// val:锁的 UUID(TryLock 时返回的值)
// expiration:新的过期时间
func (c *Client) Refresh(ctx context.Context, key, val string, expiration time.Duration) error {
// 使用 Lua 脚本续约
// ARGV[1] = val(UUID)
// ARGV[2] = expiration 的毫秒数
res, err := c.client.Eval(ctx, refreshScript,
[]string{key},
val,
expiration.Milliseconds(),
).Result()
if err != nil {
// 第一个 error:Redis 服务器出错,或者网络超时
return err
}
if res.(int64) == 0 {
// 第二个 error:锁要么不存在(已过期),要么存在但不是自己的
return ErrLockNotHold
}
return nil
}
// ==================== 手动续约的使用 ====================
// 手动续约本身很简单,难的是使用时需要考虑的问题:
//
// 问题 1:间隔多久续约一次?
// 如果过期时间是 10 秒,可以每 7-8 秒续约一次
// 留 2-3 秒的缓冲时间,防止续约请求本身因网络延迟而超时
//
// 问题 2:如果 Refresh 返回了 error,怎么处理?
// 如果返回的是超时 error → 不知道有没有续约成功
// → 可以立即再次尝试续约(大多数超时是偶发的)
// 如果返回的是其他 error → 可能是 Redis 宕机了
// → 需要考虑业务是否应该中断
//
// 问题 3:如果确认续约失败了,怎么中断后续的业务?
// 这个问题基本无解!
// 因为业务代码一旦执行,你除非自己手动检测分布式锁状态
// 并且手动中断,不然是没有办法的
2.3 手动续约的完整使用示例
package main
import (
"context"
"fmt"
"time"
)
func main() {
client := NewClient(redisClient)
ctx := context.Background()
// 步骤 1:加锁
val, err := client.TryLock(ctx, "mylock", 10*time.Second)
if err != nil {
panic(err)
}
// 确保最后释放锁
defer func() {
_ = client.Unlock(ctx, "mylock", val)
}()
// 步骤 2:启动续约 goroutine
done := make(chan struct{})
go func() {
ticker := time.NewTicker(7 * time.Second) // 每 7 秒续约一次
defer ticker.Stop()
for {
select {
case <-ticker.C:
// 续约
if err := client.Refresh(ctx, "mylock", val, 10*time.Second); err != nil {
// 续约失败!
// 这里需要通知主 goroutine 中断业务
// 但实际上,主 goroutine 可能已经在执行不可中断的操作了
fmt.Println("续约失败:", err)
return
}
fmt.Println("续约成功")
case <-done:
// 业务完成,停止续约
return
}
}
}()
// 步骤 3:执行业务逻辑
fmt.Println("开始执行业务...")
time.Sleep(30 * time.Second) // 模拟耗时业务(超过锁的 10 秒过期时间)
fmt.Println("业务执行完成")
// 步骤 4:通知续约 goroutine 停止
close(done)
}
2.4 自动续约
考虑到对大部分用户来说,处理分布式锁的各种异常情况是一个棘手的事情,我们可以考虑提供自动续约的 API。
package lock
import (
"context"
"errors"
"time"
)
// ==================== 自动续约 API ====================
// 自动续约需要面对的问题(和手动续约一样):
//
// 1. 隔多久续约,续多长?
// → 让用户指定多久续约一次(因为跟网络、Redis 稳定性有关)
// → 每次续多长,直接使用原本的过期时间
//
// 2. 如何处理超时?
// → 再次尝试续约(超时大多数是偶发的,可以立刻重试)
// → 缺点:如果 Redis 真的崩溃了,会无限次尝试
// → 超时时间让用户指定
//
// 3. 如何通知用户续约失败?
// → 只处理超时引起的续约失败(自动重试)
// → 其它 error 告诉用户遇到了无法处理的问题
//
// 4. 要不要设置续约次数上限?
// → 不设置,如果用户有这种需求,应该自己手动续约
// AutoRefresh 自动续约
// 参数:
// ctx:上下文,ctx.Done() 时停止续约
// key:锁的名称
// val:锁的 UUID
// interval:续约间隔
// expiration:每次续约设置的过期时间
// f:续约失败时的回调函数(除了超时以外的 error)
func (c *Client) AutoRefresh(
ctx context.Context,
key, val string,
interval time.Duration,
expiration time.Duration,
f func(error),
) {
// 创建定时器,每隔 interval 时间续约一次
ticker := time.NewTicker(interval)
defer ticker.Stop()
// 续约超时时间:设为 interval 的一半
// 这样如果续约超时了,还有时间在下一次 tick 之前重试
timeout := interval / 2
for {
select {
case <-ctx.Done():
// 上下文被取消,停止续约
return
case <-ticker.C:
// 到了续约时间
// 创建带超时的 context,防止续约请求卡住
refreshCtx, cancel := context.WithTimeout(ctx, timeout)
err := c.Refresh(refreshCtx, key, val, expiration)
cancel()
if err == nil {
// 续约成功,等待下一次
continue
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
// 超时了 → 立即重试
// 超时意味着不知道有没有续约成功
// 大多数超时是偶发的,可以立刻重试
// 注意:如果 Redis 真的崩溃了,这里会无限次重试
// 这是自动续约的一个缺点
continue
}
if errors.Is(err, ErrLockNotHold) {
// 锁不是自己的了(已过期被别人拿走)
// 这种情况无法恢复,通知用户
if f != nil {
f(err)
}
return
}
// 其他 error(Redis 宕机等)
// 通知用户遇到了无法处理的 error
if f != nil {
f(err)
}
return
}
}
}
// ==================== 自动续约使用示例 ====================
//
// val, _ := client.TryLock(ctx, "mylock", 10*time.Second)
//
// // 启动自动续约,每 7 秒续约一次,每次设 10 秒过期
// go client.AutoRefresh(ctx, "mylock", val, 7*time.Second, 10*time.Second, func(err error) {
// // 续约失败回调
// // 实际上到这里已经无法做什么了
// // 最多记录日志、发告警
// log.Printf("续约失败: %v", err)
// })
//
// // 执行业务...
// // 业务完成后取消 ctx,自动续约 goroutine 会退出
自动续约的可控性非常差,因此并不是很鼓励用户使用这个 API。如果用户想要万无一失地使用分布式锁,必须自己手动调用 Refresh,并且小心处理各种 error。
续约间隔的经验值:如果将分布式锁的过期时间设置为 10 秒,而且预期 2 秒内绝大概率续约成功,那么就可以考虑将续约间隔设置为 8 秒(留 2 秒缓冲)。
三、加锁重试
3.1 为什么需要重试
加锁可能遇到偶发性的失败(网络抖动、锁恰好被持有),在这种情况下可以尝试重试。
// ==================== 加锁失败的原因 ====================
//
// 1. 超时了 → 不知道锁有没有拿到,需要重试确认
// 2. 锁被人持有着 → 等别人释放锁后重试
// 3. Redis 服务器故障 → 重试也没用,直接报错
//
// 超时重试的逻辑:
// 如果超时了,则直接再次加锁
// 然后检查 key 对应的值是不是我们刚才超时加锁请求的值
// 如果是 → 前一次加锁其实成功了,直接返回
// 如果不是 → 加锁失败(别人拿到了锁)
3.2 重试策略接口
package lock
import (
"context"
"time"
)
// ==================== 重试策略接口 ====================
// 重试的接口设计成迭代器的形态
// 用户可以轻易通过扩展这个接口来实现自己的重试策略
// RetryStrategy 重试策略接口
type RetryStrategy interface {
// Next 返回下一次重试需要等待的时间
// 如果返回 false,表示不再重试
Next() (time.Duration, bool)
}
// ==================== 策略 1:不重试 ====================
type NoRetry struct{}
func (NoRetry) Next() (time.Duration, bool) {
return 0, false // 不等待,不重试
}
// ==================== 策略 2:等时间间隔重试 ====================
type EqualInterval struct {
interval time.Duration // 每次重试的间隔
maxRetry int // 最大重试次数
cnt int // 当前已重试次数
}
func NewEqualInterval(interval time.Duration, maxRetry int) *EqualInterval {
return &EqualInterval{
interval: interval,
maxRetry: maxRetry,
}
}
func (e *EqualInterval) Next() (time.Duration, bool) {
if e.cnt >= e.maxRetry {
return 0, false // 超过最大重试次数
}
e.cnt++
return e.interval, true
}
// ==================== 策略 3:指数退避重试 ====================
// 每次重试间隔翻倍,避免频繁重试压垮 Redis
type ExponentialBackoff struct {
initialInterval time.Duration // 初始间隔
maxInterval time.Duration // 最大间隔
current time.Duration // 当前间隔
maxRetry int // 最大重试次数
cnt int // 当前已重试次数
}
func NewExponentialBackoff(initial, max time.Duration, maxRetry int) *ExponentialBackoff {
return &ExponentialBackoff{
initialInterval: initial,
maxInterval: max,
current: initial,
maxRetry: maxRetry,
}
}
func (e *ExponentialBackoff) Next() (time.Duration, bool) {
if e.cnt >= e.maxRetry {
return 0, false
}
e.cnt++
wait := e.current
// 下一次间隔翻倍,但不超过最大值
e.current *= 2
if e.current > e.maxInterval {
e.current = e.maxInterval
}
return wait, true
}
// ==================== 重试接口的缺点 ====================
// 这种接口设计没有引入上下文的概念
// 用户在实现接口的时候没有办法根据上下文来判断真实情况
// 例如上一次调用的 error 来决定要不要重试
3.3 带重试的加锁实现
package lock
import (
"context"
"time"
"github.com/google/uuid"
)
// Lock 带重试的加锁
// 和 TryLock 的区别:TryLock 只尝试一次,Lock 会按策略重试
func (c *Client) Lock(
ctx context.Context,
key string,
expiration time.Duration,
retry RetryStrategy,
) (string, error) {
// 生成 UUID
val := uuid.New().String()
for {
// 尝试加锁
ok, err := c.client.SetNX(ctx, key, val, expiration).Result()
if err != nil {
// 加锁出错了(网络超时等)
// 什么情况下应该重试?
// 1. 超时了:不知道锁有没有拿到,可以重试
// 2. 锁被人持有:等别人释放后重试
// 什么情况下不应该重试?
// Redis 服务器故障:重试也没用
// 这里简化处理,所有 error 都尝试重试
} else if ok {
// 加锁成功!
return val, nil
}
// 加锁失败,检查是否应该重试
wait, retry := retry.Next()
if !retry {
// 不再重试,返回错误
if err != nil {
return "", err
}
return "", ErrLockNotHold
}
// 等待一段时间后重试
select {
case <-ctx.Done():
// 上下文被取消,停止重试
return "", ctx.Err()
case <-time.After(wait):
// 等待结束,继续下一次重试
}
}
}
// ==================== 释放锁也可以重试 ====================
// 相比之下,释放锁问题没那么严重
// 释放锁的情况下只有超时是值得重试的,其它情况都不需要重试
四、singleflight 优化分布式锁
4.1 为什么要结合 singleflight
类比:
singleflight像公司前台——10 个人同时要抢同一把门禁卡,前台拦住其中 9 个说「等他回来告诉你结果」,只派 1 个代表去抢,抢到结果后广播给所有人。这样真正去公共柜子抢卡的次数从 10 次降到 1 次。
flowchart LR
A1[g1] --> X[(Redis 4 次请求)]
A2[g2] --> X
A3[g3] --> X
A4[g4] --> X
B1[g1] --> SF{{singleflight}}
B2[g2] --> SF
B3[g3] --> SF
B4[g4] --> SF
SF -->|胜者 1 次| Y[(Redis 1 次请求)]在非常高并发并且热点集中的情况下,可以考虑结合 singleflight 来进行优化:本地所有的 goroutine 自己先竞争一把,胜利者再去抢全局的分布式锁。
没有 singleflight 优化: 有 singleflight 优化:
实例 A: 实例 A:
goroutine 1 → 抢分布式锁 goroutine 1 ─┐
goroutine 2 → 抢分布式锁 goroutine 2 │→ 本地竞争
goroutine 3 → 抢分布式锁 goroutine 3 ─┘
goroutine 4 → 抢分布式锁 ↓
↓ 胜利者 → 抢分布式锁
4 次分布式锁请求 1 次分布式锁请求
↓ ↓
Redis Redis
对 Redis 的压力:4 倍 对 Redis 的压力:1 倍
4.2 singleflight + 分布式锁实现
package lock
import (
"context"
"time"
"golang.org/x/sync/singleflight"
)
// ==================== singleflight 优化的分布式锁 ====================
// ClientWithSingleFlight 在 Client 的基础上增加 singleflight 优化
type ClientWithSingleFlight struct {
*Client // 嵌入原始 Client
g singleflight.Group // singleflight 组
}
func NewClientWithSingleFlight(client redis.Cmdable) *ClientWithSingleFlight {
return &ClientWithSingleFlight{
Client: NewClient(client),
}
}
// Lock 使用 singleflight 优化加锁
// 同一个 key 的多个 goroutine,只有一个会去抢分布式锁
// 其他 goroutine 等待结果
func (c *ClientWithSingleFlight) Lock(
ctx context.Context,
key string,
expiration time.Duration,
retry RetryStrategy,
) (string, error) {
// 使用 singleflight.Do
// 同一个 key 同时只有一个 goroutine 执行加锁逻辑
// 其他 goroutine 直接拿到结果
val, err, _ := c.g.Do(key, func() (any, error) {
return c.Client.Lock(ctx, key, expiration, retry)
})
if err != nil {
return "", err
}
return val.(string), nil
}
// ==================== 两段式加锁 ====================
// singleflight + 分布式锁的组合也叫"两段式加锁":
//
// 第一段:实例级别使用 singleflight
// → 确保一个实例只有一个 goroutine 参与全局锁竞争
//
// 第二段:全局分布式锁
// → 如果有 N 个实例,就是有 N 个 goroutine 去抢分布式锁
//
// 这样对 Redis 的压力从"总 QPS"降低到"实例数量"
// 热点越集中的应用,效果越好
五、Redis 主从切换与 Redlock
5.1 主从切换的问题
主从切换最隐蔽的坑是「异步复制」——锁写进 master 后还没同步到 slave,master 就挂了,slave 上位后「不认得这把锁」,于是别人又能抢到,互斥性瞬间破防。下面用时序图还原这个过程:
sequenceDiagram
participant A as 实例1
participant M as master
participant S as slave
participant A2 as 实例2
A->>M: SETNX 加锁成功
M-->>S: 异步复制(尚未完成)
Note over M,S: master 宕机
S->>S: 提升为新 master(无锁数据)
A2->>S: SETNX 加锁成功
Note over A,A2: 两人同时持锁 互斥性破坏前面讨论的都是单点的 Redis。在集群部署的时候,需要额外考虑一个问题:主从切换。
主从切换导致锁丢失:
一切顺利的情况:
实例1 → SETNX master → 成功(获得锁)
实例1 → 执行业务
实例1 → DEL master → 释放锁
主从切换异常情况:
t1: 实例1 → SETNX master → 成功(获得锁)
↓ master 将数据同步给 slave(异步复制!)
↓ 但同步还没完成...
t2: master 宕机了!
t3: slave 被提升为新的 master
t4: 新 master 上没有实例1的锁数据(因为异步复制还没完成)
t5: 实例2 → SETNX new_master → 成功(也获得锁!)
t6: 实例1 和实例2 同时持有锁 → 互斥性被破坏!
5.2 Redlock 算法
关于 Redlock,之前几位大佬还 battle 过(Martin Kleppmann 和 Antirez 的争论),这里只做简单介绍。
// ==================== Redlock 算法简介 ====================
//
// 思路:不再部署单一主从集群,而是多个主节点(没有从节点)
//
// 比如说部署 5 个独立的 Redis 主节点:
//
// Redis-1 Redis-2 Redis-3 Redis-4 Redis-5
// ↑ ↑ ↑ ↑ ↑
// └──────────┴──────────┴──────────┴──────────┘
// 加锁
//
// 加锁过程:
// 1. 记录当前时间 T1
// 2. 依次向 5 个 Redis 节点发送 SETNX 请求
// 3. 每个请求设置很短的超时时间(如 50ms)
// 4. 记录当前时间 T2
// 5. 如果有 majority(多数,这里是 3 个)都成功
// 且 T2 - T1 < 锁的过期时间
// 则认为加锁成功
// 6. 如果加锁成功,锁的实际有效时间 = 过期时间 - (T2 - T1)
// 7. 如果加锁失败(没拿到多数),向所有节点发送 DEL 释放锁
//
// 优点:
// - 不依赖主从复制,避免了主从切换导致锁丢失的问题
//
// 缺点:
// - 需要 5 个独立的 Redis 实例,成本高
// - 加锁和解锁需要对所有节点操作,延迟更高
// - 仍然存在争议(时钟漂移等问题)
六、分布式锁总结
// ==================== 分布式锁使用原则 ====================
//
// 1. 你不能指望框架提供万无一失的方案
// 自己还是要处理各种异常情况(超时)
//
// 2. 自己写分布式锁,要考虑过期时间,以及要不要续约
//
// 3. 不管要对锁做什么操作,首先要确认这把锁是我们自己的锁
// (用 UUID + Lua 脚本验证)
//
// 4. 大多数时候,与其选择复杂方案,不如直接让业务失败
// 有时候直接赔钱,比你部署一大堆节点、招一大堆开发、
// 搞好几个机房还要便宜,而且便宜很多
//
// 5. 选择恰好的方案,而不是完美的方案
七、缓存一致性
7.1 缓存一致性的两个根源
缓存一致性问题来自两个根源:
┌───────────────────────────────────────────────────────────┐
│ 缓存一致性的两个根源 │
├───────────────────────────────────────────────────────────┤
│ │
│ 根源 1:并发更新 │
│ 多个请求同时更新同一个 key 的缓存和数据库 │
│ → 分布式锁只能解决这个 │
│ │
│ 根源 2:部分失败 │
│ 更新 DB 成功了,但更新缓存失败了 │
│ 或者更新缓存成功了,但更新 DB 失败了 │
│ → 分布式事务解决这个 │
│ │
│ 不管先更新 DB 还是先更新缓存,又或者使用 CDC 方案 │
│ 总是有可能出现部分失败情况 │
└───────────────────────────────────────────────────────────┘
7.2 缓存模式能不能解决一致性问题
// ==================== 缓存模式与一致性 ====================
//
// cache-aside:不能解决一致性问题
// 读时先查缓存,未命中查 DB 再回写
// 写时先写 DB 再删缓存
// → 并发场景下依然可能出现不一致
//
// read-through:不能解决一致性问题
// 和 cache-aside 类似,只是把回源逻辑封装在缓存层
//
// write-through:不能解决一致性问题
// 先写 DB 再写缓存(或反过来),中间任何一步失败都会不一致
7.3 write-back 能不能解决一致性问题
// ==================== write-back 与一致性 ====================
//
// 如果使用的是 Redis 这种缓存:
//
// 情况 1:缓存未命中不回查 DB
// → 站在调用者的角度,不会有缓存不一致的问题
// → 因为所有读写都走缓存,不直接读 DB
// → 但问题是:缓存冷启动时没有数据,用户什么都读不到
//
// 情况 2:缓存未命中依旧回查 DB(大多数情况)
// → 依旧会有缓存一致性问题
// → 但在回查后写入缓存时,如果用 SetNX 命令(版本号控制)
// 也不会有一致性问题(除非在回查的瞬间,这个 key 的缓存
// 来了又过期了)
//
// 总结:write-back 在特定条件下可以缓解一致性问题
// 但不能完全解决,因为"部分失败"的根源还在
7.4 refresh-ahead 能不能解决一致性问题
// ==================== refresh-ahead 与一致性 ====================
//
// refresh-ahead 也不能解决缓存一致性的问题
//
// 场景:通过 Canal 监听 MySQL binlog,数据变更时刷新缓存
//
// 时间线:
// t1: 请求 1 读取数据,缓存未命中,从 DB 读取旧值 V1,回写缓存
// t2: 请求 2 更新 DB,DB 值变为 V2
// t3: Canal 监听到 binlog,准备刷新缓存
// t4: ← 在 t2 和 t3 之间,缓存里的值是 V1,但 DB 里已经是 V2
// t5: Canal 刷新缓存为 V2
//
// 在 Canal 刷新缓存之前(t2~t5),数据都是不一致的
7.5 缓存一致性的可能方案
方案一:一致性哈希 + singleflight
// ==================== 方案一:一致性哈希 + singleflight ====================
//
// 思路:确保某个 key 对应的请求必然打到同一个机器上
//
// 原理:
// 1. 使用一致性哈希负载均衡,同一个 key 的请求总是路由到同一台机器
// 2. 机器内部使用 singleflight,同一个 key 只有一个 goroutine 在操作
// 3. 这样就控制住了"全局只有一个 goroutine 去更新特定 key 的数据"
//
// 一致性哈希的特点:
// 扩容和缩容时,同一个 key 的请求要么还是打到原来的机器上
// 要么打到另外一台机器上(不会同时打到两台)
//
// 唯一可能出现一致性问题的场景:扩容、缩容和应用重启
// ==================== 扩容时的问题与解决 ====================
//
// 场景:扩容时 key 的路由发生变化
//
// 请求 1、2 都操作同一个 key:
// t1: 请求 1 被路由到机器 C 上
// t2: 扩容,加入了 C1 节点
// t3: 请求 2 被路由到了 C1 节点上
// t4: 请求 1 更新 DB
// t5: 请求 2 更新 DB,请求 2 更新缓存
// t6: 请求 1 更新缓存 ← 此时请求 1 和请求 2 的缓存更新顺序可能错乱!
//
// 解决方案 1:在扩容/缩容时,直接在 C 上禁止对要迁移的 key 的缓存
// → 不需要引入分布式锁,性能好
// → 但如果频繁扩容缩容,效果不好
//
// 解决方案 2:加入 C1 节点,但 C1 此时不能启用缓存
// → 等待一段时间,确认 C 上的请求都被处理之后
// → 再开启 C1 的缓存
方案二:分布式锁
类比:两段式加锁像「公司内先举手(singleflight)再出门抢(分布式锁)」——同一公司(实例)里只派一个代表去抢公共资源,抢到再回来告诉大家结果。这样全局真正去抢锁的「代表数」最多等于实例数,热点越集中省得越多。
flowchart TD
I1[实例1 内 singleflight] -->|胜者| D[(全局分布式锁)]
I2[实例2 内 singleflight] -->|胜者| D
I3[实例3 内 singleflight] -->|胜者| D
D -->|只有 1 人获得| W[执行 更新DB + 删缓存]// ==================== 方案二:分布式锁 ====================
//
// 思路:直接加全局的分布式锁
// 不管你是先更新 DB 还是先更新缓存,都没问题
// 因为同一时间只有一个请求能操作同一个 key
//
// 适合:强一致性要求,极端写少的场景
//
// 注意点:
// 1. 写但凡多一点,性能衰退都很快
// 因为分布式锁对性能影响很大(网络开销 + 锁等待)
//
// 2. 可以采用两段式加锁优化
// 第一段:实例级别应用 singleflight
// → 确保一个实例只有一个 goroutine 参与全局锁竞争
// 第二段:N 个实例的 N 个 goroutine 去抢分布式锁
// → 最终全局只有一个 goroutine 更新特定 key 的数据
//
// ==================== 两段式加锁代码示例 ====================
type CacheWithLock struct {
cache Cache // 缓存客户端
db DB // 数据库客户端
lockCli *ClientWithSingleFlight // 带单飞优化的分布式锁
}
// DB 是数据库操作的接口(简化示例)
type DB interface {
Update(ctx context.Context, key string, val any) error
}
// Update 更新数据(使用两段式加锁保证一致性)
func (c *CacheWithLock) Update(ctx context.Context, key string, val any) error {
// 第一段:实例内 singleflight(在 ClientWithSingleFlight.Lock 内部实现)
// 第二段:全局分布式锁
lockVal, err := c.lockCli.Lock(
ctx,
"lock:"+key, // 锁的 key
10*time.Second, // 过期时间
NewExponentialBackoff(100*time.Millisecond, 1*time.Second, 3), // 指数退避重试
)
if err != nil {
return err
}
defer func() {
_ = c.lockCli.Unlock(ctx, "lock:"+key, lockVal)
}()
// 此时全局只有一个 goroutine 在操作这个 key
// 可以安全地更新 DB 和缓存
// 步骤 1:先更新 DB
if err := c.db.Update(ctx, key, val); err != nil {
return err
}
// 步骤 2:再删除缓存(注意是删除,不是更新)
// 删除是幂等的,更安全
if err := c.cache.Delete(ctx, key); err != nil {
// 缓存删除失败
// 可以记录日志,下次读请求会从 DB 重新加载
log.Printf("缓存删除失败: key=%s, err=%v", key, err)
}
return nil
}
7.6 两种方案的对比
┌────────────────────┬──────────────────────────┬──────────────────────────┐
│ │ 方案一:一致性哈希+SF │ 方案二:分布式锁 │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ 一致性强度 │ 较弱(扩缩容时有窗口) │ 强 │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ 性能 │ 好(无网络开销) │ 差(网络开销 + 锁等待) │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ 复杂度 │ 中(需一致性哈希基础设施)│ 中(需 Redis 分布式锁) │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ 扩缩容影响 │ 有(需要处理迁移窗口) │ 无 │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ 适合场景 │ 读多写少,可容忍短暂 │ 强一致性要求,写极少 │
│ │ 不一致 │ │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ 共同点 │ 核心都在于控制住全局只有一个 goroutine 去更新 │
│ │ 特定一个 key 的数据 │
└────────────────────┴──────────────────────────┴──────────────────────────┘
八、面试要点总结
8.1 分布式锁
| 问题 | 要点 |
|---|---|
| 分布式锁怎么实现 | 核心是 SetNX,引入重试后需要 Lua 脚本 |
| 过期时间怎么设置 | 按业务耗时(如 P999 线)设置,重要的是引入续约机制 |
| 怎么续约 | 什么时候续约、续多长时间、续约失败怎么办(中断业务 + 回滚/补偿) |
| 加锁失败有什么原因 | 超时、网络故障、Redis 故障、锁被人持有 |
| 怎么优化性能 | 尽量避免用分布式锁,硬要用就结合 singleflight 优化 |
| 为什么要 UUID | 防止误删别人的锁 |
| 为什么要过期时间 | 防止持有者崩溃后锁永远不释放 |
| 为什么要 Lua 脚本 | 保证"检查值 + 删除"原子执行 |
| Redlock 是什么 | 多主节点投票,5 个节点 majority(3 个)成功才算加锁 |
| 主从切换问题 | 异步复制导致锁丢失,Redlock 可以缓解 |
8.2 缓存一致性
| 问题 | 要点 |
|---|---|
| 缓存模式能解决一致性吗 | 不能,包括 cache-aside、read-through、write-through |
| write-back 能解决一致性吗 | 特定条件下可以缓解(不回查 DB 或用 SetNX 版本号) |
| refresh-ahead 能解决一致性吗 | 不能,CDC 通知有延迟,延迟期间数据不一致 |
| 不一致的两个根源 | 并发更新(分布式锁解决)+ 部分失败(分布式事务解决) |
| 怎么解决不一致 | 本质上无解。强一致就别用缓存;要高性能就把缓存当唯一数据源(会丢数据) |
| 方案一:一致性哈希+singleflight | 控制同一 key 只打到同一机器,扩缩容时有短暂窗口 |
| 方案二:分布式锁 | 全局加锁,强一致但性能差,写多场景性能衰退快 |
| 两段式加锁 | 实例内 singleflight + 全局分布式锁,减少 Redis 压力 |
| 数据不一致怎么办 | 通过监控发现,然后修复;或直接不用缓存 |
8.3 分布式锁的核心原则
// ==================== 分布式锁核心原则 ====================
//
// 1. 不管要对锁做什么操作,首先要确认这把锁是我们自己的锁
// → 用 UUID 作为值,用 Lua 脚本验证
//
// 2. 必须设置过期时间
// → 防止持有者崩溃后锁永远不释放
//
// 3. 续约机制的三个核心问题
// → 什么时候续约?(过期时间过半时)
// → 续多长时间?(续为原始过期时间)
// → 续约失败怎么办?(通知业务中断 + 执行回滚/补偿)
//
// 4. 选择恰好的方案,而不是完美的方案
// → 大多数时候,直接让业务失败可能比部署复杂方案更便宜
自测题与动手练习
自测题(合上书能答出来,才算懂):
- 为什么要给分布式锁设过期时间?如果不设,持有锁的实例崩溃会发生什么?
- 为什么锁的值要用 UUID,释放时还要用 Lua 脚本?只用
DEL会有什么后果? - 续约机制解决什么问题?自动续约的「可控性差」具体差在哪?
singleflight两段式加锁把 Redis 压力从什么降到了什么?它依赖什么前提才有效?- 主从异步复制为什么会导致锁丢失?Redlock 又是怎么缓解这个问题的(代价是什么)?
动手练习(建议真做一遍):
- 用 go-redis 写一把
TryLock/Unlock,在本地 Redis 上跑通「加锁 → 执行业务 → 释放」,并故意用错 UUID 释放,观察返回ErrLockNotHold。 - 给锁加一个自动续约 goroutine(
AutoRefresh),模拟一个 30 秒的长任务、锁过期时间设 10 秒,验证业务执行期间锁不会过期。 - 用
singleflight包装加锁,起 10 个 goroutine 同时抢同一把锁,统计 Redis 实际收到的SetNX次数,确认只有 1 次(或实例数级别)。
本章小结
- 分布式锁本质是「公共柜子里的一把卡」:用
SetNX抢、用「过期时间」防崩溃死锁、用「UUID + Lua」防误删别人的锁。 - 过期时间的两难靠续约解决,但自动续约可控性差,关键业务建议手动
Refresh并处理好续约失败。 singleflight两段式加锁把 Redis 压力从「总 QPS」降到「实例数」;主从切换会丢锁,Redlock 用多主投票缓解但成本高。- 缓存一致性两大根源是「并发更新」(分布式锁解)和「部分失败」(分布式事务解),多数缓存模式并不能真正解决它,选「恰好」的方案而非「完美」的。
- 下一篇我们进入缓存击穿 / 穿透 / 雪崩的实战,看怎么用这把锁和空值/布隆过滤器兜底。