更多内容,前往 IT-BLOG
线上订单8月7日跌至0,初步排查后得知是订单服务FullGC
导致,对服务集群进行扩容和重启后问题得到缓解。开发人员通过分析DUMP
后得知该事件的根因是客户端传入了超大请求报文(单个请求报文在解压&反序列化后达到10+GB)。
为什么客户端会产生超级请求报文:客户端为了节省对象创建的开销,对Builder
对象进行了复用,每次请求都用了同一个Builder
对象。由于Builder
对象中有List
类型,每次请求都会往该List
追加元素,这就导致请求报文占用空间随着请求次数不断膨胀,最终导致客户端自身和它的上游服务都发生FullGC
。
服务端能否对请求报文进行合法校验并拦截请求来规避该故障的发生:不能。请求报文较小时服务端可对报文进行合法性校验并拦截请求,但当请求报文超出极限时,服务端将请求报文反序列化后就发生FullGC
了。
此次故障代码除了占用空间无限膨胀外是否还有其他隐患:客户端每次请求都复用了一个静态对象,且这个对象内部使用了List
,当该程序处于多线程环境时,它有可能会抛出异常或线程间相互覆盖。
客户端的对象复用是否有必要:客户端是个Job
应用,它的执行频率很低,系统压力非常小,且出问题的Request
通常情况下也是很小的(小于4KB),综上,完全没有必要对请求报文对象进行复用。如坚持要复用对象,可改为使用对象池而不是对静态对象进行复用。
对象池顾名思义就是存放对象的池,与我们常听到的线程池、数据库连接池、http连接池等一样,都是典型的池化设计思想。当需要创建对象时,先在池子中获取,如果池子中没有符合条件的对象,再进行创建新对象,同样,当对象需要销毁时,不做真正的销毁,而是将其setActive(false),并存入池子中。这样就避免了大量对象的创建。
对象池的优点: 可以集中管理池中对象,减少频繁创建和销毁长期使用的对象,从而提升复用性,以节约资源的消耗,可以有效避免频繁为对象分配内存和释放堆中内存,进而减轻jvm
垃圾收集器的负担,避免内存抖动。
对象池的缺点: 会生成脏对象,因为当对象被放回对象池后,还保留着刚刚被客户端调用时生成的数据。脏对象持有上次的使用,导致内存泄漏等问题。如果下一次使用时没有清理,可能影响程序的处理数据。脏对象的生命周期比普通对象长久。维持大量的对象池也比较占用内存空间。
【1】确保线程安全。
【2】合理设置池子大小。如果对象池没有限制,可能导致对象池持有过多的闲置对象,增加内存的占用。如果对象池闲置过小,没有可用的对象时,会造成之前对象池无可用的对象时,再次请求出现的问题。现在Java
的对象分配操作不比C语言的malloc
调用慢, 对于轻中量级的对象, 分配/释放对象的开销可以忽略不计,并发环境中, 多个线程可能(同时)需要获取池中对象, 进而需要在堆数据结构上进行同步或者因为锁竞争而产生阻塞, 这种开销要比创建销毁对象的开销高数百倍;由于池中对象的数量有限, 势必成为一个可伸缩性瓶颈;很难正确的设定对象池的大小, 如果太小则起不到作用, 如果过大, 则占用内存资源高。对象池属于空间换时间的折中。
【3】制定合理的驱逐策略。
【4】确保对象归还后,外部没有地方持有该对象的引用。
【1】Apache Common Pool2
是Apache
提供的一个通用对象池技术实现,可以方便定制化自己需要的对象池。
【2】Maven
依赖
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-pool2artifactId>
<version>${version}version>
dependency>
【3】使用案例
public class ByteArrayOutputStreamFactory extends BasePooledObjectFactory<ByteArrayOutputStream> {
private static final int DEFAULT_SIZE = 1024;
@Override
public ByteArrayOutputStream create() throws Exception {
return new ByteArrayOutputStream(DEFAULT_SIZE);
}
@Override
public PooledObject<ByteArrayOutputStream> wrap(ByteArrayOutputStream s) {
return new DefaultPooledObject<>(s);
}
@Override
public void activateObject(PooledObject<ByteArrayOutputStream> p) {
p.getObject().reset();
}
}
public class ByteArrayStreamPool {
private static final GenericObjectPool<ByteArrayOutputStream> POOL;
static {
// 根据需求自定义配置
GenericObjectPoolConfig config = new GenericObjectPoolConfig();
cfg.setJmxNamePrefix("objectPool");
// 资源耗尽时,是否阻塞等待获取资源,默认 true
config.setBlockWhenExhausted(false);
// 回收资源线程的执行周期,默认 -1 表示不启用回收资源线程
config.setTimeBetweenEvictionRunsMillis(10000);
// 对象总数
cfg.setMaxTotal(sxInferConfig.getPoolMaxTotal());
// 最大空闲对象数
cfg.setMaxIdle(sxInferConfig.getPoolMaxIdle());
// 最小空闲对象数
cfg.setMinIdle(sxInferConfig.getPoolMinIdle());
// 借对象阻塞最大等待时间
// 获取资源的等待时间。blockWhenExhausted 为 true 时有效。-1 代表无时间限制,一直阻塞直到有可用的资源
cfg.setMaxWaitMillis(sxInferConfig.getPoolMaxWait());
// 最小驱逐空闲时间
cfg.setMinEvictableIdleTimeMillis(sxInferConfig.getPoolMinEvictableIdleTimeMillis());
// 每次驱逐数量 资源回收线程执行一次回收操作,回收资源的数量。默认 3
cfg.setNumTestsPerEvictionRun(sxInferConfig.getPoolNumTestsPerEvictionRun());
POOL = new GenericObjectPool<>(new ByteArrayOutputStreamFactory(), config);
}
private ByteArrayStreamPool() {
}
public static GenericObjectPool<ByteArrayOutputStream> get() {
return POOL;
}
}
// 注意:pool须定义为单例(不要频繁创建对象池,这比频繁创建对象更糟糕)
GenericObjectPool<ByteArrayOutputStream> pool = ByteArrayStreamPool.get();
ByteArrayOutputStream stream = null;
try {
stream = pool.borrowObject();
} catch (NoSuchElementException ex) {
// 自行创建一个对象
stream = new ByteArrayOutputStream(1024);
}
// do something
try {
// 自行创建的对象returnObject会抛出异常
pool.returnObject(stream);
} catch(Exception ex) {
}
我们也可以使用SOA
拦截器,对Request
字节流进行大小校验,如超出最大可容忍大小就拦截请求(不对请求进行解压和反序列化操作)。但这种方法需要对最大可容忍的大小需要给出非常准确的判断(此次RCA
压缩的Request
字节流只有7MB
,但解压&反序列化后却达到10+GB
),否则可能误拦截正确请求,需谨慎使用该方法。
以下是SOA
拦截器的使用示例:
private static class BaijiWebListener extends BaijiListener {
@Override
protected void configure(HostConfig hostConfig) {
super.configure(hostConfig);
hostConfig.addPreRequestFilter(new RequestSizeFilter());
}
}
public class RequestSizeFilter implements PreRequestFilter {
private static final String CHECK_HEALTH = "checkhealth";
// 以下几个变量为随手写的(报文压缩格式可能是其他字符串,报文大小需要可配置)
private static final String ZSTD = "zstd";
private static final String GZIP = "gzip";
private static final int MAX_SIZE = 1024 * 1024 * 64;
private static final int MAX_ZIP_SIZE = 1024 * 1024 * 2;
private static final int REQUEST_TOO_LARGE = 416;
@Override
public void apply(ServiceHost serviceHost, HttpRequestWrapper requestWrapper, HttpResponseWrapper responseWrapper) {
String operationName = Optional.ofNullable(requestWrapper).map(HttpRequestWrapper::operationName).orElse(null);
if (StringUtils.equalsIgnoreCase(CHECK_HEALTH, operationName)) {
return;
}
// 获取请求报文压缩方式
String encoding = Optional.ofNullable(requestWrapper)
.map(item -> item.getHeader("Accept-Encoding"))
.orElse(StringUtils.EMPTY);
int byteSize = 0;
try {
InputStream inputStream = requestWrapper.requestBody();
byteSize = inputStream.available();
} catch (Exception e) {
log.error("RequestSizeFilter occurs error", e);
}
// 以下两种情况直接返回错误码:1.压缩报文size>MAX_ZIP_SIZE;2.非压缩报文size>MAX_SIZE
if ((ZSTD.equals(encoding) || GZIP.equals(encoding)) && byteSize > MAX_ZIP_SIZE
|| (!ZSTD.equals(encoding) && !GZIP.equals(encoding)) && byteSize > MAX_SIZE) {
responseWrapper.setStatus(REQUEST_TOO_LARGE, "Request size is too large");
responseWrapper.sendResponse();
}
}
}