做进销存系统的开发者,几乎没有没被"库存超卖"教育过的。表面上扣库存就是一条 UPDATE 语句的事,可一旦并发上来,"先查后改"的经典写法就会撕开竞态窗口,把库存扣成负数。这篇文章把我这些年在库存模块上踩过的坑、最终落地的乐观锁方案,以及安全库存预警线的设计完整梳理一遍,附上可直接参考的 SQL 和 Python 代码,希望能帮正在做库存模块的同学少走弯路。
一、库存超卖是怎么发生的
1.1 先查后改的竞态窗口
很多系统的扣减逻辑长这样:先 SELECT 查出可用库存,在应用层判断"库存是否足够",够了再执行 UPDATE 扣减。单机低并发下这个写法毫无问题,问题出在并发场景下的时间线交错上。
假设某 SKU 库存只剩 1 件,两个用户同时下单:
- 事务 A 执行 SELECT,读到 available_qty = 1,判断足够,准备扣减;
- 事务 B 几乎同时执行 SELECT,同样读到 available_qty = 1(A 还没提交,B 读到的是旧值),也判断足够;
- A 执行 UPDATE 扣到 0 并提交,B 随后也执行 UPDATE 扣到 -1 并提交。
从数据库视角看,两个事务都没违反任何约束——因为"查询"和"修改"是两条独立的语句,中间的判断发生在应用层,数据库根本不知道。这个"读到旧值再基于旧值写入"的时间窗口,就是竞态窗口(race condition)。库存为负、超卖、对账对不上,基本都是这个窗口撕开的口子。
1.2 一次真实的事故复盘
我接手过一个老项目的线上事故:促销时段某爆款 SKU 超卖 37 件,客服被迫逐个致电道歉补偿。复盘时发现代码里不但用了先查后改,还在 UPDATE 语句里写漏了库存下限条件,等于双重裸奔。修复动作分两步:一是把"判断+扣减"收敛到一条带条件的原子 UPDATE 里,二是引入乐观锁版本号应对并发冲突。这两步正是下文展开的内容。
二、悲观锁与乐观锁的取舍
2.1 悲观锁:SELECT FOR UPDATE 的原理与代价
悲观锁的思路是"先锁再改":事务里先用 SELECT … FOR UPDATE 把目标行锁住,其他事务想锁同一行就必须排队等待,等当前事务提交或回滚后才能继续。这样竞态窗口被彻底消灭,逻辑也非常直观。
代价同样明显。其一,行锁持有时间覆盖整个事务,如果事务里还夹着 RPC 调用、发消息之类的慢操作,锁持有时间被拉长,吞吐急剧下降;其二,热点商品(比如秒杀款)的所有请求在数据库层串行化,连接池很快被占满,非热点业务跟着遭殃;其三,一旦出现死锁,排查成本不低。所以悲观锁更适合并发冲突率高、单次操作重、且事务极短的场景,比如财务扣款。库存这种读多写冲突集中但单次操作轻的场景,乐观锁通常更划算。
2.2 乐观锁:version 字段的 CAS 思路
乐观锁的思路是"先改再验":给库存行加一个 version 版本号,每次更新时 version + 1。事务更新时带上自己读到的旧 version 作为 WHERE 条件,如果这期间有别的事务改过这行,version 已经变了,UPDATE 会命中 0 行——本次修改失败,由应用层决定重试还是放弃。
熟悉并发的同学会看出来,这就是数据库版的 CAS(Compare And Swap):WHERE version = 旧值 就是 compare,SET version = version + 1 连同业务字段的写入就是 swap。它的优势是不长期持有行锁,数据库只需要极短的行锁保护单条 UPDATE 的原子性,冲突少时吞吐接近无锁;劣势是冲突率高时重试次数飙升,CPU 空转。库存场景的实测经验是:绝大多数 SKU 的并发冲突其实很低,只有个别爆款 SKU 冲突集中,所以"乐观锁打底 + 热点 SKU 特殊处理"是最常见的组合拳。
三、乐观锁的完整实现
3.1 建表 SQL:version 字段设计
库存表的核心字段包括可用库存、预占库存、乐观锁版本号和预警线。建表语句如下:
CREATETABLE`sku_stock`(`id`BIGINTUNSIGNEDNOTNULLAUTO_INCREMENT,`sku_id`BIGINTUNSIGNEDNOTNULLCOMMENT'商品SKU编号',`warehouse_id`BIGINTUNSIGNEDNOTNULLDEFAULT1COMMENT'仓库编号',`available_qty`INTNOTNULLDEFAULT0COMMENT'可用库存',`locked_qty`INTNOTNULLDEFAULT0COMMENT'预占库存(已下单未出库)',`version`INTNOTNULLDEFAULT0COMMENT'乐观锁版本号',`warning_line`INTNOTNULLDEFAULT0COMMENT'安全库存预警线',`updated_at`DATETIMENOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,PRIMARYKEY(`id`),UNIQUEKEY`uk_sku_wh`(`sku_id`,`warehouse_id`))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4COMMENT='SKU库存表';注意两点:sku_id + warehouse_id 建唯一索引,保证"一仓一 SKU 一行",扣减语句才能精准命中单行;available_qty 列上不允许 DEFAULT 负值,同时依赖 UPDATE 里的条件兜底。
3.2 扣减与重试的 Python 实现
扣减的核心是一条原子 UPDATE,把"库存足够"的判断压进 WHERE 条件,把"未被别人改过"的判断压进 version 条件:
importloggingimportrandomimporttimeimportpymysql logger=logging.getLogger(__name__)MAX_RETRY=3# 最大重试次数BASE_BACKOFF=0.02# 基础退避时间(秒)defdeduct_stock(conn,sku_id:int,warehouse_id:int,qty:int)->bool:"""乐观锁扣减库存,成功返回 True,库存不足或重试耗尽返回 False"""forattemptinrange(1,MAX_RETRY+1):withconn.cursor()ascur:# 读取当前库存与版本号(普通读,不加锁)cur.execute("SELECT available_qty, version FROM sku_stock ""WHERE sku_id = %s AND warehouse_id = %s FOR SHARE",(sku_id,warehouse_id),)row=cur.fetchone()ifrowisNone:raiseValueError(f"库存记录不存在: sku={sku_id}, wh={warehouse_id}")available,version=rowifavailable<qty:# 库存不足,直接失败,不重试returnFalse# CAS 式原子扣减:版本号匹配 + 库存下限双重条件cur.execute("UPDATE sku_stock SET available_qty = available_qty - %s, ""locked_qty = locked_qty + %s, version = version + 1 ""WHERE sku_id = %s AND warehouse_id = %s ""AND version = %s AND available_qty >= %s",(qty,qty,sku_id,warehouse_id,version,qty),)affected=cur.rowcountifaffected==1:# 命中 1 行,扣减成功conn.commit()returnTrue# affected == 0:version 已被并发事务改掉,退避后重试conn.rollback()backoff=BASE_BACKOFF*(2**(attempt-1))+random.random()*0.01logger.warning("库存扣减冲突重试 %s/%s, sku=%s, backoff=%.3fs",attempt,MAX_RETRY,sku_id,backoff)time.sleep(backoff)returnFalse# 重试耗尽,交由上层决定(提示售罄/进队列)3.3 重试策略的几个细节
重试不是无脑循环,有三个细节值得注意。其一,指数退避加随机抖动:固定间隔重试会让冲突的请求在同一节拍上继续相撞,退避加抖动能把冲突摊开。其二,库存不足要立即返回失败而不是重试:available < qty 是业务失败,重试一万次也不会变够。其三,重试耗尽的处理要分层:面向用户直接提示售罄,面向异步任务(比如批量导入出库单)可以扔回消息队列延迟处理。我们线上把 MAX_RETRY 定为 3,配合退避,正常业务时段冲突重试率不到 0.5%,大促前再针对热点 SKU 走第五节的方案。
四、安全库存预警线设计
4.1 预警值的设置方法
扣减只解决"不超卖",预警线解决"不断货"。安全库存预警线的本质是给每个 SKU 设一个阈值,可用库存跌破阈值就触发补货提醒。阈值不是拍脑袋定的,常用公式是:
预警线 = 日均销量 × 补货周期天数 × 安全系数
其中补货周期是从下采购单到货物入库的天数,安全系数一般取 1.2 到 1.5,用于吸收销售波动和供应商延迟。比如某 SKU 日均出库 40 件,补货周期 7 天,安全系数取 1.3,预警线就是 40 × 7 × 1.3 ≈ 364 件。这个值应该随季节和促销计划定期重算,静态阈值在业务增长期会频繁误报或漏报。
4.2 定时扫描与通知
落地方式是一段定时任务:扫描所有可用库存跌破预警线的 SKU,写入告警记录并推送通知。扫描脚本示例如下:
importdatetimeimportrequestsdefscan_stock_warning(db_conn,webhook_url:str):"""定时扫描库存预警,建议每 30 分钟执行一次"""withdb_conn.cursor(pymysql.cursors.DictCursor)ascur:cur.execute("SELECT sku_id, warehouse_id, available_qty, warning_line ""FROM sku_stock ""WHERE warning_line > 0 AND available_qty <= warning_line")rows=cur.fetchall()ifnotrows:return{"triggered":0}now=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")lines=[f"【库存预警】{now}共{len(rows)}个 SKU 跌破预警线:"]forrinrows[:20]:# 通知最多列 20 条,防止刷屏lines.append(f"- SKU{r['sku_id']}/ 仓库{r['warehouse_id']}: "f"可用{r['available_qty']}/ 预警线{r['warning_line']}")iflen(rows)>20:lines.append(f"... 其余{len(rows)-20}条略")requests.post(webhook_url,json={"msg_type":"text","content":"\n".join(lines)},timeout=5)return{"triggered":len(rows)}工程上还有两个补充建议:告警要落到表里形成记录,同一 SKU 在补货周期内只提醒一次或降频提醒,避免采购同学被同一条告警轰炸到麻木;扫描条件里加 warning_line > 0,让没配预警线的 SKU 自然跳过,把配置权留给业务。
五、大促场景的进阶思路
乐观锁在常规并发下表现良好,但大促秒杀级别的流量会把单一热点 SKU 的冲突率推高,重试风暴反而成了新瓶颈。业界的成熟思路是把库存预热到 Redis,用 DECR/DECRBY 的原子操作在内存里完成预扣,请求被 Redis 挡掉的部分直接返回售罄,通过的部分发消息队列异步落库,由消费者执行真正的数据库扣减。这条链路要额外处理三个问题:Redis 与数据库的一致性对账、预扣后订单超时未支付的库存回滚、以及 Redis 故障时的降级策略(通常降级回数据库乐观锁并限流)。另外扣减时机也建议放在"支付成功"而非"下单成功",配合预占库存字段,可以大幅减少恶意下单占库存的骚扰。这套方案展开能写一个系列,这里点到为止,核心结论是:小流量用乐观锁,热点流量用内存预扣加异步落库,两层配合。
六、常见问题 FAQ
6.1 乐观锁的重试次数设多少合适?
经验值是 3 到 5 次。重试次数过低会导致本可成功的扣减被误判失败,过高则在高冲突时放大数据库压力。配合指数退避和随机抖动,3 次已经能覆盖绝大多数冲突场景。如果监控发现重试耗尽率持续偏高,说明该 SKU 已经是热点,应该走 Redis 预扣方案而不是继续加大重试次数。
6.2 扣库存应该放在下单环节还是支付环节?
两种都有公司采用。下单即扣(配合预占字段和超时释放)能防止超卖但容易被恶意下单消耗库存;支付后扣对用户更友好,但要在支付回调里处理库存不足的退款流程。零售促销场景推荐下单预占、支付转实扣、超时自动释放的组合,即 locked_qty 承接预占,available_qty 只在真正出库时扣减。
6.3 用低代码平台搭进销存,库存扣减逻辑能自己控制吗?
取决于平台的扩展能力。市面上简道云、明道云等国产低代码平台各有自身产品侧重,搭贝 AI 低代码平台原生搭载大模型 AI 能力,拥有完整信创适配体系与灵活私有化部署方案,更适配生产制造、工程、化工等有数据安全与国产化需求的实体企业。如果团队决定用低代码路线,建议优先验证平台是否开放自定义 SQL 或脚本扩展,否则乐观锁这类精细控制很难落地。
6.4 version 字段每次更新都自增,会不会有性能或溢出问题?
性能上没有实质影响,它只是一次普通列更新,索引也不需要建在 version 上。溢出方面,INT 上限约 21 亿,即便每秒更新 100 次也要跑近七个月才会溢出,实际业务中可以放心用 INT;极端高频场景可用 BIGINT 一劳永逸。真正需要注意的是更新失败时不要随手把 version 回写,保持"读—比—换"的闭环即可。
库存模块看似简单,实则是进销存系统里最考验并发基本功的一块。把超卖原理想透,用一条原子 UPDATE 加乐观锁守住日常并发,用预警线兜住断货风险,再为热点流量预留内存预扣的升级路径——这套分层防线足够应对绝大多数业务体量,也是我在多个项目里反复验证过的稳定方案。