【Redis|高级篇3】Redis最佳实践|键值设计、批处理优化、服务端优化、服务器优化、集群还是主从

高级篇最后一点,讲讲实践过程中的一些建议

文章目录

3.Redis最佳实践

3.1Redis键值设计

3.1.1key的设计

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

  • 遵循基本格式:[业务名称]:[数据名]:[id]
  • 长度不超过44字节
  • 不包括特殊字符

优点:

  1. 可读性强
  2. 避免key冲突
  3. 方便管理
  4. 更节省内存:key是string类型,底层编码包含int、embstr和raw三种。embstr在小于44字节使用,采用连续内存空间,内存占用更小

OBJECT ENCODING key 命令的作用是查看指定键(Key)在内存中具体的底层实现方式(编码格式)

3.1.2BigKey问题

BigKey通常以Key中成员的数量来综合判定,eg:

  • Key本身的数据量过大:一个String类型的Key,它是值为5MB
  • Key中的成员数过多:一个ZSET类型的Key,它的成员数量为10000个
  • Key中成员的数据量过大:一个Hash类型的Key,它的成员数量虽然只有1000个但这些成员的Value值总大小为100MB

MEMORY USAGE key查看指定键的占用字节大小

推荐值

  • 单个key的value小于10KB
  • 对于集合类型的key,建议元素数量小于1000

BigKey的危害

  • 网络阻塞

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

  • 数据倾斜

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

  • Redis阻塞

    对元素较多的hash、list、zset等做运算会耗时较旧,使主线程被阻塞。

  • CPU压力

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

如何发现BigKey

  • redis-cli --bigkeys

    利用redis-cli提供的--bigkeys参数,可以遍历分析所有key,并返回Key的整体统计信息与每个数据的Top1的big key

    统计只能看到第一名,不够完整

  • scan扫描

    自己编程,利用scan扫描Redis中的所有key,利用strlen、hlen等命令判断key的长度(此处不建议使用MEMORY USAGE)

    MEMORY USAGE 的计算逻辑非常重,在 BigKey 场景下极易导致 Redis 主线程阻塞(卡顿)

    java 复制代码
    import com.zhengge.jedis.util.JedisConnectionFactory;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import redis.clients.jedis.Jedis;
    import redis.clients.jedis.ScanResult;
     
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
     
    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();
            }
        }
     
    }
    特性 Jedis StringRedisTemplate
    定位 底层客户端,Redis 驱动 Spring 的高级封装模板
    层级 基础层 抽象层,基于 Jedis/Lettuce
    连接管理 需手动管理连接池,实例线程不安全 自动管理连接池,模板本身线程安全
    数据序列化 原生数据,无自动序列化 默认使用字符串序列化,存取都是 String
    使用场景 非 Spring 项目、极致性能场景 Spring 项目、主要操作字符串数据
  • 第三方工具

    利用第三方工具,如 Redis-Rdb-Tools 分析RDB快照文件,全面分析内存使用情况

  • 网络监控

    自定义工具,监控进出Redis的网络数据,超出预警值时主动告警

如何删除BigKey

BigKey内存占用较多,在删除它时也需要小号很长时间,导致Redis主线程阻塞,引发一系列问题

  • Redis3.0及以下版本

    如果是集合类型,则遍历BigKey的元素,先逐个删除子元素,最后删除BigKey

  • Redis4.0以后

    Redis4.0后提供了异步删除的命令:unlink

把BigKey删除打散后,还需要选择合适的数据结构把它们的数据存进去

3.1.3选择合适的数据结构

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

存在的问题:

  1. hash的entry数量超过500时,会使用哈希表而不是ZipList,内存占用较多
  2. 可以通过hash-max-ziplist-entries配置entry上限,但是如果entry过多就会导致BigKey问题

方案1:拆分为string类型

存在的问题:

  1. string结构底层没有太多的内存优化,内存占用较多
  2. 想要批量获取这些数据比较麻烦

方案2:拆分成小的hash,将id/100作为key,将id%100作为field,每100个元素为一个hash

java 复制代码
package com.zhengge.test;
 
import com.project.jedis.util.JedisConnectionFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.ScanResult;
 
import java.util.HashMap;
import java.util.List;
import java.util.Map;
 
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);
    }
 
    @Test
    void testSetBigKey() {
        Map<String, String> map = new HashMap<>();
        for (int i = 1; i <= 650; i++) {
            map.put("hello_" + i, "world!");
        }
        jedis.hmset("m2", map);
    }
 
    @Test
    void testBigHash() {
        Map<String, String> map = new HashMap<>();
        for (int i = 1; i <= 100000; i++) {
            map.put("key_" + i, "value_" + i);
        }
        jedis.hmset("test:big:hash", map);
    }
 
    @Test
    void testBigString() {
        for (int i = 1; i <= 100000; i++) {
            jedis.set("test:str:key_" + i, "value_" + i);
        }
    }
 
    @Test
    void testSmallHash() {
        int hashSize = 100;
        Map<String, String> map = new HashMap<>(hashSize);
        for (int i = 1; i <= 100000; i++) {
            int k = (i - 1) / hashSize;
            int v = i % hashSize;
            map.put("key_" + v, "value_" + v);
            if (v == 0) {
                jedis.hmset("test:small:hash_" + k, map);
            }
        }
    }
 
    @AfterEach
    void tearDown() {
        if (jedis != null) {
            jedis.close();
        }
    }
}

总结

3.2批处理优化

3.2.1MSET和Pipeline

大量数据的导入方式有:一次运一点分多次运输,一次运很多几次就运完

单个命令的执行流程: 一次命令的响应时间 = 1次往返的网络传输耗时 + 1次Redis执行命令耗时

单个命令的执行耗时非常短

N条命令一个一个传输,在传输上就非常耗时

N条命令批量执行

怎么批量执行?

MSET

Redis提供了很多Mxxx这样的命令,可以实现批量插入数据

利用mset批量插入10万条数据

java 复制代码
@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);
        }
    }
}

但是一次传输太多也会把带宽占满,导致网络阻塞

Pipeline

MSET虽然可以批处理,但是却只能操作部分数据类型,因此如果有复杂的数据类型要处理,建议使用Pipeline

java 复制代码
@Test
void testPipeline() {
    // 创建管道
    Pipeline pipeline = jedis.pipelined();
    long b = System.currentTimeMillis();
    for (int i = 1; i <= 100000; 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));
}

为啥MSET比Pipeline执行速度快?

是因为MSET是Redis自带的且具有原子性,而在执行Pipeline时还会有别的命令插队

特性 MSET Pipeline
本质 Redis服务端的原生原子命令 客户端的网络优化技术
执行方式 一次解析,内部批量处理 打包发送,服务端逐个执行
原子性 有(执行期间无"插队") 无(执行期间可能"插队")
灵活性 低(只能用于String类型的SET) 高(可混合SET, GET, LPUSH等任意命令)

总结

3.2.2集群下的批处理

如MSET或Pipeline这样的批处理需要在一次请求中携带多条命令,而此时如果Redis是一个集群,那批处理命令的多个key必须落在一个插槽中,否则会导致执行失败

  1. 串行命令

    java 复制代码
    JedisCluster jedisCluster = new JedisCluster(nodes);
    List<String> keys = Arrays.asList("k1", "k2", "k3", "k4");
    
    // 纯粹的 for 循环
    for (String key : keys) {
        // 1. 客户端计算 key 在哪个节点
        // 2. 建立连接(或从池取)
        // 3. 发送命令
        // 4. 等待响应
        jedisCluster.set(key, "value"); 
    }
  2. 串行slot

    java 复制代码
    // 1. 将 keys 按照 slot 分组
    Map<Integer, List<String>> slotKeysMap = new HashMap<>();
    for (String key : keys) {
        int slot = JedisClusterCRC16.getSlot(key);
        slotKeysMap.computeIfAbsent(slot, k -> new ArrayList<>()).add(key);
    }
    
    // 2. 串行处理每一个 Slot 组
    for (Map.Entry<Integer, List<String>> entry : slotKeysMap.entrySet()) {
        int slot = entry.getKey();
        List<String> groupKeys = entry.getValue();
        
        // 获取该 slot 对应的节点连接 (JedisCluster 内部逻辑较复杂,这里简化演示)
        JedisSlotBasedConnectionHandler connectionHandler = ...; 
        Jedis jedis = connectionHandler.getConnectionFromSlot(slot);
        
        // 对该节点的这组 key 开启 Pipeline
        Pipeline pipeline = jedis.pipelined();
        for (String k : groupKeys) {
            pipeline.set(k, "value");
        }
        pipeline.sync(); // 提交这一组的命令
        jedis.close();
    }
  3. 并行slot

    java 复制代码
    ExecutorService executor = Executors.newFixedThreadPool(10);
    
    for (Map.Entry<Integer, List<String>> entry : slotKeysMap.entrySet()) {
        // 提交任务给线程池,并行执行
        executor.submit(() -> {
            int slot = entry.getKey();
            List<String> groupKeys = entry.getValue();
            
            Jedis jedis = connectionHandler.getConnectionFromSlot(slot);
            Pipeline pipeline = jedis.pipelined();
            for (String k : groupKeys) {
                pipeline.set(k, "value");
            }
            pipeline.sync();
            jedis.close();
        });
    }
    
    executor.shutdown();
  4. hash_tag

    java 复制代码
    List<String> keys = Arrays.asList(
        "order:{2026}:001", 
        "order:{2026}:002", 
        "order:{2026}:003"
    ); 
    // 注意:{} 中的内容决定了 slot,所以它们都在同一个 slot
    
    // 既然都在同一个 slot,也就在同一个节点
    // 直接获取该节点的连接
    Jedis jedis = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.getSlot("order:{2026}"));
    
    Pipeline pipeline = jedis.pipelined();
    for (String k : keys) {
        pipeline.set(k, "value");
    }
    pipeline.sync();
    jedis.close();
    • 优点:代码最简单(就像操作单机一样),性能极高(只涉及 1 次网络交互)。
    • 缺点数据倾斜 。如果所有 Key 都带 {2026},那它们都挤在一个节点上,这个节点内存爆了,其他节点却闲着。通常只用于强关联的数据(比如一个订单下的多个商品)

spring集群环境下批处理

java 复制代码
   @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.3服务端优化

3.3.1持久化配置

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

  1. 用来做缓存的Redis实例尽量不要开启持久化功能

  2. 建议关闭RDB持久化功能,使用AOF持久化

    AOF 的数据完整性远高于 RDB

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

    RDB 的 bgsave 虽然是在子进程做的,但主进程(父进程)需要 fork 一个子进程

  4. 设置合理的rewrite阙值,避免频繁的bgrewrite

    重写很耗资源:重写过程同样需要 fork 子进程,消耗大量 CPU 和内存带宽

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

    当子进程正在重写 AOF 文件时,主进程暂时不要把新的命令同步到磁盘(fsync),只写在内存缓冲区里

部署有关建议

  1. Redis实例的物理机要预留足够内存,应对fork和rewrite
  2. 单个Redis实例内存上限不要太大,例如4G或8G。可以加快fork的速度、减少主从同步、数据迁移压力
  3. 不要与CPU密集型应用部署在一起
  4. 不要与高硬盘负载应用一起部署。例如:数据库、消息队列
3.3.2慢查询问题

慢查询:在Redis执行时耗时超过超过某个阙值的命令,称为慢查询

慢查询可能会导致的问题

  • 拖慢应用响应速度
  • 耗尽数据库连接资源
  • 引发系统雪崩效应
  • 加剧锁竞争

查看慢查询日志列表

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

3.4服务器优化

3.4.1命令及安全配置

Redis会绑定在0.0.0.0:6379,这样将会将Redis服务暴露到公网是,而Redis如果没有做身份认证,会出现严重的漏洞重现方式https://cloud.tencent.com/developer/article/1039000

漏洞出现的核心原因

  • Redis未设置密码
  • 利用了Redis的config set命令动态修改Redis配置
  • 使用了root账号权限启动Redis

避免漏洞的方法

  1. Redis 一定要设置密码
  2. 禁止线上使用下面命令:keys、flushall、flushdb、config set等命令。 可以利用 rename-command 禁用。
  3. bind:限制网卡,禁止外网网卡访问
  4. 开启防火墙
  5. 不要使用 Root 账户启动Redis
  6. 尽量不是有默认的端口
3.4.2内存安全和配置

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

Redis提供了一些命令,可以看到Redis目前的内存分配状态:

  • info memory
  • memory xxx

内存缓冲区常见的有三种:

  1. 复制缓冲区:主从复制的 repl_backlog_buf,如果太小可能导致频繁的全量复制,影响性能。通过 replbacklog-size 来设置,默认1mb
  2. AOF缓冲区:AOF 刷盘之前的缓存区域,AOF 执行 rewrite 的缓冲区。无法设置容量上限
  3. 客户端缓冲区:分为输入缓冲区和输出缓冲区,输入缓冲区最大 1G 且不能设置。输出缓冲区可以设置

3.4集群最佳实践

集群还是主从

集群完整性问题

集群虽然具备高可用特性,能实现自动故障恢复,但是如果使用不当,也会存在一些问题:

  1. 集群完整性问题
  2. 集群带宽问题
  3. 数据倾斜问题
  4. 客户端性能问题
  5. 命令的集群兼容性问题
  6. lua和事务问题

集群带宽问题

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

  • 插槽信息
  • 集群状态信息

集群中节点越多,集群状态信息数据量也越大,10个节点的相关信息可能达到1kb,此时每次集群互通需要的带宽会非常高。

解决途径:

  1. 避免大集群,集群节点数不要太多,最好少于1000,如果业务庞大,则建立多个集群。
  2. 避免在单个物理机中运行太多Redis实例
  3. 配置合适的cluster-node-timeout值

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

相关推荐
matlabgoodboy1 小时前
留学生计算机cs作业辅导java SQL数据库 c语言编程 软件工程辅导
java·数据库·sql
一江寒逸1 小时前
【30天做一个生产级RAG知识库系统】第8篇:并发优化与缓存设计,解决多用户访问崩服务的问题
缓存·架构
Cache技术分享1 小时前
384. Java IO API - Java 文件复制工具:Copy 示例完整解析
前端·后端
aXin_ya1 小时前
微服务 第一天
java·运维·微服务
俺不要写代码1 小时前
Linux上一个简单的echo服务器搭建
linux·运维·服务器
努力努力再努力wz1 小时前
【MySQL入门系列】:不只是建表:MySQL 表约束与 DDL 执行机制全解析
android·linux·服务器·数据结构·数据库·c++·mysql
8Qi81 小时前
Elasticsearch 初识篇:核心概念与环境搭建
java·大数据·分布式·elasticsearch·搜索引擎·中间件
霸道流氓气质1 小时前
SpringBoot中集成LangChain4j实现集成阿里百炼平台进行AI快速对话
人工智能·spring boot·后端·langchain4j