仓管员拿着 PDA 在仓库里扫码出入库,是很多中小企业的标准操作。但一到大促或盘点高峰,三五个仓管员同时扫同一个 SKU,账面库存经常变成负数、发生超卖,甚至同一笔出库被扣两次。这些问题几乎都出在「并发控制」上。去年双十一,某服装仓就因为「先查后改」的老写法,爆款卫衣被超卖 200 多件,客服电话被打爆,根因正是没处理并发。下面把最常见的 4 个坑和对应的正确做法拆开讲。
坑一:先查库存、再改库存,两步不是原子的
最典型的错误写法:先 SELECT stock FROM sku WHERE id=? 拿到库存,在代码里判断够不够,再 UPDATE sku SET stock = stock - ?。两个并发请求同时读到「还剩 10」,都判定通过,都减 5,结果库存变成 5,实际应该剩 0——超卖了 5。
正确的做法是用单条 SQL 把判断和扣减变成原子操作:
-- 扣减库存,只有剩余量足够才生效UPDATE skuSET stock = stock - :deltaWHERE id = :sku_id AND stock >= :delta;
// Node.js(mysql2),靠受影响行数判断成败const [res] =awaitconn.execute(`UPDATE sku SET stock = stock - ? WHERE id = ? AND stock >= ?`, [delta, skuId, delta]);if (res.affectedRows===0) {// 库存不足或行不存在,直接回滚业务,不要重试扣减thrownewError('库存不足');}
affectedRows === 0 就说明库存不够,没必要在应用层再做判断。这一步把「读—判断—写」压缩进数据库的单行更新,天然防超卖。
坑二:开了事务,却没加锁,更新照样丢
有人会说「我包了事务啊」。但在 MySQL 默认的 REPEATABLE READ 下,两个事务各自读到旧值、各自提交,后提交的会覆盖前一个的修改,发生丢失更新(lost update)。事务隔离并不等于互斥。
需要强一致时,用悲观锁把行锁住:
# Python(SQLAlchemy 风格),事务内加行锁withengine.begin()astx:row=tx.execute(text("SELECT stock FROM sku WHERE id=:id FOR UPDATE"),{"id":sku_id},).fetchone()ifrow.stock<delta:raiseValueError("库存不足")tx.execute(text("UPDATE sku SET stock = stock - :d WHERE id=:id"),{"d":delta,"id":sku_id},)
FOR UPDATE 会让并发的第二个事务阻塞等待,直到第一个提交,从而串行化对同一行的修改。顺便提醒,悲观锁要关注 innodb_lock_wait_timeout(默认 50 秒),高冲突下大量事务排队等待会拖垮连接池,必要时配合超时快速失败,而不是无限等待。如果冲突不频繁、追求高吞吐,可以改用乐观锁:表上加 version 字段,更新时带上版本号,版本不匹配就重试。
UPDATE sku SET stock = stock - :d, version=version+1WHERE id = :id ANDversion= :old_version;
坑三:扫码重复提交,库存被扣两次
PDA 在仓库信号差,扫码后没收到回执就会重发;仓管员也可能手滑连扫两下。结果是同一笔出库扣了两次库存。这不是并发问题,是幂等问题。
最稳的办法是业务唯一键:每一笔出入库明细用「业务单号 + 明细行 ID + 操作类型(in/out)」生成唯一标识,存到一张流水表,并对该标识加唯一索引。重复提交会直接撞唯一约束而失败。
CREATETABLE stock_flow ( id BIGINTPRIMARYKEY AUTO_INCREMENT, biz_key VARCHAR(64) NOTNULL, sku_id BIGINTNOTNULL, delta INTNOTNULL,UNIQUEKEY uk_biz (biz_key));-- 重复插入相同 biz_key 会抛 DuplicateKey 异常,应用捕获后视为「已处理」
如果一定要在缓存层先挡一道,可以用 SETNX 给 biz_key 加短期锁,但最终兜底必须落在数据库唯一索引上——缓存随时可能失效,只有数据库约束不会骗你。
坑四:上了多实例,单机锁全部失效
前面如果用的是 synchronized 或进程内锁,一旦服务部署成多副本(K8s、多台机器),进程内锁就管不到别的实例,坑一和坑二的保护瞬间归零。
省心的解法:把库存扣减收敛到数据库单行 UPDATE(坑一那种原子更新)。数据库本身就是分布式环境下唯一的互斥点,不依赖应用实例数量。这一点对微信小程序尤其重要:不少团队把出入库逻辑写在云函数或云开发上,平台会按流量自动扩缩容成多实例,进程内锁从上线第一天起就不可靠,所以更该把扣减收敛到 DB。如果业务复杂、必须先在应用层协调,再上 Redis 分布式锁,但同样要保留数据库唯一约束或原子更新作为兜底,别把正确性押在锁一定不超时上。
四种方案怎么选
上线前自查清单
[ ] 扣减用单条 UPDATE ... WHERE stock >= ?,靠 affectedRows 判成败
[ ] 需要复杂校验时加 FOR UPDATE 或用 version 乐观锁
[ ] 每笔流水有业务唯一键 + 数据库唯一索引,保证幂等
[ ] 多实例部署下不依赖进程内锁,扣减收敛到 DB
[ ] 缓存去重只做第一道,最终正确性由数据库约束保证
并发控制没有银弹,但把这 4 个坑堵上,PDA 扫码出入库的库存就能稳得住。
阅读原文:点击这里
该文章在 2026/10/31 18:36:07 编辑过