一、优化概述
对于秒杀系统的优化方式有很多很多,从这篇博文开始我们一步一步的对系统进行性能的优化,同时记录每一步优化的主要思路和相关的代码实现原理,并在每次优化后使用 jmeter 对系统进行压测。这篇博文主要使用 Redis 缓存、JVM 缓存和 MQ 异步执行任务来对系统进行优化。
项目源码 GitHub:https://github.com/TIYangFan/SpikeSystem(如果可以帮到你,请帮我 star ~ ^_^)
二、Redis 缓存优化
2.1 主要思想
使用 Redis 来进行优化,主要是因为 Redis 是存储在内存当中,所以数据的存取速度非常的快,同时因为 Redis 的自身的良好设计,单节点的 Redis 就能够支持上万的 QPS,这相比于数据库的几百 QPS 有了很大的提升。我们使用 Redis 来进行优化的主要思想是因为在秒杀的场景中,我们需要改变的数据属性很少,一般仅仅是改变商品的库存即可,但是因为我们之前的数据都是直接保存在数据库中,所以哪怕我们仅仅是改变一个属性值,都需要进行一次完整的数据库请求,这样在高并发的情况下,相当于把所以的流量直接全部压在数据库上,这样的做法很容易导致数据库直接挂掉,另一方面如果我们的数据库没有进行分库分表,那么这样的一个秒杀功能模块的请求可能一瞬间把数据库压垮后直接影响到其他功能模块的正常使用。
同时,因为相对于可能成千上万的访问量,商品的库存仅仅是十几个,这样来看两个数据相差了好几个数量级,也就是说其实真正有必要去进行数据库操作的仅仅是那十几个访问请求。因此我们在这里优化的主要思路就是将 Redis 作为一道缓存过滤( 因为在系统中对于 Redis 的访问会比 MySQL 快很多 ),即我们首先将数据库中的商品数据同步到 Redis 当中,当用户来访问的时候,我们直接去对 Redis 进行操作,一种情况是前库存数个人正常完成了 Redis 当中的减库存操作,那么他们可以直接进入到对数据库的减库存操作,另一种情况是当 Redis 当中的库存已经减光后,此时数据库中的商品库存也应被清空(最终一致性),那么这些后面的用户就直接在 Redis 缓存层被打回,这样就减少了对数据库的直接访问量。
通过下图和之前数据的对比,我们可以看到优化后的 TPS 大概为1400 左右,相比于优化前的 550 大概有了一倍的提升( 因为 Redis 可支持的 QPS 量比较大,所以这里直接采用了比较大的访问量,这样看起来比较直观,但是因为我没有进行 Tomcat 等等一些参数的配置,所以很多的请求直接被 Tomcat 挂掉了,所以失误率比较高,问题不大,当把访问量调小的时候或者配置好 Tomcat 的相关参数后就没问题了 )。
2.2 优化后效果
2.3 优化代码
@Autowired
private StringRedisTemplate stringRedisTemplate;
@PostConstruct
public void initRedis() {
// 将数据库中所有商品的库存信息同步到 Redis 中
List<Product> products = productService.getAllProduct();
for (Product product: products) {
stringRedisTemplate.opsForValue().set(Constants.PRODUCT_STOCK_PREFIX + product.getId(), product.getStock() + "");
}
}
@PostMapping("/{productId}")
public String spike(@PathVariable("productId") Long productId) {
try {
// Redis 缓存中减库存
Long stock = stringRedisTemplate.opsForValue().decrement(Constants.PRODUCT_STOCK_PREFIX + productId);
// 如果 Redis 当中的库存已被减完则直接打回
if (stock < 0) {
return "fail";
}
// 数据库中减库存
orderService.spike(productId);
} catch (Exception e){
return "fail";
}
return "success";
}
2.4 代码分析
针对上面的代码我们再进行简单的分析,StringRedisTemplate 这个类是 SpringBoot 提供的一个 Redis 的操作工具类,其功能类似于我们之间所使用的的 Jedis,在 Redis 当中的配置也比较简单,直接在 pom 文件中添加下面的代码即可。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
对于 @PostConstruct 这个注解,被它修饰的方法会在服务器加载 Servlet 的时候运行,并且只会被服务器执行一次。PostConstruct 在构造函数之后执行,init()方法之前执行。因此,我们通过这个函数来实现商品库存信息在启动时自动加载到 Redis 当中。
其次代码的大概思路就是先去 Redis 中减库存,只有在 Redis 当中减库存成功后才能进入到对数据库的操作,如果 Redis 减库存失败,则访问直接被打回。这里主要注意的是 stringRedisTemplate.opsForValue().decrement() 这个函数的使用,顾名思义他的功能就是直接将我们指定 Key 对应的 value 值减一,但是需要注意的是它所返回的值是减完后的 vaule 值,因此上面的判断应当是 stock < 0 而不是 stock < 1,因为当最后还剩一个商品的时候,它减完后的 stock 值刚好为 0 ,下一个线程再访问的时候因为已经没有库存,所以此时减完后应为小于零的负数。
2.5 存在问题
但是上面的代码还存在一些问题,比如假如当一个线程成功的在 Redis 当中完成了减库存操作后,它下一步应当进行数据库中的减库存操作,但是如果这时它在减数据库的操作过程中抛出了错误,那么它就会被代码 catch 到异常后直接返回,但是此时 Redis 当中已经把它的库存减掉了,但是数据库中还未减掉这个线程所对应的库存,那么此时数据库和 Redis 中的库存数据已经不一致,而且因为这个线程已经返回了,并且 Redis 已经把它对应的线程减掉了,那么就表示数据库中对应的这个库存是再也没有机会被减掉了,这样就会导致数据库中最后还剩余库存而 Redis 中的库存已被减空。
2.6 解决方案
这里的话有两种解决的思路,可以根据自己的需要选择不同的解决方案。
(1)“回滚” Redis 库存,这个解决的方案思路比较简单,即当前这个线程失败返回了,那么我们可以让其他线程来嘛,所以当其失败后,我们直接在 catch 中恢复 Redis 中的商品库存就可以了,也就是将上面减掉的库存数再加回去。但是这里存在一个问题,如果我们仅仅是简单的在 catch 中把 Redis 中商品的库存加一,其实并没有太大的卵用,因为我们都知道 Redis 执行的比较快,可能当我们在这里报错之后,Redis 已经执行完毕了或者已经执行掉大部分了,因为我们上面有减 Redis 中库存的逻辑,那么就会造成此时库存数是一个负很多的数,这个时候我们 catch 中简单的加一个库存其实是没卵用的。
比如,我们有 10000 个访问量,但是我们的商品只有 100 个,然后当我们执行到第 50个线程访问数据库减库存的时候代码抛出了异常,但是因为 Redis 执行的比较快,可能此时 stock 已经被减到了 -5000,那么这个时候我们从 catch 中简单的给 Redis 的库存 stock 加一,其实 stock 也是等于 -4999,那么它还是一个负的,也就是说按照我们的代码逻辑,它仍然会被打回,最后还是会导致数据库中存在商品无法被卖完。
因此,我们的思路应当是不仅仅在 catch 中恢复库存,也要在前面的 if 返回中恢复,保证每次减 Redis 失败的逻辑执行完之后 stock 的值都为 0,这样当我们在减数据库中的库存失败时,简单的加一就可以将 Redis 当中的库存数据恢复了,具体的逻辑如下。
代码如下。
@PostMapping("/{productId}")
public String spike(@PathVariable("productId") Long productId) {
try {
Long stock = stringRedisTemplate.opsForValue().decrement(Constants.PRODUCT_STOCK_PREFIX + productId);
if (stock < 0) {
// 保证 Redis 当中的商品库存恒为非负数
stringRedisTemplate.opsForValue().increment(Constants.PRODUCT_STOCK_PREFIX + productId);
// 将请求直接打回
return "fail";
}
// 数据库中减库存
orderService.spike(productId);
} catch (Exception e){
// 回滚 Redis 中的库存
stringRedisTemplate.opsForValue().increment(Constants.PRODUCT_STOCK_PREFIX + productId);
return "fail";
}
return "success";
}
(2)MQ失败重试 ,第二种解决的思路这里就先不展开来讲了,以后会写博文来记录一下实现的方式,大概的思路是可以使用 MQ 来实现失败重试机制,即当我们操作数据库失败后,在 catch 里面直接将这个访问的请求放到消息队列里面,等过段时间再进行重试操作即可,这样的话可以保证的是数据的最终一致性。
三、JVM 缓存优化
3.1 主要思想
上面一步我们已经使用了 Redis 缓存来对系统进行了第一轮的优化,优化的效果还是比较明显的,但是通过对代码的观察其实我们发现这里还有一些可以优化的空间,我们知道 Redis 与我们的系统是属于两个程序,所以还是需要进行一些系统间的数据传输。同时通过对上面代码的观察我们也可以发现, 我们的访问量很大而库存量很少,这也就意味着其实真正有用的数据交互其实就仅仅是库存个数,其余的数据交互其实都是没有意义的,也就是说当 Redis 当中的库存已被减空后,我们再反复的去跟 Redis 交互其实是没有什么意义的。
因此我们其实可以进一步使用 JVM 缓存来对系统进行优化,JVM 缓存其实说白了也就是将数据存储在虚拟机的堆上,更直白一点就是将数据缓存在 Java 对象中,这样当我们再去访问数据的时候,就不再需要同别的系统来进行交互,可以进一步的提升系统的性能。但是我们暂时还不能够将所有商品的库存量都缓存在 JVM 中,因为在没有很好的同步机制时,我们暂时是没有办法保证多个 JVM 中缓存的一致性的(后面可以使用 ZK),所以暂时我们先仅仅去在 JVM 中保存一个商品是否售罄的标记,当 Redis 当中该商品的库存已被清空后设置标记,然后每次要访问 Redis 之前先从 JVM 缓存中确认一遍当前商品是否已经售罄即可。
下图展示了优化后的结果,为了能够更好的显示出优化后的效果,所以这里的访问量设置为五次每次四千,来降低失误率对系统的影响,但是从这里我们也能看出,到现在为止的性能相比于优化前已经提升了两倍多(优化前为 550 左右)。
3.2 优化后效果
3.3 优化代码
// 售罄商品列表
private ConcurrentHashMap<Long, Boolean> productSoldOutMap = new ConcurrentHashMap<>();
@PostMapping("/{productId}")
public String spike(@PathVariable("productId") Long productId) {
if (productSoldOutMap.get(productId) != null){
return "fail";
}
try {
Long stock = stringRedisTemplate.opsForValue().decrement(Constants.PRODUCT_STOCK_PREFIX + productId);
if (stock < 0) {
// 商品销售完后将其加入到售罄列表记录中
productSoldOutMap.put(productId, true);
// 保证 Redis 当中的商品库存恒为非负数
stringRedisTemplate.opsForValue().increment(Constants.PRODUCT_STOCK_PREFIX + productId);
return "fail";
}
// 数据库中减库存
orderService.spike(productId);
} catch (Exception e){
// 数据库减库存失败回滚已售罄列表记录
if (productSoldOutMap.get(productId) != null) {
productSoldOutMap.remove(productId);
}
// 回滚 Redis 中的库存
stringRedisTemplate.opsForValue().increment(Constants.PRODUCT_STOCK_PREFIX + productId);
return "fail";
}
return "success";
}
3.4 代码分析
首先我们需要注意的是,记录商品是否售罄的标记列表一定是支持并发的,也就是线程安全的,所以这里我们使用 ConcurrentHashMap 来进行数据的记录。
代码的逻辑比较简单,即当商品的库存在 Redis 当中减空后,在记录列表中添加该商品的售罄标记,然后每次要访问 Redis 之前,先判断记录列表中是否存在该商品的售罄信息,如果包含就说明当前商品在 Redis 当中的库存已被清空,没有必要再去与 Redis 进行交互了,直接打回访问请求,反之如果不存在则让其进一步的与 Redis 交互完成减库存的操作。
3.5 存在问题
跟上面使用 Redis 缓存进行优化时存在一样的问题,即当线程在执行数据库减库存时如果抛出异常,那么其实这个线程就会直接返回,但是标记列表中该商品的售罄标记仍然会存在,导致后面的线程没有办法再与 Redis 进行交互,最终导致数据库中存在未售罄的秒杀商品。
3.6 解决方案
问题的解决方案其实已经写在了上面的代码中,即 catch 中的回滚记录列表中的标记,也就是当在执行数据库减库存抛出异常后,我们会移除售罄列表中的该商品已售罄的标记,这样的话剩下的线程就可以有机会再次与 Redis 进行交互,顶替刚刚执行失败的线程来将商品库存清空。
四、MQ 异步任务优化
4.1 主要思想
上面我们已经使用了 Redis 缓存和 JVM 缓存技术对系统进行了优化,接着再往下进行梳理,我们可以看到当前线程在进行数据库减库存操作时都是同步进行的,即每一个线程都需要等到数据库减库存任务完全执行完毕后才会返回,但其实思考一下,这些线程并没有必要去等待减库存任务执行完毕,它只需在提交任务后就可以反悔了,因此在这里我们可以采用异步执行的思想,即每个线程只需提交减库存的任务后就可以直接返回,而不需要等待他完全执行完成。
为了实现任务异步化执行,这里我们采用消息队列来对系统进行进一步的优化,每次当线程执行到数据库减库存的操作时,我们只需通过生产者线程向消息队列中添加一条减库存的任务后就可以直接返回,然后减库存任务的具体执行交由消费者线程来执行即可。因为订单的的数量较少,数据库减库存优化可能较前两个相比较优化后的效果没有那么的明显,通过 jmeter 的执行报告观察可能也会有所偏差,所以这里就先不展示优化后的执行报告了。
4.2 优化代码
@Autowired
private Producer producer;
@PostMapping("/{productId}")
public String spike(@PathVariable("productId") Long productId) {
if (productSoldOutMap.get(productId) != null){
return "fail";
}
try {
Long stock = stringRedisTemplate.opsForValue().decrement(Constants.PRODUCT_STOCK_PREFIX + productId);
if (stock < 0) {
// 商品销售完后将其加入到售罄列表记录中
productSoldOutMap.put(productId, true);
// 保证 Redis 当中的商品库存恒为非负数
stringRedisTemplate.opsForValue().increment(Constants.PRODUCT_STOCK_PREFIX + productId);
return "fail";
}
// 数据库中减库存
// orderService.spike(productId);
// 数据库异步减库存
producer.spike(productId);
} catch (Exception e){
// 数据库减库存失败回滚已售罄列表记录
if (productSoldOutMap.get(productId) != null) {
productSoldOutMap.remove(productId);
}
// 回滚 Redis 中的库存
stringRedisTemplate.opsForValue().increment(Constants.PRODUCT_STOCK_PREFIX + productId);
return "fail";
}
return "success";
}
@Service
public class Producer {
@Autowired
private RabbitTemplate rabbitTemplate;
@Scheduled(fixedDelay = 1000L)
public void spike(Long productId){
rabbitTemplate.convertAndSend("order", productId);
}
}
@Service
@RabbitListener(queues = "order")
public class Consumer {
@Autowired
private OrderService orderService;
@RabbitHandler
public void consume(@Payload Long productId) {
orderService.spike(productId);
}
}
@Configuration
public class SpringConfig {
@Bean
public Queue orderQueue() {
return new Queue("order", true);
}
}
4.3 代码分析
优化后的目录结构如下。
代码的逻辑比较简单,就是将之前同步数据库减库存操作替换为消息队列的异步执行,具体的代码贴在上面就不再赘述了,需要注意的是在通过 Spring 使用消息队列时,需要通过 xml 或者 class 对其进行相关的配置,并在 pom 文件中引入相关的依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
最后大概说一下几个注解的含义:
- @Payload 注入消息体到一个 JavaBean 中;
- @RabbitListener 指定消费者监听的消息队列;
- @RabbitHandler 指定处理消息的消费者方法;
- @Scheduled 定时任务;