Go语言项目的线上库存从100被扣到-43,超买超卖订单堆了快两百单,凌晨两点的客服群被用户投诉刷屏——这是我空降到某个电商项目时遇到的第一现场。定位结果一点都不新鲜:扣库存用的是“先查库存、再比较、最后UPDATE”三段式,并发一上来,检查到扣减之间的空窗期被无限放大,数据库里那一行库存直接被穿成了负数。
下面是我那次改造的完整复盘。解决思路说穿了就两步:用Go配合Redis Lua脚本,把“判断库存是否充足”和“扣减库存”合并成一次原子操作,先挡住高并发冲击;再用预占、释放、对账、监控这套兜底设计,把Redis和数据库的账抹平。改造过程中踩过的坑,包括重复扣减、落库失败、集群限制,我都会逐个拆开讲。文章适合正在做电商后端、准备接秒杀抢购场景、或者纯粹想搞懂库存扣减方案的Go开发者。代码基于Go 1.20+和go-redis/v9验证,本地只要有一个Redis实例就能跑起来。如果你还在Go语言学习路线的入门阶段,也不用怕,这篇文章核心代码量不大,看懂脚本逻辑就能自己复现。Go语言安装直接用官方安装包,默认配置一路下一步,五分钟内就绪。
1. 超卖是怎么发生的:一次并发抢购的现场还原
1.1 “先查再扣”的三段式代码,死在检查与扣减之间的空窗期
先看最常见的错误写法。很多项目的第一版库存扣减都是这个模式:
go复制// 错误示例:并发下必出问题
func DeductStockBad(db *sql.DB, skuID string, num int64) error {
// 第一步:查库存
var stock int64
err := db.QueryRow("SELECT stock FROM inventory WHERE sku_id = ?", skuID).Scan(&stock)
if err != nil {
return err
}
// 第二步:判断是否充足
if stock < num {
return ErrStockNotEnough
}
// 第三步:扣减
_, err = db.Exec("UPDATE inventory SET stock = stock - ? WHERE sku_id = ?", num, skuID)
return err
}
单看每行代码都没问题,但三步连起来就是典型的“检查后使用”(check-then-act)竞态。假设库存只剩1件,两个请求同时进来,都读到stock=1,都通过“库存是否充足”的判断,然后都执行UPDATE,库存最终变成-1。注意,UPDATE本身是原子的,但“检查”和“UPDATE”不是同一个原子操作,中间那几微秒的空窗,在高并发下会被成百上千个请求同时踩中。
换成生活场景就很好理解:你和另外九个人同时看到售票页面显示“余票1张”,于是同时点了提交,系统最后超卖了十份。问题不在最后扣减那一锤子,而在于所有人在同一时刻都通过了“有票”的检查。这个空窗期本质上是时间差,并发越高,踩中的概率越大,直到库存被扣成负数才停。
1.2 为什么加了事务和FOR UPDATE,仍然不是最优解
发现三段式有问题之后,大部分人的第一反应是加事务,用数据库行锁来保证串行:
go复制tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var stock int64
err = tx.QueryRowContext(ctx,
"SELECT stock FROM inventory WHERE sku_id = ? FOR UPDATE", skuID).Scan(&stock)
if err != nil {
return err
}
if stock < num {
return ErrStockNotEnough
}
_, err = tx.ExecContext(ctx,
"UPDATE inventory SET stock = stock - ? WHERE sku_id = ?", num, skuID)
if err != nil {
return err
}
return tx.Commit()
SELECT ... FOR UPDATE 把这一行库存锁住了,并发安全确实解决了。但代价很直接:所有打到同一个SKU的请求全部串行排队,数据库连接被快速占满,行锁等待超时、死锁报错接踵而至。秒杀场景下几千个请求打一个SKU,数据库的CPU和连接池都会被打爆,整体吞吐可能还不如不加锁的时候。
还有一点经常被忽略:MySQL默认的REPEATABLE READ隔离级别下,普通SELECT是快照读,不加锁。如果你只开事务、不加FOR UPDATE,两个事务照样读到同一个旧库存,超卖问题纹丝不动。加上FOR UPDATE,又回到性能和死锁的窘境。所以我的结论是:悲观锁不是不行,它适合后台人工调库存、采购入库这类低频操作,放在前台承接用户流量就是灾难。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:悲观锁、乐观锁、Redis Lua之间到底怎么选
2.1 悲观锁:把并发排队的问题全丢给数据库
悲观锁的思路是“先锁住,再操作”。FOR UPDATE就是最典型的实现,它假设冲突一定会发生,所以一开始就把资源占住。代码简单、思路直观,这是它的优点;缺点是并发能力受限于行锁粒度,所有请求在锁上排队,数据库连接池很快被吃光。而且事务里如果混入了RPC调用、Redis访问这类耗时操作,锁的持有时间会被拉长,连坐效应非常明显。
实际项目中我只在两种地方用它:运营后台手动调整库存,以及采购入库这种必须严格串行的操作。这两个场景的特点是QPS低、数据一致性要求极高,悲观锁的缺点完全不影响。
2.2 乐观锁:一条条件UPDATE扛住中等并发
乐观锁的思路反过来:“我不提前锁,先尝试更新,失败再说”。用在库存扣减上,最优雅的变体是连版本号都不需要,直接用带条件的UPDATE:
sql复制UPDATE inventory
SET stock = stock - ?
WHERE sku_id = ? AND stock >= ?
Go里面判断影响行数即可:
go复制func DeductStockOptimistic(ctx context.Context, db *sql.DB, skuID string, num int64) error {
res, err := db.ExecContext(ctx,
"UPDATE inventory SET stock = stock - ? WHERE sku_id = ? AND stock >= ?",
num, skuID, num)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return ErrStockNotEnough
}
return nil
}
数据库执行UPDATE时会对命中的行加锁,但锁只在这一瞬间持有,比FOR UPDATE从查询到提交全程持锁短得多,并发能力明显上一个台阶,代码量还更少。缺点在高冲突场景下会暴露:当库存余量很少、抢购请求很多时,大部分UPDATE会失败,如果业务上要求失败后重试,重试风暴会反噬数据库。所以它适合库存量相对充足、冲突概率不高的中低并发场景。
2.3 Redis Lua:高并发库存扣减的平衡点
Redis是单线程执行命令的,Lua脚本在Redis里运行时,整个脚本天然原子,中间不会有其他命令插进来。这意味着“先检查剩余库存,够了再扣减”这个逻辑放进一个Lua脚本后,就等效于Redis层面的一个原子操作,既没有显式锁,也没有并发空窗,而且走内存操作,性能比数据库高两个数量级。单实例Redis扛每秒几万次扣减请求是很轻松的事。
三种方案的取舍,我用一张表总结:
| 方案 | 原子性实现 | 并发能力 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|
| 数据库悲观锁 | 行锁 | 低,行级串行 | 低 | 后台调库存、低频管理 |
| 数据库乐观锁 | 条件更新+影响行数 | 中,冲突多时需重试 | 低 | 库存充足、中低并发 |
| Redis Lua | Lua脚本原子执行 | 高,单实例数万QPS | 中 | 秒杀、抢购、高并发扣减 |
选择Redis方案不代表数据库就不管了。Redis是挡在流量入口的前置防线,MySQL是最终账本,两者之间必须靠异步落库和定时对账把账抹平。这个第五节专门展开。
3. Go落地实现:库存数据结构与Lua扣减核心代码
3.1 库存用Hash不用String,一个key管住三本账
很多教程喜欢用SET stock:sku:1001 100这种String结构存库存,配合DECR扣减。String方案的问题在于信息太单薄,一个SKU的库存状态至少应该包含“可售库存”和“锁定库存”两个维度:下单时预占一部分、支付时确认扣减、取消时释放回来。这些状态用String得拆好几个key,跨key的一致性维护起来非常痛苦。
我推荐用Hash,一个key管一个SKU:
code复制stock:sku:1001 -> { remaining: 100, locked: 0, sold: 0 }
- remaining:当前可售库存,扣减和预占都从这里减
- locked:被预占但还没支付的库存
- sold:累计已售,不一定实时维护,按需更新
Hash的好处是HSET、HGET、HINCRBY都是现成的原子命令,脚本里取值和自减都很方便,而且一个key内部各字段天然一致,不会出现多个key之间数值对不上的尴尬。
3.2 核心Lua脚本:判断和扣减一步到位
直接给出我验证过的扣减脚本:
lua复制-- KEYS[1]: 库存hash的key,格式 stock:sku:{skuID}
-- ARGV[1]: 本次要扣减的数量
local remaining = tonumber(redis.call('hget', KEYS[1], 'remaining'))
if not remaining then
return -1 -- 库存key不存在,调用方按初始化异常处理
end
if remaining < tonumber(ARGV[1]) then
return 0 -- 库存不足
end
redis.call('hincrby', KEYS[1], 'remaining', -tonumber(ARGV[1]))
return remaining - tonumber(ARGV[1]) -- 返回扣减后的剩余库存
脚本定义了三类返回值:-1是key不存在,0是库存不足,正数是扣减成功后的剩余量。把业务判断放进脚本,就是为了避免“先GET判断再DECR”这种两段式调用的竞态。Redis单条命令是原子的,但多条命令组合起来不是,这也是为什么不能用两个Redis命令拼出扣减逻辑。
Go这边用go-redis/v9封装:
go复制package stock
import (
"context"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
)
var ErrStockNotEnough = errors.New("剩余库存不足")
var ErrStockNotExists = errors.New("库存key不存在")
var deductScript = redis.NewScript(`
local remaining = tonumber(redis.call('hget', KEYS[1], 'remaining'))
if not remaining then
return -1
end
if remaining < tonumber(ARGV[1]) then
return 0
end
redis.call('hincrby', KEYS[1], 'remaining', -tonumber(ARGV[1]))
return remaining - tonumber(ARGV[1])
`)
// DeductStock 原子扣减库存,返回扣减后的剩余库存
func DeductStock(ctx context.Context, rdb *redis.Client, skuID string, num int64) (int64, error) {
if num <= 0 {
return 0, fmt.Errorf("扣减数量必须大于0")
}
key := fmt.Sprintf("stock:sku:%d", skuID)
res, err := deductScript.Run(ctx, rdb, []string{key}, num).Int64()
if err != nil {
return 0, fmt.Errorf("执行扣减脚本失败: %w", err)
}
switch res {
case -1:
return 0, ErrStockNotExists
case 0:
return 0, ErrStockNotEnough
default:
return res, nil
}
}
// InitStock 初始化库存,生产环境只在商品上架时调用一次
func InitStock(ctx context.Context, rdb *redis.Client, skuID string, total int64) error {
key := fmt.Sprintf("stock:sku:%d", skuID)
return rdb.HSet(ctx, key, "remaining", total, "locked", 0).Err()
}
3.3 初始化、批量购买、参数校验这些边角不能漏
几个容易忽略的细节,我在生产环境里都吃过亏:
第一,go-redis的redis.NewScript内部会做脚本缓存。第一次执行走EVAL,之后走EVALSHA,遇到NOSCRIPT错误会自动回退到EVAL。这个机制是现成的,不用自己再包一层SCRIPT LOAD,否则反而画蛇添足。
第二,批量购买(一次买多件)也走同一个脚本,把num传进去就行。脚本里的判断用的是remaining < tonumber(ARGV[1]),天然支持批量扣减,不需要为“买3件”单独写一套逻辑。
第三,参数校验一定要做。num <= 0这种非法入参必须拦在调用Redis之前,否则负数扣减等于加库存,这是审计事故级别的bug。另外初始化库存时如果key已经存在,要明确是覆盖还是报错。我的习惯是初始化走HSET,上架新商品时由商品服务创建,更新库存走另外的加锁接口,避免误覆盖。
第四,key的命名规范建议统一成stock:sku:{skuID}。后面如果要上Redis Cluster,这个key要写成带hash tag的形式,比如stock:sku:{1001},这样所有和该SKU相关的资源才能落到同一个slot,Lua脚本才能正常执行。这个坑在第四节细说。
4. 并发压测与五个绕不开的坑
4.1 1000个并发请求打一个SKU,实测结果
代码写完先写并发测试,这是验证防超卖最直接的手段:
go复制func TestConcurrentDeduct(t *testing.T) {
rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
ctx := context.Background()
rdb.FlushDB(ctx)
if err := InitStock(ctx, rdb, 1001, 100); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
var success, fail int64
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := DeductStock(ctx, rdb, 1001, 1)
if err == nil {
atomic.AddInt64(&success, 1)
} else {
atomic.AddInt64(&fail, 1)
}
}()
}
wg.Wait()
remain, _ := rdb.HGet(ctx, "stock:sku:1001", "remaining").Int64()
t.Logf("success=%d fail=%d final=%d", success, fail, remain)
if remain != 0 {
t.Fatalf("剩余库存 = %d, 期望 0", remain)
}
if success != 100 {
t.Fatalf("成功数 = %d, 期望 100", success)
}
}
我本地实际跑出来的结果是success=100、fail=900、final=0,全部符合预期。再用go test -race检查一遍,也没有数据竞争。这个测试在每次改动库存代码后都应该跑一遍,我建议把它塞进CI。
有个血的教训必须提醒:测试里千万别连生产Redis,FlushDB会把线上库存清掉。我身边真实发生过这种事,一次误操作,一周的库存数据全没了,最后靠数据库快照恢复,好几单已经发出去了,赔了一堆优惠券。
4.2 坑一:Lua脚本里直接写死key
Lua脚本里最容易犯的错误,是用字符串拼接把key直接写进脚本,而不是通过KEYS参数传进来:
lua复制-- 错误写法:key被写死在脚本里
local remaining = tonumber(redis.call('hget', 'stock:sku:' .. KEYS[1], 'remaining'))
这个问题的严重性在单机Redis上不明显,但一上Redis Cluster就翻车。Cluster模式要求一个脚本涉及的所有key必须落在同一个slot,而Redis是通过key的CRC16哈希决定slot的。key拿字符串拼进去,集群无法进行路由计算,脚本执行直接报跨slot错误。正确的做法是:所有key统一通过KEYS数组传入,脚本内部只使用KEYS[1]、KEYS[2]这样的引用。在单机模式下这个习惯也要养成,否则哪天迁移到集群,排查这种问题非常痛苦。
4.3 坑二:Redis扣减成功了,数据库没落上库
这是Redis方案被问得最多的问题:如果只扣Redis库存,Redis宕机重启后库存丢了怎么办?如果先扣Redis、再异步写数据库,异步任务失败导致两边数据不一致怎么办?
我的做法是分两个层面防。第一层,Redis开启AOF持久化,线上至少用appendfsync everysec,可以接受极端情况下丢一秒的数据,但绝不能容忍重启后库存凭空消失。更稳妥的配置是AOF和RDB同时开,具体参数根据你的业务容忍度调。第二层,建立补偿链路:扣减Redis成功后,把扣减事件写入MQ或者binlog消费管道,由消费者异步更新MySQL库存。如果消费者执行失败,MQ重试;重试还失败,进死信队列人工处理。再加上第五节的对账任务定期比对两边数据,把漏掉的账补回来。
这里有个架构上的认知要摆正:对于秒杀这类瞬时高并发场景,Redis才是真正承接扣减的“前线”,MySQL是最终记账的“后台”。你不可能让MySQL扛住秒杀峰值,但也不能让Redis数据成为无源之水。两者是协作关系,不是替代关系。
4.4 坑三:同一张订单被扣了两次库存
接口超时后客户端重试,或者用户在支付回调里重复点击“确认支付”,都可能让同一笔订单执行两次扣减。如果扣减逻辑不具备幂等性,库存会多扣,用户以为只买了一件,实际系统给他扣了两件的库存。
解决方案是在扣减脚本里加入幂等标记。给每个订单生成一个唯一的业务键,比如order:once:{orderId},扣减时先SETNX这个键:
lua复制-- KEYS[1]: 库存key
-- KEYS[2]: 订单去重键
-- ARGV[1]: 扣减数量
local existed = redis.call('setnx', KEYS[2], '1')
if existed == 0 then
return -2 -- 重复请求,直接拒绝
end
redis.call('expire', KEYS[2], 86400)
local remaining = tonumber(redis.call('hget', KEYS[1], 'remaining'))
if not remaining then
redis.call('del', KEYS[2])
return -1
end
if remaining < tonumber(ARGV[1]) then
redis.call('del', KEYS[2])
return 0
end
redis.call('hincrby', KEYS[1], 'remaining', -tonumber(ARGV[1]))
return remaining - tonumber(ARGV[1])
扣减成功后去重键保留24小时,这期间同一订单号再进来直接返回-2。脚本里要注意:如果库存不足导致扣减失败,要把去重键删掉,因为用户可能稍后重试同一笔订单。如果key不存在,同理也要删掉,否则这个订单号就被永久“拉黑”了。
4.5 坑四:Redis Cluster下的slot限制
前面提到了hash tag,这里展开说。Redis Cluster把数据分散在16384个slot里,每个key通过CRC16计算归属slot。Lua脚本执行时,要求脚本里所有的key都在同一个slot,否则Redis直接拒绝执行。解决方式就是给key加上hash tag:
code复制stock:sku:{1001}
花括号{}里的部分参与哈希计算,所以stock:sku:{1001}和order:sku:{1001}会落到同一个slot。在设计库存key和订单key时,把skuID或orderID放进hash tag里,可以保证相关的key在同一节点,脚本才能跨key操作。这个设计要在一开始就定好规则,否则后期改key格式涉及大量数据迁移和代码变更。
4.6 坑五:数据库库存字段类型没有兜底
最后一道防线是数据库字段。MySQL里库存字段必须用BIGINT UNSIGNED,不能让它存负数。这样就算前面所有逻辑都失守,UPDATE试图把库存扣到负数时,MySQL会直接报ERROR 1264 (22003): Out of range value,宁可失败也不产生负库存脏数据。
不过要注意,字段类型加上了,Go代码里Scan到这个值要能正确处理报错,不要panic。另外Redis侧没有天然的负数保护,所以Lua脚本里的“先判断再扣减”是唯一防线,如果脚本写错,HINCRBY照样能把剩余库存减成负数。因此脚本的单元测试同样要覆盖“库存刚好等于扣减数量”和“库存不足”两个边界。
5. 防超卖只是起点:预占、释放、对账和监控的完整闭环
5.1 下单和支付之间的库存预占
如果业务只有“下单立即扣库存、失败就不让下单”这一种模式,第四节的扣减脚本已经够用。但绝大多数电商还有“下单后30分钟未支付自动取消”的逻辑,这就要把“预占”和“扣减”拆开。
预占的意思是:创建订单时,把库存从可售变成锁定,但还没有真正消耗;支付成功后才正式确认;取消或超时则释放回可售。对应到Hash结构里,就是remaining减n、locked加n:
lua复制-- 预占库存脚本
-- KEYS[1]: 库存key
-- ARGV[1]: 预占数量
local remaining = tonumber(redis.call('hget', KEYS[1], 'remaining'))
if not remaining or remaining < tonumber(ARGV[1]) then
return 0
end
redis.call('hincrby', KEYS[1], 'remaining', -tonumber(ARGV[1]))
redis.call('hincrby', KEYS[1], 'locked', tonumber(ARGV[1]))
return remaining - tonumber(ARGV[1])
释放库存的脚本正好反过来,locked减n、remaining加n:
lua复制-- 释放库存脚本
-- KEYS[1]: 库存key
-- ARGV[1]: 释放数量
local locked = tonumber(redis.call('hget', KEYS[1], 'locked'))
if not locked or locked < tonumber(ARGV[1]) then
return 0
end
redis.call('hincrby', KEYS[1], 'remaining', tonumber(ARGV[1]))
redis.call('hincrby', KEYS[1], 'locked', -tonumber(ARGV[1]))
return 1
这样设计的好处是,前台可以实时看到“可售库存”和“被锁定库存”两个数字,运营也能准确判断哪些库存被占着但还没付款。这一层不复杂,但对后续的释放、对账很关键。
5.2 超时未支付与取消订单的库存释放
预占之后要解决的问题是:用户下单不付款,库存一直被锁着怎么办。常见的方案有三种:
第一,延迟队列。订单创建后扔一个延迟消息,比如30分钟,到点检查订单状态,如果还是未支付就触发释放。这个方案在订单量不大时很好用,实现也简单。
第二,定时任务扫描。每隔几分钟扫一次超时未支付的订单,批量释放。对于大促这种订单量暴涨的场景,延迟队列可能出现积压,定时任务反而更可控。我在项目里用的是每5分钟扫一次,把超过30分钟未支付的订单捞出来批量处理。注意释放操作本身要幂等,同一个订单被两个定时任务同时捞到,也只能释放一次,否则会重复加库存。
第三,用Redis的key过期事件。给每个预占订单设置TTL,过期后通过keyspace notification回调触发释放。这个方案依赖Redis的过期事件投递,消息可能丢失,生产环境不太建议作为唯一手段。
无论哪种方案,释放时都要校验订单状态,只能在“未支付”状态下释放,防止支付成功的同时库存被误释放。这一步在事务里做最稳妥。
5.3 定时对账、负库存告警和超卖补偿
即便有原子扣减、幂等、预占释放这么多层保护,生产环境还是可能出现Redis和数据库不一致的情况:消息队列丢消息了、脚本执行半路网络断了、有人手动改了库存等等。所以最后一道防线是定时对账。
我实现了一个对账任务,每隔一小时扫描一次所有SKU,把Redis里的remaining和MySQL里的stock做比对。对账逻辑很简单:
go复制func ReconcileStock(ctx context.Context, db *sql.DB, rdb *redis.Client, skuID string) error {
var dbStock int64
err := db.QueryRowContext(ctx,
"SELECT stock FROM inventory WHERE sku_id = ?", skuID).Scan(&dbStock)
if err != nil {
return err
}
redisRemain, err := rdb.HGet(ctx, fmt.Sprintf("stock:sku:%d", skuID), "remaining").Int64()
if err != nil {
return err
}
if redisRemain != dbStock {
// 记录差异,触发告警,必要时以MySQL为准回写Redis
log.Errorf("stock mismatch sku=%d redis=%d db=%d", skuID, redisRemain, dbStock)
}
return nil
}
对账发现差异后,要有一套明确的分歧解决规则。我的经验是:区分预期差异和非预期差异。比如大促期间有大量预占库存,Redis的remaining和MySQL的stock本来就可能因为异步落库延迟而暂时不一致,这类差异要统计但不告警;真正要告警的是负库存、或者差异超过阈值而且长时间不消除。告警通知到人之后,人工介入核对,而不是自动化地“以谁为准”,因为自动纠正可能掩盖底层bug。
防超卖的完整闭环里,监控指标我建议至少看三样:Redis剩余库存曲线、扣减失败率、对账差异数量。前两个是实时防线,第三个是事后防线。有一次大促复盘,我就是靠对账差异告警发现某个SKU的预占释放少执行了一轮,追回了一万多件库存,避免了第二天开门就超卖。
最后说点个人体会。库存系统没有“绝对防住超卖”的银弹,只有一层防不住就再加一层的纵深防御:Redis Lua原子扣减挡掉绝大多数并发冲击,幂等键挡住重复请求,预占和释放理顺生命周期,对账和告警兜住最后的漏网之鱼。每次大促前,我都会把这几层逐个演练一遍,重点确认Redis和DB的数字对得上才敢放量。这套设计现在还在线上跑着,希望这份复盘能帮你少走我走过的弯路。
