事故
案例描述
- 某系统在双十一大促期间,遇到了一个严重的线上事故。业务人员在创建一个大型活动,该大型活动由于活动条件和活动奖励比较多,导致生成的缓存内容非常大。活动上线后,系统就开始出现各种异常告警,核心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