MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

缓存穿透问题的防御措施

2024-06-265.3k 阅读

什么是缓存穿透

在后端开发中,缓存是提高系统性能和响应速度的重要手段。缓存的基本原理是将经常访问的数据存储在速度更快的存储介质(如内存)中,当有相同的请求到来时,直接从缓存中获取数据,而不需要经过数据库等相对较慢的数据源。

然而,缓存穿透是一种常见的缓存使用问题。简单来说,缓存穿透指的是客户端请求的数据在缓存中不存在,并且在数据库中也不存在,导致请求每次都会穿透缓存直接查询数据库。这种情况如果大量发生,会给数据库带来巨大的压力,甚至可能导致数据库崩溃。

想象一个电商系统,用户查询商品信息。正常情况下,商品信息会先从缓存中获取,如果缓存中没有,再去数据库查询,然后将查询结果存入缓存。但如果有恶意用户不断请求一个不存在的商品ID,每次请求都会绕过缓存去查询数据库,这就形成了缓存穿透。

缓存穿透产生的原因

  1. 恶意攻击:如前文所述,恶意用户可能故意构造大量不存在的数据请求,以此来消耗数据库资源,达到破坏系统的目的。例如,黑客可能利用自动化工具,不断发送不存在的用户ID或订单号的查询请求。
  2. 业务逻辑漏洞:在某些情况下,业务代码可能存在逻辑错误,导致产生不合理的查询条件。比如,在一个用户注册系统中,如果对用户输入的手机号码没有进行严格的格式校验,用户可能输入一个格式错误且不存在的手机号码进行查询,而系统没有对这种情况进行合理处理,就会引发缓存穿透。
  3. 数据更新不同步:当数据库中的数据被删除,但缓存中的数据没有及时更新时,也可能导致缓存穿透。假设一个商品被从数据库中删除,但由于缓存更新的操作出现异常(如网络问题、缓存服务故障等),缓存中仍然保留着该商品的信息。此时,如果有请求查询该商品,就会因为缓存中数据已失效,而数据库中数据已删除,导致请求穿透到数据库。

缓存穿透的危害

  1. 数据库压力增大:大量原本可以被缓存拦截的请求直接到达数据库,会使数据库的负载急剧上升。数据库需要处理远超正常水平的查询请求,可能导致数据库响应变慢,甚至出现服务不可用的情况。在高并发场景下,这种压力可能瞬间压垮数据库服务器。
  2. 系统性能下降:由于请求需要等待数据库的响应,整体系统的响应时间会显著增加。这不仅会影响用户体验,还可能导致前端页面长时间加载不出数据,降低用户对系统的满意度。在一些对响应时间要求极高的业务场景(如实时交易系统)中,缓存穿透可能直接导致交易失败,给企业带来经济损失。
  3. 可用性降低:当数据库因承受过大压力而崩溃时,整个依赖数据库的业务系统都将无法正常运行。这可能导致服务中断,影响企业的正常运营。例如,一个在线购物平台如果因为缓存穿透导致数据库崩溃,用户将无法进行商品查询、下单等操作,严重影响平台的商业活动。

缓存穿透的防御措施

1. 布隆过滤器(Bloom Filter)

布隆过滤器原理

布隆过滤器是一种概率型数据结构,它可以高效地判断一个元素是否存在于一个集合中。它的原理基于一组哈希函数和一个位数组。

假设有一个位数组,初始时所有位都为0。当一个元素加入集合时,通过多个哈希函数对该元素进行计算,得到多个哈希值,然后将位数组中对应哈希值的位置设为1。当要判断一个元素是否在集合中时,同样通过哈希函数计算哈希值,检查位数组中对应位置是否都为1。如果都为1,则该元素可能存在(存在误判的可能性);如果有任何一个位置为0,则该元素一定不存在。

例如,有一个位数组长度为10,有两个哈希函数H1和H2。当元素“apple”加入集合时,H1(“apple”) = 3,H2(“apple”) = 7,那么将位数组的第3位和第7位设为1。当要判断“banana”是否在集合中时,计算H1(“banana”) = 5,H2(“banana”) = 8,如果位数组的第5位和第8位都是1,就认为“banana”可能在集合中。

使用布隆过滤器解决缓存穿透

在处理缓存穿透问题时,可以在缓存之前添加布隆过滤器。当有请求到来时,先通过布隆过滤器判断数据是否存在。如果布隆过滤器判断数据不存在,就直接返回,不再查询数据库,从而避免缓存穿透。

以下是使用Guava库实现布隆过滤器的Java代码示例:

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

public class BloomFilterExample {
    private static final int EXPECTED_INSERTIONS = 1000000;
    private static final double FALSE_POSITIVE_PROBABILITY = 0.01;
    private static BloomFilter<Integer> bloomFilter = BloomFilter.create(
            Funnels.integerFunnel(), EXPECTED_INSERTIONS, FALSE_POSITIVE_PROBABILITY);

    static {
        // 假设这里从数据库加载已有的数据ID,并添加到布隆过滤器中
        for (int i = 0; i < 1000000; i++) {
            bloomFilter.put(i);
        }
    }

    public static boolean mightContain(int id) {
        return bloomFilter.mightContain(id);
    }
}

在实际应用中,可以结合业务逻辑,在查询数据库之前调用mightContain方法:

public class ProductService {
    public Product getProductById(int productId) {
        if (!BloomFilterExample.mightContain(productId)) {
            return null;
        }
        // 从缓存中查询产品
        Product product = cache.get(productId);
        if (product != null) {
            return product;
        }
        // 缓存中没有,从数据库查询
        product = database.getProductById(productId);
        if (product != null) {
            cache.put(productId, product);
        }
        return product;
    }
}

布隆过滤器的优缺点

优点:

  • 空间效率高:相比于存储所有元素的集合,布隆过滤器占用的空间非常小。因为它只需要一个位数组和几个哈希函数,不需要存储元素本身。
  • 查询速度快:布隆过滤器的查询操作只需要计算几个哈希值并检查位数组中的对应位置,时间复杂度接近常数级,非常适合高并发场景下的快速判断。

缺点:

  • 存在误判:布隆过滤器只能判断元素可能存在,无法精确判断元素是否存在。误判率与位数组的大小、哈希函数的个数等因素有关。不过,通过合理设置参数,可以将误判率控制在较低水平。
  • 无法删除元素:由于布隆过滤器的设计原理,直接删除元素会影响其他元素的判断结果。如果需要支持删除操作,需要使用一些扩展的布隆过滤器(如Counting Bloom Filter),但这会增加实现的复杂度和空间消耗。

2. 缓存空值

缓存空值原理

缓存空值是一种简单直接的解决缓存穿透的方法。当查询数据库发现数据不存在时,仍然将一个空值(如null或者自定义的空对象)存入缓存,并设置一个较短的过期时间。这样,后续相同的请求就会直接从缓存中获取到空值,而不会穿透到数据库。

例如,在查询商品信息时,如果数据库中不存在ID为123的商品,就将null存入缓存,缓存的过期时间设置为1分钟。在这1分钟内,再次查询ID为123的商品,直接从缓存中获取到null,避免了对数据库的查询。

缓存空值的代码示例(以Python和Redis为例)

import redis

redis_client = redis.Redis(host='localhost', port=6379, db=0)


def get_product(product_id):
    product = redis_client.get(product_id)
    if product is not None:
        if product == b'null':
            return None
        return product.decode('utf-8')
    product = get_product_from_database(product_id)
    if product is None:
        redis_client.setex(product_id, 60, 'null')  # 缓存空值1分钟
        return None
    redis_client.setex(product_id, 3600, product)  # 缓存正常数据1小时
    return product


def get_product_from_database(product_id):
    # 模拟从数据库查询
    if product_id == 1:
        return 'Product 1'
    return None


缓存空值的优缺点

优点:

  • 实现简单:不需要引入额外的复杂数据结构,只需要在缓存操作中添加对空值的处理逻辑即可。对于已经使用缓存的系统,这种方式很容易集成。
  • 兼容性好:几乎适用于所有的缓存系统,无论是Redis、Memcached还是其他缓存技术,都可以采用这种方法。

缺点:

  • 占用缓存空间:虽然空值占用的空间相对较小,但如果存在大量的空值缓存,仍然会占用一定的缓存资源。特别是在缓存空间有限的情况下,可能会影响正常数据的缓存。
  • 数据一致性问题:如果数据库中的数据发生变化(如原本不存在的数据被插入),由于缓存空值设置了过期时间,在过期之前,查询仍然会返回空值,导致数据不一致。需要在数据插入时及时清理相关的空值缓存。

3. 接口层校验

接口层校验原理

在系统的接口层对输入参数进行严格校验,可以避免不合理的请求到达缓存和数据库。例如,对请求参数的格式、范围等进行检查,确保请求的数据是合法且可能存在的。

以用户登录接口为例,对输入的用户名和密码进行格式校验,用户名必须是字母和数字组成,长度在6到20位之间,密码长度在8到16位之间。如果参数不符合要求,直接返回错误信息,不再进行后续的缓存和数据库查询。

接口层校验的代码示例(以Spring Boot和Java为例)

import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;

@RestController
@Validated
public class UserController {

    @PostMapping("/login")
    public String login(@RequestBody @Valid LoginRequest request) {
        // 进行业务逻辑处理,这里省略
        return "Login success";
    }

    static class LoginRequest {
        @NotBlank(message = "用户名不能为空")
        @Size(min = 6, max = 20, message = "用户名长度必须在6到20位之间")
        private String username;

        @NotBlank(message = "密码不能为空")
        @Size(min = 8, max = 16, message = "密码长度必须在8到16位之间")
        private String password;

        // getters and setters
        public String getUsername() {
            return username;
        }

        public void setUsername(String username) {
            this.username = username;
        }

        public String getPassword() {
            return password;
        }

        public void setPassword(String password) {
            this.password = password;
        }
    }
}

接口层校验的优缺点

优点:

  • 简单有效:通过简单的参数校验逻辑,可以拦截大量不合理的请求,从源头上减少缓存穿透的可能性。这种方法不需要复杂的技术架构,容易实现和维护。
  • 提高系统安全性:除了防止缓存穿透,接口层校验还可以防止一些恶意的参数攻击,如SQL注入、XSS攻击等,增强系统的安全性。

缺点:

  • 无法应对所有情况:对于一些合法但不存在的数据请求,接口层校验无法拦截。例如,在一个商品查询系统中,用户输入一个合法的但不存在的商品ID,接口层无法判断该ID是否真实存在,仍然可能导致缓存穿透。
  • 业务逻辑耦合:接口层校验的规则可能与业务逻辑紧密相关,当业务规则发生变化时,需要同时修改接口校验逻辑,增加了维护成本。

4. 动态实时更新缓存

动态实时更新缓存原理

在数据发生变化(如插入、更新、删除)时,及时更新缓存,确保缓存中的数据与数据库保持一致。这样可以避免因为缓存数据与数据库数据不一致而导致的缓存穿透问题。

例如,在电商系统中,当一个商品被删除时,不仅要从数据库中删除该商品记录,还要立即从缓存中删除对应的商品信息。当有新商品添加时,同时将新商品信息存入缓存。

动态实时更新缓存的代码示例(以MySQL和Redis为例,使用Spring Data JPA和Spring Data Redis)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@EnableJpaRepositories
public class ProductService {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private CacheManager cacheManager;

    @Transactional
    public Product saveProduct(Product product) {
        Product savedProduct = productRepository.save(product);
        cacheManager.getCache("products").put(savedProduct.getId(), savedProduct);
        return savedProduct;
    }

    @Transactional
    public void deleteProductById(Long id) {
        productRepository.deleteById(id);
        cacheManager.getCache("products").evict(id);
    }

    public Product getProductById(Long id) {
        Product product = cacheManager.getCache("products").get(id, Product.class);
        if (product == null) {
            product = productRepository.findById(id).orElse(null);
            if (product != null) {
                cacheManager.getCache("products").put(id, product);
            }
        }
        return product;
    }
}

动态实时更新缓存的优缺点

优点:

  • 数据一致性高:能够确保缓存中的数据与数据库实时同步,从根本上避免因为数据不一致导致的缓存穿透问题。在对数据一致性要求较高的业务场景中,这种方法非常适用。
  • 减少误判:与布隆过滤器等方法相比,动态实时更新缓存不存在误判的情况,因为缓存中的数据始终与数据库保持一致。

缺点:

  • 实现复杂:需要在数据的增删改操作中都添加缓存更新逻辑,并且要确保缓存更新操作的原子性和可靠性。如果系统中存在多个数据源或者复杂的业务逻辑,实现起来会比较困难。
  • 性能开销:每次数据变化都要更新缓存,会增加系统的性能开销。特别是在高并发场景下,缓存更新操作可能会成为系统的性能瓶颈。需要合理设计缓存更新策略,如批量更新、异步更新等,以降低性能影响。

5. 互斥锁(Mutex Lock)

互斥锁原理

互斥锁是一种控制并发访问的机制。在处理缓存穿透问题时,可以利用互斥锁来保证在同一时间只有一个请求能够查询数据库。当缓存中没有数据时,第一个请求获取互斥锁,查询数据库并将结果存入缓存,然后释放互斥锁。后续的请求在等待互斥锁释放后,直接从缓存中获取数据。

例如,在一个高并发的商品查询系统中,当多个请求同时查询一个不存在于缓存中的商品时,只有一个请求能够获得互斥锁去查询数据库。其他请求等待该请求查询完成并将数据存入缓存后,再从缓存中获取数据。

互斥锁的代码示例(以Java和Redis实现分布式互斥锁为例)

import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;

public class MutexLockExample {
    private static final String LOCK_KEY = "product_lock:";
    private static final String LOCK_VALUE = System.currentTimeMillis() + "";
    private static final int EXPIRE_TIME = 10; // 锁的过期时间,单位秒

    public static boolean tryLock(String productId, Jedis jedis) {
        SetParams setParams = new SetParams().nx().ex(EXPIRE_TIME);
        String result = jedis.set(LOCK_KEY + productId, LOCK_VALUE, setParams);
        return "OK".equals(result);
    }

    public static void unlock(String productId, Jedis jedis) {
        jedis.del(LOCK_KEY + productId);
    }
}

在商品查询方法中使用互斥锁:

public class ProductService {
    public Product getProductById(String productId) {
        Jedis jedis = new Jedis("localhost", 6379);
        Product product = getProductFromCache(productId);
        if (product == null) {
            if (MutexLockExample.tryLock(productId, jedis)) {
                try {
                    product = getProductFromDatabase(productId);
                    if (product != null) {
                        setProductToCache(productId, product);
                    }
                } finally {
                    MutexLockExample.unlock(productId, jedis);
                }
            } else {
                // 等待一段时间后重试获取锁或者直接返回,这里简单处理直接返回
                return null;
            }
        }
        jedis.close();
        return product;
    }

    private Product getProductFromCache(String productId) {
        // 从缓存获取商品逻辑
        return null;
    }

    private Product getProductFromDatabase(String productId) {
        // 从数据库获取商品逻辑
        return null;
    }

    private void setProductToCache(String productId, Product product) {
        // 将商品存入缓存逻辑
    }
}

互斥锁的优缺点

优点:

  • 有效防止缓存穿透:通过控制并发请求对数据库的访问,避免了大量请求同时查询数据库,从而有效防止缓存穿透。
  • 实现相对简单:相比于一些复杂的分布式系统设计,基于互斥锁的方法实现起来相对容易理解和实现,对于一些小型系统或者对性能要求不是特别高的场景比较适用。

缺点:

  • 性能瓶颈:在高并发场景下,互斥锁会成为性能瓶颈。因为同一时间只有一个请求能够获取锁并查询数据库,其他请求需要等待,这会降低系统的并发处理能力。
  • 死锁风险:如果在获取锁后,业务逻辑出现异常导致锁没有正常释放,就可能会出现死锁问题,影响系统的正常运行。需要在代码中添加异常处理机制,确保锁能够被正确释放。

6. 结合多种防御措施

在实际应用中,单一的防御措施可能无法完全解决缓存穿透问题,或者在某些场景下存在局限性。因此,通常会结合多种防御措施来提高系统的健壮性。

例如,可以在接口层进行参数校验,减少不合理的请求;同时使用布隆过滤器快速判断数据是否可能存在;对于查询到不存在的数据,采用缓存空值的方式;在数据发生变化时,通过动态实时更新缓存保证数据一致性。

以一个电商订单查询系统为例,系统架构如下:

  1. 接口层:对订单ID进行格式校验,确保订单ID是合法的数字格式。如果格式不正确,直接返回错误信息。
  2. 布隆过滤器:在缓存之前部署布隆过滤器,将历史订单ID加入布隆过滤器。当有订单查询请求时,先通过布隆过滤器判断订单ID是否可能存在。如果布隆过滤器判断不存在,直接返回,不再查询缓存和数据库。
  3. 缓存层:如果布隆过滤器判断订单ID可能存在,先从缓存中查询订单信息。如果缓存中有数据,直接返回;如果缓存中没有数据,获取互斥锁。
  4. 数据库层:获取互斥锁的请求查询数据库。如果数据库中存在订单信息,将订单信息存入缓存,并返回给客户端;如果数据库中不存在订单信息,缓存空值,并返回。同时,在订单数据发生变化(如新增订单、修改订单状态、删除订单)时,及时更新缓存和布隆过滤器。

通过这种综合的防御措施,可以在不同层面应对缓存穿透问题,提高系统的稳定性和性能。

综上所述,缓存穿透是后端开发中需要重视的问题,通过合理选择和组合上述防御措施,可以有效地减少缓存穿透对系统造成的影响,确保系统的高效稳定运行。在实际应用中,需要根据业务场景的特点、系统的性能要求等因素,灵活选择和优化缓存穿透的防御策略。