缓存大热key解决方案-真实案例

事故

案例描述
  • 某系统在双十一大促期间,遇到了一个严重的线上事故。业务人员在创建一个大型活动,该大型活动由于活动条件和活动奖励比较多,导致生成的缓存内容非常大。活动上线后,系统就开始出现各种异常告警,核心UMP监控可用率由100%持续下降到20%,系统访问Redis的调用次数和查询性能也断崖式下降,后续更是产生连锁反应影响了其他多个核心接口的可用率,导致整个系统服务不可用。
原因分析
  • 在这个系统中,为了提高查询活动的性能,我们开发团队决定使用Redis作为缓存系统。将每个活动信息作为一个key-value存储在Redis中。由于业务需要,有时候业务运营人员也会创建一个非常庞大的活动,来支撑双十一期间的各种玩法。针对这种庞大的活动,我们开发团队也提前预料到了可能会出现的大key和热key问题,所以在查询活动缓存之前增加了一层本地jvm缓存,本地jvm缓存5分钟,缓存失效后再去回源查询Redis中的活动缓存,本以为会万无一失,没想到最后还是出了问题。
  • 为什么加了本地缓存还是出了问题?
    这里其实就存在着第一个缓存陷阱:缓存击穿问题。首先解释一下什么是缓存击穿;缓存击穿(Cache Miss)是指在高并发的系统中,如果某个缓存键对应的值在缓存中不存在(即缓存失效),那么所有请求都会直接访问后端数据库,导致数据库的负载瞬间增加,可能会引发数据库宕机或服务不可用的情况。所以在本次事故里边,运营人员审批活动上线的一瞬间,活动缓存只是写入到了Redis缓存中,但是本地缓存还都是空的,所以此时就会有大量请求来同时访问Redis。
    按照以往经验,Redis缓存都是纯内存操作,查询性能可以满足大量请求同时查询活动缓存,就在此时我们却陷入了第二个缓存陷阱:网络带宽瓶颈;Redis的高并发性能毋庸置疑,但是我们却忽略了一个大key和热key对网络带宽的影响,本次引发问题的大热key大小达到了1.5M,经过事后了解京东Redis对单分片的网络带宽也有限流,默认200M,根据换算,该热key最多只能支持133次的并发访问。所以就在活动上线的同一时刻,加上缓存击穿的影响,迅速达到了Redis单分片的带宽限流阈值,导致Redis线程进入阻塞状态,以至于所有的业务服务器都无法查询Redis缓存成功,最终引发了缓存雪崩效应。
解决方案
  • 大key治理:更换缓存对象序列化方法,由原来的JSON序列化调整为Protostuff序列化方式。治理效果:缓存对象大小由1.5M减少到了0.5M。

  • 使用压缩算法:在存储缓存对象时,再使用压缩算法(如gzip)对数据进行压缩,注意设置压缩阈值,超过一定阈值后再进行压缩,以减少占用的内存空间和网络传输的数据量。压缩效果:500k压缩到了17k。

  • 监控和优化Redis配置:定期监控Redis网络传输情况,根据实际情况调整Redis的限流配置,以确保Redis的稳定运行。

方案代码
  • 添加protostuff依赖
<dependency>
            <groupId>io.protostuff</groupId>
            <artifactId>protostuff-api</artifactId>
            <version>1.6.2</version>
        </dependency>
        <dependency>
            <groupId>io.protostuff</groupId>
            <artifactId>protostuff-collectionschema</artifactId>
            <version>1.6.2</version>
        </dependency>
        <dependency>
            <groupId>io.protostuff</groupId>
            <artifactId>protostuff-core</artifactId>
            <version>1.6.2</version>
        </dependency>
        <dependency>
            <groupId>io.protostuff</groupId>
            <artifactId>protostuff-runtime</artifactId>
            <version>1.6.2</version>
        </dependency>

ProtostuffUtil
package com.gouying.web.home;

import io.protostuff.LinkedBuffer;
import io.protostuff.ProtostuffIOUtil;
import io.protostuff.Schema;
import io.protostuff.runtime.RuntimeSchema;
import lombok.extern.slf4j.Slf4j;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;


@Slf4j
public class ProtostuffUtil {
    private static Map<Class<?>, Schema<?>> cachedSchema = new ConcurrentHashMap<>();
    private final static ThreadLocal<LinkedBuffer> localBuffer = ThreadLocal.withInitial(() -> LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE));


    private static <T> Schema<T> getSchema(Class<T> clazz) {
        @SuppressWarnings("unchecked")
        Schema<T> schema = (Schema<T>) cachedSchema.get(clazz);
        if (schema == null) {
            schema = RuntimeSchema.getSchema(clazz);
            if (schema != null) {
                cachedSchema.putIfAbsent(clazz, schema);
            }
        }
        return schema;
    }

    /**
     * 将对象序列化
     *
     * @param obj 对象
     * @return
     */
    public static <T> byte[] serialize(T obj) {
        if (obj == null) {
            return null;
        }
        @SuppressWarnings("unchecked")
        Class<T> clazz = (Class<T>) obj.getClass();
        LinkedBuffer buffer = localBuffer.get();
        try {
            Schema<T> schema = getSchema(clazz);
            return ProtostuffIOUtil.toByteArray(obj, schema, buffer);
        } catch (Exception e) {
            log.error(e.getMessage() + ",serialize error;obj=" + obj, e);
            return null;
        } finally {
            buffer.clear();
        }
    }

    /**
     * 将字节数组数据反序列化
     *
     * @param data  字节数组
     * @param clazz 对象
     * @return
     */
    public static <T> T deserialize(byte[] data, Class<T> clazz) {
        try {
            if (data == null) {
                return null;
            }
            T obj = clazz.newInstance();
            Schema<T> schema = getSchema(clazz);
            ProtostuffIOUtil.mergeFrom(data, obj, schema);
            return obj;
        } catch (Exception e) {
            throw new IllegalStateException(e.getMessage(), e);
        }
    }

}

ByteCompressionUtil

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

public class ByteCompressionUtil {
    private static final int COMPRESS_BYTE_MIN_LENGTH = 1024;

    public ByteCompressionUtil() {
    }

    public static byte[] compress(byte[] data) throws IOException {
        if (data.length < 1024) {
            return data;
        } else {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            GZIPOutputStream gzip = new GZIPOutputStream(bos);
            gzip.write(data);
            gzip.close();
            return bos.toByteArray();
        }
    }

    public static byte[] decompress(byte[] compressedData) {
        if (!isGzipCompressed(compressedData)) {
            return compressedData;
        } else {
            try {
                GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressedData));
                byte[] buffer = new byte[1024];
                ByteArrayOutputStream bos = new ByteArrayOutputStream();

                int len;
                while((len = gzip.read(buffer)) != -1) {
                    bos.write(buffer, 0, len);
                }

                gzip.close();
                return bos.toByteArray();
            } catch (IOException var5) {
                return compressedData;
            }
        }
    }

    public static boolean isGzipCompressed(byte[] data) {
        if (data != null && data.length >= 2) {
            return data[0] == 31 && data[1] == -117;
        } else {
            return false;
        }
    }
}

活动缓存类相关

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
 * 活动索引缓存对象
 *
 * @author caozhifei
 * @date 2023/12/4 17:21
 **/
@AllArgsConstructor
@NoArgsConstructor
@Data
public class ActivityCache implements Serializable {
    /**
     * 主键Id,活动id
     */
    private Long id;
    /**
     * 业务BU
     */
    private Integer buId;
    /**
     * 活动名称
     */
    private String name;
    /**
     * 玩法类型
     */
    private Integer playType;
    /**
     * 可用库存数量
     */
    private Integer availableStock;
    /**
     * 状态
     */
    private Byte status;

    /**
     * 审核状态
     */
    private Byte auditStatus;
    /**
     * 阶梯命中规则
     */
    private Integer stepHitRule;
    /**
     * 是否支持预申请
     */
    private Integer supportApply;
    /**
     * 是否支持库存预占
     */
    private Integer supportPreempt;
    /**
     * 开奖时间
     */
    private Long awardTime;
    /**
     * 开始时间
     */
    private Long beginTime;

    /**
     * 结束时间
     */
    private Long endTime;

    /**
     * 预热开始时间
     */
    private Long heatBeginTime;

    /**
     * 预热结束时间
     */
    private Long heatEndTime;
    /**
     * 任务截止统计时间
     */
    private Long actLastDoneTime;
    /**
     * 版本号
     */
    private Long version;
    /**
     * 创建人
     */
    private String createdOperator;
    /**
     * 最后一个修改人
     */
    private String modifiedOperator;
    /**
     * 扩展信息
     */
    private Map<String, String> displayExt;
    /**
     * 扩展信息
     */
    private Map<String, String> ext;
    /**
     * 奖品列表
     */
    private List<AwardBO> awardBOList;
    public ActivityCache(Long id) {
        this.id = id;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {return true;}
        if (o == null || getClass() != o.getClass()) {return false;}
        if(o instanceof ActivityCache) {
            ActivityCache that = (ActivityCache) o;
            return Objects.equals(id, that.id);
        }else {
            return false;
        }
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}





import lombok.Data;

import java.io.Serializable;

/**
 * 动态字段业务对象
 *
 * @author caozhifei
 * @date 2023/12/11 09:55
 **/
@Data
public class ExtensibleFieldBO implements Serializable {
    /**
     * 字段名称
     */
    private String fieldName;
    /**
     * 字段描述
     */
    private String fieldDesc;
    /**
     * 字段类型
     */
    private Class fieldType;
    /**
     * 是否必填
     */
    private Boolean required;
    /**
     * 字段值
     */
    private Object fieldValue;

}


import lombok.Data;

import java.io.Serializable;
import java.util.List;
import java.util.Map;

/**
 * 激励奖品业务对象
 *
 * @author caozhifei
 * @date 2023/10/19 18:01
 **/
@Data
public class AwardBO implements Serializable {
    private static final long serialVersionUID = -4357256438316213420L;
    /**
     * 主键Id
     */
    private Long id;

    /**
     * 活动Id
     */
    private Long activityId;

    /**
     * 阶梯id
     */
    private Long stepId;

    /**
     * 奖品池id
     */
    private Long awardPoolId;

    /**
     * 奖品池类型
     */
    private Integer awardPoolType;

    /**
     * 玩法类型
     */
    private Integer playType;

    /**
     * 奖品名称
     */
    private String awardName;
    /**
     * 奖品类型
     */
    private Integer awardType;
    /**
     * 奖品库存控制方式
     */
    private Integer stockControlMode;
    /**
     * 奖品库存数量
     */
    private Integer awardStock;

    /**
     * 可用奖品库存数量
     */
    private Integer availableStock;

    /**
     * 延迟发奖时间,秒
     */
    private Long lazySendTime;

    /**
     * 发奖数量计算方式,固定数额的字段存于awardQuantity
     */
    private Integer quantityCalType;

    /**
     * 奖品数量
     */
    private Integer awardQuantity;

    /**
     * 奖品扩展字段
     */
    private List<ExtensibleFieldBO> awardExt;

    /**
     * 其他扩展字段
     */
    private Map<String, Object> extMap;

    /**
     * 版本号
     */
    private Long version;

    /**
     * 数据有效性:0,无效 1,有效
     */
    private Byte yn;

    /**
     * 创建人
     */
    private String createdOperator;

    /**
     * 最后一个修改人
     */
    private String modifiedOperator;

    /**
     * 创建时间
     */
    private Long createdTime;

    /**
     * 最后一次修改时间
     */
    private Long modifiedTime;


    /**
     * 权重值,中奖概率
     */
    private Integer weight;

}

缓存数据
  • bigKey.txt
放不下
https://gitee.com/gouyingcode/supermarket/blob/master/building/bigKey.txt 这里下载 并且放在test的resource目录下
测试类

    @Test
    public void testProto() throws IOException {
        StringBuilder longString = new StringBuilder();
        ClassLoader classLoader = RedisDemo.class.getClassLoader();
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(classLoader.getResourceAsStream("bigKey.txt")))) {
            String line;
            while ((line = reader.readLine()) != null) {
                longString.append(line);
            }
        } catch (IOException e) {
            // Handle exception
        }
        System.out.println("proto原始length=" + longString.toString().getBytes().length);
        JSONObject json = JSONObject.parseObject(longString.toString());
        Map<String, String> map = JSONObject.toJavaObject(json,Map.class);
        // System.out.println(map);
        String info = map.get("info");
        JSONObject json1 = JSONObject.parseObject(info);
        ActivityCache activityCache = JSONObject.toJavaObject(json1,ActivityCache.class);
        System.out.println(activityCache);

        String award = map.get("award");

        if (!StringUtils.isEmpty(award)) {
            activityCache.setAwardBOList(JSONObject.parseArray(award, AwardBO.class));
        }

        byte[] serialize = ProtostuffUtil.serialize(activityCache);
        System.out.println("proto压缩前length="+serialize.length);
        byte[] compress = ByteCompressionUtil.compress(serialize);
        System.out.println("proto压缩后length="+compress.length);
        System.out.println("proto是否压缩="+ByteCompressionUtil.isGzipCompressed(compress));
        long t1 = System.currentTimeMillis();


    }
  • 运行结果
  • 直接影响了将近32倍的网络传输大小
proto原始length=1479236
proto压缩前length=523088
proto压缩后length=16754
proto是否压缩=true
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值