100 件货卖出 116 件,库存停在 -16

库存 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 就万事大吉,直到打印出来那一行负数,才反应过来原子操作护住的是单个动作,不是一段逻辑。

你手上要是有类似的扣减逻辑,别急着改,先去跑一遍不带锁的版本,看它会不会卖出超过库存的数。跑出来是多少,评论区说一声。


相关推荐
泡海椒11 分钟前
jquick-pdf 多重折线(JMultiLineChartData)实战:Java 生成工单 SLA 月报
后端
Swot13 分钟前
SvelteKit Remote Functions 使用指南
前端·后端
Java旅途20 分钟前
让项目过程清晰可追溯:从课题双选到文档归档的一体化平台
后端
黄连木笛22 分钟前
不改 Redis Insight 原有代码,把它汉化了:DOM 整段匹配 + XHR 拦截 + asar 重打包
redis·后端
flash俊杰23 分钟前
v2.34-02 静默截断:10,000 个会话与 500 条消息是怎么被发现的
后端
vaskilab32 分钟前
Agent 上线后翻车的 6 种死法:一个 20 万人规模项目的四版重构实录
后端
鶴哥只手遮天1 小时前
《rustc 内幕》第 1 章 全景:rustc 编译流水线与 crate 版图
后端·rust·开源
starzy19901 小时前
深入理解Scala隐式值:编译期自动装配
开发语言·后端·scala
nice_lcj5202 小时前
MySQL 存储引擎入门:InnoDB 和 MyISAM 到底怎么选
java·数据库·mysql·面试·innodb·存储引擎·myisam