本文主要总结了在
ICBU的核心沟通场景下
服务端在此次性能优化过程中做的工作,供大家参考讨论。
ICBU的核心沟通场景有了10年的“积累”,核心场景的界面响应耗时被拉的越来越长,也让性能优化工作提上了日程,先说结论,经过这一波前后端齐心协力的优化努力,两个核心界面90分位的数据,FCP平均由2.6s下降到1.9s,LCP平均由2.8s下降到2s。本文主要着眼于服务端在此次性能优化过程中做的工作,供大家参考讨论。
分块传输编码(Chunked Transfer Encoding)是一种HTTP/1.1协议中的数据传输机制,它允许服务器在不知道整个内容大小的情况下,就开始传输动态生成的内容。这种机制特别适用于生成大量数据或者由于某种原因数据大小未知的情况。
在分块传输编码中,数据被分为一系列的“块”(chunk)。每一个块都包括一个长度标识(以十六进制格式表示)和紧随其后的数据本身,然后是一个CRLF(即"\r\n",代表回车和换行)来结束这个块。块的长度标识会告诉接收方这个块的数据部分有多长,使得接收方可以知道何时结束这一块并准备好读取下一块。
当所有数据都发送完毕时,服务器会发送一个长度为零的块,表明数据已经全部发送完毕。零长度块后面可能会跟随一些附加的头部信息(尾部头部),然后再用一个CRLF来结束整个消息体。
我们可以借助分块传输协议完成对切分好的vm进行分块推送,从而达到整体HTML界面流式渲染的效果,在实现时,只需要对HTTP的header进行改造即可:
public void chunked(HttpServletRequest request, HttpServletResponse response) {
try (PrintWriter writer = response.getWriter()) {
oriResponse.setContentType(MediaType.TEXT_HTML_VALUE + ";charset=UTF-8");
oriResponse.setHeader("Transfer-Encoding", "chunked");
oriResponse.addHeader("X-Accel-Buffering", "no");
Context modelMain = getmessengerMainContext(request, response, aliId);
flushVm("/velocity/layout/Main.vm", modelMain, writer);
Context modelSec = getmessengerSecondContext(request, response, aliId, user);
flushVm("/velocity/layout/Second.vm", modelSec, writer);
Context modelThird = getmessengerThirdContext(request, response, user);
flushVm("/velocity/layout/Third.vm", modelThird, writer);
} catch (Exception e) {
}
}
private void flushVm(String templateName, Context model, PrintWriter writer) throws Exception {
StringWriter tmpWri = new StringWriter();
engine.mergeTemplate(templateName, "UTF-8", model, tmpWri);
writer.write(tmpWri.toString());
writer.flush();
}
我们现在的大部分应用都是springmvc架构,浏览器发起请求,后端服务器进行数据准备与vm渲染,之后返回html给浏览器。
从请求到达服务端开始计算,一次HTML请求到页面加载完全要经过网络请求、网络传输与前端资源渲染三个阶段:
HTML流式输出,思路是对HTML界面进行拆分,之后由服务器分批进行推送,这样做有两个好处:
这个思路对需要加载资源较多的页面有很明显的效果,在我们此次的界面优化中,页面的FCP与LCP均有300ms-400ms的性能提升,在进行vm界面的数据拆分时,有以下几个技巧:
此次优化的应用与界面本身历史包袱很重,在进行流式改造的过程中,我们遇到了不少的阻力与挑战,在解决问题的过程也学到了很多东西,这部分主要对遇到的问题进行整理。
-
二方包或自定义的HTTP请求 filter 会改写 response 的 header,导致分块传输失效。如果应用中有这种情况,我们在进行流式推送时,可以获取到最原始的response,防止被其他filter影响:
private static HttpServletResponse getResponse(HttpServletResponse response) {
ServletResponse resp = response;
while (resp instanceof ServletResponseWrapper) {
ServletResponseWrapper responseWrapper = (ServletResponseWrapper) resp;
resp = responseWrapper.getResponse();
}
return (HttpServletResponse) resp;
}
-
谷歌浏览器禁止跨域名写入cookie,我们的应用界面会以iframe的形式嵌入其他界面,谷歌浏览器正在逐步禁止跨域名写cookie,如下所示:
为了确保cookie能正常写入,需要指定cookie的SameSite=None。
-
VelocityEngine模板引擎的自定义tool。
我们的项目中使用的模板引擎为VelocityEngine,在流式分块传输时,需要手动渲染vm:
private void flushVm(String templateName, Context model, PrintWriter writer) throws Exception {
StringWriter tmpWri = new StringWriter();
engine.mergeTemplate(templateName, "UTF-8", model, tmpWri);
writer.write(tmpWri.toString());
writer.flush();
}
需要注意的是VelocityEngine模板引擎支持自定义tool,在vm文件中是如下的形式,当vm引擎渲染到对应位置时,会调用配置好的方法进行解析:
<span class="code-snippet__variable">$tool</span>.<span class="code-snippet__keyword">do</span>(<span class="code-snippet__string">"xx"</span>, <span class="code-snippet__string">"$!{arg}"</span>)
如果用注解的形式进行vm渲染,框架本身会帮我们自动做tools的初始化。但如果我们想手动渲染vm,那么需要将这些tools初始化到context中:
private Context initContext(HttpServletRequest request, HttpServletResponse response) {
ViewToolContext viewToolContext = null;
try {
ServletContext servletContext = request.getServletContext();
viewToolContext = new ViewToolContext(engine, request, response, servletContext);
VelocityToolsRepository velocityToolsRepository = VelocityToolsRepository.get(servletContext);
if (velocityToolsRepository != null) {
viewToolContext.putAll(velocityToolsRepository.getTools());
}
} catch (Exception e) {
LOGGER.error("createVelocityContext error", e);
return null;
}
}
对于比较古老的应用,
VelocityToolsRepository
需要将二方包版本进行升级,而且需要注意,
velocity-spring-boot-starter
升级后可能存在
tool.xml
文件失效的问题,建议可以采用注解的形式实现
tool
,并且注意
tool
对应java类的路径。
@DefaultKey("assetsVersion")
public class AssertsVersionTool extends SafeConfig {
public String get(String key) {
return AssetsVersionUtil.get(key);
}
}
-
server {
location ~ ^/chunked {
add_header X-Accel-Buffering no;
proxy_http_version 1.1;
proxy_cache off;
proxy_buffering off;
chunked_transfer_encoding on;
proxy_pass http://backends;
}
}
-
ngnix配置本身可能存在对流式输出的不兼容,这个问题是很难枚举的,我们遇到的问题是如下配置,需要将
SC_Enabled
关闭。
SC_Enabled on;
SC_AppName gangesweb;
SC_OldDomains //b.alicdn.com;
SC_NewDomains //b.alicdn.com;
SC_OldDomains //bg.alicdn.com;
SC_NewDomains //bg.alicdn.com;
SC_FilterCntType text/html;
SC_AsyncVariableNames asyncResource;
SC_MaxUrlLen 1024;
详见:
https://github.com/dinic/styleCombine3
-
ngnix缓冲区大小,在我们优化的过程中,某个应用并没有指定缓冲区大小,取的默认值,我们的改造导致http请求的header变大了,导致报错
upstream sent too big header while reading response header from upstream
proxy_buffers 128 32k;
proxy_buffer_size 64k;
proxy_busy_buffers_size 128k;
client_header_buffer_size 32k;
large_client_header_buffers 4 16k;
如果页面在浏览器上有问题时,可以通过curl命令在服务器上直接访问,排查是否为ngnix的问题:
curl --trace - 'http://127.0.0.1:7001/chunked' \
-H 'cookie: xxx'
-
ThreadLocal与StreamingResponseBody
在开始,我们使用StreamingResponseBody来实现的分块传输:
@GetMapping("/chunked")
public ResponseEntity streamChunkedData() {
StreamingResponseBody stream = outputStream -> {
Context modelMain = getmessengerMainContext(request, response, aliId);
flushVm("/velocity/layout/Main.vm", modelMain, writer);
Context modelSec = getmessengerSecondContext(request, response, aliId, user);
flushVm("/velocity/layout/Second.vm", modelSec, writer);
Context modelThird = getmessengerThirdContext(request, response, user);
flushVm("/velocity/layout/Third.vm", modelThird, writer);
}
};
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(stream);
}
}
但是我们在运行时发现vm的部分变量会渲染失败,卡点了不少时间,后面在排查过程中发现应用在处理http请求时会在ThreadLocal中进行用户数据、request数据与部分上下文的存储,而后续vm数据准备时,有一部分数据是直接从中读取或者间接依赖的,而StreamingResponseBody本身是异步的(可以看如下的代码注释),这就导致新开辟的线程读不到原线程ThreadLocal的数据,进而渲染错误: