悲观锁、乐观锁、无锁: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 国际许可协议进行许可。