秒杀活动作为电商平台的重要营销手段,对库存管理的精确性提出了极高要求。防止超卖,即确保商品在秒杀过程中库存不会被过度消耗,是秒杀功能实现的关键。本文将探讨几种防止超卖的经典方案。
1.悲观锁机制
悲观锁机制通过锁定数据库中的某行数据,确保在高并发情况下只有一个用户可以修改库存。在用户请求秒杀时,数据库会锁定库存行,直到操作完成后才释放锁。
优缺点
- 优点:强一致性保障,确保在高并发下不会出现超卖问题。
- 缺点:锁的开销较大,容易导致数据库性能瓶颈。在高并发场景下,过多的悲观锁可能导致锁等待和死锁问题。
使用场景适用于对一致性要求极高,但并发量相对较小的场景。
Demo
@Mapper
public interface ProductMapper {
@Select("SELECT stock FROM products WHERE product_id = #{productId} FOR UPDATE")
Integer selectStockForUpdate(@Param("productId") int productId);
@Update("UPDATE products SET stock = stock - 1 WHERE product_id = #{productId}")
int updateStock(@Param("productId") int productId);
}
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Transactional
public boolean seckill(int productId) {
Integer stock = productMapper.selectStockForUpdate(productId);
if (stock != null && stock > 0) {
productMapper.updateStock(productId);
return true; // 秒杀成功
} else {
return false; // 库存不足
}
}
}
2.乐观锁机制
乐观锁通常通过“版本号”机制来实现。在库存表中增加一个version字段,每次更新库存时,检查version是否与上次读取的一致,如果一致则更新库存和version,如果不一致则说明库存已经被其他用户修改过,需要重新尝试。
优缺点
- 优点:无需锁表,对数据库性能影响较小,适合中小规模并发。
- 缺点:并发过高时可能导致更新失败频繁,用户体验下降。在高并发场景下,乐观锁可能导致大量重试,增加系统负担。
使用场景适合于高并发但冲突不频繁的场景。
Demo
// 假设有一个Product实体类,包含stock和version字段
// 在Service层进行库存更新操作
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Transactional
public boolean updateStock(int productId, int version) {
Product product = productMapper.selectByPrimaryKey(productId);
if (product.getVersion() != version) {
return false; // 版本不匹配,更新失败
}
product.setStock(product.getStock() - 1);
product.setVersion(product.getVersion() + 1);
productMapper.updateByPrimaryKey(product);
return true; // 更新成功
}
}
3.分布式锁
分布式锁可以确保在多台服务器上并发处理库存时不会导致超卖。常用Redis来实现分布式锁。当用户请求秒杀时,先尝试通过Redis获得锁,如果获得锁则执行扣减库存操作,并释放锁;如果未获得锁则等待或重试。
优缺点
- 优点:适合大规模并发场景,锁机制能够确保多台服务器在并发情况下安全修改库存。
- 缺点:如果Redis出现故障,可能会影响锁的管理和库存的正确性。锁的粒度要控制好,锁的过大可能影响性能。在高并发场景下,分布式锁可能导致网络延迟和锁竞争问题。
使用场景适用于高并发场景,特别是多台服务器分布式部署时,对一致性要求较高。
Demo
@Service
public class SeckillService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public boolean seckill(int productId) {
String lockKey = "lock:product:" + productId;
boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 5, TimeUnit.SECONDS);
if (lock) {
try {
String stockKey = "stock:" + productId;
Integer stock = (Integer) redisTemplate.opsForValue().get(stockKey);
if (stock != null && stock > 0) {
redisTemplate.opsForValue().decrement(stockKey);
return true; // 秒杀成功
} else {
return false; // 库存不足
}
} finally {
redisTemplate.delete(lockKey); // 释放锁
}
} else {
return false; // 秒杀失败,未获得锁
}
}
}
4.库存预减+异步处理
在用户请求秒杀时,使用缓存进行库存预减(即在用户下单前就先减少库存),然后通过异步队列(如Kafka或RabbitMQ)将订单请求发往后端进行异步处理。商品秒杀开始前,将商品库存预先加载到Redis。当用户请求秒杀时,先从Redis中预减库存,将请求通过消息队列发送到后端进行订单处理和库存的最终确认。如果订单处理失败(如支付失败等),则通过异步任务将Redis中的库存回补。
优缺点
- 优点:使用缓存大幅减少数据库压力,适合大规模并发场景。削峰填谷,利用消息队列将订单处理异步化,缓解高并发对数据库的冲击。
- 缺点:需要处理订单失败后的库存回补,增加了系统复杂性。在极端情况下可能出现Redis库存与数据库库存不一致的问题,需要通过补偿机制来解决。同时,对缓存的可靠性和一致性要求较高。
使用场景适用于高并发场景,特别是需要减轻数据库压力时,对性能要求较高,但对一致性要求可以容忍一定延迟的场景。
Demo(仅展示库存预减部分,异步处理部分需结合消息队列实现):
@Service
public class SeckillService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public boolean preDecreaseStock(int productId) {
String stockKey = "stock:" + productId;
Integer stock = (Integer) redisTemplate.opsForValue().get(stockKey);
if (stock != null && stock > 0) {
redisTemplate.opsForValue().decrement(stockKey);
return true; // 库存预减成功
} else {
return false; // 库存不足
}
}
}
5.小结
以上四种方案各有优劣,选择合适的方法取决于业务需求和技术栈。在实际应用中,可以根据并发量、系统性能要求、一致性需求等因素综合考虑选择合适的方案。