库存 100 件,200 个 goroutine 一起抢,跑完卖出 116 件,库存显示 -16。
同一份代码我跑了四次,卖出去 116、116、118、116,最后库存 -16、-16、-18、-16。
这不是压测环境里偶发的脏数据,是我今天上午坐在工位上,一个文件一个文件敲出来的结果,代码不到一百行。下面把这四次输出、完整代码、以及两个能兜住它的改法都摆出来。
先把环境交代清楚
Go 1.27.0,库存初始 100,并发 200 个 goroutine,每个 goroutine 的启动时间错开 300 微秒,模拟请求陆续到达,抢购窗口 5 毫秒,代表查完库存到真正扣减之间那点业务耗时。
三个实验组共用同一套参数,唯一变的是保护手段。A 组什么都不加,直接读库存再减。B 组用 sync.Mutex。C 组用 CAS 乐观锁。
文件在本地,叫 main.go,A、B、C 三段是同一个程序的三个顺序块,中间重置库存。下面贴的代码都是从那个文件里原样摘出来的,不是我事后重写的版本。
A 组:不加锁,先查再扣
常量长这样。
go
const (
stock0 = 100
buyers = 200
winNoLck = 5 * time.Millisecond
)
扣减那段是这样。
go
var stock int64 = stock0
var sold, oversold, refused int64
var wg sync.WaitGroup
for i := 0; i < buyers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
time.Sleep(time.Duration(i) * 300 * time.Microsecond) // 请求到达有先后
if atomic.LoadInt64(&stock) > 0 { // 查
time.Sleep(winNoLck) // 业务处理窗口
atomic.AddInt64(&stock, -1) // 扣
atomic.AddInt64(&sold, 1)
} else {
atomic.AddInt64(&refused, 1)
}
}(i)
}
wg.Wait()
我第一次写这段的时候,心里是踏实的。用了 atomic.LoadInt64,用了 atomic.AddInt64,看着就比 stock-- 讲究。很多人到这一步就收手了,觉得「我都上原子操作了还能有什么问题」。
四次实跑,控制台原样打印:
text
第 1 次
A 结果:请求 200,下单成功 116,被拒 84,最终库存 -16,超卖 16
第 2 次
A 结果:请求 200,下单成功 116,被拒 84,最终库存 -16,超卖 16
第 3 次
A 结果:请求 200,下单成功 118,被拒 82,最终库存 -18,超卖 18
第 4 次
A 结果:请求 200,下单成功 116,被拒 84,最终库存 -16,超卖 16
四次,没有一次是 100。库存那一栏,没有一次是非负数。
这里还有一个细节值得停一下:第三次是 118。同一份代码,同一台机器,同样的参数,换个时间跑就变成 118。也就是说,这个问题不是「刚好跑出来一次」的偶发,它每次都在,但每次咬掉多少口不一样。
为什么库存会变成负数
先拆掉一个特别常见的误会:atomic.LoadInt64 保证的是「读这一下不会读到被写了一半的数」,atomic.AddInt64 保证的是「这一次减法不会丢」。两个操作各自都是原子的。
问题是,它们中间隔着东西。原子 + 原子,不等于一段原子。
我把库存剩 1 件那一段单独拎出来,两个 goroutine 的时序摊平了看。用「先到的那个」和「后到的那个」代指相邻的两次请求,它们的启动时间差 300 微秒。
| 时刻 | 先到的那个 | 后到的那个 | 内存里的库存 |
|---|---|---|---|
| 30.0ms | Load 到 1,判断 1 > 0 通过 | 还没到 | 1 |
| 30.3ms | 进 5ms 业务窗口,睡 | Load 到 1,判断 1 > 0 通过 | 1 |
| 35.0ms | Add -1,1 变 0,sold++ | 还在睡 | 0 |
| 35.3ms | 结束 | Add -1,0 变 -1,sold++ | -1 |
后到的那个在 30.3ms 那一刻读到的就是 1。它没有读错,那时候内存里确实是 1,因为先到的那个虽然已经通过了判断,但还没动手扣。它看到「还有货」,合理合法地放行。
等到 35.3ms,后到的那个扣完,库存已经掉到 -1。第 101 件货就这么卖出去了,全程没有任何一个操作违反原子性。
这就是「读和写之间没有原子性」的全部含义。判断的结论和扣减的动作之间,有一个 5 毫秒的窗口,这 5 毫秒里,别人读到的还是旧值。
同样的参数跑四次,第 1 次多卖了 16 件,第 3 次多卖了 18 件。
第 1 次被拒的那 84 个,是进来的时候库存已经看到小于等于 0 的。它们没做错什么,只是排得靠后。
顺带说一个对照:A 组被拒 84 个,B 组被拒 100 个。同一个 200 人、同一个 100 件库存,这一次,A 组比 B 组多放进来 16 个人。这 16 个人不是运气好,是他们的判断发生在别人扣完之前,读到的是一个已经过期的正数。被放进来的人多了 16 个,库存就多掉 16 件,账上就多欠 16 件。
B 组:用一把互斥锁把「查」和「扣」缝起来
最直接的修法,是把判断和扣减塞进同一段临界区。
go
var mu sync.Mutex
mu.Lock()
if stock > 0 {
time.Sleep(winNoLck)
stock--
sold++
} else {
refused++
}
mu.Unlock()
跟 A 组的差别就一处:if 和 stock-- 之间不再有缝。谁拿到锁,谁就把判断和扣减一口气做完,做完了才放人进来。第二个人进来的时候,看到的必然是第一个人扣完之后的值。
四次实跑:
text
第 1 次
B 结果:请求 200,下单成功 100,被拒 100,最终库存 0,超卖 0
第 2 次
B 结果:请求 200,下单成功 100,被拒 100,最终库存 0,超卖 0
第 3 次
B 结果:请求 200,下单成功 100,被拒 100,最终库存 0,超卖 0
第 4 次
B 结果:请求 200,下单成功 100,被拒 100,最终库存 0,超卖 0
四次全部 100 成功,库存 0,超卖 0。
这里有个容易看漏的地方。我这把锁把 time.Sleep(winNoLck) 也圈进去了,也就是说,模拟的那 5 毫秒业务耗时在锁里。这意味着,每一个抢到货的人,都要独自霸占这把锁 5 毫秒,别人才进得来。200 个人里 100 个成功,这 100 个人一个接一个地各占 5 毫秒。
被拒的那 100 个人走的是 else 分支,不睡,几乎立刻就把锁放回去。所以队列主要堵在成功的那 100 个人身上,而不是堵在全部 200 个人身上。
我特意保留了这个写法,因为现实里太多人就是这么写的:锁一加,把查库、算价、写订单、发消息全包进去了。功能上没错,超卖也确实没了,但排队的人等的是你锁里所有的东西,包括那些本来不需要独占的部分。
如果只是为了让这个数干净,把 time.Sleep 移到 mu.Lock() 外面也行,锁的持有时间立刻短一大截。这一点在下面讲取舍的时候还会回来。
C 组:用 CAS 把「查 + 扣」压成一次比较交换
第三条路不排队,它赌的是「我下手的时候没人动过」。
go
for {
cur := atomic.LoadInt64(&stock)
if cur <= 0 {
atomic.AddInt64(&refused, 1)
return
}
time.Sleep(winNoLck)
if atomic.CompareAndSwapInt64(&stock, cur, cur-1) {
atomic.AddInt64(&sold, 1)
return
}
}
CompareAndSwapInt64(addr, old, new) 的意思是:你看一眼现在是不是还等于 old,如果还是,就把它改成 new 并且返回 true;如果这中间已经被别人改过,什么都不做,返回 false。
对应到扣库存,就是我先把 cur 记成 99,跟系统说「如果还是 99,就改成 98」。这中间如果有人插进来把 99 改成了 98,我的这次交换就失败,我重新读一遍最新的值,再来一次。
四次实跑:
text
第 1 次
C 结果:请求 200,下单成功 100,被拒 100,最终库存 0,超卖 0
第 2 次
C 结果:请求 200,下单成功 100,被拒 100,最终库存 0,超卖 0
第 3 次
C 结果:请求 200,下单成功 100,被拒 100,最终库存 0,超卖 0
第 4 次
C 结果:请求 200,下单成功 100,被拒 100,最终库存 0,超卖 0
跟 B 组一样,100 成功,库存 0,超卖 0。
但我要主动把这个方案的三个缺口说清楚,不然这段代码贴出去就是害人。
第一个缺口,这段代码能兜住,靠的是每次失败都回头重新 LoadInt64。cur 是在 for 循环体里面读的,每一轮都是最新的值。要是把它挪到循环外面,CAS 永远拿一个过期的 99 去比,库存早就不是 99 了,它就一直失败一直转,转不出来。
第二个缺口,5 毫秒的睡眠放在 CAS 之前。这意味着输掉竞争的人,要重新读值、重新睡 5 毫秒、再试一次。库存越少、抢的人越多,撞车的次数越多,重试的次数就越多。这次我没在代码里统计重试次数,所以这批读数里看不出到底空转了多少轮。这是这个实验的缺口,我认,不补一个编出来的数字上去。
第三个缺口,CAS 只兜得住一个数。库存是一个数,它兜得住。但真实的扣库存往往连着写订单、扣优惠券、记流水,那是一个整体,CAS 兜不住「三件事要么全做要么全不做」。这时候它会退化成「我 CAS 成功了你却没写进去」,比超卖更难查。
两种兜法各自适合什么场景
我不打算在这里给「哪个更好」的结论,因为它们兜的根本不是同一样东西。锁兜的是一段逻辑,CAS 兜的是一个值。
临界区里不止一个写操作的时候,锁更顺手。 扣库存、生成订单号、写订单行、扣优惠券,这四件事必须一起成立,中间不能有别人插进来看到半成品。这种「一段逻辑」的互斥,CAS 表达不出来,硬写就是一个巨大的状态机,比锁难维护得多。
临界区里只有一个变量,而且能用一次比较交换表达的时候,CAS 更轻。 扣库存、改状态位、累加计数,都属于这一类。它不需要排队,不需要唤醒,失败的人自己重来,不占别人的时间。库存充裕、冲突少的时候,几乎看不到重试。
有一条是我这次实验里最想让人看见的:临界区里有 IO 的时候,两个都别把 IO 圈进去。B 组我故意把 5 毫秒放在锁里,那 5 毫秒在真实系统里可能是查一次数据库、调一次风控、发一条消息。锁里放 IO,等于把你下游的延迟乘给所有排队的人。CAS 里放 IO 更糟,因为你 IO 完可能发现值早变了,IO 白做一遍。
失败重试的成本,两边形态也不一样。锁的成本是等,等的时候 goroutine 挂着,占着内存不占 CPU。CAS 的成本是转,转的时候占着 CPU。等待时间可控,空转次数不可控。后者在库存见底的时候最容易失控。
再补一个粒度上的事。上面 B 组用的是一把全局锁,200 个请求抢的是同一把。真实系统里更常见的做法是按 key 分锁,一个 SKU 一把,不相干的商品互不干扰。粒度切细,等待就短,但代价是锁的管理变复杂,而且同一个热点 SKU 该排的队一点没少。粒度不是越细越好,切到「不相干的请求不互相挡」就够了。
还有一个前提条件,前面全部没覆盖:多实例部署。 我这台机器上跑的是一个进程,sync.Mutex 和 atomic 都在进程内存里有效。线上四个 Pod,每个 Pod 各自维护一份 stock,等于四个 A 组并行跑,每个都觉得自己手里有 100 件。这时候进程内的锁一点用都没有,你得把这道闸挪到进程外面去。
落到自己项目里,先加这一行
进程内锁管不了多实例,服务重启、扩缩容之后内存里的变量也没了。真正能兜住最后一道的,还是数据库那行带条件的更新。
sql
UPDATE stock SET qty = qty - 1 WHERE sku = 'SKU-A100' AND qty > 0;
执行完看 RowsAffected。等于 1,说明你抢到了,这行确实被改了。等于 0,说明 qty > 0 这个条件在那一刻不成立,货已经没了,直接返回售罄,不要再去查一遍再判断。
这一条语句的价值在于,「查」和「扣」被压进了数据库的同一把行锁里,中间没有缝。你在应用层写多少行 if,都不如把条件写进 WHERE 里。
要提醒的是,它会把压力集中到同一行上,qty > 0 让所有请求排在同一行的锁上。所以它适合当最后一道兜底,不适合当唯一入口。前面该有一层预扣,Redis 也好,队列也好,先把大部分无效请求挡在外面,让打到数据库的请求少一点。
至于应用层,我的建议是这个顺序:先把不加锁的版本跑一遍,看看它会卖出多少件,心里有个数;然后把条件写进 SQL 的 WHERE 里;最后才去纠结要不要在应用层再加一把锁或者一个 CAS。顺序反了,你会在一个本来就不该由应用层解决的问题上,反复调参数。
数据库层除了条件更新,还有一种常见写法是给行加版本号。读的时候把 version 一起读出来,写的时候把它带进 WHERE,更新成功就让 version 加一。改没改,还是看 RowsAffected。这跟 CAS 是同一个思路,只是把比较交换挪进了 SQL。
两者失败的含义不一样,这点容易被忽略。条件更新失败,意味着货没了;版本号更新失败,意味着有人抢先改了你的行,未必是没货。业务含义不同,返回给前端的文案也不该一样,一个是售罄,一个是「请重新提交」。
我自己是被这个 -16 教育了一次的。当时我也觉得用了 atomic 就万事大吉,直到打印出来那一行负数,才反应过来原子操作护住的是单个动作,不是一段逻辑。
你手上要是有类似的扣减逻辑,别急着改,先去跑一遍不带锁的版本,看它会不会卖出超过库存的数。跑出来是多少,评论区说一声。