在使用 Redis 缓存时,经常会遇到一些异常问题。
概括来说有 4 个方面:
- 缓存中的数据和数据库中的不一致;
- 缓存雪崩;
- 缓存击穿;
- 缓存穿透。
关于第一个问题【缓存中的数据和数据库中的不一致】,在之前的文章中,已经深入分析过了,可以参考: 深入解析缓存模式下的数据一致性问题
今天重点看下后面三个问题。
缓存雪崩
缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。
产生原因
产生缓存雪崩的原因有两种:
- 缓存系统本身不可用,导致大量请求直接回源到数据库;
- 应用设计层面大量的 Key 在同一时间过期,导致大量的数据回源。
针对第一个原因,可以通过主从节点的方式构建 Redis缓存高可用集群,如果主节点故障宕机了,从节点还可以切换成为主节点,继续提供缓存服务,避免了由于缓存实例宕机而导致的缓存雪崩问题。
重点看下第二种原因,在缓存数据同时过期的情况下,对系统性能的影响。
假如我们有一个查询城市数据的需求。
- 在系统初始化时,将 1000 条城市数据放入 Redis 缓存中,过期时间为 30s;
- Redis 中数据过期后,从数据库中获取数据,然后写入缓存,每查询一次数据库,计数器加 1;
- 程序启动的同时,启动一个定时任务线程每隔一秒输出计数器的值,并把计数器归零。
package com.redis.demo.controller;@Slf4j
@RestController
public class RedisController {@Autowiredprivate RedisUtil redisUtil;private AtomicInteger atomicInteger = new AtomicInteger(); // 全局 QPS 计数器@PostConstructpublic void init() {//初始化1000个城市数据到Redis,所有缓存数据有效期30秒IntStream.rangeClosed(1, 1000).forEach(i -> redisUtil.set("city" + i, getCityFromDb(i), 30));//每秒一次,输出数据库访问的QPS,同时把计数器置0Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {log.info("DB QPS : {}", atomicInteger.getAndSet(0));}, 0, 1, TimeUnit.SECONDS);}@GetMapping("/city")public String city() {//随机查询一个城市int id = ThreadLocalRandom.current().nextInt(1000) + 1;String key = "city" + id;String data = redisUtil.get(key);if (data == null) {//回源到数据库查询data = getCityFromDb(id);if (!StringUtils.isEmpty(data))//缓存30秒过期redisUtil.set(key, data, 30);}return data;}/*** 从数据库中查询数据* @param cityId 城市ID* @return*/private String getCityFromDb(int cityId) {try {TimeUnit.MICROSECONDS.sleep(100); //模拟数据库查询操作} catch (InterruptedException e) {e.printStackTrace();}//模拟查询数据库,查一次计数器加一atomicInteger.incrementAndGet();return "citydata" + System.currentTimeMillis();}
}
使用 wrk 工具,设置 4 线程 4 连接压测 city 接口:
wrk -c4 -t4 -d100s http://localhost:8080/city
缓存数据 30s 后过期,回源的数据库 QPS 最高达到了635.
2024-11-14 14:48:21.525 INFO 11546 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 307
2024-11-14 14:48:22.525 INFO 11546 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 635
2024-11-14 14:48:23.525 INFO 11546 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 74
解决方案
由于缓存 Key 同时大规模过期导致数据库压力激增的解决方式有两种:
随机化过期时间
随机化过期时间,不要让大量的 Key 在同一时间过期。
在系统初始化时,将过期时间设置为 30s + 10s 以内的随机值,这样的话,Key就会被分散到 30~40s 之间过期了。
@PostConstructpublic void init() {//初始化1000个城市数据到Redis,所有缓存数据有效期30~40秒IntStream.rangeClosed(1, 1000).forEach(i -> redisUtil.set("city" + i, getCityFromDb(i), 30 + ThreadLocalRandom.current().nextInt(10)));}
缓存过期后,回源数据库不会集中在同一秒,数据库的 QPS 从 600 多降到了 100 左右。
2024-11-14 15:01:31.556 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 93
2024-11-14 15:01:32.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 95
2024-11-14 15:01:33.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 107
2024-11-14 15:01:34.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 91
2024-11-14 15:01:35.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 104
2024-11-14 15:01:36.557 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 120
2024-11-14 15:01:37.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 89
2024-11-14 15:01:38.558 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 114
2024-11-14 15:01:39.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 99
2024-11-14 15:01:40.558 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 54
2024-11-14 15:01:41.557 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 13
缓存永不过期
让缓存永不过期,启动一个后台线程 60 秒一次定时把所有数据更新到缓存,而且通过适当的休眠,控制从数据库更新数据的频率,降低数据库压力。
@PostConstructpublic void rightInit2() throws InterruptedException {CountDownLatch countDownLatch = new CountDownLatch(1);//每隔60秒全量更新一次缓存Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {log.info("定时去更新缓存");IntStream.rangeClosed(1, 1000).forEach(i -> {String data = getCityFromDb(i);// 通过适当的休眠,来控制访问数据库的频率try {TimeUnit.MILLISECONDS.sleep(20);} catch (InterruptedException e) {}if (!StringUtils.isEmpty(data)) {//缓存永不过期,被动更新redisUtil.set("city" + i, data);}});log.info("定时更新缓存完成");//启动程序的时候需要等待首次更新缓存完成countDownLatch.countDown();}, 0, 60, TimeUnit.SECONDS);Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {log.info("DB QPS : {}", atomicInteger.getAndSet(0));}, 0, 1, TimeUnit.SECONDS);countDownLatch.await();}
修改后,虽然缓存整体更新的耗时在 21 秒左右,但数据库的压力会比较稳定:
2024-11-14 15:29:03.620 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:29:04.614 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 42
2024-11-14 15:29:05.618 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 44
2024-11-14 15:29:06.613 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 44
2024-11-14 15:29:07.614 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 42
2024-11-14 15:29:08.615 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 45
2024-11-14 15:29:09.615 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 42
2024-11-14 15:29:10.617 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 45
2024-11-14 15:29:11.617 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 43
2024-11-14 15:29:12.613 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 45
2024-11-14 15:29:13.618 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 44
2024-11-14 15:29:14.613 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 45
2024-11-14 15:29:15.615 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 45
2024-11-14 15:29:16.613 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 44
2024-11-14 15:29:17.614 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 41
2024-11-14 15:29:18.613 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 45
2024-11-14 15:29:19.617 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 45
2024-11-14 15:29:20.616 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 44
2024-11-14 15:29:21.613 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 43
2024-11-14 15:29:22.615 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 43
2024-11-14 15:29:23.618 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 44
2024-11-14 15:29:24.613 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 43
2024-11-14 15:29:25.618 INFO 12381 --- [pool-2-thread-1] c.redis.demo.controller.RedisController : DB QPS : 43
注意事项
- 内存是有限的,使用方案二要注意内存问题;
- 在从数据库查询到数据回写缓存时,要判断数据的合法性。
缓存击穿
缓存击穿是指针对某个访问非常频繁的热点数据的请求,无法在缓存中获取,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,直接影响数据库无法处理其他请求。
产生原因
比如,在程序启动的时候,初始化一个热点数据到 Redis 中,过期时间设置为 5 秒,每隔 1 秒输出一下回源的 QPS:
@PostConstructpublic void init() {//初始化一个热点数据到Redis中,过期时间设置为5秒redisUtil.set("hotspot", getExpensiveData(), 5);//每隔1秒输出一下回源的QPSExecutors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {log.info("DB QPS : {}", atomicInteger.getAndSet(0));}, 0, 1, TimeUnit.SECONDS);}@GetMapping("/hotspot")public String wrong() {String data = redisUtil.get("hotspot");if (StringUtils.isEmpty(data)) {data = getExpensiveData();//重新加入缓存,过期时间还是5秒redisUtil.set("hotspot", getExpensiveData(), 5);}return data;}private String getExpensiveData() {try {TimeUnit.MILLISECONDS.sleep(20); //模拟数据库查询操作} catch (InterruptedException e) {e.printStackTrace();}//模拟查询数据库,查一次计数器加一atomicInteger.incrementAndGet();return "hotspotdata" + System.currentTimeMillis();}
可以看到,每隔 5 秒数据库都有 16 的 QPS:
2024-11-14 15:51:04.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:05.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:06.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:07.287 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:08.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 16
2024-11-14 15:51:09.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:10.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:11.289 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:12.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:13.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 16
2024-11-14 15:51:14.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:15.299 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:16.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:17.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:18.287 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 16
2024-11-14 15:51:19.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:20.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 15:51:21.286 INFO 12843 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
解决方案
热点数据永不过期
对于访问特别频繁的热点数据,不设置过期时间。这样一来,对热点数据的访问请求,都可以在缓存中进行处理,而 Redis 数万级别的高吞吐量可以很好地应对大量的并发请求访问。
互斥锁机制限制回源
当热点数据过期时,使用互斥锁来保证只有一个请求去查询数据库并更新缓存,其他请求等待或直接返回过期数据。
@GetMapping("/hotspot")public String right() {String data = redisUtil.get("hotspot");if (StringUtils.isEmpty(data)) {//去获取分布式锁Boolean lock = redisUtil.setIfAbset("hotspot_lock", "1", 1, TimeUnit.SECONDS);if (lock) {try {data = redisUtil.get("hotspot");if (StringUtils.isEmpty(data)) {data = getExpensiveData();redisUtil.set("hotspot", data, 5);}} finally {// 释放锁redisUtil.delete("hotspot_lock");}} else {TimeUnit.SECONDS.sleep(1);data = redisUtil.get("hotspot");}}return data;}
通过锁机制可以把回源到数据库的并发限制在 1。
2024-11-14 16:58:49.396 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:58:50.393 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 1
2024-11-14 16:58:51.397 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:58:52.393 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:58:53.393 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:58:54.393 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:58:55.394 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 1
2024-11-14 16:58:56.392 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:58:57.392 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:58:58.392 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:58:59.393 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:59:00.396 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 1
2024-11-14 16:59:01.392 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:59:02.392 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:59:03.392 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:59:04.392 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:59:05.392 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 1
2024-11-14 16:59:06.392 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:59:07.393 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:59:08.392 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:59:09.394 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 16:59:10.392 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 1
2024-11-14 16:59:11.393 INFO 14525 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
在真实的业务场景下,不一定要这么严格地使用双重检查分布式锁进行全局的并发限制,因为这样虽然可以把数据库回源并发降到最低,但也限制了缓存失效时的并发。
可以考虑的方式是:
- 方案一,使用进程内的锁进行限制,这样每一个节点都可以以一个并发回源数据库;
- 方案二,不使用锁进行限制,而是使用类似
Semaphore
的工具限制并发数,比如限制为5,这样既限制了回源并发数不至于太大,又能使得一定量的线程可以同时回源。
以信号量为例:
private Semaphore semaphore = new Semaphore(5); // 信号量限制最大并发数@GetMapping("/hotspot")
public String right(){String data = redisUtil.get("hotspot");if (!StringUtils.isEmpty(data)) {return data;}try {// 尝试获取信号量semaphore.acquire(1);try {// 再次检查缓存,防止多个线程同时到达这里data = redisUtil.get("hotspot");if (!StringUtils.isEmpty(data)) {return data;}data = getExpensiveData();redisUtil.set("hotspot", data, 5);} finally {// 释放信号量semaphore.release();}} catch (InterruptedException e) {Thread.currentThread().interrupt();log.info("Failed to acquire semaphore");}return data;
}
2024-11-14 17:25:50.841 INFO 15182 --- [nio-8080-exec-2] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2024-11-14 17:25:50.847 INFO 15182 --- [nio-8080-exec-2] o.s.web.servlet.DispatcherServlet : Completed initialization in 6 ms
2024-11-14 17:25:51.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:25:52.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:25:53.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 5
2024-11-14 17:25:54.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:25:55.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:25:56.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:25:57.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:25:58.622 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 5
2024-11-14 17:25:59.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:26:00.620 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:26:01.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:26:02.620 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:26:03.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 5
2024-11-14 17:26:04.621 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:26:05.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:26:06.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:26:07.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
2024-11-14 17:26:08.618 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 5
2024-11-14 17:26:09.621 INFO 15182 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 0
缓存穿透
缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。
此时,应用也无法从数据库中读取数据再回写缓存,来服务后续请求,这样一来,缓存也就成了“摆设”,如果应用持续有大量请求访问数据,就会同时给缓存和数据库带来巨大压力。
这里需要注意,缓存穿透和缓存击穿的区别:
- 缓存穿透是指,缓存没有起到压力缓冲的作用;
- 而缓存击穿是指,缓存失效时瞬时的并发打到数据库。
产生原因
- 恶意攻击:专门访问数据库中没有的数据;
- 业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据。
比如这段代码,去访问一个无效的 id 为 0 的数据,每次都会回源数据库。
@GetMapping("/wrong")public String wrong(@RequestParam("id") int id) {String key = "user" + id;String data = redisUtil.get(key);//无法区分是无效用户还是缓存失效if (StringUtils.isEmpty(data)) {data = getCityFromDb(id);redisUtil.set(key,data,30);}return data;}private String getCityFromDb(int id) {atomicInteger.incrementAndGet();//注意,只有ID介于0(不含)和10000(包含)之间的用户才是有效用户,可以查询到用户信息if (id > 0 && id <= 10000) return "userdata";//否则返回空字符串return "";}
压测后的 QPS:
2024-11-14 17:48:06.497 INFO 15668 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 12
2024-11-14 17:48:07.494 INFO 15668 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 2530
2024-11-14 17:48:08.498 INFO 15668 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 3787
2024-11-14 17:48:09.495 INFO 15668 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 6910
2024-11-14 17:48:10.494 INFO 15668 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 6311
2024-11-14 17:48:11.494 INFO 15668 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 5244
2024-11-14 17:48:12.494 INFO 15668 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 5519
2024-11-14 17:48:13.494 INFO 15668 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 6242
如果这种漏洞被恶意利用的话,就会对数据库造成很大的性能压力。
解决方案
设置一个特殊的值
对于不存在的数据,设置一个特殊的 Value 到缓存中,比如当数据库中查出的用户信息为空的时候,设置 NODATA 这样具有特殊含义的字符串到缓存中,这样下次请求缓存的时候还是可以命中缓存,即直接从缓存返回结果,不查询数据库。
@GetMapping("/right")public String right(@RequestParam("id") int id) {String key = "user" + id;String data = redisUtil.get(key);if (StringUtils.isEmpty(data)) {data = getCityFromDb(id);if (!StringUtils.isEmpty(data)) {redisUtil.set(key,data,30);} else {//设置一个特殊的值redisUtil.set(key,"NODATA",30);}}return data;}
注意:这种方式会把大量无效的数据加入到缓存中,占用空间。
布隆过滤器
布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。
当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:
- 首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
- 然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
- 最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。
如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过数据,那么,bit 数组对应 bit 位的值仍然为 0。
当需要查询某个数据时,就执行刚刚说的计算过程,先得到这个数据在 bit 数组中对应的 N 个位置。
紧接着,查看 bit 数组中这 N 个位置上的 bit 值。只要这 N 个 bit 值有一个为 0,这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。
所以,可以把所有可能的值保存在布隆过滤器中,从缓存读取数据前先过滤一次:
- 如果布隆过滤器认为值不存在,那么值一定是不存在的,无需查询缓存也无需查询数据库;
- 对于极小概率的误判请求,才会最终让非法 Key 的请求走到缓存或数据库。
private BloomFilter bloomFilter;@PostConstruct
public void init() {//创建布隆过滤器,元素数量10000,期望误判率1%bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01);//填充布隆过滤器IntStream.rangeClosed(1, 10000).forEach(bloomFilter::put);
}@GetMapping("right2")
public String right2(@RequestParam("id") int id) {String data = "";//通过布隆过滤器先判断if (bloomFilter.mightContain(id)) {String key = "user" + id;//走缓存查询data = redisUtil.get(key);if (StringUtils.isEmpty(data)) {//走数据库查询data = getCityFromDb(id);if (!StringUtils.isEmpty(data)) {redisUtil.set(key,data,30);} else {//设置一个特殊的值redisUtil.set(key,"NODATA",30);}}}return data;
}
前端进行请求检测
对于大量的恶意请求访问不存在的数据,可以在请求入口前端,对业务系统接收到的请求进行合法性检测,把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和数据库。