缓存设计
在高并发的情况下,例如秒杀、抢票以及双11活动下单等场景,由于业务量巨大,同一时间打在服务器上的流量过大,就会导致超时响应的情况出现,严重的甚至还会致使服务器崩溃,这是任何一个开发者都不愿意见到的。
因此,在高性能的服务架构设计中,我们常使用缓存这一中间件来分担服务器的压力,最常见的就是使用Redis这一数据库了。不过,随着业务量的增大,仅仅使用Redis这种远程缓存可能还不够,所以这时候就需要在Web应用和Redis之间再增加一个本地缓存。以秒杀系统为例,先不考虑流量削峰及填谷的情况,当我们想要根据商品id查询其详情的时候,请求数据的流程图是下面这样的。
二级缓存的执行流程?
- 浏览器发送请求,到达本地缓存,如果本地缓存中有数据,那么直接返回。
- 如果本地缓存中无数据,请求到达Redis,Redis中若有数据,先将数据存放在本地缓存,然后再返回。
- 如果Redis中无数据,那么将直接请求MySQL,MySQL将数据先写入二级缓存后,再将其返回给浏览器。
什么是本地缓存?
本地缓存是基于本机环境的缓存,它使用的是JVM内存,常见的实现有Guava
和Caffeine
,由于JVM内存是高度敏感的,所以我们一般都将那些经常被访问的热点数据存放在本地缓存中。
压力测试
我们可以使用Apache Jmeter工具来进行压力测试,本次测试在我的本机执行,线程组的配置如下,由于我是第一次使用Jmeter,所以参数设置方面还不是很理解,凑活着先用。
控制方法的代码如下所示,可以从缓存中查找商品详情,也可以从MySQL数据库中直接查找商品详情。
@ResponseBody
@RequestMapping(path = "/detail/{id}", method = RequestMethod.GET)
public ResponseModel getDetail(@PathVariable("id") String id) {
// Item item = itemService.getDetailInCache(id);
Item item = itemService.getDetailByID(id);
if (item == null) {
return ResponseModel.createFailure(CommonErrorEnum.STOCK_NOT_EXISTS);
}
return ResponseModel.createSuccess(item);
}
无缓存时
在不使用任何缓存时,所有的流量打到MySQL服务器上,如下图所示,在全部15000次请求中,有386次失败,平均响应时间为744.5ms,TPS为2987.45。
使用Redis
下面我将展示使用Redis缓存时,服务器的性能能提高多少,这时我们查找商品详情先去Redis中找,Service层的代码如下所示,这里我对id做了一个校验,因为数据库中所有的id都是32位的,所以我希望传进来的查询字段也是32位的(其实应该在Controller层就校验的,but whatever)。
@Override
public Item getDetailInCache(@Length(min = 32, max = 32, message = "参数不合法") String id) {
Item item = null;
// Redis
String key = "item:" + id;
item = (Item) redisTemplate.opsForValue().get(key);
if (item != null) {
return item;
}
// MySqL
item = this.getDetailByID(id);
if (item != null) {
// 放到Redis中去,3分钟后失效
redisTemplate.opsForValue().set(key, item, 3, TimeUnit.MINUTES);
}
return item;
}
首先发起一次请求,先将商品详情添加到Redis中,然后再做一次同样的压测,结果如下图所示。可以看到,此时失败的请求数量降低到了12个,平均响应时间提高到了132.12ms,TPS也提高到了6541.65,看来加了一层Redis缓存后效果是显著的。
使用Caffeine
从SpringBoot2开始,spring官方已经放弃了Guava,而改用Caffeine作为默认的本地缓存组件,本着与时俱进的原则,我这里也使用Caffeine来做测试,它的Github地址是https://github.com/ben-manes/caffeine。
Caffeine简介
根据官网介绍,Caffeine是Java缓存库近乎最优的实践,它的底层类似于ConcurrentMap
,但又有所不同,其最大的区别在于Caffeine会自动驱逐那些可能不被使用的元素,Caffeine基于三种缓存淘汰策略:内存优先、超时优先和引用优先,这个留待我们后续再来研究,先来看看如何使用Caffeine。
使用Caffeine
- 首先需要引入Caffeine依赖,这里需要注意,如果使用的是JDK11及以上的版本,那么就引入3.1.x的Caffeine,否则就引入2.9.x,我使用的是JDK8。
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.1</version>
</dependency>
- 编写缓存配置类
@Configuration
public class CacheConfig {
@Bean
public Cache<String, Object> caffeineCache() {
return Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入10分钟后过期
.initialCapacity(100) // 初始缓存空间大小
.maximumSize(1000) // 最大缓存数量
.build();
}
}
这样就通过配置类的方式将caffeineCache
这个Bean注入到容器中了,后面要使用这个缓存也就非常简单了。
- 使用本地缓存
下面将服务层的代码修改如下
@Service
public class ItemServiceImpl implements ItemService {
@Autowired
private ItemMapper itemMapper;
@Autowired
private ItemStockMapper itemStockMapper;
@Autowired
private PromotionMapper promotionMapper;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private Cache<String, Object> caffeineCache;
@Override
public Item getDetailByID(String id) {
// 查出来一个商品
Item item = itemMapper.selectByPrimaryKey(id);
// 添加库存信息
ItemStock itemStock = itemStockMapper.selectByItemId(id);
item.setItemStock(itemStock);
// 添加促销活动信息
Promotion promotion = promotionMapper.selectByItemId(id);
item.setPromotion(promotion);
return item;
}
@Override
public Item getDetailInCache(@Length(min = 32, max = 32, message = "参数不合法") String id) {
Item item = null;
// Caffeine
String key = "item:" + id;
item = (Item) caffeineCache.getIfPresent(key);
if (item != null) {
return item;
}
// Redis
item = (Item) redisTemplate.opsForValue().get(key);
if (item != null) {
// 将商品其放入本地缓存中
caffeineCache.put(key, item);
return item;
}
// MySqL
item = this.getDetailByID(id);
if (item != null) {
// 放到Redis中去,3分钟后失效
redisTemplate.opsForValue().set(key, item, 3, TimeUnit.MINUTES);
}
return item;
}
}
再使用Jmeter做压力测试,结果如下图所示。平均响应时间已经来到了10.08ms,较只使用Redis做缓存时又提高了一大截,然而看起来TPS似乎略有下降,于是我测试了很多次,大多数情况下TPS都是大于7000的,总之也有所提高,这里就不做截图展示了。
补充
Caffeine提供了四种缓存加载的策略,分别是:手动加载、自动加载、手动异步加载和自动异步加载,我们这里使用的是最简单的手动加载方式,关于其他的实现请参考官方文档
总结
使用这种本地+远程二级缓存的方式固然能够大大提升并发次数,应对更为复杂的业务场景,但我们在学习Redis的时候就知道,当数据量巨大时,如何保证缓存和MySQL中的数据一致性就成了问题,有关分布式缓存中存在的各种问题,等我学习消化完毕后,再记录在另一篇文章里。