Redis 最佳实践

一、Redis 键值设计

1.1 优雅的 key 结构

Redis Key 虽然可以自定义,但最好遵循下面的几个最佳实践约定:

        1、遵循基本格式:[业务名称]:[数据名]:[id]

        2、长度不超过 44 字节(不同版本可能具体的大小不同)

        3、不包含特殊字符

例如:我们的登录业务,保存用户信息,其 key 可以设计成如下格式:

这样设计的好处:

        1、可读性强

        2、避免 key 冲突

        3、方便管理

        4、更节省内存: key string 类型,底层编码包含 intembstr raw 三种。embstr 在小于 44 字节使用,采用连续内存空间,内存占用更小。当字节数大于 44 字节时,会转为 raw 模式存储,在 raw 模式下,内存空间不是连续的,而是采用一个指针指向了另外一段内存空间,在这段空间里存储 SDS 内容,这样空间不连续,访问的时候性能也就会收到影响,还有可能产生内存碎片,如下图:

1.2 拒绝 BigKey

        BigKey 通常以 Key 的大小和 Key 中成员的数量来综合判定,例如:

        1、Key 本身的数据量过大:一个 String 类型的 Key,它的值为 5 MB

        2、Key 中的成员数过多:一个 ZSET 类型的 Key,它的成员数量为 10000

        3、Key 中成员的数据量过大:一个 Hash 类型的 Key,它的成员数量虽然只有 1000 个但这些成员的 Value(值)总大小为 100 MB

        那么如何判断元素的大小呢?redis 也给我们提供了命令

推荐值:

        1、单个 key value 小于 10KB

        2、对于集合类型的 key,建议元素数量小于 1000

1.2.1 BigKey的危害

        网络阻塞:对 BigKey 执行读请求时,少量的 QPS 就可能导致带宽使用率被占满,导致 Redis 实例,乃至所在物理机变慢。

        数据倾斜:BigKey 所在的 Redis 实例内存使用率远超其他实例,无法使数据分片的内存资源达到均衡。

        Redis 阻塞:对元素较多的 hashlistzset 等做运算会耗时较旧,使主线程被阻塞。

        CPU 压力:对 BigKey 的数据序列化和反序列化会导致 CPU 的使用率飙升,影响 Redis 实例和本机其它应用。

1.2.2 如何发现BigKey

        方式一:执行下面的命令,可以遍历分析所有 key,并返回 Key 的整体统计信息与每个数据的 Top1 big key

redis-cli -a 密码 --bigkeys

        但这种方式只能统计每种类型的 top1,不适合我们使用

        方式二:自己编码,利用 scan 扫描 Redis 中的所有 key,利用 strlenhlen 等命令判断 key 的长度(此处不建议使用 MEMORY USAGE

        scan 命令调用完后每次会返回 2 个元素,第一个是下一次迭代的光标,第一次光标会设置为 0,当最后一次 scan 返回的光标等于 0 时,表示整个 scan 遍历结束了,第二个返回的是 List,一个匹配的 key 的数组,代码如下:

public class JedisTest {
    private Jedis jedis;

    @BeforeEach
    void setUp() {
        // 1.建立连接
        // jedis = new Jedis("192.168.150.101", 6379);
        jedis = JedisConnectionFactory.getJedis();
        // 2.设置密码
        jedis.auth("123321");
        // 3.选择库
        jedis.select(0);
    }

    final static int STR_MAX_LEN = 10 * 1024;
    final static int HASH_MAX_LEN = 500;

    @Test
    void testScan() {
        int maxLen = 0;
        long len = 0;

        String cursor = "0";
        do {
            // 扫描并获取一部分key
            ScanResult<String> result = jedis.scan(cursor);
            // 记录cursor
            cursor = result.getCursor();
            List<String> list = result.getResult();
            if (list == null || list.isEmpty()) {
                break;
            }
            // 遍历
            for (String key : list) {
                // 判断key的类型
                String type = jedis.type(key);
                switch (type) {
                    case "string":
                        len = jedis.strlen(key);
                        maxLen = STR_MAX_LEN;
                        break;
                    case "hash":
                        len = jedis.hlen(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "list":
                        len = jedis.llen(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "set":
                        len = jedis.scard(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "zset":
                        len = jedis.zcard(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    default:
                        break;
                }
                if (len >= maxLen) {
                    System.out.printf("Found big key : %s, type: %s, length or size: %d %n", key, type, len);
                }
            }
        } while (!cursor.equals("0"));
    }
    
    @AfterEach
    void tearDown() {
        if (jedis != null) {
            jedis.close();
        }
    }

}

1.2.3 如何删除BigKey

        BigKey 内存占用较多,即便时删除这样的 key 也需要耗费很长时间,导致 Redis 主线程阻塞,引发一系列问题。

        针对于 redis 3.0 及以下版本,如果是集合类型,则遍历 BigKey 的元素,先逐个删除子元素,最后删除 BigKey

        针对于 Redis 4.0 以后,Redis 4.0 后提供了异步删除的命令:unlink

1.3 恰当的数据类型

1.3.1 示例一

现在需要存储一个 User 对象,我们有三种存储方式:

        第一种方式为 json 字符串,如下图,这种方式的优点是实现起来简单粗暴,缺点是数据耦合较高,不够灵活。

user:1{"name": "Jack", "age": 21}

        第二种方式是将字段打散,如下,这种方式的优点是可以灵活访问对象任意字段,缺点是占用空间大、没办法做统一控制。

user:1:name Jack
user:1:age21

        第三种方式是 hash(推荐)存储,这种方式的优点是底层使用 ziplist,空间占用小,可以灵活访问对象的任意字段,缺点是代码相对复杂。

user:1 name Jack
age21

1.3.2 示例二

        假如有 hash 类型的 key,其中有 100 万对 field valuefield 是自增 id,这个 key 存在什么问题?如何优化?

key field value
someKey id:0 value0
..... .....
id:999999 value999999

存在的问题:

        hash entry 数量超过 500 时,会使用哈希表而不是 zipList,内存占用较多,如下图:

        可以通过执行 hash-max-ziplist-entries 命令配置 entry 上限(还是不超过 1000 好)。但是如果 entry 过多就会导致 BigKey 问题。

解决方案一:

        可以将其拆分为 string 类型,如下:

key value
id:0 value0
..........
id:999999 value999999

        但是 string 结构底层没有太多内存优化,内存占用较多,甚至比以前更多了,而且想要批量获取这些数据比较麻烦,如下图:

解决方案二:

        可以将其拆分为小的 hash,将 id / 100 作为 key, 将 id % 100 作为 field,这样每 100 个元素为一个 Hash,如下:

key field value
key:0 id:00value0
..... .....
id:99 value99
key:1 id:00value100
..........
id:99value199
.....
key:9999id:00value999900
..........
id:99value999999

        此时的内存空间占用率如下所示:

1.4 总结

1.4.1 Key 的最佳实践

        1、固定格式:[业务名]:[数据名]:[id]

        2、足够简短:不超过 44 字节

        3、不包含特殊字符

1.4.2 Value 的最佳实践

        1、合理的拆分数据,拒绝 BigKey

        2、选择合适数据结构

        3、Hash 结构的 entry 数量不要超过 1000

        4、设置合理的超时时间

二、批处理优化

2.1 Pipeline

2.1.1 交互方式

        针对于单个命令的执行流程,我们的客户端与 redis 服务器是这样交互的,如下图:

        针对于 N 条命令的执行流程,交互的流程如下图:

        redis 处理指令是很快的,主要花费的时候在于网络传输。于是乎很容易想到将多条指令批量的传输给 redis,如下图:

2.1.2 MSet

        Redis 提供了很多 Mxxx 这样的命令,可以实现批量插入数据,例如:mset hmset,执行下面的代码,利用 mset 批量插入10万条数据。

    @Test
    void testMxx() {
        String[] arr = new String[2000];
        int j;
        long b = System.currentTimeMillis();
        for (int i = 1; i <= 100000; i++) {
            j = (i % 1000) << 1;
            arr[j] = "test:key_" + i;
            arr[j + 1] = "value_" + i;
            if (j == 0) {
                jedis.mset(arr);
            }
        }
        long e = System.currentTimeMillis();
        System.out.println("time: " + (e - b));
    }

        可以看到,花了很短的时间

2.1.3 Pipeline

        MSET 虽然可以批处理,但是却只能操作部分数据类型,因此如果有对复杂数据类型的批处理需要,建议使用 Pipeline,执行下面的代码,利用 pipeline 批量插入10万条数据。

    @Test
    void testPipeline() {
        // 创建管道
        Pipeline pipeline = jedis.pipelined();
        long b = System.currentTimeMillis();
        for (int i = 1; i <= 100004; i++) {
            // 放入命令到管道
            pipeline.set("test:key_" + i, "value_" + i);
            if (i % 1000 == 0) {
                // 每放入1000条命令,批量执行
                pipeline.sync();
            }
        }
        long e = System.currentTimeMillis();
        System.out.println("time: " + (e - b));
    }

        可以看到,执行起来也是挺快的 

2.1.4 总结

批量处理的方案:

        1、使用原生的 M 操作

        2、使用 Pipeline 批处理

注意事项:

        1、批处理时不建议一次携带太多命令

        2、Pipeline 的多个命令之间不具备原子性 

2.2 集群下的批处理

        如果 MSET Pipeline 这样的批处理需要在一次请求中携带多条命令,而此时如果 Redis 是一个集群,那批处理命令的多个 key 必须落在一个插槽中,否则就会导致执行失败。大家可以想一想这样的要求其实很难实现,因为我们在批处理时,可能一次要插入很多条数据,这些数据很有可能不会都落在相同的节点上,这就会导致报错了,如下图:

        这个时候我们有四种解决方案,如下图:

        第一种方案:串行执行,所以这种方式没有什么意义,当然,执行起来就很简单了,缺点就是耗时过久。

        第二种方案:串行 slot,简单来说,就是执行前,客户端先计算一下对应的 key slot,一样 slot key 就放到一个组里边,不同的,就放到不同的组里边,然后对每个组执行 pipeline 的批处理,他就能串行执行各个组的命令,这种做法比第一种方法耗时要少,但是缺点呢,相对来说复杂一点,所以这种方案还需要优化一下。

        第三种方案:并行 slot,相较于第二种方案,在分组完成后串行执行,第三种方案,就变成了并行执行各个命令,所以他的耗时就非常短,但是实现呢,也更加复杂。

        第四种:hash_tagredis 计算 key slot 的时候,其实是根据 key 的有效部分来计算的,通过这种方式就能一次处理所有的 key,这种方式耗时最短,实现也简单,但是如果通过操作 key 的有效部分,那么就会导致所有的 key 都落在一个节点上,产生数据倾斜的问题,所以我们推荐使用第三种方式。

2.2.1 串行化执行代码实践

public class JedisClusterTest {

    private JedisCluster jedisCluster;

    @BeforeEach
    void setUp() {
        // 配置连接池
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        poolConfig.setMaxTotal(8);
        poolConfig.setMaxIdle(8);
        poolConfig.setMinIdle(0);
        poolConfig.setMaxWaitMillis(1000);
        HashSet<HostAndPort> nodes = new HashSet<>();
        nodes.add(new HostAndPort("192.168.150.101", 7001));
        nodes.add(new HostAndPort("192.168.150.101", 7002));
        nodes.add(new HostAndPort("192.168.150.101", 7003));
        nodes.add(new HostAndPort("192.168.150.101", 8001));
        nodes.add(new HostAndPort("192.168.150.101", 8002));
        nodes.add(new HostAndPort("192.168.150.101", 8003));
        jedisCluster = new JedisCluster(nodes, poolConfig);
    }

    @Test
    void testMSet() {
        jedisCluster.mset("name", "Jack", "age", "21", "sex", "male");

    }

    @Test
    void testMSet2() {
        Map<String, String> map = new HashMap<>(3);
        map.put("name", "Jack");
        map.put("age", "21");
        map.put("sex", "Male");
        //对Map数据进行分组。根据相同的slot放在一个分组
        //key就是slot,value就是一个组
        Map<Integer, List<Map.Entry<String, String>>> result = map.entrySet()
                .stream()
                .collect(Collectors.groupingBy(
                        entry -> ClusterSlotHashUtil.calculateSlot(entry.getKey()))
                );
        //串行的去执行mset的逻辑
        for (List<Map.Entry<String, String>> list : result.values()) {
            String[] arr = new String[list.size() * 2];
            int j = 0;
            for (int i = 0; i < list.size(); i++) {
                j = i<<2;
                Map.Entry<String, String> e = list.get(0);
                arr[j] = e.getKey();
                arr[j + 1] = e.getValue();
            }
            jedisCluster.mset(arr);
        }
    }

    @AfterEach
    void tearDown() {
        if (jedisCluster != null) {
            jedisCluster.close();
        }
    }
}

2.2.2 Spring 集群环境批处理

@Test
void testMSetInCluster() {
	Map<String, String> map = new HashMap<>(3);
	map.put("name", "Rose");
	map.put("age", "21");
	map.put("sex", "Female");
	stringRedisTemplate.opsForValue().multiSet(map);


	List<String> strings = stringRedisTemplate.opsForValue().multiGet(Arrays.asList("name", "age", "sex"));
	strings.forEach(System.out::println);

}

三、服务端优化

3.1 持久化配置

        Redis 的持久化虽然可以保证数据安全,但也会带来很多额外的开销,因此持久化请遵循下列建议:

        1、用来做缓存的 Redis 实例尽量不要开启持久化功能,失效了就会重新加载。

        2、建议关闭 RDB 持久化功能,使用 AOF 持久化,因为太耗内存。

        3、利用脚本定期在 slave 节点做 RDB,实现数据备份。

        4、设置合理的 rewrite AOF 持久化时需要做 rewrite)阈值,避免频繁的 bgrewrite

        5、配置 no-appendfsync-on-rewrite = yes,禁止在 rewrite 期间做 aof,避免因 AOF 引起的阻塞。

部署有关建议:

        1、Redis 实例的物理机要预留足够内存,应对 fork rewrite

        2、单个 Redis 实例内存上限不要太大,例如 4G 8G。可以加快 fork 的速度、减少主从同步、数据迁移压力。

        3、不要与 CPU 密集型应用部署在一起

        4、不要与高硬盘负载应用一起部署。例如:数据库、消息队列

3.2 慢查询优化

3.2.1 什么是慢查询

        并不是很慢的查询才是慢查询,而是在 Redis 执行时耗时超过某个阈值的命令,称为慢查询。

        慢查询的危害:由于 Redis 是单线程的,所以当客户端发出指令后,他们都会进入到 redis 底层的 queue 来执行,如果此时有一些慢查询的数据,就会导致大量请求阻塞,从而引起报错,所以我们需要解决慢查询问题。

        慢查询的阈值可以通过配置指定,慢查询会被放入慢查询日志中,日志的长度有上限,可以通过配置指定。

        slowlog-log-slower-than:慢查询阈值,单位是微秒。默认是10000,建议 1000

        slowlog-max-len:慢查询日志(本质是一个队列)的长度。默认是 128,建议 1000

        修改这两个配置可以使用:config set 命令:

3.2.2 如何查看慢查询

        知道了以上内容之后,那么咱们如何去查看慢查询日志列表呢,可以通过执行以下的命令:

# 查询慢查询日志长度
slowlog len
# 读取n条慢查询日志
slowlog get [n]
# 清空慢查询列表
slowlog reset:

3.3 命令及安全配置

        安全可以说是服务器端一个非常重要的话题,如果安全出现了问题,那么一旦这个漏洞被一些坏人知道了之后,并且进行攻击,那么这就会给咱们的系统带来很多的损失,所以我们这节课就来解决这个问题。

        Redis 会绑定在 0.0.0.0:6379,这样将会将 Redis 服务暴露到公网上,而 Redis 如果没有做身份认证,会出现严重的安全漏洞。漏洞重新方式访问这篇文章

        为什么会出现不需要密码也能够登录呢,主要是 Redis 考虑到每次登录都比较麻烦,所以 Redis 就有一种 ssh 免秘钥登录的方式,生成一对公钥和私钥,私钥放在本地,公钥放在 redis 端,当我们登录时服务器,再登录时候,他会去解析公钥和私钥,如果没有问题,则不需要利用 redis 的登录也能访问,这种做法本身也很常见,但是这里有一个前提,前提就是公钥必须保存在服务器上,才行,但是 Redis 的漏洞在于在不登录的情况下,也能把秘钥送到 Linux 服务器,从而产生漏洞,漏洞出现的核心的原因有以下几点:

        1、Redis 未设置密码

        2、利用了 Redis config set 命令动态修改 Redis 配置

        3、使用了 Root 账号权限启动 Redis

为了避免这样的漏洞,这里给出一些建议:

        1、Redis 一定要设置密码

        2、禁止线上使用下面命令:keysflushallflushdbconfig set 等命令。可以利用rename-command 禁用。

        3、bind:限制网卡,禁止外网网卡访问

        4、开启防火墙

        5、不要使用 Root 账户启动 Redis

        6、尽量不是有默认的端口

3.4 Redis 内存划分和内存配置

        当 Redis 内存不足时,可能导致 Key 频繁被删除、响应时间变长、QPS 不稳定等问题。当内存使用率达到 90% 以上时就需要我们警惕,并快速定位到内存占用的原因。

        有关碎片问题分析:Redis 底层分配并不是这个 key 有多大,他就会分配多大,而是有他自己的分配策略,比如 8,16,20 等等,假定当前 key 只需要 10 个字节,此时分配 8 肯定不够,那么他就会分配 16 个字节,多出来的 6 个字节就不能被使用,这就是我们常说的碎片问题。

        进程内存问题分析: 这片内存,通常我们都可以忽略不计。

        缓冲区内存问题分析:一般包括客户端缓冲区、AOF 缓冲区、复制缓冲区等。客户端缓冲区又包括输入缓冲区和输出缓冲区两种。这部分内存占用波动较大,所以这片内存也是我们需要重点分析的内存问题。

内存占用说明
数据内存是 Redis 最主要的部分,存储 Redis 的键值信息。主要问题是 BigKey 问题、内存碎片问题
进程内存Redis 主进程本身运⾏肯定需要占⽤内存,如代码、常量池等等;这部分内存⼤约⼏兆,在⼤多数⽣产环境中与 Redis 数据占⽤的内存相⽐可以忽略。
缓冲区内存一般包括客户端缓冲区、AOF 缓冲区、复制缓冲区等。客户端缓冲区又包括输入缓冲区和输出缓冲区两种。这部分内存占用波动较大,不当使用 BigKey,可能导致内存溢出

        于是我们就需要通过一些命令,可以查看到 Redis 目前的内存分配状态:

        info memory:查看内存分配的情况,如下:

        memory xxx:查看 key 的主要占用情况

        接下来我们看到了这些配置,最关键的缓存区内存如何定位和解决呢?内存缓冲区常见的有三种:

        1、复制缓冲区:主从复制的 repl_backlog_buf,如果太小可能导致频繁的全量复制,影响性能。通过 replbacklog-size 来设置,默认 1mb

        2、AOF 缓冲区:AOF 刷盘之前的缓存区域,AOF 执行 rewrite 的缓冲区。无法设置容量上限。

        3、客户端缓冲区:分为输入缓冲区和输出缓冲区,输入缓冲区最大 1G 且不能设置。输出缓冲区可以设置

        以上复制缓冲区和 AOF 缓冲区不会有问题,最关键就是客户端缓冲区的问题。

        客户端缓冲区:指的就是我们发送命令时,客户端用来缓存命令的一个缓冲区,也就是我们向 redis 输入数据的输入端缓冲区和 redis 向客户端返回数据的响应缓存区,输入缓冲区最大 1G 且不能设置,所以这一块我们根本不用担心,如果超过了这个空间,redis 会直接断开,因为本来此时此刻就代表着 redis 处理不过来了,我们需要担心的就是输出端缓冲区。

        我们在使用 redis 过程中,处理大量的 big value,那么会导致我们的输出结果过多,如果输出缓存区过大,会导致 redis 直接断开,而默认配置的情况下, 其实他是没有大小的,这就比较坑了,内存可能一下子被占满,会直接导致咱们的 redis 断开,所以解决方案有两个:

        1、设置一个大小

        2、增加我们带宽的大小,避免我们出现大量数据从而直接超过了 redis 的承受能力。

四、集群最佳实践

4.1 集群存在问题

        集群虽然具备高可用特性,能实现自动故障恢复,但是如果使用不当,也会存在一些问题: 集群完整性问题、集群带宽问题、数据倾斜问题、客户端性能问题、命令的集群兼容性问题、lua 和事务问题等。

4.1.1 集群完整性问题

        在 Redis 的默认配置中,如果发现任意一个插槽不可用,则整个集群都会停止对外服务,如下:

        大家可以设想一下,如果有几个 slot 不能使用,那么此时整个集群都不能用了,我们在开发中,其实最重要的是可用性,所以需要把如下配置修改成 no,即有 slot 不能使用时,我们的 redis 集群还是可以对外提供服务,如下图:

4.1.2 集群带宽问题

        集群节点之间会不断的互相 Ping 来确定集群中其它节点的状态。每次 Ping 携带的信息至少包括插槽信息和集群状态信息。

        集群中节点越多,集群状态信息数据量也越大,10 个节点的相关信息可能达到 1kb,此时每次集群互通需要的带宽会非常高,这样会导致集群中大量的带宽都会被 ping 信息所占用,这是一个非常可怕的问题,所以我们需要去解决这样的问题。解决方式如下:

        1、避免大集群,集群节点数不要太多,最好少于 1000,如果业务庞大,则建立多个集群。

        2、避免在单个物理机中运行太多 Redis 实例

        3、配置合适的 cluster-node-timeout 值,小了过于频繁,大了宕机不能即使发现。

4.1.3 数据倾斜问题

        当数据出现 bigkey,或者做集群批处理时,一旦使用了相同的 hashtag,就会导致部分节点的负担过重,这是不合理的。

4.1.4 客户端性能问题

        一旦使用了集群,将来我们的客户端不管是 jedis 还是 lettuce ,它们在访问集群的时候都需要在集群中做节点的选择和读写分离的判断,以及插槽的判断等等,这样势必会对客户端的性能造成影响

4.1.5 命令的集群兼容性问题

        有关这个问题咱们已经探讨过了,当我们使用批处理的命令时,redis 要求我们的 key 必须落在相同的 slot 上,然后大量的 key 同时操作时,是无法完成的,所以客户端必须要对这样的数据进行处理,这些方案我们之前已经探讨过了,所以不再这个地方赘述了。

4.1.6 lua 和事务问题

        lua 和事务都是要保证原子性问题,如果你的 key 不在一个节点,那么是无法保证 lua 的执行和事务的特性的,所以在集群模式是没有办法执行 lua 和事务的。

4.2 集群还是主从

        那我们到底是集群还是主从 ?单体 Redis(主从 Redis)已经能达到万级别的 QPS,并且也具备很强的高可用特性。如果主从能满足业务需求的情况下,所以如果不是在万不得已的情况下,尽量不搭建 Redis 集群。

  • 13
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

快乐的小三菊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值