目录
[(一)String 类型](#(一)String 类型)
[(二)Set、List、Hash、ZSet 等集合类型](#(二)Set、List、Hash、ZSet 等集合类型)
[(一)解决策略一:拆分大 Key](#(一)解决策略一:拆分大 Key)
干货分享,感谢您的阅读!
在高并发场景下,缓存作为前置查询机制,显著减轻了数据库的压力,提高了系统性能。然而,这也带来了缓存失效、增加回溯率等风险。常见的问题包括缓存穿透、缓存雪崩、热Key和大Key等。这些问题如果不加以处理,会影响系统的稳定性和性能。因此,采用有效的缓存策略,如缓存空结果、布隆过滤器、缓存过期时间随机化、多级缓存等,对于保障系统在高并发情况下的可靠性至关重要。本次我们将详细探讨大Key及其应对策略。

一、问题描述
大 Key 是指在缓存系统中,某些 Key 对应的值(Value)存储的数据量非常大,大 Key 可能会导致一系列性能问题和系统不稳定性:
- 响应超时 :由于 Redis 是单线程的,如果某个 Key 的 Value 很大,在进行
GET或SET操作时会占用 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 问题需要综合考虑业务需求、数据特性以及系统架构,选择合适的技术手段和策略进行优化和改进。