← 返回
AI技术

我用 100 个线程跑了一场秒杀,没等来超卖,等来了更阴险的东西

✍️ zhirenhun 📅 2026/9/9 👁 50 阅读 ⏱ 6 分钟
我用 100 个线程跑了一场秒杀,没等来超卖,等来了更阴险的东西

我用 100 个线程跑了一场秒杀,没等来超卖,等来了更阴险的东西

写这篇的起因是 dev.to 热榜上那句"AI 没有杀死系统设计的需要,只是让烂系统设计更容易上线了"。话是漂亮,但空口无凭。系统设计的经典反面教材——秒杀超卖——我决定亲手复现一遍,看看教科书里的"超卖"到底长什么样。

结果第一轮就颠覆认知:1500 次购买请求全部返回成功,库存一分没超卖,账面还剩 793 件。不是系统扛住了,是它坏得比教科书写的更隐蔽。

竞态实验真实输出:账实分裂

实验设定

一个极简秒杀:库存 1000 件,100 个买家线程,每人抢购 15 次,合计 1500 次请求。库存就放在一个列表元素里,扣减逻辑三步:读出来,判断够不够,减一写回去。

def worker():
    for _ in range(BUY_PER_REQ):
        s = stock[0]          # 第一步:读
        if s >= 1:            # 第二步:判断
            time.sleep(0)     # 模拟真实业务里的IO/调度间隙
            stock[0] = s - 1  # 第三步:写回

那个 time.sleep(0) 是点睛之笔——它让出 GIL,制造线程交错的窗口。真实系统里这行不需要存在,数据库往返、日志落盘、任何一次锁竞争都会自然产生同样的间隙。

教科书说会超卖,GIL 说不一定

按经典理论,两个线程同时读到库存 1,都判断通过,都减一,最后库存变 -1,超卖一张。但我连跑四轮,一次负数都没出现。取而代之的是这组数字:

1500 人收到"购买成功"。库存实际只扣了 207 次。账面剩余 793 件。

这就是并发 bug 的另一种形态:丢失更新。线程 A 读到库存 500,这时线程 B 已经把库存扣到 499,A 拿着手里那张过期快照写回 499——B 的那次扣减就这么凭空蒸发了。卖出去的次数比库存少,钱收了,货记录上还在,两边账对不上。

为什么不是超卖而是少卖?因为 CPython 的 GIL 恰好让"读-判断"大多能挤在同一个字节码窗口里完成,真正频繁被切断的是"判断-写回"那一步。换到 Go 或 Java,同样的代码可能就翻车成负库存。换句话说:这个 bug 的表现形态取决于运行时的脾气,而"账对不上"是所有形态的共同结局。

修复只需一行

把三步整体塞进锁里:

def worker():
    for _ in range(BUY_PER_REQ):
        with lock:                # 读-判断-写,不可分割
            s = stock[0]
            if s >= 1:
                stock[0] = s - 1
            else:
                rejected.append(1)

修复后的数字干净得像教科书:卖出 1000,拒绝 500,库存归零,账实一致。四轮复跑,一次不差。

真正想说的

这个实验最吓人的地方,是版本 A 在单机压测里"看起来完全正常":接口不报错、不崩溃、响应飞快,用户满意度拉满。账实分裂要等到财务月底对账才炸,那时候 500 张空头订单已经发了出去。

这就是我一直跟团队强调系统设计 review 的原因。AI 代码助手让"能跑的代码"的生产速度快了十倍,但并发正确性从来不是"能不能跑"的问题,是"两个操作同时发生时谁是真相"的问题。这类问题不会出现在任何一次单元测试里——我的版本 A 四个测试全绿——只会出现在生产环境的对账单上。

三行代码的检查清单,比十页 AI 生成的"最佳实践"都管用:这个操作是先读后写吗?读和写之间有没有可能插进别的线程?有,就加原子保护,没有例外。

实验代码 60 行,纯标准库,Python 3.12 亲测四轮数字稳定复现。拿去跑跑你自己的服务,尤其是那些"从来没人改过"的老接口——它们可能正在安静地丢失更新。

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

并发 竞态条件 秒杀 系统设计