分布式专题——6 Redis缓存设计与性能优化

发布于:2025-09-11 ⋅ 阅读:(17) ⋅ 点赞:(0)

1 多级缓存架构

在这里插入图片描述

2 缓存设计

2.1 缓存穿透

2.1.1 简介

  • 缓存穿透是什么?当查询一个根本不存在的数据时,缓存层和存储层都不会命中。正常逻辑下,存储层查不到数据就不会写入缓存层。这会导致:每次请求这个不存在的数据,都要去存储层查询,缓存就失去了保护后端存储的意义,存储层可能被大量无效请求压垮;

  • 出现缓存穿透的原因一般有以下两个:

    • 自身业务代码或数据有问题,导致查询了本不该存在的数据;

    • 遭遇恶意攻击、爬虫等,它们会发起大量查询不存在数据的请求。

2.1.2 缓存空对象

String get(String key) {
    // 从缓存中获取数据
    String cacheValue = cache.get(key);
    // 如果缓存为空(包括空字符串或null值)
    if (StringUtils.isBlank(cacheValue)) {
        // 缓存未命中,从持久存储层(如数据库)中获取数据
        String storageValue = storage.get(key);
        // 将从存储层获取的值写入缓存,以便后续请求可以直接从缓存中获取
        cache.set(key, storageValue);
        // 如果存储层中也不存在该键对应的值(即值为null)
        if (storageValue == null) {
            // 将"空值"(null)也存入缓存,并设置一个较短的过期时间(5分钟)
            // 这可以防止缓存穿透(大量请求查询不存在的key)
            cache.expire(key, 60 * 5);
        }
        // 返回从存储层获取的值(可能为null)
        return storageValue;
    } else {
        // 缓存命中,直接返回缓存中的值
        return cacheValue;
    }
}

2.1.3 布隆过滤器

  • 针对恶意攻击等导致的“大量请求不存在数据”的缓存穿透场景,可以用布隆过滤器先做一次过滤,其判定逻辑是:

    • 如果布隆过滤器说“某个值不存在”,那这个值肯定不存在
    • 如果它说“某个值存在”,这个值可能不存在(有误判概率);
  • 原理

    在这里插入图片描述

    • 它由大型位数组多个无偏 hash 函数(“无偏”指能把元素的 hash 值分布得比较均匀)组成;

    • 添加元素(如 key):用多个 hash 函数对 key 做 hash,得到整数索引值,其再对位数组长度取模,得到具体位置(每个 hash 函数都会算得一个不同的位置),最后把这些位置都置为 1,就完成了添加;

    • 查询元素是否存在:同样用多个 hash 函数算出 key 对应的位置,看位数组中这些位置是否都为 1。只要有一个位为 0,说明 key 不存在;如果都为 1,只能说“很可能存在”(因为其他 key 也可能把这些位置置为 1,导致误判);

    • 位数组越稀疏(空闲位多),误判概率越大;越拥挤(1 多),误判概率越低;

  • 适用场景:数据命中不高(很多请求查的是不存在数据)、数据相对固定(新增/变更不频繁)、实时性低(能接受一定延迟或误判)且数据集较大的场景。

  • 它的优势是缓存空间占用极少,但代码维护相对复杂;

  • 注意:布隆过滤器不能删除数据,如果要删除元素,得重新初始化所有数据(因为位数组的位一旦置为 1,无法精准回退,删除会破坏现有判断逻辑);

  • 可以用 Redisson 实现布隆过滤器,引入依赖:

    <dependency>
       <groupId>org.redisson</groupId>
       <artifactId>redisson</artifactId>
       <version>3.6.5</version>
    </dependency>
    
  • 示例伪代码:初始化与使用

    package com.redisson;
    
    import org.redisson.Redisson;
    import org.redisson.api.RBloomFilter;
    import org.redisson.api.RedissonClient;
    import org.redisson.config.Config;
    
    public class RedissonBloomFilter {
    
        public static void main(String[] args) {
            // 创建Redisson配置对象
            Config config = new Config();
            // 配置单机Redis服务器地址
            config.useSingleServer().setAddress("redis://localhost:6379");
            // 根据配置创建Redisson客户端实例
            RedissonClient redisson = Redisson.create(config);
    
            // 从Redisson客户端获取或创建一个名为"nameList"的布隆过滤器
            RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
            // 初始化布隆过滤器参数:
            // - 预计要插入的元素数量:100,000,000个
            // - 期望的误判率:3%(即允许有3%的概率将不存在的元素误判为存在)
            // 底层会根据这两个参数自动计算所需的bit数组大小和哈希函数数量
            bloomFilter.tryInit(100000000L, 0.03);
            // 向布隆过滤器中插入元素"shisan"
            bloomFilter.add("shisan");
    
            // 检查元素是否可能存在于布隆过滤器中
            System.out.println(bloomFilter.contains("guojia")); // false
            System.out.println(bloomFilter.contains("jiating")); // false
            System.out.println(bloomFilter.contains("shisan")); // true
            
            // 注意:布隆过滤器的特性:
            // 1. 如果contains()返回false,则该元素一定不存在
            // 2. 如果contains()返回true,则该元素可能存在(可能有误判)
            // 3. 布隆过滤器不支持元素删除操作
        }
    }
    
  • 使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在后续新增数据时也要记得往布隆过滤器里放,布隆过滤器缓存过滤伪代码:

    RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
    bloomFilter.tryInit(100000000L,0.03);
            
    // 初始化方法:将所有数据预先加载到布隆过滤器中
    // 这通常在系统启动时执行一次,用于构建完整的布隆过滤器
    void init(){
        // 遍历所有键,将其添加到布隆过滤器中
        for (String key: keys) {
            // 将键放入布隆过滤器,建立快速查找的索引
            bloomFilter.put(key);
        }
    }
    
    // 根据键获取值的业务方法,集成了布隆过滤器优化查询
    String get(String key) {
        // 首先通过布隆过滤器快速判断key是否可能存在
        Boolean exist = bloomFilter.contains(key);
        // 如果布隆过滤器确认key不存在,直接返回空字符串,避免后续不必要的缓存和存储查询
        if(!exist){
            return "";
        }
        
        // 布隆过滤器判断key可能存在,继续从Redis缓存中获取数据
        String cacheValue = cache.get(key);
        if (StringUtils.isBlank(cacheValue)) {
            String storageValue = storage.get(key);
            cache.set(key, storageValue);
            if (storageValue == null) {
                cache.expire(key, 60 * 5);
            }
            return storageValue;
        } else {
            return cacheValue;
        }
    }
    

2.2 缓存击穿(失效)

  • 缓存击穿(失效)是什么?大批量缓存数据在同一时间过期失效时,会导致大量请求同时无法从缓存中获取数据,只能直接穿透到数据库查询。这可能瞬间给数据库带来巨大压力,甚至导致数据库崩溃;

  • 这种情况通常发生在:

    • 系统初始化时批量加载了一批数据到缓存,且设置了相同的过期时间;

    • 某类热点数据集中过期(比如电商促销活动结束时间统一);

  • 解决方案:错开缓存过期时间,即在批量设置缓存时,将过期时间分散在一个时间区间内,而不是使用固定值。这样可以避免大量缓存同时失效,将数据库压力分散到不同时间点;

    String get(String key) {
        
        String cacheValue = cache.get(key);
        if (StringUtils.isBlank(cacheValue)) {
            String storageValue = storage.get(key);
            cache.set(key, storageValue);
            // 生成一个300到600秒之间的随机过期时间(5到10分钟)
            // 这种随机化策略可以有效防止缓存雪崩(大量缓存同时失效导致请求直接打到数据库)
            int expireTime = new Random().nextInt(300) + 300;
            if (storageValue == null) {
                // 为不存在的键设置随机过期时间
                // 这样即使大量不存在的键同时被缓存,它们也会在不同的时间点过期
                cache.expire(key, expireTime);
            }
            return storageValue;
        } else {
            return cacheValue;
        }
    }
    

2.3 缓存雪崩

  • **缓存雪崩是什么?**当缓存层因为各种原因(像遭遇超大并发请求但是系统扛不住、缓存设计不佳,比如大量请求访问“大 key”导致缓存能支撑的并发量急剧下降等),无法正常提供服务甚至宕机时,原本由缓存层承接的大量请求,会像失控的野牛一样,全部涌向后端存储层。由于存储层原本依赖缓存层分担压力,突然面临这么多请求,调用量暴增,很可能也会被压垮,进而引发级联宕机的严重情况;

  • 如何预防和解决缓存雪崩?

    • 保证缓存层服务高可用性:可以采用像 Redis Sentinel(哨兵模式)或者 Redis Cluster(集群模式)这样的方案。Redis Sentinel 能对 Redis 进行监控、提醒和自动故障转移,确保缓存服务的可用性;Redis Cluster 则通过将数据分布在多个节点上,实现数据的高可用和高并发访问;

    • 依赖隔离组件进行后端限流、熔断与降级:可以使用 Sentinel 或者 Hystrix 这类限流降级组件

      • 服务降级方面,能针对不同数据采取不同处理方式;
      • 比如对于非核心数据(像电商商品属性、用户信息等),当缓存层出问题时,暂时停止从缓存查询这些数据,直接返回预先定义好的默认降级信息、空值或者错误提示;
      • 而对于核心数据(像电商商品库存),仍然允许查询缓存,若缓存里没有,还能从数据库读取;
      • 这样既保障核心业务不受太大影响,又减轻了存储层压力;
    • 提前演练:在项目上线之前,模拟缓存层宕机的情况,演练应用以及后端的负载情况和可能出现的问题,然后基于演练结果制定相应的预案,以便在实际出现缓存雪崩时能快速应对。

2.4 热点缓存 key 重建优化

  • 当同时满足以下两个条件时,可能对应用造成致命危害:

    • 热点key:某个缓存key访问量极大(比如热门新闻、爆款商品)

    • 缓存重建耗时:从数据源(如数据库)重新加载并计算该 key 对应的缓存数据需要很长时间(可能涉及复杂SQL、多次IO操作或多个依赖服务调用)

    • 此时,当这个热点 key 的缓存过期失效瞬间,会有大量并发线程同时发现缓存缺失,然后同时去重建缓存,这会导致后端数据源压力骤增,甚至可能让应用崩溃;

  • 解决方案:互斥锁机制,即只允许一个线程负责重建缓存,其他线程等待重建完成后再从缓存获取数据,避免大量线程同时冲击数据源

    String get(String key) {
        // 从Redis缓存中获取指定key的数据
        String value = redis.get(key);
        // 如果缓存中不存在该key的值(缓存未命中)
        if (value == null) {
            // 创建互斥锁的key,格式为"mutext:key:原key",用于防止缓存击穿
            String mutexKey = "mutext:key:" + key;
            // 尝试获取分布式锁:设置一个值为"1"的锁,过期时间为180秒,只有在key不存在时才能设置成功(NX选项)
            if (redis.set(mutexKey, "1", "ex 180", "nx")) {
                try {
                    // 成功获取到锁的线程,从数据库(或其他数据源)获取真实数据
                    value = db.get(key);
                    // 将获取到的数据写入Redis缓存,并设置正常的过期时间
                    redis.setex(key, timeout, value);
                } finally {
                    // 无论是否成功获取数据,都释放分布式锁
                    redis.delete(mutexKey);
                }
            } else {
                // 未获取到锁的线程(其他线程正在重构缓存),等待50毫秒,让持有锁的线程完成缓存重构
                Thread.sleep(50);
                // 递归调用自身,重新尝试从缓存获取数据(此时可能已经重构完成)
                return get(key);
            }
        }
        // 返回获取到的值(可能来自缓存,也可能是刚重构的数据)
        return value;
    }
    

2.5 缓存与数据库双写不一致

  • 在大并发下,同时操作缓存与数据库会存在数据不一致性的问题;

    • 双写不一致:多线程并发写数据库和更新缓存时,因执行顺序差异,可能导致缓存与数据库数据不匹配

      在这里插入图片描述

      • 比如线程1先写数据库后更新缓存,线程2写数据库后更新缓存,若线程1的缓存更新动作滞后,就会使缓存数据不符合最终数据库状态;
    • 读写并发不一致:读写操作并发时,也易引发数据不一致

      在这里插入图片描述

      • 像线程1写数据库后删除缓存,线程2写数据库后删除缓存,线程3查缓存(为空)后查数据库(取到线程1写入的旧数据)并更新缓存,最终缓存会留存旧数据,与数据库最新数据(线程2写入的)不符;
  • 解决方案

    • 对于并发概率小的数据(如个人订单、用户数据),因本身并发冲突少,很少出现缓存不一致,可给缓存设过期时间,通过定时读操作主动更新缓存;

    • 若业务能容忍短时缓存不一致(如商品名称、分类菜单),给缓存加过期时间,也能满足大部分业务对缓存的需求,过期后缓存会重新从数据库加载最新数据;

    • 若有强一致性要求

      • 加分布式读写锁,保证并发读写或写写操作有序进行,读操作间无锁,既保障数据一致性,又尽可能减少对读性能的影响;

      • 用阿里开源的 Canal,监听数据库 binlog 日志,实时修改缓存。当数据库数据变更,binlog 记录变更,Canal 监听到后同步更新缓存,能实时保证缓存与数据库一致,但引入了新中间件,增加了系统复杂度;

        在这里插入图片描述

  • 总结:

    • 缓存主要用于读多写少场景以提升性能,若写多读多且不能容忍缓存不一致,直接操作数据库更合适;若数据库压力大,也可将缓存作为主存储,异步同步数据到数据库,数据库作为备份;

    • 放入缓存的数据应是对实时性、一致性要求不高的,不要为追求缓存绝对一致做过度设计,否则会徒增系统复杂度。

3 开发规范与性能优化

3.1 键与值的设计

3.1.1 key 的设计

  • 可读性和可管理性

    • 避免不同业务 / 模块的 key 冲突,同时让 key 更容易理解和维护;

    • 业务名(或数据库名)作为前缀,用**冒号(:)**分隔不同层级的信息;

    • 例:trade:order:1,能清晰看出这是“交易(trade)”业务下,“订单(order)”模块中 ID 为 1 的订单数据对应的 key;

  • 简洁性

    • 减少 key 的长度,降低内存占用(当 key 数量极多时,长 key 会累积占用较多内存);

    • 保证语义清晰的前提下,对 key 进行简化缩写;

    • 例:把 user:{uid}:friends:messages:{mid} 简化为 u:{uid}:fr:m:{mid},通过缩写(userufriendsfrmessagesm)缩短 key 长度,同时仍能体现“用户 - 好友 - 消息”的层级关系;

  • 不要包含特殊字符

    • 避免特殊字符导致 key 解析、存储或访问时出现异常(如语法错误、转义问题等);

    • key 中不能包含空格、换行、单双引号以及其他转义字符(如 \ 等);

    • 原因:这些特殊字符可能会干扰缓存系统对 key 的识别,甚至引发程序报错,影响缓存的正常使用。

3.1.2 value 的设计

  • 在 Redis 中,一个字符串最大 512 MB,一个二级数据结构(例如 Hash、List、Set、Zset)可以存储大约 40 亿(2^32-1)个元素,但实际上如果出现下面两种情况,就认为它是 bigkey

    • 字符串类型:它的“big”体现在单个 value 值很大,一般认为超过 10KB 就是 bigkey;
    • 非字符串类型:哈希、列表、集合、有序集合,它们的 big 体现在元素个数太多(建议不超过5000个);
  • bigkey 的危害

    • Redis阻塞:操作 bigkey 需要消耗更多 CPU 和内存资源,可能导致 Redis 服务阻塞;

    • 网络拥塞:bigkey 会产生大量网络流量,例如 1MB 的 bigkey 每秒被访问 1000 次,会产生 1000MB/s 的流量,远超普通千兆网卡的承载能力;

    • 过期删除问题:bigkey 过期时,如果没有启用 Redis 4.0 的异步删除功能,删除操作可能阻塞 Redis;

  • bigkey 的产生原因:程序设计不当、对数据规模预估不足。例:

    • 社交类:粉丝列表,对于某些明星或者大v的粉丝列表,如果不精心设计一下,必是 bigkey;
    • 统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是 bigkey;
    • 缓存类:将数据从数据库 load 出来序列化放到 Redis 时,把所有字段或关联数据都缓存,造成 bigkey;
  • bigkey 的优化方案

    • 拆分

      • 对于 List:将一个大 List 拆分为多个小 List;
      • 对于Hash:将大 hash 按分段存储(如将100万用户数据拆分为200个key,每个存储5000个用户);
    • 如果有 bigkey,对其的操作:避免一次性获取或删除所有元素(会阻塞),使用 hmget 而非 hgetall,采用 hscan、sscan、zscan 等渐进式删除方法;

    • 选择适合的数据类型

      • 反例:将一个实体的不同属性用多个 string 存储(如set user:1:nameset user:1:age等);
      • 正例:使用 Hash 存储实体数据(如hmset user:1 name tom age 19 favor football),更节省内存且操作更高效;
    • 控制 key 的生命周期

      • Redis 通常不是用于持久存储,应给 key 设置合理的过期时间;
      • 条件允许时,应打散过期时间,避免大量 key 集中过期导致缓存击穿问题。

3.2 命令使用

  • 关注O(N)命令的N值大小

    • hgetalllrangesmemberszrangesinter等命令的时间复杂度是O(N),执行效率与数据量N直接相关;

      hgetall:获取哈希表中所有字段和值,适用于小型哈希但可能阻塞Redis服务
      lrange:获取列表指定范围内的元素,支持分页查询列表数据
      smembers:返回集合中的所有成员,当集合很大时会导致Redis阻塞
      zrange:返回有序集合中指定排名范围的成员(可带分数),支持按排名范围查询
      sinter:计算多个集合的交集,返回所有给定集合中都存在的成员

    • 并非不能使用这些命令,但必须清楚N的具体数量,避免在大数据量上(比如 bigkey)执行;

    • 有遍历需求时,推荐使用hscansscanzscan等渐进式遍历命令,它们可以分批获取数据,避免一次性处理大量数据导致 Redis 阻塞;

      hscan:增量迭代哈希表中的键值对,避免一次性获取大哈希造成的阻塞
      sscan:增量迭代集合中的元素,安全遍历大集合的解决方案
      zscan:增量迭代有序集合中的元素和分数,用于处理大型有序集合

  • 禁用危险命令

    • 禁止在线上环境使用keysflushallflushdb等命令:

      • keys命令会遍历整个数据库,在数据量大时会严重阻塞Redis
      • flushallflushdb会清空数据库,风险极高
    • 可以通过Redis的rename机制禁用这些命令,或用scan命令替代keys进行渐进式处理

  • 合理使用select命令(多数据库)

    • Redis 的多数据库功能较弱,其通过数字(0-15)区分不同数据库

    • 很多客户端对多数据库支持不好,且多业务共用同一 Redis 实例的不同数据库时,仍然是单线程处理,会相互干扰

    • 建议谨慎使用多数据库,更好的做法是按业务拆分不同的 Redis 实例

  • 使用批量操作提高效率

    • 原生命令:如mgetmset,可以一次性操作多个key,减少网络往返

    • pipeline:非原生命令的批量处理方式,能打包多个命令一次性发送

    • 注意事项:

      • 控制批量操作的元素个数(建议500以内,具体与元素大小有关),避免单次操作过大
      • 原生命令是原子操作,pipeline 是非原子操作
      • pipeline 可以打包不同命令,原生命令只能处理同类型命令
      • pipeline 需要客户端和服务端同时支持
  • 谨慎使用事务功能

    • Redis 的事务功能相对较弱,不建议过多依赖

    • 可以使用 Lua 脚本替代事务,Lua 脚本在 Redis 中是原子执行的,能保证复杂操作的原子性

3.3 客户端使用

3.3.1 连接池

  • 客户端实例使用建议:避免多个应用共用一个 Redis 实例,建议不同业务拆分 Redis 实例,可防止公共数据服务劣化,让各业务数据访问更独立、稳定;

  • 推荐使用连接池:能有效控制连接数,提升效率;

    // 通过JedisPoolConfig配置连接池参数
    JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
    jedisPoolConfig.setMaxTotal(5); // 最大连接数
    jedisPoolConfig.setMaxIdle(2); // 最大空闲连接数
    jedisPoolConfig.setTestOnBorrow(true); // 向资源池借用连接时是否做连接有效性检测(ping)
    
    // 基于配置创建JedisPool获取连接
    JedisPool jedisPool = new JedisPool(jedisPoolConfig, "192.168.0.60", 6379, 3000, null);
    
    Jedis jedis = null;
    try {
        jedis = jedisPool.getResource();
        //具体的命令
        jedis.executeCommand()
    } catch (Exception e) {
        logger.error("op key {} error: " + e.getMessage(), key, e);
    } finally {
        //注意这里不是关闭连接,在JedisPool模式下,Jedis会被归还给资源池
        if (jedis != null) 
            jedis.close();
    }
    

3.3.2 连接池参数含义与优化建议(连接池预热)

  • maxTotal:最大连接数(早期版本叫maxActive),需结合业务 Redis 并发量、客户端执行命令耗时、Redis 资源等因素确定,通常比理论计算值略大,同时要注意不能超过 Redis 最大连接数(maxclients)限制;

    以一个例子说明,假设:

    • 单连接处理能力:每个Redis连接完成一次命令操作(获取连接+执行命令+归还连接)平均耗时1ms
    • 单连接QPS:1000(因为1秒/1ms = 1000)
    • 目标业务QPS:50000

    理论最小连接数 = 目标QPS / 单连接QPS = 50000 / 1000 = 50个连接

    实际配置考虑:

    • 需要预留缓冲资源:实际mxTotal应该略大于理论值(如55-60),以应对流量波动和突发请求
    • 连接数不是越多越好:过多连接会消耗客户端和服务端资源,增加管理开销
    • 性能瓶颈分析:Redis是单线程模型,如果遇到大命令阻塞(如keys*、大型集合操作),即使增加再多客户端连接也无法提高吞吐量,因为服务端处理能力已成为瓶颈

    所以:连接池大小设置需要平衡理论计算、资源消耗和实际业务特性,同时要认识到连接数不能解决 Redis 单线程架构下的命令阻塞问题

  • maxIdle:最大空闲连接数,建议设为业务所需最大连接数,maxTotal为其留出余量,且maxIdle不超过maxTotal,避免资源浪费;

  • minIdle:最小空闲连接数,即至少需要保持的空闲连接数,用于维持连接池基本空闲连接,若连接数超过minIdle,多余连接会在完成业务后被移出连接池释放;

    如果系统启动完马上就会有很多的请求过来,那么可以给 Redis 连接池做预热

    比如快速地创建一些 Redis 连接,执行一些简单命令(比如ping()),快速地将连接池里的空闲连接提升到minIdle的数量;

    连接池预热示例代码:

    // 创建一个ArrayList用于存储预热的Jedis连接,初始容量设置为连接池的最小空闲连接数
    List<Jedis> minIdleJedisList = new ArrayList<Jedis>(jedisPoolConfig.getMinIdle());
    
    // 第一阶段:预热连接池,创建最小空闲连接数指定的连接数量
    for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
        Jedis jedis = null;
        try {
            // 从连接池获取一个Jedis连接实例
            jedis = pool.getResource();
            // 将获取的连接添加到预热列表中暂存
            minIdleJedisList.add(jedis);
            // 执行ping命令测试连接有效性,确保连接是可用的
            jedis.ping();
        } catch (Exception e) {
            // 记录连接预热过程中的异常信息
            logger.error(e.getMessage(), e);
        } finally {
            // 此处不能立即归还连接,否则连接池会重复使用同一个连接,目的是保持多个不同的连接实例在预热列表中
            // jedis.close();
        }
    }
    
    // 第二阶段:将所有预热的连接统一归还到连接池
    for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
        Jedis jedis = null;
        try {
            // 从预热列表中获取预先创建的连接
            jedis = minIdleJedisList.get(i);
            // 将连接归还回连接池,此时连接池中就会有minIdle个已初始化的可用连接
            jedis.close();
        } catch (Exception e) {
            // 记录连接归还过程中的异常信息
            logger.error(e.getMessage(), e);
        } finally {
            // 可选的清理操作
        }
    }
    
  • blockWhenExhausted:当连接池用尽时,是否让调用者等待,建议用默认值true

  • maxWaitMillis:调用者等待连接的最大时长,不建议用默认的“不超时”,避免长时间阻塞;

  • testOnBorrowtestOnReturn:分别在借连接和还连接时测试连接可用性(如ping),业务量大时建议开启,确保连接有效;

3.3.3 高并发与安全建议

  • 高并发下,建议客户端添加熔断功能(如结合 Sentinel、Hystrix),增强系统稳定性;
  • 设置合理密码,必要时用 SSL 加密访问,保障 Redis 访问安全。

3.3.4 Redis 的过期键清理策略

  • Redis 对于过期键有三种清除策略:

    • 被动删除:当读写一个已过期的键时,会触发惰性删除策略删除该键;

    • 主动删除:由于惰性删除策略无法保证冷数据被及时删掉,所以 Redis 会定期(默认100ms)主动淘汰一部分已过期键;

      • 注意:这里淘汰的是一部分,所以可能会出现部分 key 已经过期但还没有被清理掉的情况;
    • 内存超限触发:当内存超过maxmemory限制时,触发主动清理策略;

  • 主动清理策略在 Redis 4.0 之前一共实现了 6 种内存淘汰策略,在 4.0 之后,又增加了 2 种策略,总共8种:

    • 针对设置了过期时间的 key 做处理:

      • volatile-ttl:按过期时间先后删除

      • volatile-random:随机删除

      • volatile-lru:用 LRU 算法删除

        LRU 算法(Least Recently Used,最近最少使用):淘汰很久没被访问过的数据,以最近一次访问时间作为参考;

        存在热点数据时 LRU 效率好;

      • volatile-lfu:用 LFU 算法删除

        LFU 算法(Least Frequently Used,最不经常使用):淘汰最近一段时间被访问次数最少的数据,以次数作为参考;

        但若有偶发、周期性批量操作,LRU 命中率会下降,缓存污染严重,此时 LFU 更优;

    • 针对所有的key做处理:

      • allkeys-random:随机删除
      • allkeys-lru:用 LRU 算法删除
      • allkeys-lfu:用 LFU 算法删除
    • noeviction:不删除任何数据,拒绝所有写入操作并返回客户端错误信息(error) OOM command not allowed when used memory,此时 Redis 只响应读操作;

  • 推荐配置maxmemory-policy(默认是noeviction)为volatile-lru,且要设置最大内存,否则 Redis 内存超出物理内存限制时会频繁换页(Swap),导致性能急剧下降。

  • 当 Redis 运行在主从模式时,只有主节点才会执行过期删除策略,再将删除操作del key同步到从节点删除数据。

4 系统内核参数优化

4.1 虚拟内存交换(vm.swappiness

  • 作用

    • 当物理内存不足时,系统会把部分内存页(page)交换到磁盘的 swap 分区,暂时缓解内存压力。但 swap 依赖磁盘 IO,高并发场景下磁盘 IO 会成为系统瓶颈;
    • 在 Linux 中,并不是要等到所有物理内存都使用完才会使用到 swap,系统参数 swppiness 会决定操作系统使用 swap 的倾向程度;
  • swappiness 取值范围是 0 - 100,值越大,系统越倾向用 swap;值越小,越倾向用物理内存;

  • Redis 优化建议

    • 若 Linux 内核版本 < 3.5,设 swappiness = 0,减少 swap 使用,避免触发 OOM killer(系统内存不足时强制杀用户进程);

    • 若内核版本 >= 3.5,设 swappiness = 1,同样减少 swap 依赖,降低 OOM killer 风险;

    • 操作示例:

      cat /proc/version  # 查看Linux内核版本
      echo 1 > /proc/sys/vm/swappiness # 临时写入
      echo vm.swapiness=1 >> /etc/sysctl.conf # 写入配置文件,永久生效
      

4.2 内存超额提交(vm.overcommit_memory

  • 参数含义:控制内核是否允许“超额”分配物理内存

    • 0:检查可用物理内存,足够才允许内存申请,否则申请失败;
    • 1:内核允许分配所有物理内存,不管当前内存状态;
  • Redis 优化建议:设为 1,确保 fork 操作(Redis 持久化等场景会用到)能在内存不足时也成功执行,避免因内存申请失败导致 fork 等操作异常;

  • 操作示例

    cat /proc/sys/vm/overcommit_memory
    echo "vm.overcommit_memory=1" >> /etc/sysctl.conf
    sysctl vm.overcommit_memory=1 # 使其立即生效
    

4.3 文件句柄数

  • 问题场景:系统进程打开文件(或者称作句柄)的数量达到上限时,会报 Too many open files 错误,影响 Redis 等服务的文件操作(如网络连接、日志文件等);
  • 优化操作
    • ulimit -a 查看系统文件句柄数限制;
    • ulimit -n 65535 临时提高文件句柄数(此处设为 65535),也可通过系统配置永久调整,让 Redis 能支持更多并发连接等文件操作。

4.4 慢查询日志(slowlog

  • 作用:记录 Redis 中执行耗时超阈值的命令,用于排查性能问题(如慢查询导致的 Redis 卡顿);
  • 关键配置与命令
    • config get slowlog-*:查看慢查询日志相关配置;
    • config set slowlog-log-slower-than 1000:设置慢查询阈值(单位微秒,示例中设为 1000 微秒即 1 毫秒,超过该耗时的命令会被记录)。若要更高并发场景下的细粒度监控,可设为 500 微秒;
    • config set slowlog-max-len 1024:设置慢查询日志最大保存条数,满了会删除最早的记录,保留最新的;
    • config rewrite:将当前生效的配置持久化到 redis.conf
    • slowlog len:查看慢查询日志当前长度;
    • slowlog get 5:获取最新 5 条慢查询日志,每条包含标识 ID、发生时间、命令耗时、命令及参数;
    • slowlog reset:重置慢查询日志。

网站公告

今日签到

点亮在社区的每一天
去签到