在软件开发的职业生涯中,几乎每位开发者都会遇到那个让你彻夜难眠的“至暗时刻”。它可能是一个偶发的线上崩溃,一个难以复现的数据不一致,或者是一个在高并发下瞬间崩塌的性能瓶颈。今天,我们不谈那些轻描淡写的“小bug”,而是复盘一个真实且极具挑战性的技术难题:在电商大促场景下,如何解决高并发导致的“库存超卖”问题。这不仅是一次代码的修补,更是一场对系统架构、事务机制和并发控制的深度考验。
问题的爆发:看似简单的扣减逻辑
故事发生在一个中型电商平台的“双11”预热活动中。我们的任务是为一款热门限量商品(库存仅500件)设计秒杀功能。初期的代码逻辑非常直观,也是大多数初学者会写的模式:
@Transactional
public void buyProduct(Long productId, Integer quantity) {
// 1. 查询库存
Product product = productMapper.selectById(productId);
if (product.getStock() < quantity) {
throw new BusinessException("库存不足");
}
// 2. 扣减库存
product.setStock(product.getStock() - quantity);
productMapper.updateById(product);
// 3. 创建订单
orderService.createOrder(productId, quantity);
}
在测试环境,单线程或少量并发下,这段代码运行完美。然而,当压测工具模拟每秒2000个请求(QPS)涌入时,灾难发生了。数据库监控显示CPU飙升至90%,大量锁等待超时,而最可怕的是,最终售出的商品数量达到了520件——超卖了20件。
对于用户而言,超卖意味着付了钱却发不出货,这是严重的生产事故。对于开发团队,这意味着必须立即止血,并找到根因。
根因分析:并发竞争与事务隔离的博弈
面对超卖,我们首先排除了业务逻辑错误,确认代码流程无误。随后,我们将目光锁定在“并发”二字上。
1. 竞态条件(Race Condition)
在多线程环境下,上述代码的“查询-判断-更新”三个步骤并非原子操作。
- 线程A读取库存为10。
- 线程B同时也读取库存为10。
- 线程A判断10>1,执行扣减,库存变为9。
- 线程B判断10>1,执行扣减,库存变为9(实际上应该是8,或者如果只剩1个,B不该成功)。
这就是典型的“读-改-写”竞态条件。虽然加了@Transactional注解,但默认的事务隔离级别(Read Committed)无法阻止这种逻辑层面的覆盖。
2. 数据库锁的局限
有人可能会问:“数据库不是有行锁吗?”确实,UPDATE语句会加行锁。但在我们的代码中,SELECT是普通的查询,不加锁。当两个线程都执行完SELECT后,才去争抢UPDATE的行锁。此时,两个线程都认为库存充足,从而都通过了判断。等到执行UPDATE时,虽然串行化了,但基于的是过期的旧数据。
3. 性能瓶颈
如果我们简单粗暴地在SELECT时加上FOR UPDATE(悲观锁),虽然能解决超卖,但在高并发下,所有请求都会排队等待锁释放。数据库连接池迅速耗尽,接口响应时间从几十毫秒飙升到几秒甚至超时,导致整个系统雪崩。
解决方案的演进:从数据库到缓存的突围
找到病根后,我们制定了三步走的优化方案,层层递进,最终实现了既防超卖又抗高并发的目标。
方案一:数据库乐观锁(Optimistic Locking)
这是成本最低的改造。我们在数据库表中增加一个版本号字段version,或者直接在UPDATE语句中利用库存值作为条件。
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = #{id} AND stock >= 1;
原理:利用数据库行锁的特性,确保stock >= 1这个条件在执行更新瞬间依然成立。如果库存已被其他线程扣减,条件不满足,更新行数为0,程序捕获后抛出库存不足异常。
效果:解决了超卖问题,代码改动小。
缺陷:在高并发下,大量请求会因为更新失败而重试,导致数据库压力依然很大,吞吐量提升有限。实测QPS只能勉强支撑500左右,离目标2000还有差距。
方案二:引入Redis预扣减(Cache-First Strategy)
为了将流量挡在数据库之外,我们将库存热点数据前置到Redis中。Redis是单线程模型,其原子操作天然适合做计数器。
流程:
- 活动开始前,将库存500预热到Redis Key中(如
stock:product:1001)。 - 用户请求进来,先执行Redis的
DECR命令。 - 如果返回值>=0,说明扣减成功,进入后续创建订单流程(异步写入数据库)。
- 如果返回值<0,说明库存已空,直接返回失败,不再访问数据库。
// 伪代码
Long stock = redisTemplate.execute(new RedisCallback<Long>() {
@Override
public Long doInRedis(RedisConnection connection) {
return connection.decr(key.getBytes());
}
});
if (stock < 0) {
// 恢复库存(因为decr已经减了,需要补回)或直接报错
redisTemplate.opsForValue().increment(key);
throw new BusinessException("库存已售罄");
}
// 发送消息到MQ,异步创建订单
mqProducer.sendOrderMessage(productId, userId);
效果:QPS瞬间提升至3000+,数据库压力几乎为零。Redis的原子性彻底杜绝了超卖。
挑战:引入了新的复杂性——数据一致性。如果Redis扣减成功,但后续创建订单失败(如MQ宕机、服务异常),会导致Redis库存少了,但实际没生成订单(少卖)。
方案三:最终一致性与补偿机制
为了解决“少卖”问题,我们引入了消息队列(RocketMQ)的事务消息机制和定时补偿任务。
- 本地消息表:在扣减Redis成功后,先在本地数据库记录一条“待发送”的消息记录,状态为
TO_SEND。 - 事务提交:本地事务提交后,由后台线程轮询本地消息表,将消息发送至MQ。
- 消费者处理:订单服务消费消息,创建订单,扣减数据库真实库存(此时数据库仅作持久化,不抗并发)。
- 异常补偿:如果消费者处理失败,消息会重试;若多次重试仍失败,进入死信队列,由人工或定时任务进行回滚(将Redis库存加回)。
此外,我们还设计了“库存对账”脚本,每分钟比对Redis库存、数据库库存和已销售订单数,一旦发现不一致,自动触发报警并尝试修复。
实施后的反思与收获
经过这一轮改造,系统平稳度过了大促峰值,零超卖,零少卖,接口平均响应时间控制在50ms以内。回顾整个过程,有几个关键点值得铭记:
1. 不要过早优化,也不要忽视瓶颈
起初我们以为数据库能扛住,直到压测打脸。性能问题往往隐藏在看似正常的逻辑中,只有通过真实的压力测试才能暴露。
2. 空间换时间,缓存是利器
在高并发场景下,数据库往往是最后的防线,而不是第一道关卡。利用Redis等内存数据库抗流量,是互联网架构的标配。但必须警惕缓存带来的数据一致性挑战。
3. 异步解耦是系统稳定的基石
将同步的“扣库存-创订单”拆分为“Redis预扣减 + MQ异步下单”,不仅提升了吞吐量,还实现了模块间的解耦。即使订单服务暂时不可用,秒杀入口依然可以正常接收请求,待服务恢复后慢慢消费。
4. 兜底思维至关重要
无论架构设计多完美,都要假设它会出错。补偿机制、对账脚本、降级预案,这些“非功能性”的代码,往往是系统在危机时刻的救命稻草。
结语
技术挑战从来不是为了难倒开发者,而是为了推动架构的演进。那次库存超卖的危机,让我们团队从单纯的“功能实现者”转变为“系统设计者”。我们学会了在并发中寻找平衡,在一致性中取舍效率,在故障中构建韧性。
如果你也在开发中遇到了类似的棘手问题,不妨停下来,画一画时序图,压一压测试环境,也许答案就藏在那些被忽略的细节里。毕竟,每一个复杂的Bug背后,都藏着一个让系统变得更强大的机会。