背景
上文我基于数据库乐观锁实现秒杀中的库存扣减并进行压测,得出的结论是UPDATE语句的行锁竞争是当前系统的绝对瓶颈。本文引入redis组件,将库存扣减这一动作放到redis,由redis的decr命令来保证原子性,并压测其性能。
环境搭建
要在springboot引入redis,添加如下依赖和配置即可。
依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
单机redis配置如下:
spring:
data:
redis:
host: 192.168.171.128
port: 6379
database: 0
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
在代码上,首先要进行库存预热。顺便提一下关于库存预热,可以在项目启动时预热,可以在定时任务中预热,可以手动请求预热接口预热,也可以发mq消息预热。本文的重点在于性能测试,采用项目启动时预热,实现
ApplicationRunner接口(也可以用CommandLineRunner),代码如下:
@Component
@Slf4j
public class InventoryWarmUpRunner implements ApplicationRunner {
@Autowired
private InventoryService inventoryService;
@Override
public void run(ApplicationArguments args) {
log.info("开始执行库存预热...");
try {
inventoryService.warmUpProductStock(1L);
} catch (Exception e) {
log.error("库存预热失败", e);
}
}
}
然后实现一个基于redis扣减的接口,对该接口进行压测:
@PostMapping("/redis")
public String seckillByRedis(@RequestParam Long productId) {
String stockKey = "seckill:stock:" + productId;
// 1. 原子扣减库存(核心)
Long currentStock = stringRedisTemplate.opsForValue().decrement(stockKey);
if (currentStock < 0) {
// 库存不足,需要回滚(把刚才减去的加回来)
stringRedisTemplate.opsForValue().increment(stockKey);
log.info("库存不足,商品ID: {}", productId);
return "库存不足";
}
// 3. 扣减成功,进入后续流程
log.info("秒杀成功,商品ID: {},剩余库存: {}", productId, currentStock);
//TODO: 这里暂时先同步创建订单,后续要改为异步
boolean orderCreated = createOrder(productId);
if (!orderCreated) {
// 订单创建失败,需要回滚库存(重要!)
stringRedisTemplate.opsForValue().increment(stockKey);
return "订单创建失败,请重试";
}
return "秒杀成功";
}
private boolean createOrder(Long productId) {
//这里同db方案一样不实际创建订单
return true;
}
压测记录:
第一次压测
采用200个线程同时请求,库存量充足和库存不充足压测数据如下。 库存充足:
库存不充足:
从结果可以看到qps在10000,average响应时间在20ms左右。库存不足时有一个判断再加回的动作,qps略低,符合预期。对比上次基于mysql的方案,可以看到性能提升了一个数量级。事实上本文使用的redis是在一个虚拟机上的redis容器,资源受限,生产上的单机redis性能比这高得多。可以得出结论,性能瓶颈从数据库行锁转移至网络I/O和Redis单点性能。
问题
注意以下代码片段:
Long currentStock = redisTemplate.opsForValue().decrement(stockKey);
if (currentStock < 0) { // 这行代码执行前,其他请求可能已经介入
redisTemplate.opsForValue().increment(stockKey); // 回滚
}
这里“扣减-判断-回滚”这个动作是非原子性的。虽然在目前这个极简模式下不会出现经典的“超卖”问题,但是在业务上看来,库存在某些时刻竟然小于0,同时“扣减-判断-回滚”发生2次网络往返。应将“判断-扣减”这个动作做成原子性的。Redis提供lua脚本的方案将这个动作原子执行,同时性能损耗很小。所以我对库存扣减做lua脚本改造。库存扣减的lua脚本如下:
-- 脚本:如果库存大于0,则扣减1;否则返回0
local stock = redis.call('get', KEYS[1])
if stock and tonumber(stock) > 0 then
return redis.call('decr', KEYS[1])
else
return 0
end
改造完成后我对该接口再做一次压测,结果如下:
可以看到性能和之前保持同一个数据量级。
总结和思考
本文没有实际创建订单,而是直接压测redis扣减库存这一动作的性能,可以看到相对于数据行锁方案,性能大幅提升,性能瓶颈从数据库行锁转移至网络I/O和Redis的单点性能。为了保证库存判断和扣减的原子性,使用了lua脚本。如果同步创建订单,那么性能瓶颈马上又回到数据库上,因为在串行化瓶颈下,系统吞吐量取决于最慢的操作单元。因此要保证高并发,订单必须异步创建,于是引入消息中间件做流量削峰与缓冲。另外,如果redis崩溃或重启,或者订单创建失败如何保证库存一致性呢?接下来将解决这些问题。