一个缓存击穿的实验
何为缓存击穿
所谓的缓存击穿是指,一些大量的并发请求持续查询服务器的热点数据,由于热点数据通常都要事先预热保存在缓存服务器中,典型的例子是网站当前登录账号,促销商品库存等等,而一些不怀好意的人可能会向服务器发送一些参数合法但结果不存在的高并发请求,于是这些请求全都绕过了缓存服务器来到服务器的心脏持久层(这里只是一个模型,生产环境中当然要通过各种手段限制这些恶意请求,如限制短时间的请求次数,加设验证码等等),最终导致连接池达到上限,影响正常用户的访问,甚至服务器宕机.
怎样解决
设想一下这样一个场景:你家小区的看门大爷保存着一份小区所有住户的名单,他保证每次有住户搬进来或搬出去他都及时更新这份名单.有一天你的一个朋友来找你,他向看门大爷报告他要找谁,看门大爷对照它的小本子一看,嗯有这个人,你进去吧,但我不保证他现在一定在家.他去敲你家门,你可能在家,也可能出去了;又来了一个人,他告诉大爷我要找狗蛋儿,大爷一看没这个人,“我们小区没这号人,我100%肯定,你走吧”.
看吧,有个看门大爷这效率就高了,他保证了1:只要他说小区没有的人物,他肯定是对的;2:他说小区里有的,很大可能有,也可能出去了,或者刚搬走还没来得及交接.
50年前就有个叫布隆的人思考了这个问题,他设计了一个过滤器,作用是从高数量级的数据中快速判断某个给定输入是否存在其中.
这个东西的本质就是一个 BitMap,每个位非0即1的一段二进制序列
现在有一个输入,我们需要在它存入持久层之前先映射到这个位图上,怎么做呢,没错 hash,通过某种 hash算法将得出的值模序列的位长度,并将对应的位置为1.
hash算法也无法避免 hash碰撞,因为数据无论在长度上还是内容上都是没有上限的,而 hash算法只是输入的固定长度摘要,无论如何都不能以有限的摘要完全映射所有可能的输入.
解决的办法就是通过多次不同的hash算法使一个输入映射到位图的三个位置上,对一个固定输入而言,我们要求这个 hash算法得出的’'插槽’使不同的.
这样有什么好处呢
上图是一个输入经过三次 hash后的状态,假设有另一个输入经过过滤器,经过三次 hash后,对应的位置不是全1,那么我可以断定,在这之前,这个数据一定没有存入持久层,因为我们要求在存入数据库前要先将这个输入映射到位图中.
那么全返回1就一定存在么?不一定,因为有 hash碰撞的存在,可能一个输入的三个’坑’在这之前都被别人占了,这很好理解吧,但我们可以通过扩大序列长度和使用散列性能更好的算法来尽量使这种"误报率"尽量的低
上代码
了解了原理后我们来感受下它的强大!
demo 使用 springboot+mybatisPlus+redisTemplate
@SpringBootTest
public class CacheBreakDownTest {
private static final long MAX_SIZE = 10000000L;//实体数量
private static final int THREAD_COUNT = 2000;//并发数
private static AtomicInteger count;//线程计数器
private static AtomicInteger hit;//报告缓存命中
private static AtomicInteger breakDown;//报告缓存击穿
private static AtomicInteger bfReportNotExist;//布隆过滤器报告的不存在输入的个数
private static BloomFilter<String> bf;//guava 包的工具
@Autowired
UserService userService;封装了查询操作
Logger logger = LoggerFactory.getLogger(RpcProviderApplicationTests.class);
@Resource(name = "redisTemplate")
private RedisTemplate<String, Object> template;//redis模板
测试类需要使用的全局变量
@Test
public void testInsert() {
List<User> users = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
if (i % 1000 == 0) {
userService.saveBatch(users, 100);
users.clear();
}
User user = new User();
user.setName(UUID.randomUUID().toString());
users.add(user);
}
for (int i = 0; i < 1000; i++) { //太丑了...前面逻辑不对导致少添加了1000个,这里补上
User user = new User();
user.setName(UUID.randomUUID().toString());
users.add(user);
}
userService.saveBatch(users, 1000);
}
初始化数据库,使用 UUID作为输入
@PostConstruct
public void init() {
long start = System.currentTimeMillis();
// template.delete("{user.names}");
count = new AtomicInteger(0);
hit = new AtomicInteger(0);
breakDown = new AtomicInteger(0);
bfReportNotExist = new AtomicInteger(0);
template.setValueSerializer(new StringRedisSerializer(StandardCharsets.UTF_8));
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.le("id", 100000);
allUsers = userService.list(queryWrapper);
bf = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8), allUsers.size(), 0.03);
List<String> names = allUsers.parallelStream().map(e -> {
String name = e.getName();
bf.put(name);
return name;
}).collect(Collectors.toList());
// names.forEach(e->template.opsForSet().add("{user:names}", e));
long end = System.currentTimeMillis();
logger.info("数据库写缓存以及 bloomFilter 映射总用时: " + (end - start));
将name写入缓存,这里由于我的 redis部署在阿里云上,还是集群模式,为节省时间,我只复制了前1w 条数据,但这对我们的测试来说足够了
注意这里BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8), allUsers.size(), 0.03);
最后的0.03叫 false positive probability ***“假阳率?”***,稍后就知道它是干嘛的
@Test
public void testBloomFilter() {
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8), MAX_SIZE, 0.0001f);
Set<String> stringSet = new HashSet<>();
List<String> stringList = new ArrayList<>();
for (int i = 0; i < MAX_SIZE; i++) {
String s = UUID.randomUUID().toString();
bloomFilter.put(s);
stringSet.add(s);//模仿缓存数据
stringList.add(s);//模仿实际输入
}
long right = 0;
long wrong = 0;
for (int i = 0; i < MAX_SIZE; i++) {
//每100个我们输入一个真实存在的数据,总共100w/100=1w 个
String s = i % 100 == 0 ? stringList.get(i / 100) : UUID.randomUUID().toString();//100w次测试中,不存在的数据99w 存在的1w
if (bloomFilter.mightContain(s)) {//看到没,人家很谦虚,没说一定存在而是可能存在,可能存在包含了必定存在和不存在的部分
if (stringSet.contains(s)) {
right++;
} else {
wrong++;
}
}
}
NumberFormat numberFormat = NumberFormat.getPercentInstance();
numberFormat.setMaximumFractionDigits(8);
System.out.println("1w个合法输入 bloomFilter判断合法个数为: " + right);
System.out.println("1000w个输入中bloomFilter判断数据库中存在存在个数为: " + (wrong + right) + "正确率率为: " + numberFormat.format((float) (MAX_SIZE - wrong) / MAX_SIZE));
}
结果
2020-04-21 18:13:10.567 INFO 31581 --- [ main] c.f.r.RpcProviderApplicationTests : 数据库写缓存以及 bloomFilter 映射总用时: 2579
1w个合法输入 bloomFilter判断合法个数为: 100000
1000w个输入中bloomFilter判断数据库中存在存在个数为: 100992正确率率为: 99.99008179%
现在知道 false positive probability (0.0001)是啥意思了不,就是布隆过滤器给我们的保证,他保证了有99.99%的报告都是正确的,剩下的那0.01%的缓存击穿我们在乎么??
如果你让这个值更小(但不能>=1,原理你懂的),你会发现你的程序越来越慢,debug发现它底层创建的位图长度也是越来越长,就是通过时间和空间开销换取更可靠的报告,在生产环境中需要根据数据量和JVM内存上限做出权衡
下面来模拟业务中的缓存击穿,使用并发包的 CyclicBarrier制造大量并发线程来访问缓存服务器(这里我们略过击穿后访问 mysql的过程)
@Test
public void testCacheBreakDown() {
long start = System.currentTimeMillis();
CyclicBarrier cyclicBarrier = new CyclicBarrier(THREAD_COUNT);
ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
for (int i = 0; i < THREAD_COUNT; i++) {//2000个并发线程
executorService.execute(new MyThread(cyclicBarrier, template, userService));
}
executorService.shutdown();//不再添加新线程
while (!executorService.isTerminated()) {//等待所有线程执行完毕
}
long stop = System.currentTimeMillis();
System.out.println("并发数: " + THREAD_COUNT + ",新建线程总用时: " + (stop - start) + "毫秒");
System.out.println("命中缓存次数: " + hit.get() + "缓存击穿次数: " + breakDown.get());
System.out.printf("BloomFilter 报告不存在的输入%s个,实际%s个\n", bfReportNotExist.get(), THREAD_COUNT / 2);
}
public static class MyThread implements Runnable {
private final RedisTemplate<String, Object> template;
private final CyclicBarrier cyclicBarrier;
private final UserService userService;
public MyThread(CyclicBarrier cyclicBarrier, RedisTemplate<String, Object> template, UserService userService) {
this.cyclicBarrier = cyclicBarrier;
this.template = template;
this.userService = userService;
}
@Override
public void run() {
try {
cyclicBarrier.await();//它会把自己上锁,由于所有线程都持有它的引用,每个线程走到这一步时获取不到锁都堵塞了,它内部有一个计数器,当堵塞数量达到初始设置的2000后,他会释放自己,达到大量线程并发的效果
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
//1.产生数据库不存在的对象,模拟恶意攻击,此时 bloomFilter 里不存在它的映射
String randomUser;
//真实存在和不存在的各占一半
if (count.getAndIncrement() % 2 == 0) randomUser = UUID.randomUUID().toString();//UUID重复的可能性几乎为0
else {
randomUser = userService.getById(count.get()).getName();
}
//2.1
if (!bf.mightContain(randomUser)) {
bfReportNotExist.incrementAndGet();
return;
}
//2.2
DateFormat format = DateFormat.getDateInstance(DateFormat.LONG);
Boolean result = template.opsForSet().isMember("{user:names}", randomUser);
Assert.notNull(result, "系统错误!");
if (result) {
Date now = new Date();
System.out.println(format.format(now) + " :: 命中缓存!");
hit.incrementAndGet();
return;
}
synchronized (randomUser) {
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("name", randomUser);
User one = userService.getOne(queryWrapper);
if (Objects.isNull(one)) {
System.out.println("缓存命中失败,发生缓存穿透,目前穿透次数: " + breakDown.incrementAndGet());
}
}
}
先把布隆过滤器的判断语句注释掉,我们来看输出
2020年4月21日 :: 命中缓存!
2020年4月21日 :: 命中缓存!
并发数: 2000,并发总用时: 12686毫秒
命中缓存次数: 1000缓存击穿次数: 1000
BloomFilter 报告不存在的输入0个,实际1000个
和预想的一样,各占一半,设想一下不加任何防护措施,这么多的并发全交给 mysql处理,真是让人头大,要知道每个数据库连接的建立开销都是很大的,而连接池也不容许同时有这么多连接,最后的结果就是连接池异常,甚至 mysql拒绝连接.为了应对这种场面,网站也是各出奇招,有个有趣的例子就是,12306的购票系统越是节假日往返人数激增的时候,网站的验证码就越模糊,就是为了减少高并发缓解数据库压力.
布隆过滤器说,是时候展现真正的技术了! 打开注释
if (!bf.mightContain(randomUser)) { bfReportNotExist.incrementAndGet(); //你不是合法的,哪来的回哪去吧 return; }
看门大爷一出手,就知有没有…
2020年4月21日 :: 命中缓存!
2020年4月21日 :: 命中缓存!
并发数: 2000,并发总用时: 7446毫秒
命中缓存次数: 1000缓存击穿次数: 29
BloomFilter 报告不存在的输入971个,实际1000个
由于这里我们设置的fpp 为0.03(也是默认值),可以看到布隆过滤器保守的报告了 971个非法输入(它保证了这 971个一定是数据库不存在的),剩下的 29个是因为 hash碰撞的原因, BloomFilter底层使用5个独立的 hash 函数(所谓独立是指 hashCode%bitMap.size 互不相等),这个29个并未载入 bitMap 的5个位被别的数据置1了而导致的误判.但这已经很好了,要知道减少3,4个数量级的访问一般的数据库优化是不可能做到的
如果你仔细阅读 BloomFilter 的 API,你肯定会疑惑, 有 put,contain 等,唯独没有 delete呢,万一我的数据库删除了数据,你不同步更新,那时间久了两者差距越来越大,不就会产生不可预测的结果
解决的办法,要么每隔一段对于插入操作,每次写入持久层前先更新 bitmap,另外每隔一段时间执行一个后台计划任务,同步 bitmap.
还有一个更骚的操作,为bitMap 的每一位 添加一个计数器,每次删除时,更新这条数据 hash后的5个插槽计数器 减 1,只有当计数器为0时,对应位才 置 0,这样就可以保证 两者的行为基本一致