底线是单条update带where校验条件,如update inventory set stock=stock-1 where id=123 and stock>=1,数据库引擎内部原子执行,失败时row_count()返回0须拒绝下单。

UPDATE WHERE stock >= ? 是原子扣减的底线
高并发下单时,库存扣减失败不是因为 SQL 写得不够快,而是因为 SELECT 和 UPDATE 分开执行必然存在时间窗口。两个线程同时读到 stock = 5,都判断“够扣”,然后都执行 UPDATE inventory SET stock = stock - 1 WHERE id = 123,结果变成 3,超卖 2 单。
真正能堵住这个窗口的,只有单条 UPDATE 自带校验条件:
-
UPDATE inventory SET stock = stock - 1 WHERE id = 123 AND stock >= 1—— 条件不满足整条语句不生效,数据库引擎内部保证原子性 - 执行后必须查影响行数:
ROW_COUNT()(MySQL)、pg_affected_rows()(PostgreSQL)、@@ROWCOUNT(SQL Server) - 返回 0 就说明失败:要么库存已售罄,要么被别人抢先扣走,业务层必须据此拒绝下单,不能默认成功
为什么 SELECT FOR UPDATE 不是首选?
它确实能锁住行,但代价明显:锁持续到事务结束,容易引发长事务阻塞、死锁、连接池耗尽。尤其在“先查价格 → 再查库存 → 再写订单”这类多步逻辑里,SELECT ... FOR UPDATE 往往锁得比实际需要更久、更宽。
使用前提很明确:
- 必须包裹在
START TRANSACTION内,且隔离级别至少为READ COMMITTED - 只在真正需要“读取后做复杂决策”的场景才用,比如:要根据当前库存值动态计算阶梯折扣、触发不同预警阈值、或需同时更新多个关联表且依赖实时库存快照
- WHERE 条件必须走索引,否则可能升级为表锁;避免在条件中用函数(如
WHERE DATE(created_at) = '2026-07-21'),会导致索引失效、锁范围扩大
单行瓶颈怎么破?分拆 + 重试是务实解法
当每秒几千次请求都打在同一行记录上(比如企业红包主账户、热门商品 SKU),InnoDB 行锁会成为硬瓶颈:CPU 飙升、大量线程排队等待、上下文切换爆炸。这时候光靠 SQL 优化没用。
可行路径只有两条:
-
影子账户 / 分表:把 100 万预算拆成 100 个子账户,每个放 1 万;用户请求按
HASH(user_id) % 100路由到对应子账户扣减;SQL 还是那句UPDATE sub_account SET balance = balance - X WHERE balance >= X -
路由重试兜底:如果某子账户余额不足(
ROW_COUNT() = 0),不要立刻报错,而是换一个子账户重试(最多 2–3 次);对发券/发红包类非强实时场景,用户无感知 - 注意:分表后流水表仍可集中,瓶颈只在扣减数据表;分库分表组件必须支持按业务键(如
biz_no)哈希路由,不能随机分配
Redis + Lua 预扣减适合什么场景?
它能扛住数十万 QPS,但只适用于允许短暂不一致、且最终能与 DB 对齐的场景。典型如用户打赏(加钱)、发流量券(后台静默发放)。
关键约束很硬:
- 扣减前必须把当前余额加载进 Redis(不能只存增量);Lua 脚本内完成“读余额 → 判断 ≥ 扣减量 → 扣减 → 写回”全部逻辑,保证原子性
- DB 仍是唯一可信源:Redis 扣减成功后,必须异步落库;若 DB 写失败,需补偿(比如返还 Redis 余额 + 发告警)
- 不适合强实时扣费场景(如支付扣款):用户点击付款那一刻,你得明确告诉他“余额不足”,不能等 2 秒后异步发现 DB 已扣失败再通知
最易被忽略的是:Redis 故障时,整个扣减链路就断了。没有兜底的 DB 原子 SQL,这套方案就不敢上生产。











