说个反直觉的事:大部分人遇到的并发问题,根本不需要分布式锁、不需要Redis、不需要消息队列。一条写对的SQL就够了。
但问题是,大部分人写不对这条SQL。
那条"看起来没问题"的代码
库存扣减,最常见的写法:
@Transactional
public void buyProduct(Long productId) {
Product product = productDao.selectById(productId);
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
productDao.update(product);
}
}
加了 @Transactional,先查后改,还判断了库存大于0。看起来滴水不漏。
但这段代码在并发下一定会超卖。
原因在于"先查后改"这个模式本身就有问题。A和B同时进来,都读到库存是1,都通过了 stock > 0 的判断,都去执行 stock - 1。结果库存变成了-1。
有人会说:不是加了事务吗?事务不是保证原子性吗?
这是对事务最大的误解。MySQL默认的REPEATABLE READ隔离级别,读的时候拿到的是快照(snapshot),不加锁。两个事务可以同时读到 stock=1,各自觉得可以扣。等到执行UPDATE的时候MySQL确实会加行锁,但问题是 if (stock > 0) 这个判断是在应用层基于快照数据做的,锁都没来得及介入,判断就已经通过了。
两个事务各自读到 stock=1,各自通过了应用层的判断,各自提交——每一步都在事务里,每一步都"正确",但组合起来就是个bug。这就是竞态条件(race condition),问题出在"基于快照做判断,基于当前值做更新"这个错位上。
最简单的正确写法
别先查再改,直接改:
UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0
一条SQL,没有中间状态。stock = stock - 1 是在数据库引擎内部完成的,MySQL会对这行加行锁,保证同一时刻只有一个UPDATE在执行。stock > 0 确保不会扣成负数。
执行完看 affected rows,返回1就是扣减成功,返回0就是库存不够。
就这么简单。不需要version字段,不需要 SELECT ... FOR UPDATE,不需要任何额外的锁机制。对于绝大多数业务系统——QPS几十到几百的那种——这一条SQL就是最优解。
很多人不信。觉得并发问题哪有这么简单,肯定要上点"高级"的东西才靠谱。
但你想想,MySQL的InnoDB引擎处理行锁已经二十多年了,这是它最基本的能力。你不需要在应用层重新发明一遍锁机制。
什么时候这条SQL不够用
当同一行数据的写入竞争非常激烈的时候。
比如一个爆款商品,每秒有几千个请求都在扣同一行库存。虽然MySQL能保证正确性,但所有请求都在排队等这一行的行锁,吞吐量上不去。数据库连接池被占满,连带着其他不相关的查询也变慢了。
这时候有两个方向。
一个是乐观锁。表里加个version字段:
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 123 AND version = 10 AND stock > 0
读的时候不加锁,谁都可以读。更新的时候靠version字段做冲突检测——只有version匹配的那个请求能成功,其他的拿到 affected rows = 0,业务层决定是重试还是放弃。好处是读不阻塞,坏处是冲突激烈时大量请求在空转重试,数据库CPU会飙上去。
另一个方向是把热点数据从数据库里搬出来。库存预加载到Redis,用 DECR 扣减:
DECR product:123:stock
Redis单线程执行命令,DECR 天然原子。返回值 >= 0 就是成功,< 0 就是没库存了。绝大部分请求在Redis这一层就被过滤掉了,只有扣减成功的请求才会去写数据库。
这也是各大电商秒杀系统的基本思路。阿里在2019年的一篇技术博客里提到过,双11的库存扣减全部在缓存层完成,数据库只做最终的异步持久化。
但代价是系统复杂度翻了好几倍:Redis和数据库的一致性怎么保证?Redis扣成功了数据库写失败怎么补偿?Redis宕机了库存数据怎么恢复?每一个问题都能再写一篇长文。
一个很多人不愿意承认的事实
大部分系统根本到不了需要Redis扣减库存的量级。
一个内部管理系统、一个小型电商、一个课程设计项目,并发量可能也就个位数。这种场景下搞分布式锁、搞Redis预扣减、搞消息队列异步补偿,就像开着坦克去菜市场买菜。
技术选型最难的不是"怎么解决问题",而是"克制住不去解决不存在的问题"。
Knuth那句"过早优化是万恶之源"都被说烂了,但很多人只记住了优化,没想过架构设计也一样。你为一个日活200的系统设计了能扛住10万QPS的库存方案,这不叫技术能力强,这叫浪费时间。
那条带 WHERE stock > 0 的UPDATE,够用就先用着。等哪天真的扛不住了,你自然会知道该往哪个方向演进——因为到那时候,监控和日志会告诉你瓶颈在哪。