
背景:
最近系统内缓存CPU使用率一直报警,超过设置的70%报警阀值,针对此场景,需要对应解决缓存是否有大key使用问题,扫描缓存集群的大key,针对每个key做优化处理。
以下是扫描出来的大key,此处只放置了有效关键信息。

图1
大key介绍:
想要解决大key,首先我们得知道什么定义为大key。
什么是大KEY:
大key 并不是指 key 的值很大,而是 key 对应的 value 很大(非常占内存)。此处为中间件给出的定义:
•单个String类型的Key大小达到20KB并且OPS高
•单个String达到100KB
•集合类型的Key总大小达到1MB
•集合类型的Key中元素超过5000个
大KEY带来的影响:
知道了大key的定义,那么我们也得知道大key的带来的影响:
•客户端超时阻塞。 Redis 执行命令是单线程处理,然后在大 key处理时会比较耗时,那么就会发生阻塞 ,期间就会各种业务超时出现。
•引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于服务器来说是灾难性的。
•阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,无法处理后续的命令。
•内存分布不均。集群各分片内存使用不均。某个分片占用内存较高或OOM,发送缓存区增大等,导致该分片其他Key被逐出,同时也会造成其他分片的资源浪费。
大KEY解决手段:
1、历史key未使用
场景描述:
针对这种key场景,其实存在着历史原因,可能是伴随着某个业务下线或者不使用,往往对应实现的缓存操作代码会删除,但是对于缓存数据往往不会做任何处理,久而久之,这种脏数据会一直堆积,占用着资源。那么如果确定已经无使用,并且可以确认有持久化数据(如mysql、es等)备份的话,可以直接将对应key删除。
实例经验:
如图1上面的元素个数488649,其实整个系统查看了下,没有使用的地方,最近也没有访问,相信也是因为一直没有用到, 否则系统内一旦用了这个key来操作hgetall、smembers等,那么缓存服务应该就会不可用了。
2、元素数过多
场景描述:
针对于Set、HASH这种场景,如果元素数量超过5000就视为大的key,以上面图1为例,可以看到元素个数有的甚至达到了1万以上。针对这种的如果对应value值不大,我们可以采取平铺的形式,
实例经验:
比如系统内历史的设计是存储下每个品牌对应的名称,那么就设置了统一的key,然后不同的品牌id作为fild,操作了hSet和hGet来存储获取数据,降低查询外围服务的频率。但是随着品牌数量的增长,导致元素逐步增多,元素个数就超过了大key的预设值了。这种根据场景,我们其实存储本身只有一个品牌名称,那么我们就针对于品牌id对应加上一个统一前缀作为唯一key,采用平铺方式缓存对应数据即可。那么针对这种数据的替换,我这里也总结了下具体要实现的步骤:
修改代码查询和赋值逻辑:
•把原始的hGet的逻辑修改为get获取;
•把原始hSet的逻辑修改为set赋值。
历史数据刷新到新缓存key:
为了避免上线之后出现缓存雪崩,因为替换了新的key,我们需要通过现有的HASH的数据刷新到新的缓存中,所以需要历史数据处理。
通过hGetAll获取所以元素数据
循环缓存元素数据操作存储新的缓存key和value。
1public String refreshHistoryData(){ 2 try { 3 String key = "historyKey"; 4 Map<String, String> redisInfoMap= redisUtils.hGetAll(key); 5 if (redisInfoMap.isEmpty()){ 6 return "查询缓存无数据"; 7 } 8 for (Map.Entry<String, String> entry : redisInfoMap.entrySet()) { 9 String redisVal = entry.getValue(); 10 String filedKey = entry.getKey(); 11 String newDataRedisKey = "newDataKey"+filedKey; 12 redisUtils.set(newDataRedisKey,redisVal); 13 } 14 return "success"; 15 }catch (Exception e){ 16 LOG.error("refreshHistoryData 异常:",e); 17 } 18 return "failed"; 19}
注意:这里一定要先刷历史数据,再上线代码业务逻辑的修改。防止引发****缓存雪崩
3、大对象转换存储形式
场景描述:
复杂的大对象可以尝试将对象分拆成几个key-value, 使用mGet和mSet操作对应值或者pipeline的形式,最后拼装成需要返回的大对象。这样意义在于可以分散单次操作的压力,将操作压力平摊到多个redis实例中,降低对单个redis的IO影响;
实例经验:
这里以系统内订单对象为例:订单对象Order基础属性有几十个,如订单号、金额、时间、类型等,除此之外还要包含订单下的商品OrderSub、预售信息PresaleOrder、发票信息OrderInvoice、订单时效OrderPremiseInfo、订单轨迹OrderTrackInfo、订单详细费用OrderFee等信息。
那么对于每个订单相关信息,我们可以设置为单独的key,把订单信息和几个相关的关联数据每个按照单独key存储,接着通过mGet方式获取每个信息之后,最后封装成整体Order对象。下面仅展示关键伪代码以mSet和mGet实现:
缓存定义:
1public enum CacheKeyConstant { 2 3 /** 4 * 订单基础缓存key 5 */ 6 REDIS_ORDER_BASE_INFO("ORDER_BASE_INFO"), 7 8 /** 9 * 订单商品缓存key 10 */ 11 ORDER_SUB_INFO("ORDER_SUB_INFO"), 12 13 /** 14 * 订单预售信息缓存key 15 */ 16 ORDER_PRESALE_INFO("ORDER_PRESALE_INFO"), 17 18 /** 19 * 订单履约信息缓存key 20 */ 21 ORDER_PREMISE_INFO("ORDER_PREMISE_INFO"), 22 23 /** 24 * 订单发票信息缓存key 25 */ 26 ORDER_INVOICE_INFO("ORDER_INVOICE_INFO"), 27 28 /** 29 * 订单轨迹信息缓存key 30 */ 31 ORDER_TRACK_INFO("ORDER_TRACK_INFO"), 32 33 /** 34 * 订单详细费用信息缓存key 35 */ 36 ORDER_FEE_INFO("ORDER_FEE_INFO"), 37 ; 38 /** 39 * 前缀 40 */ 41 private String prefix; 42 43 /** 44 * 项目统一前缀 45 */ 46 public static final String COMMON_PREFIX = "XXX"; 47 48 49 CacheKeyConstant(String prefix){ 50 this.prefix = prefix; 51 } 52 53 public String getPrefix(String subKey) { 54 if(StringUtil.isNotEmpty(subKey)){ 55 return COMMON_PREFIX + prefix + "_" + subKey; 56 } 57 return COMMON_PREFIX + prefix; 58 } 59 60 public String getPrefix() { 61 return COMMON_PREFIX + prefix; 62 } 63}
缓存存储:
1/** 2 * @description 刷新订单到缓存 3 * @param order 订单信息 4 */ 5public boolean refreshOrderToCache(Order order){ 6 if(order == null || order.getOrderId() == null){ 7 return ; 8 } 9 String orderId = order.getOrderId().toString(); 10 //设置存储缓存数据 11 Map<String,String> cacheOrderMap = new HashMap<>(16); 12 cacheOrderMap.put(CacheKeyConstant.ORDER_BASE_INFO.getPrefix(orderId), JSON.toJSONString(buildBaseOrderVo(order))); 13 cacheOrderMap.put(CacheKeyConstant.ORDER_SUB_INFO.getPrefix(orderId), JSON.toJSONString(order.getCustomerOrderSubs())); 14 cacheOrderMap.put(CacheKeyConstant.ORDER_PRESALE_INFO.getPrefix(orderId), JSON.toJSONString(order.getPresaleOrderData())); 15 cacheOrderMap.put(CacheKeyConstant.ORDER_INVOICE_INFO.getPrefix(orderId), JSON.toJSONString(order.getOrderInvoice())); 16 cacheOrderMap.put(CacheKeyConstant.ORDER_TRACK_INFO.getPrefix(orderId), JSON.toJSONString(order.getOrderTrackInfo())); 17 cacheOrderMap.put(CacheKeyConstant.ORDER_PREMISE_INFO.getPrefix(orderId), JSON.toJSONString( order.getPresaleOrderData())); 18 cacheOrderMap.put(CacheKeyConstant.ORDER_FEE_INFO.getPrefix(orderId), JSON.toJSONString(order.getOrderFeeVo())); 19 superRedisUtils.mSetString(cacheOrderMap); 20}
缓存获取:
1/** 2 * @description 通过订单号获取缓存数据 3 * @param orderId 订单号 4 * @return Order 订单实体信息 5 */ 6public Order getOrderFromCache(String orderId){ 7 if(StringUtils.isBlank(orderId)){ 8 return null; 9 } 10 //定义查询缓存集合key 11 List<String> queryOrderKey = Arrays.asList(CacheKeyConstant.ORDER_BASE_INFO.getPrefix(orderId),CacheKeyConstant.ORDER_SUB_INFO.getPrefix(orderId), 12 CacheKeyConstant.ORDER_PRESALE_INFO.getPrefix(orderId),CacheKeyConstant.ORDER_INVOICE_INFO.getPrefix(orderId),CacheKeyConstant.ORDER_TRACK_INFO.getPrefix(orderId), 13 CacheKeyConstant.ORDER_PREMISE_INFO.getPrefix(orderId),CacheKeyConstant.ORDER_FEE_INFO.getPrefix(orderId)); 14 15 //查询结果 16 List<String> result = redisUtils.mGet(queryOrderKey); 17 //基础信息 18 if(CollectionUtils.isEmpty(result)){ 19 return null; 20 } 21 String[] resultInfo = result.toArray(new String[0]); 22 23 //基础信息 24 if(StringUtils.isBlank(resultInfo[0])){ 25 return null; 26 } 27 BaseOrderVo baseOrderVo = JSON.parseObject(resultInfo[0],BaseOrderVo.class); 28 Order order = coverBaseOrderVoToOrder(baseOrderVo); 29 30 //订单商品 31 if(StringUtils.isNotBlank(resultInfo[1])){ 32 List<OrderSub> orderSubs =JSON.parseObject(result.get(1), new TypeReference<List<OrderSub>>(){}); 33 order.setCustomerOrderSubs(orderSubs); 34 } 35 //订单预售 36 if(StringUtils.isNotBlank(resultInfo[2])){ 37 PresaleOrderData presaleOrderData = JSON.parseObject(resultInfo[2],PresaleOrderData.class); 38 order.setPresaleOrderData(presaleOrderData); 39 } 40 //订单发票 41 if(StringUtils.isNotBlank(resultInfo[3])){ 42 OrderInvoice orderInvoice = JSON.parseObject(resultInfo[3],OrderInvoice.class); 43 order.setOrderInvoice(orderInvoice); 44 } 45 //订单轨迹 46 if(StringUtils.isNotBlank(resultInfo[5])){ 47 OrderTrackInfo orderTrackInfo = JSON.parseObject(resultInfo[5],OrderTrackInfo.class); 48 order.setOrderTrackInfo(orderTrackInfo); 49 } 50 //订单履约信息 51 if(StringUtils.isNotBlank(resultInfo[6])){ 52 List<OrderPremiseInfo> orderPremiseInfos =JSON.parseObject(result.get(6), new TypeReference<List<OrderPremiseInfo>>(){}); 53 order.setPremiseInfos(orderPremiseInfos); 54 } 55 //订单费用明细信息 56 if(StringUtils.isNotBlank(resultInfo[7])){ 57 OrderFeeVo orderFeeVo = JSON.parseObject(resultInfo[7],OrderFeeVo.class); 58 order.setOrderFeeVo(orderFeeVo); 59 } 60 return order; 61}
注意:获取缓存的结果跟传入的key的顺序保持对应即可。
缓存util方法封装:
1/** 2 * 3 * @description 同时将多个 key-value (域-值)对设置到缓存中。 4 * @param mappings 需要插入的数据信息 5 */ 6public void mSetString(Map<String, String> mappings) { 7 CallerInfo callerInfo = Ump.methodReg(UmpKeyConstants.REDIS.REDIS_STATUS_READ_MSET); 8 try { 9 redisClient.getClientInstance().mSetString(mappings); 10 } catch (Exception e) { 11 Ump.funcError(callerInfo); 12 }finally { 13 Ump.methodRegEnd(callerInfo); 14 } 15} 16/** 17 * 18 * @description 同时将多个key的结果返回。 19 * @param queryKeys 查询的缓存key集合 20 */ 21public List<String> mGet(List<String> queryKeys) { 22 CallerInfo callerInfo = Ump.methodReg(UmpKeyConstants.REDIS.REDIS_STATUS_READ_MGET); 23 try { 24 return redisClient.getClientInstance().mGet(queryKeys.toArray(new String[0])); 25 } catch (Exception e) { 26 Ump.funcError(callerInfo); 27 }finally { 28 Ump.methodRegEnd(callerInfo); 29 } 30 return new ArrayList<String>(queryKeys.size()); 31}
这里附上通过pipeline的util封装,可参考。
1/** 2 * @description pipeline放松查询数据 3 * @param redisKeyList 4 * @return java.util.List<java.lang.String> 5 */ 6public List<String> getValueByPipeline(List<String> redisKeyList) { 7 if(CollectionUtils.isEmpty(redisKeyList)){ 8 return null; 9 } 10 List<String> resultInfo = new ArrayList<>(redisKeyList); 11 CallerInfo callerInfo = Ump.methodReg(UmpKeyConstants.REDIS.REDIS_STATUS_READ_GET); 12 try { 13 14 PipelineClient pipelineClient = redisClient.getClientInstance().pipelineClient(); 15 16 //添加批量查询任务 17 List<JimFuture> futures = new ArrayList<>(); 18 redisKeyList.forEach(redisKey -> { 19 futures.add(pipelineClient.get(redisKey.getBytes())); 20 }); 21 //处理查询结果 22 pipelineClient.flush(); 23 //可以等待future的返回结果,来判断命令是否成功。 24 for (JimFuture future : futures) { 25 resultInfo.add(new String((byte[])future.get())); 26 } 27 28 } catch (Exception e) { 29 log.error("getValueByPipeline error:",e); 30 Ump.funcError(callerInfo); 31 return new ArrayList<>(redisKeyList.size()); 32 }finally { 33 Ump.methodRegEnd(callerInfo); 34 } 35 return resultInfo; 36 }
注意:Pipeline不建议用来设置缓存值,因为本身不是原子性的操作。
4、压缩存储数据
压缩方法结果:
单个元素时:

| 压缩方法 | 压缩前大小Byte | 压缩后大小Byte | 压缩耗时 | 解压耗时 | 压缩解压后比对结果 |
|---|---|---|---|---|---|
| DefaultOutputStream | 446(0.43kb) | 254 (0.25kb) | 1ms | 0ms | 相同 |
| GzipOutputStream | 446(0.43kb) | 266 (0.25kbM) | 1ms | 1ms | 相同 |
| ZlibCompress | 446(0.43kb) | 254 (0.25kb) | 1ms | 0ms | 相同 |
四百个元素集合:

| 压缩方法 | 压缩前大小Byte | 压缩后大小Byte | 压缩耗时 | 解压耗时 | 压缩解压后比对结果 |
|---|---|---|---|---|---|
| DefaultOutputStream | 6732(6.57kb) | 190 (0.18kb) | 2ms | 0ms | 相同 |
| GzipOutputStream | 6732(6.57kb) | 202 (0.19kb) | 1ms | 1ms | 相同 |
| ZlibCompress | 6732(6.57kb) | 190 (0.18kb) | 1ms | 0ms | 相同 |
四万个元素集合时:

| 压缩方法 | 压缩前大小Byte | 压缩后大小Byte | 压缩耗时 | 解压耗时 | 压缩解压后比对结果 |
|---|---|---|---|---|---|
| DefaultOutputStream | 640340(625kb) | 1732 (1.69kb) | 37ms | 2ms | 相同 |
| GzipOutputStream | 640340(625kb) | 1744 (1.70kb) | 11ms | 3ms | 相同 |
| ZlibCompress | 640340(625kb) | 1732 (1.69kb) | 69ms | 2ms | 相同 |
压缩代码样例
DefaultOutputStream
1public static byte[] compressToByteArray(String text) throws IOException { 2 ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 3 Deflater deflater = new Deflater(); 4 DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(outputStream, deflater); 5 6 deflaterOutputStream.write(text.getBytes()); 7 deflaterOutputStream.close(); 8 9 return outputStream.toByteArray(); 10}
1public static String decompressFromByteArray(byte[] bytes) throws IOException { 2 ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes); 3 Inflater inflater = new Inflater(); 4 InflaterInputStream inflaterInputStream = new InflaterInputStream(inputStream, inflater); 5 ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 6 7 byte[] buffer = new byte[1024]; 8 int length; 9 while ((length = inflaterInputStream.read(buffer)) != -1) { 10 outputStream.write(buffer, 0, length); 11 } 12 13 inflaterInputStream.close(); 14 outputStream.close(); 15 16 byte[] decompressedData = outputStream.toByteArray(); 17 return new String(decompressedData); 18}
GZIPOutputStream
1public static byte[] compressGzip(String str) { 2 ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 3 GZIPOutputStream gzipOutputStream = null; 4 try { 5 gzipOutputStream = new GZIPOutputStream(outputStream); 6 } catch (IOException e) { 7 throw new RuntimeException(e); 8 } 9 try { 10 gzipOutputStream.write(str.getBytes("UTF-8")); 11 } catch (IOException e) { 12 throw new RuntimeException(e); 13 }finally { 14 try { 15 gzipOutputStream.close(); 16 } catch (IOException e) { 17 throw new RuntimeException(e); 18 } 19 } 20 return outputStream.toByteArray(); 21 }
1 public static String decompressGzip(byte[] compressed) throws IOException { 2 ByteArrayInputStream inputStream = new ByteArrayInputStream(compressed); 3 GZIPInputStream gzipInputStream = new GZIPInputStream(inputStream); 4 ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 5 byte[] buffer = new byte[1024]; 6 int length; 7 while ((length = gzipInputStream.read(buffer)) > 0) { 8 outputStream.write(buffer, 0, length); 9 } 10 gzipInputStream.close(); 11 outputStream.close(); 12 return outputStream.toString("UTF-8"); 13 }
ZlibCompress
1 public byte[] zlibCompress(String message) throws Exception { 2 String chatacter = "UTF-8"; 3 byte[] input = message.getBytes(chatacter); 4 BigDecimal bigDecimal = BigDecimal.valueOf(0.25f); 5 BigDecimal length = BigDecimal.valueOf(input.length); 6 byte[] output = new byte[input.length + 10 + new Double(Math.ceil(Double.parseDouble(bigDecimal.multiply(length).toString()))).intValue()]; 7 Deflater compresser = new Deflater(); 8 compresser.setInput(input); 9 compresser.finish(); 10 int compressedDataLength = compresser.deflate(output); 11 compresser.end(); 12 return Arrays.copyOf(output, compressedDataLength); 13 }
1public static String zlibInfCompress(byte[] data) { 2 String s = null; 3 4 Inflater decompresser = new Inflater(); 5 decompresser.reset(); 6 decompresser.setInput(data); 7 ByteArrayOutputStream o = new ByteArrayOutputStream(data.length); 8 try { 9 byte[] buf = new byte[1024]; 10 while (!decompresser.finished()) { 11 int i = decompresser.inflate(buf); 12 o.write(buf, 0, i); 13 } 14 s = o.toString("UTF-8"); 15 } catch (Exception e) { 16 e.printStackTrace(); 17 } finally { 18 try { 19 o.close(); 20 } catch (IOException e) { 21 e.printStackTrace(); 22 } 23 } 24 decompresser.end(); 25 return s; 26 }
可以看到压缩效率比较好,压缩效率可以从几百kb压缩到几kb内;当然也是看具体场景。不过这里就是最好是避免调用量大的场景使用,毕竟解压和压缩数据量大会比较耗费cpu性能。如果是黄金链路使用,还需要具体配合压测,对比前后接口性能。
5、替换存储方案
如果数据量庞大,那么其实本身是不是就不太适合redis这种缓存存储了。可以考虑es或者mongo这种文档式存储结构,存储大的数据格式。
总结:
redis缓存的使用是一个支持业务和功能高并发的很好的使用方案,但是随着使用场景的多样性以及数据的增加,可能逐渐的会出现大key,日常使用中都可以注意以下几点:
1.分而治之:如果需要存储大量的数据,避免直接放到缓存中。可以将其拆分成多个小的value。就像是咱们日常吃饭,盛到碗里,一口一口的吃,俗话说的好呀:“细嚼慢咽”。
2.避免使用不必要的数据结构。例如,如果只需要存储一个字符串结构的数据,就不要过度设计,使用Hash或者List等数据结构。
3.定期清理过期的key。如果Redis中存在大量的过期key,就会导致Redis的性能下降,或者场景非必要以缓存来持久存储的,可以添加过期时间,定时清理过期的key,就像是家中的日常垃圾类似,定期的清洁和打扫,居住起来咱们才会更加舒服和方便。
4.对象压缩。将大的数据压缩成更小的数据,也是一种好的解决方案,不过要注意压缩和解压的频率,毕竟是比较耗费cpu的。
以上是我根据现有实际场景总结出的一些解决手段,记录了这些大key的优化经验,希望可以在日常场景中帮助到大家。大家有其他的好的经验,也可以分享出来。
