悲观锁、乐观锁、无锁:Go 并发控制的三种姿势

Posted on Wed 28 January 2026 in Tech

Abstract Go 并发控制:悲观锁 vs 乐观锁 vs 无锁
Authors Walter Fan
Category 技术笔记
Status v1.0
Updated 2026-01-28
License CC-BY-NC-ND 4.0

开篇:一个让你加班到凌晨的 Bug

周五下午 5 点,你正准备收拾东西下班。

突然,线上告警:库存超卖了

你一看代码,逻辑很简单:

// 扣减库存
if stock > 0 {
    stock--
}

"这能有什么问题?"

然后你发现——100 个并发请求同时进来,每个 goroutine 都读到 stock = 1,然后每个都执行了 stock--

结果:库存变成了 -99。

老板问你:"我们的库存是负数,这是什么黑科技?"


这就是经典的数据竞争(Data Race)问题。

解决方案?加锁。

但锁也分好几种"姿势":

  • 悲观锁:先锁上,再干活("我不信任你们,一个一个来")
  • 乐观锁:先干活,提交时再检查冲突("我赌你们不会同时改")
  • 无锁:压根不用锁,靠设计来避免竞争("我换个思路")

今天这篇文章,用 Go 代码实战,带你理解这三种并发控制策略的原理、实现和适用场景。


一、悲观锁:宁可错杀,不可放过

1.1 核心思想

悲观锁的哲学是:

"我假设一定会有人跟我抢资源,所以我先把资源锁住,用完再放开。"

这是一种"防御性编程"的思路——在访问共享资源之前,先获取互斥锁(Mutex),确保同一时刻只有一个 goroutine 能操作。

1.2 Go 实现:sync.Mutex

Go 标准库提供了 sync.Mutex,用法非常简单:

package main

import (
    "fmt"
    "sync"
)

var (
    stock int = 100
    mu    sync.Mutex
)

func deduct() {
    mu.Lock()         // 加锁
    defer mu.Unlock() // 确保释放锁

    if stock > 0 {
        stock--
    }
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 200; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            deduct()
        }()
    }

    wg.Wait()
    fmt.Println("Final stock:", stock) // 输出: Final stock: 0
}

关键点

  • mu.Lock() 会阻塞,直到获取到锁
  • defer mu.Unlock() 确保即使 panic 也能释放锁
  • 同一时刻只有一个 goroutine 能进入临界区

1.3 读写锁:sync.RWMutex

如果你的场景是读多写少,可以用 sync.RWMutex

var (
    data map[string]string
    rwmu sync.RWMutex
)

// 读操作:可以多个 goroutine 同时读
func get(key string) string {
    rwmu.RLock()
    defer rwmu.RUnlock()
    return data[key]
}

// 写操作:独占锁
func set(key, value string) {
    rwmu.Lock()
    defer rwmu.Unlock()
    data[key] = value
}

区别

方法 行为
RLock() 读锁,多个 goroutine 可以同时持有
Lock() 写锁,独占,会阻塞所有读和写

1.4 优缺点

优点 缺点
实现简单,逻辑清晰 性能开销大(锁竞争、上下文切换)
强一致性保证 高并发下容易成为瓶颈
不会出现 ABA 问题 可能导致死锁(如果锁嵌套使用不当)

1.5 适用场景

  • 写操作多,竞争激烈
  • 临界区逻辑复杂(不只是简单的加减)
  • 对数据一致性要求极高

二、乐观锁:先干活,出问题再说

2.1 核心思想

乐观锁的哲学是:

"我赌大部分情况下不会冲突,所以先不加锁,等提交时再检查有没有人改过。"

如果检查发现被别人改了,就重试放弃

2.2 Go 实现:sync/atomic + CAS

Go 的 sync/atomic 包提供了原子操作,其中 CompareAndSwap(CAS)是乐观锁的核心:

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

var stock int64 = 100

func deductCAS() bool {
    for {
        old := atomic.LoadInt64(&stock)
        if old <= 0 {
            return false // 库存不足
        }

        // CAS:如果 stock 还是 old,就减 1
        if atomic.CompareAndSwapInt64(&stock, old, old-1) {
            return true // 扣减成功
        }
        // 如果失败(说明被别人改了),自动重试
    }
}

func main() {
    var wg sync.WaitGroup
    successCount := int64(0)

    for i := 0; i < 200; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            if deductCAS() {
                atomic.AddInt64(&successCount, 1)
            }
        }()
    }

    wg.Wait()
    fmt.Println("Final stock:", stock)         // 输出: 0
    fmt.Println("Success count:", successCount) // 输出: 100
}

关键点

  • atomic.LoadInt64() 原子读取当前值
  • CompareAndSwapInt64(addr, old, new) 如果 *addr == old,则设置为 new,返回 true;否则返回 false
  • 失败后自旋重试(spin)

2.3 常用原子操作

// 原子加减
atomic.AddInt64(&counter, 1)   // counter++
atomic.AddInt64(&counter, -1)  // counter--

// 原子读写
val := atomic.LoadInt64(&counter)
atomic.StoreInt64(&counter, 100)

// CAS
swapped := atomic.CompareAndSwapInt64(&counter, oldVal, newVal)

2.4 乐观锁的"版本号"变体

在数据库场景中,乐观锁通常用版本号实现:

type Item struct {
    ID      int
    Stock   int
    Version int // 版本号
}

func deductWithVersion(item *Item) error {
    // 1. 读取当前数据
    oldVersion := item.Version

    // 2. 业务逻辑
    if item.Stock <= 0 {
        return errors.New("insufficient stock")
    }
    item.Stock--

    // 3. 带版本号更新(伪代码,实际是 SQL)
    // UPDATE items SET stock = ?, version = version + 1 
    // WHERE id = ? AND version = ?
    affected := db.Update(item, oldVersion)

    if affected == 0 {
        return errors.New("concurrent modification, retry")
    }
    return nil
}

2.5 优缺点

优点 缺点
无锁竞争,高并发性能好 冲突多时,重试开销大
不会死锁 可能出现 ABA 问题(见下文)
适合读多写少场景 逻辑复杂时难以使用

2.6 ABA 问题

CAS 的隐患:如果值从 A 变成 B 再变回 A,CAS 会认为"没变过"。

时间线:
goroutine 1: 读到 stock = 100
goroutine 2: stock 100 -> 99 -> 100(被别人扣了又加回来)
goroutine 1: CAS(100, 99) 成功(但其实中间发生过变化)

解决方案:加版本号或时间戳。

2.7 适用场景

  • 读多写少,冲突概率低
  • 操作简单(只是加减、替换)
  • 对性能要求高,能接受偶尔重试

三、无锁:换个思路,绕过竞争

3.1 核心思想

无锁的哲学是:

"与其费劲加锁,不如从设计上避免共享状态。"

这不是说"不用任何同步机制",而是用其他方式(Channel、不可变数据、分区)来避免数据竞争。

3.2 Go 实现:Channel(CSP 模型)

Go 的设计哲学是:

"Don't communicate by sharing memory; share memory by communicating." (不要通过共享内存来通信,而要通过通信来共享内存。)

用 Channel 重写库存扣减:

package main

import (
    "fmt"
)

// 库存管理器(单一 goroutine 持有状态)
func stockManager(stock int, requests <-chan chan bool) {
    for req := range requests {
        if stock > 0 {
            stock--
            req <- true  // 扣减成功
        } else {
            req <- false // 库存不足
        }
    }
}

func main() {
    requests := make(chan chan bool)

    // 启动库存管理器
    go stockManager(100, requests)

    // 模拟 200 个并发请求
    done := make(chan bool, 200)
    successCount := 0

    for i := 0; i < 200; i++ {
        go func() {
            reply := make(chan bool)
            requests <- reply   // 发送请求
            success := <-reply  // 等待响应
            done <- success
        }()
    }

    // 统计结果
    for i := 0; i < 200; i++ {
        if <-done {
            successCount++
        }
    }

    fmt.Println("Success count:", successCount) // 输出: 100
}

关键点

  • 状态(stock)只被一个 goroutine 持有
  • 其他 goroutine 通过 Channel 发送"请求"
  • 没有共享内存,所以不需要锁

3.3 不可变数据(Immutable Data)

另一种无锁思路:永远不修改数据,只创建新版本

type Config struct {
    DBHost   string
    DBPort   int
    LogLevel string
}

var currentConfig atomic.Value // 存储 *Config

func init() {
    currentConfig.Store(&Config{
        DBHost:   "localhost",
        DBPort:   5432,
        LogLevel: "info",
    })
}

// 读取配置(无锁)
func GetConfig() *Config {
    return currentConfig.Load().(*Config)
}

// 更新配置(创建新对象,原子替换)
func UpdateConfig(newHost string) {
    old := GetConfig()
    newConfig := &Config{
        DBHost:   newHost,
        DBPort:   old.DBPort,
        LogLevel: old.LogLevel,
    }
    currentConfig.Store(newConfig)
}

关键点

  • 读操作完全无锁
  • 写操作创建新对象,用 atomic.Value 原子替换
  • 适合配置、元数据等读多写少场景

3.4 分区(Sharding)

如果必须有共享状态,可以把状态拆分成多个分区,减少竞争:

const shardCount = 16

type ShardedCounter struct {
    shards [shardCount]struct {
        sync.Mutex
        count int64
    }
}

func (c *ShardedCounter) Inc(key string) {
    // 根据 key 哈希到某个分区
    shard := &c.shards[hash(key)%shardCount]
    shard.Lock()
    shard.count++
    shard.Unlock()
}

func (c *ShardedCounter) Total() int64 {
    var total int64
    for i := range c.shards {
        c.shards[i].Lock()
        total += c.shards[i].count
        c.shards[i].Unlock()
    }
    return total
}

关键点

  • 锁竞争从 1 个锁变成 16 个锁
  • 不同 key 大概率打到不同分区,互不干扰

3.5 优缺点

方案 优点 缺点
Channel Go 原生支持,代码清晰 有 Channel 开销,不适合极高性能场景
不可变数据 读完全无锁,线程安全 写操作需要复制,内存开销大
分区 减少锁竞争,可扩展 聚合操作需要遍历所有分区

3.6 适用场景

  • Channel:任务分发、消息传递、Pipeline
  • 不可变数据:配置管理、缓存、读多写少
  • 分区:高并发计数器、分布式锁

四、三种方式对比总结

维度 悲观锁 乐观锁 无锁
假设 一定会冲突 大概率不冲突 避免共享状态
实现 Mutex / RWMutex CAS / 版本号 Channel / 不可变
性能 竞争多时差 竞争少时好 取决于设计
复杂度 简单 中等 需要设计思维
死锁风险
ABA 问题

选择建议

问自己一个问题:写操作多还是少?

        写操作多?
          / \
         /   \
        是    否
       /        \
   悲观锁      冲突概率高?
               /  \
              /    \
             是    否
            /        \
       悲观锁      乐观锁/无锁

更具体的场景推荐:

场景 推荐方案
库存扣减(高并发) 悲观锁 + 数据库行锁
计数器(精确) atomic
配置热更新 atomic.Value + 不可变数据
任务分发 Channel
缓存(读多写少) sync.RWMutex 或 不可变数据
高并发计数(允许近似) 分区计数器

五、实战:用 Benchmark 验证性能差异

Talk is cheap,跑个 benchmark:

package main

import (
    "sync"
    "sync/atomic"
    "testing"
)

var (
    counterMutex int64
    mu           sync.Mutex
    counterAtomic int64
)

// 悲观锁
func BenchmarkMutex(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu.Lock()
            counterMutex++
            mu.Unlock()
        }
    })
}

// 乐观锁(atomic)
func BenchmarkAtomic(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            atomic.AddInt64(&counterAtomic, 1)
        }
    })
}

运行:

go test -bench=. -benchmem -cpu=1,4,8

典型结果(仅供参考,实际取决于 CPU):

方案 1 CPU 4 CPU 8 CPU
Mutex 50 ns/op 200 ns/op 400 ns/op
Atomic 10 ns/op 15 ns/op 20 ns/op

结论

  • 单核差距不大
  • 多核下 Mutex 性能急剧下降(锁竞争)
  • Atomic 扩展性更好

总结

锁类型 一句话 Go 实现
悲观锁 先锁后干活 sync.Mutex / sync.RWMutex
乐观锁 先干活后检查 atomic.CompareAndSwap / 版本号
无锁 换个思路避免竞争 Channel / 不可变数据 / 分区

记忆口诀

写多用悲观,读多用乐观,能不共享就不共享。


Checklist:Go 并发控制

  • [ ] 确认是否真的需要共享状态(能不能用 Channel 替代?)
  • [ ] 确认读写比例(读多 → RWMutex / atomic;写多 → Mutex)
  • [ ] 锁的粒度是否合理(锁太大影响并发,锁太小容易出错)
  • [ ] 是否有死锁风险(检查锁的获取顺序)
  • [ ] 用 go run -race 检测数据竞争
  • [ ] 高并发场景跑 benchmark 验证

扩展阅读


写在最后

回到开头那个库存超卖的 bug——

如果你用了悲观锁,它不会发生。 如果你用了乐观锁,它会被检测到并重试。 如果你用了 Channel,它压根就不可能发生。

并发问题的本质,是"多个人同时改一个东西"。

解决方案无非三种: 1. 排队一个个来(悲观锁) 2. 改完检查有没有冲突(乐观锁) 3. 让每个人改自己的东西(无锁)

选哪个?看场景。

下次写并发代码时,先问自己:

「这个共享状态,真的非共享不可吗?」

很多时候,最好的锁是——不加锁。


@startmindmap
* 并发控制三种姿势
** 悲观锁
*** 先锁后干活
*** sync.Mutex
*** sync.RWMutex
*** 写多竞争激烈
** 乐观锁
*** 先干活后检查
*** CAS 实现
*** 版本号变体
*** ABA 问题
*** 读多写少
** 无锁
*** 避免共享状态
*** Channel CSP
*** 不可变数据
*** 分区 Sharding
** 选择建议
*** 写多用悲观锁
*** 读多用乐观/无锁
*** Benchmark 验证
@endmindmap

并发控制思维导图


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。