这是一个非常硬核的场景设计题,通常出现在高并发电商库存扣减(如秒杀、热点商品)的面试中。
背景是:为了解决单行记录(Single Row)更新时的数据库锁竞争问题,我们采用了**分段库存(Inventory Sharding)**方案,把 Total: 100 拆成了 Sub1: 20, Sub2: 20... 等多个“子库存行”。
问题核心: 当用户下单购买的数量(N=2)超过了任意单个子库存行的剩余数量(比如每个行只剩1个),必须跨行扣减。这时候面临两大挑战:
- 原子性(Atomicity): 要么两行都扣成功,要么都不扣。不能出现扣了 A 行,B 行失败的情况。
- 死锁(Deadlock): 并发场景下,多线程同时锁多行,极易产生死锁。
以下是具体的解决方案,按推荐程度从高到低排列:
在极高并发下,库存通常不在 MySQL 直接扣,而是在 Redis 扣。
核心思路: Redis 的 Lua 脚本执行具有原子性(脚本执行期间不会插入其他命令)。我们可以把“寻找足够库存的 bucket”和“扣减”逻辑都写在 Lua 里。
执行流程:
- 传入参数: 商品ID,购买数量(2个)。
- Lua 内部逻辑:
- 遍历该商品所有的库存分桶 Key(例如
item:1001:stock:0,item:1001:stock:1...)。 - 贪心策略: 优先找一个剩余库存 >= 2 的桶。如果有,直接扣减,返回成功。
- 跨桶策略: 如果单桶不够,开始凑单。
- 记录需要扣减的桶和对应的数量(如:Bucket A 扣 1 个,Bucket B 扣 1 个)。
- 检查这些桶的总库存是否满足需求。
- 如果满足,批量执行扣减(在内存中操作,极快)。
- 如果总数不够,返回库存不足。
- 遍历该商品所有的库存分桶 Key(例如
优点: * 无死锁: Redis 是单线程执行 Lua,不会出现线程A锁了Key1等Key2,线程B锁了Key2等Key1的情况。
- 高性能: 纯内存操作。
如果你必须在 MySQL 层面解决,或者作为 Redis 的强一致性兜底,必须遵循**“锁排序”**原则来避免死锁。
场景复现(死锁陷阱):
- 线程 A 需要扣减 Bucket 1 和 Bucket 2。
- 线程 B 需要扣减 Bucket 2 和 Bucket 1。
- 如果 A 锁了 1,B 锁了 2,两者互相等待,造成死锁。
解决方案(按照 ID 排序加锁):
不管业务逻辑如何凑单,在执行 UPDATE 语句之前,必须将涉及到的库存行 ID 进行从小到大排序。
执行步骤:
- 计算分配: 在应用层(Java/Go代码中)先查出所有库存行,在内存中计算好需要扣减哪几行。
- 结果:
Target = { Row_ID_5: 扣1个, Row_ID_2: 扣1个 }
- 结果:
- 排序资源: 将结果按 ID 排序 ->
[{ID:2, count:1}, {ID:5, count:1}]。 - 开启事务:
START TRANSACTION; -- 1. 先锁 ID 小的 UPDATE stock_table SET num = num - 1 WHERE id = 2 AND num >= 1; -- 检查上一条 update 的 affected_rows,如果为0,回滚并报错(乐观锁失败) -- 2. 再锁 ID 大的 UPDATE stock_table SET num = num - 1 WHERE id = 5 AND num >= 1; -- 检查 affected_rows COMMIT;
优点: 强一致性,严格遵循 ACID。 缺点: 性能较差,锁竞争依然存在,只是避免了死锁。
这是对上述方案的补充优化。在进行拆行扣减之前,算法应该尽量避免跨行。
算法逻辑:
- Try Best Fit: 先遍历所有分桶,看有没有哪个桶
stock >= 2。如果有,锁定该单行扣减(性能最高)。 - Fallback to Multi-Row: 如果所有桶都不够 2 个,再执行上述的“跨桶扣减”逻辑。
你可以这样回答面试官:
"针对这个问题,核心难点在于保证原子性的同时避免死锁。
第一,在分配逻辑上,我会采用‘贪心算法’。应用层先判断是否存在单行库存充足的情况,优先进行单行扣减,因为单行事务性能最高。
第二,如果必须跨行扣减(比如各剩1个),为了避免死锁,必须遵循**‘资源有序分配’**原则。
- 如果是在 MySQL 中做,我会开启一个事务,在代码里计算好要扣减的哪几行 ID,并强制将这些 ID 从小到大排序。然后依次执行 Update 语句。这样所有线程加锁顺序一致(都是先锁小ID,再锁大ID),彻底解决死锁问题。
- 如果是在 Redis 中做(通常也是高并发的最佳实践),我会使用 Lua 脚本。在脚本内部遍历分桶 Key,凑够数量后一次性扣除。因为 Redis Lua 脚本的执行是原子的且单线程的,天然不存在死锁和部分失败的问题。"