本质是通过updlock+holdlock将「读+改」变为数据库原子操作:select加更新锁并持至事务结束,阻塞其他并发读,确保扣减不超卖。

用 SqlTransaction + SELECT ... WITH (UPDLOCK, HOLDLOCK) 实现原子扣减
并发扣库存出超卖,本质是多个线程同时读到同一库存值、各自减1再写回。关键不是“加锁”,而是让「读+改」变成数据库层面的原子操作。SQL Server 支持在查询时直接申请更新锁并持有到事务结束:SELECT stock FROM products WHERE id = @id WITH (UPDLOCK, HOLDLOCK)。这行语句会阻塞其他同样带 UPDLOCK 的查询,直到当前事务提交或回滚。
实操建议:
- 必须在
SqlTransaction内执行该查询,HOLDLOCK才能延续到事务末尾;单独执行只锁住当前语句范围 - 后续的
UPDATE不需要再加锁提示——因为行已被UPDLOCK占住,且 SQL Server 默认开启行级锁 - 不要用
NOLOCK或不加任何提示的SELECT,否则锁无效,照样超卖 - 示例片段:
using var tran = conn.BeginTransaction(); cmd.Transaction = tran; cmd.CommandText = "SELECT stock FROM products WHERE id = @id WITH (UPDLOCK, HOLDLOCK)"; cmd.Parameters.AddWithValue("@id", productId); int currentStock = (int)cmd.ExecuteScalar(); if (currentStock
为什么不用 sp_getapplock 做应用层分布式锁
sp_getapplock 是 SQL Server 提供的应用锁机制,看起来很适合做“库存ID级”互斥,但它有硬伤:锁名长度限制 255 字符、不支持跨数据库、锁超时不可靠(依赖客户端心跳),更致命的是——它和数据行锁不同步。比如你用 sp_getapplock 锁住了 product_123,但另一个事务绕过锁、直接执行 UPDATE products SET stock = ... WHERE id = 123,照样能改成功。
所以它只适合协调“非数据修改类”的并发行为(如预热缓存、触发异步任务),不适合库存这类强一致性场景。
常见错误现象:
- 加了
sp_getapplock还是超卖 → 因为没约束所有写路径,漏掉某个接口或后台 Job 直接 UPDATE - 锁突然释放、日志报
AppLock timeout→ 客户端连接中断或未显式调用sp_releaseapplock - 高并发下锁争抢严重,TPS 断崖下跌 → 应用锁是串行化瓶颈,不如行锁天然支持并行(不同商品 ID 的行锁互不干扰)
分布式环境必须用 TRY...CATCH + XACT_ABORT ON 防事务残留
微服务或多实例部署下,网络抖动、超时、进程崩溃都可能导致连接中断,而 SQL Server 默认不会自动回滚未完成事务。残留的未提交事务会一直占着 UPDLOCK 行锁,导致后续请求无限等待,最终拖垮整个库存服务。
必须在连接字符串里加 Connection Timeout=30;...,并在 SQL 批处理开头强制启用事务中止策略:
SET XACT_ABORT ON; BEGIN TRY BEGIN TRANSACTION; SELECT @stock = stock FROM products WHERE id = @id WITH (UPDLOCK, HOLDLOCK); IF @stock 0 ROLLBACK TRANSACTION; THROW; END CATCH
要点:
-
XACT_ABORT ON确保任意运行时错误(如主键冲突、类型转换失败)都会自动回滚整个事务,不依赖CATCH块手动判断 - 客户端代码也要设置合理的 CommandTimeout(建议 ≤ 10 秒),避免一个慢查询卡死整个连接池
- 别信“只要 catch 住异常就安全”——没
XACT_ABORT时,某些错误(如客户端取消)不会触发CATCH,事务仍挂着
Redis 分布式锁只适合兜底,不能替代数据库行锁
如果业务已上 Redis,有人会想用 SET key value EX 10 NX 先锁再查库。这看似分布式,实际引入新问题:锁和数据库操作不在同一事务内,存在“锁成功但 DB 更新失败”或“DB 更新成功但锁释放失败”的状态不一致。
它唯一合理用途是:防止极端情况下的重复提交(比如用户狂点下单按钮),作为前置过滤器,而不是库存扣减的核心逻辑。
使用前提:
- 锁 Key 必须包含业务唯一标识,如
stock_lock:product_123,不能只用stock_lock全局一把锁 - 必须用 Lua 脚本保证
GET + DEL原子性释放锁,否则解锁时可能删掉别人持有的锁 - 绝对不要在 Redis 锁内执行耗时操作(如远程 HTTP 调用),超时会导致锁误释放
- 如果 DB 扣减失败,Redis 锁必须立即释放;如果 DB 成功但后续步骤失败,锁是否保留取决于业务语义——这点没有银弹,得按场景设计
真正难的不是怎么加锁,是怎么界定“锁的粒度”和“事务边界”。一个订单扣多个商品库存,要锁多行;库存扣完还要生成订单记录,这两步必须包在一个事务里——否则扣了库存却写失败,就真成负库存了。











