优先使用 UPDATE ... WHERE 带条件校验,因其是原子操作,可避免超卖;仅当业务逻辑无法单条 SQL 实现时才加锁,且需规范使用事务、锁名和超时设置。

先说一个核心判断:在绝大多数并发场景下,最轻量、最可靠、也最容易被忽略的解法,其实就是 UPDATE ... WHERE 带上条件校验。不是先查再更新,而是直接让数据库替你判断——这条简单原则,真正做到的团队并不多。
为什么 UPDATE ... WHERE 比 SELECT + UPDATE 更安全
说到这个,很多团队第一反应就是“先查库存,再扣减”,对吧?逻辑上听起来似乎没问题——但别忘了,多线程场景下两个线程几乎同时执行时,都会看到 stock = 10,都判断“够扣”,然后双双执行 UPDATE。结果 stock 变成 8,超卖 2 单。这个时间窗口靠 Sleep 或重试根本堵不住。
UPDATE products SET stock = stock - 1 WHERE id = 1001 AND stock >= 1,这是一条原子操作:条件不满足,整条语句就不生效,根本不会进入“扣减逻辑”。执行完只需检查影响行数:MySQL 用 ROW_COUNT(),PostgreSQL 用 pg_affected_rows(),SQL Server 用 @@ROWCOUNT。
返回 0?说明失败,要么已被别人抢走,要么已经售罄。整个过程不需要锁、不依赖隔离级别、也不需要额外字段。干净利落。
什么时候必须加锁?怎么加才不卡死
当然,有些场景单条 UPDATE 搞不定。比如要跨多张表校验,或者调外部接口后再决定是否扣减,这才轮到锁出场。但锁这个东西,极易被滥用。
- SQL Server 的
sp_getapplock必须包在BEGIN TRANSACTION里面,事务结束前不能退出存储过程,否则锁会残留。 - 锁名一定要带业务上下文,比如
'order_pay_' + CAST(@order_id AS VARCHAR(20))。如果硬编码一个'mylock',所有请求都会被串行化,那还不如不用。 @LockTimeout必须设具体毫秒值,比如 5000。别用默认的 -1,那相当于无限等待。- 返回值必须检查:
IF @result < 0就表示失败(-1=超时,-2=死锁牺牲品)。别只盯着@@ERROR,那个远远不够。
MySQL 怎么模拟应用级锁
MySQL 的情况比较特殊,它没有 sp_getapplock 这种事务安全的应用锁。GET_LOCK() 是唯一跨会话方案,但它本身不是事务安全的。
- 锁名必须唯一稳定,推荐格式
'db_shop_voucher_use_' + CAST(@voucher_id AS CHAR)。纯数字或固定字符串太容易冲突。 RELEASE_LOCK()必须出现在两个地方:正常流程末尾,以及EXIT HANDLER异常处理器里。否则一次崩溃就可能让锁永远挂住。IS_USED_LOCK()只适合用来诊断,不要拿来轮询等待——它自己也会加锁,高并发下反而成瓶颈。
视图、临时表、自增 ID 在并发下特别容易翻车
这些数据结构看起来“只读就安全”,其实远没那么简单。
- 视图查询会真实访问基表。如果视图里包含无索引的
JOIN或WHERE字段,容易触发大量 S 锁,和正在UPDATE的事务互相等待,直接死锁。 - 多线程用
SELECT MAX(oid)算新 ID 插入,必然错乱——A 和 B 线程同时读到 oid=100,都插 101,结果就是主键冲突或数据错位。 - 正确的做法是建一张
max_oid表,用UPDATE max_oid SET current_val = current_val + N原子获取一批连续 ID,再插入。
真正难的不是写锁,而是判断“哪一步真需要锁”。90% 的并发问题,一条带条件的 UPDATE 就能解决,剩下 10% 才轮到锁和事务设计。但多数人一上来就加锁,反而把系统拖慢。
