三、Redis 分布式锁与缓存一致性

2021-02-19T14:21:02+08:00 | 28分钟阅读 | 更新于 2021-02-19T14:21:02+08:00

@

学习目标

学完本章你应该能够:

  1. 说清单机锁(sync.Mutex)为什么在多实例部署下失效,从而引出分布式锁要解决的问题。
  2. SetNX 实现一把最小可用的分布式锁,并讲清「过期时间」「UUID 值」「Lua 释放」三者为什么缺一不可。
  3. 讲清续约机制如何解决「过期时间两难」,以及手动续约 / 自动续约各自的坑。
  4. 解释 singleflight 两段式加锁如何把 Redis 压力从「总 QPS」降到「实例数」。
  5. 在面试里把主从切换丢锁、Redlock、缓存一致性的两个根源讲成有逻辑的故事。

前置知识

  • Go 基础:interface、goroutine、contexttime
  • Redis 基本命令:SETNXEXPIREEVAL(Lua 脚本)。
  • 对单机锁 sync.Mutex 的工作方式已有了解(见上一章)。

本章你会动手做的事

  1. 用 go-redis 写一把 TryLock / Unlock,本地连 Redis 跑通「加锁 + 释放」。
  2. 给锁加一个自动续约 goroutine,模拟一个超过过期时间的长任务,观察锁不被提前释放。
  3. singleflight 包装加锁,起 10 个 goroutine 抢同一把锁,观察只有 1 次真正打到 Redis。

类比:分布式锁就像公司茶水间唯一的一把门禁卡。以前大家都在一个房间(单机)里,谁用谁拿就行;现在公司扩成多个分公司(多实例),得把卡放在一个所有人都能访问的公共柜子(Redis)里,谁抢到谁进。难的是:有人拿着卡去开会(业务慢)忘了还、或者卡丢了(实例崩溃)——所以才需要「过期自动失效」「还卡时验证是不是本人」这些设计。

flowchart LR
    A[实例 A] -->|SetNX 抢锁| R[(Redis 锁)]
    B[实例 B] -->|SetNX 抢锁| R
    C[实例 C] -->|SetNX 抢锁| R
    R -->|只有一人成功| W[获得锁者执行业务]

前言:从单机锁到分布式锁

在上一章中,我们学习了 sync.Mutexsync.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 脚本保证原子性

释放锁的时候,需要做两件事:

  1. 看看是不是自己加的锁(比较 Redis 里的值是不是自己的 UUID)
  2. 如果是,直接删除锁

关键问题:这两步必须原子执行。如果先 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. 选择恰好的方案,而不是完美的方案
//    → 大多数时候,直接让业务失败可能比部署复杂方案更便宜

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 为什么要给分布式锁设过期时间?如果不设,持有锁的实例崩溃会发生什么?
  2. 为什么锁的值要用 UUID,释放时还要用 Lua 脚本?只用 DEL 会有什么后果?
  3. 续约机制解决什么问题?自动续约的「可控性差」具体差在哪?
  4. singleflight 两段式加锁把 Redis 压力从什么降到了什么?它依赖什么前提才有效?
  5. 主从异步复制为什么会导致锁丢失?Redlock 又是怎么缓解这个问题的(代价是什么)?

动手练习(建议真做一遍)

  1. 用 go-redis 写一把 TryLock / Unlock,在本地 Redis 上跑通「加锁 → 执行业务 → 释放」,并故意用错 UUID 释放,观察返回 ErrLockNotHold
  2. 给锁加一个自动续约 goroutine(AutoRefresh),模拟一个 30 秒的长任务、锁过期时间设 10 秒,验证业务执行期间锁不会过期。
  3. singleflight 包装加锁,起 10 个 goroutine 同时抢同一把锁,统计 Redis 实际收到的 SetNX 次数,确认只有 1 次(或实例数级别)。

本章小结

  • 分布式锁本质是「公共柜子里的一把卡」:用 SetNX 抢、用「过期时间」防崩溃死锁、用「UUID + Lua」防误删别人的锁。
  • 过期时间的两难靠续约解决,但自动续约可控性差,关键业务建议手动 Refresh 并处理好续约失败。
  • singleflight 两段式加锁把 Redis 压力从「总 QPS」降到「实例数」;主从切换会丢锁,Redlock 用多主投票缓解但成本高。
  • 缓存一致性两大根源是「并发更新」(分布式锁解)和「部分失败」(分布式事务解),多数缓存模式并不能真正解决它,选「恰好」的方案而非「完美」的。
  • 下一篇我们进入缓存击穿 / 穿透 / 雪崩的实战,看怎么用这把锁和空值/布隆过滤器兜底。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!