高并发场景下的大 Key 问题及应对策略

目录

一、问题描述

二、大key的认定

[(一)String 类型](#(一)String 类型)

[(二)Set、List、Hash、ZSet 等集合类型](#(二)Set、List、Hash、ZSet 等集合类型)

三、解决策略分析

[(一)解决策略一:拆分大 Key](#(一)解决策略一:拆分大 Key)

注意事项

(二)解决策略二:分拆集合数据

注意事项

(三)解决策略三:压缩方案

四、总结


干货分享,感谢您的阅读!

在高并发场景下,缓存作为前置查询机制,显著减轻了数据库的压力,提高了系统性能。然而,这也带来了缓存失效、增加回溯率等风险。常见的问题包括缓存穿透、缓存雪崩、热Key和大Key等。这些问题如果不加以处理,会影响系统的稳定性和性能。因此,采用有效的缓存策略,如缓存空结果、布隆过滤器、缓存过期时间随机化、多级缓存等,对于保障系统在高并发情况下的可靠性至关重要。本次我们将详细探讨大Key及其应对策略。

一、问题描述

大 Key 是指在缓存系统中,某些 Key 对应的值(Value)存储的数据量非常大,大 Key 可能会导致一系列性能问题和系统不稳定性:

  • 响应超时 :由于 Redis 是单线程的,如果某个 Key 的 Value 很大,在进行 GETSET 操作时会占用 Redis 的单线程,导致其他请求被阻塞,从而引发响应超时。另外集合类型(如 Set、List、Hash、ZSet)的元素较多时,删除或读取这些大集合的时间复杂度为 O(n),会严重阻塞 Redis 进程,导致应用服务的超时和崩溃。
  • 数据倾斜:大 Key 会导致 Redis 集群中某些节点存储的数据量远大于其他节点,从而引起数据分布不均衡的问题。集群负载不均衡会导致某些节点的内存和计算资源紧张,降低整体性能。

二、大key的认定

缓存系统中,一般大 Key 的定义如下:

(一)String 类型

  • Value 大于 10K 为"大" Key
  • Value 大于 100K 为"超大" Key

(二)Set、List、Hash、ZSet 等集合类型

  • 元素个数超过 1000 为"大" Key
  • 元素个数超过 10000 为"超大" Key

三、解决策略分析

(一)解决策略一:拆分大 Key

通过将大 Key 拆分成多个小 Key,可以分散数据存储和访问压力,避免单点故障和性能瓶颈。拆分大 Key 的具体步骤和实现方法如下:

  • 确定拆分策略:拆分大 Key 的策略通常包括按照数据块大小、按照数据量等方式进行。在实际应用中,可以根据业务需求和数据特性来选择合适的拆分方法。
  • 定义拆分规则:确定如何将大 Key 拆分成多个小 Key,需要设计清晰的拆分规则。例如,可以按照固定大小的数据块进行拆分,或者根据数据内容的逻辑关系进行拆分。
  • 实现拆分逻辑:实现拆分大 Key 的逻辑,将大 Key 拆分成多个小 Key,并分别存储在不同的分片(或不同的 Redis 节点)上。

实现将大 Key 拆分为多个小 Key 并存储:

java 复制代码
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

import java.util.ArrayList;
import java.util.List;

/**
 * @program: zyfboot-javabasic
 * @author: zhangyanfeng
 * @create: 2013-03-25 20:39
 **/
public class LargeKeySplitter {

    private static final int CHUNK_SIZE = 1024; // 每个小块的大小
    private static final List<JedisPool> shardPools = new ArrayList<>();

    static {
        // 假设有三个 Redis 分片
        shardPools.add(new JedisPool(new JedisPoolConfig(), "localhost", 6379));
        shardPools.add(new JedisPool(new JedisPoolConfig(), "localhost", 6380));
        shardPools.add(new JedisPool(new JedisPoolConfig(), "localhost", 6381));
    }

    public static void setLargeKey(String key, String value) {
        int numChunks = (int) Math.ceil((double) value.length() / CHUNK_SIZE);
        for (int i = 0; i < numChunks; i++) {
            String chunkKey = key + "_chunk_" + i;
            String chunkValue = value.substring(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, value.length()));
            int shardIndex = Math.abs(chunkKey.hashCode()) % shardPools.size(); // 根据 key 计算分片索引
            try (Jedis jedis = shardPools.get(shardIndex).getResource()) {
                jedis.set(chunkKey, chunkValue);
            }
        }
    }

    public static String getLargeKey(String key, int numChunks) {
        StringBuilder value = new StringBuilder();
        for (int i = 0; i < numChunks; i++) {
            String chunkKey = key + "_chunk_" + i;
            int shardIndex = Math.abs(chunkKey.hashCode()) % shardPools.size(); // 根据 key 计算分片索引
            try (Jedis jedis = shardPools.get(shardIndex).getResource()) {
                String chunkValue = jedis.get(chunkKey);
                if (chunkValue != null) {
                    value.append(chunkValue);
                }
            }
        }
        return value.toString();
    }

    public static void main(String[] args) {
        String key = "large_key";
        String value = new String(new char[5000]).replace('\0', 'a'); // 模拟大 Key 的值
        setLargeKey(key, value);
        System.out.println("Cache value: " + getLargeKey(key, 5));
    }
}
注意事项
  • 数据一致性:拆分大 Key 后,需要确保数据的一致性和完整性。例如,可以使用事务或分布式锁来确保数据操作的原子性。
  • 分片策略:根据业务特性选择合适的分片策略,避免某一分片负载过重或过于空闲。

(二)解决策略二:分拆集合数据

针对集合存储了过多值的问题,特别是对于大型数据集合如 hash、set、list、zset 等,可以采用分拆的方法来减轻单个集合导致的性能问题。

对于 Redis 中的集合类型数据,如 hash,可以通过分拆的方式将原始的大集合分解为多个小集合。例如,将 hash 类型的 field 根据其 hash 值的模运算结果,分配到不同的桶(新的 hashKey)中存储。这样可以减少单个 hashKey 的数据量,从而减少单个操作对 Redis 服务的压力。

实现在 Redis 中对 hash 类型数据进行分拆存储:

java 复制代码
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

import java.util.HashMap;
import java.util.Map;

/**
 * @program: zyfboot-javabasic
 * @author: zhangyanfeng
 * @create: 2013-03-25 20:56
 **/
public class HashSplitter {

    private static final int NUM_BUCKETS = 10000; // 桶的数量
    private static final JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), "localhost", 6379);

    public static void hset(String hashKey, String field, String value) {
        int bucket = Math.abs(field.hashCode()) % NUM_BUCKETS;
        String newHashKey = hashKey + ":" + bucket;
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.hset(newHashKey, field, value);
        }
    }

    public static String hget(String hashKey, String field) {
        int bucket = Math.abs(field.hashCode()) % NUM_BUCKETS;
        String newHashKey = hashKey + ":" + bucket;
        try (Jedis jedis = jedisPool.getResource()) {
            return jedis.hget(newHashKey, field);
        }
    }

    public static void main(String[] args) {
        String hashKey = "myHash";
        String field1 = "field1";
        String value1 = "value1";
        String field2 = "field2";
        String value2 = "value2";

        // 存储数据
        hset(hashKey, field1, value1);
        hset(hashKey, field2, value2);

        // 获取数据
        System.out.println("Field1 value: " + hget(hashKey, field1));
        System.out.println("Field2 value: " + hget(hashKey, field2));
    }
}
注意事项
  • 一致性问题:分拆后的数据需要保证一致性,可以使用事务或者分布式锁来确保数据操作的原子性。
  • 桶的数量选择:选择合适的桶数量非常重要,需要根据数据分布和业务特性来进行调整,避免某些桶过度负载或者浪费空间。

(三)解决策略三:压缩方案

压缩 Redis 中存储的 value 可以显著减少存储空间,并且在网络传输时也能减少带宽消耗。在 Redis 中,常用的压缩算法包括但不限于:

  • GZIP 压缩:使用 GZIP 算法对 value 进行压缩,适合对文本数据和可压缩数据进行压缩。
  • LZ4 压缩:LZ4 是一种快速压缩算法,适合对数据进行实时压缩和解压,适用于需要低延迟的场景。
  • Snappy 压缩:Snappy 也是一种快速压缩算法,比较适合对图片、音频等二进制数据进行压缩。
  • BZIP2 压缩:适合于对需要更高压缩率的数据进行处理,但压缩速度相对较慢。

选择压缩算法时需要考虑数据的特性、压缩率、解压速度和对 CPU 的消耗等因素。

如在 Redis 中使用 GZIP 进行 value 的压缩和解压:

java 复制代码
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

/**
 * @program: zyfboot-javabasic
 * @author: zhangyanfeng
 * @create: 2013-03-25 21:13
 **/
public class RedisValueCompression {

    private static final JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), "localhost", 6379);

    // 将字符串压缩为字节数组
    public static byte[] compress(String data) throws IOException {
        ByteArrayOutputStream bos = new ByteArrayOutputStream(data.length());
        try (GZIPOutputStream gzip = new GZIPOutputStream(bos)) {
            gzip.write(data.getBytes());
        }
        return bos.toByteArray();
    }

    // 将字节数组解压为字符串
    public static String decompress(byte[] compressedData) throws IOException {
        ByteArrayInputStream bis = new ByteArrayInputStream(compressedData);
        StringBuilder sb = new StringBuilder();
        try (GZIPInputStream gis = new GZIPInputStream(bis)) {
            byte[] buffer = new byte[1024];
            int bytesRead;
            while ((bytesRead = gis.read(buffer)) != -1) {
                sb.append(new String(buffer, 0, bytesRead));
            }
        }
        return sb.toString();
    }

    // 将压缩后的数据存入 Redis
    public static void setCompressedValue(String key, String data) throws IOException {
        try (Jedis jedis = jedisPool.getResource()) {
            byte[] compressedData = compress(data);
            jedis.set(key.getBytes(), compressedData);
        }
    }

    // 从 Redis 中获取压缩后的数据并解压
    public static String getDecompressedValue(String key) throws IOException {
        try (Jedis jedis = jedisPool.getResource()) {
            byte[] compressedData = jedis.get(key.getBytes());
            if (compressedData != null) {
                return decompress(compressedData);
            }
            return null;
        }
    }

    public static void main(String[] args) throws IOException {
        String key = "compressedKey";
        String originalValue = "This is a test string to be compressed and stored in Redis.";

        // 存储压缩后的数据
        setCompressedValue(key, originalValue);

        // 从 Redis 获取并解压数据
        String retrievedValue = getDecompressedValue(key);
        System.out.println("Original value: " + originalValue);
        System.out.println("Retrieved value: " + retrievedValue);
    }
}

四、总结

在高并发场景下,大 Key 问题是缓存系统中常见的挑战之一,它可能导致响应超时、数据倾斜等严重性能问题和系统稳定性隐患。为有效解决大 Key 带来的各种挑战,可以采取多种策略和技术手段:

  • 首先,拆分大 Key 是一种常见的解决方案。通过将大 Key 拆分成多个小 Key,可以分散数据访问压力,避免单点故障和性能瓶颈。这需要明确的拆分策略和实现逻辑,确保数据的一致性和操作的原子性。
  • 其次,针对集合类型数据,如 Hash、Set、List、ZSet 等,可以采用分拆的方法来降低单个集合导致的性能问题。例如,将大型 Hash 数据按照哈希值进行分桶存储,可以有效减少单个操作对 Redis 的影响。
  • 另外,压缩 Redis 中存储的大 Value 可以显著减少存储空间和网络传输消耗,提升系统整体性能。选择合适的压缩算法,如 GZIP、LZ4、Snappy 等,需要综合考虑数据特性和压缩效率。

有效应对大 Key 问题需要综合考虑业务需求、数据特性以及系统架构,选择合适的技术手段和策略进行优化和改进。

相关推荐
张彦峰ZYF2 小时前
高并发场景下的缓存击穿问题探析与应对策略
redis·分布式·缓存
Li_7695322 小时前
Redis进阶(二)—— Redis 事务
数据库·redis·缓存
极客小云3 小时前
【Dockerfile 编写最佳实践:优化镜像构建与层缓存】
缓存·docker·k8s
CodeAmaz3 小时前
Redis大Key与热点Key问题解决方案
redis·大key·热点key
java1234_小锋4 小时前
Redis是单线程还是多线程?
数据库·redis·缓存
云计算-Security4 小时前
基于 Keepalived 的 Redis 主备高可用架构设计与实现
redis·keepalived
柒.梧.4 小时前
深度解析MyBatis缓存机制:从基础原理到实战配置
缓存·mybatis
222you5 小时前
在云服务器上配置redis环境(OpenCloudOS)
数据库·redis·缓存
Wang's Blog5 小时前
Kafka: 生产者客户端工作机制深度解析
分布式·kafka