不来看一看HTML请求后端性能优化的实战总结吗?
阿里妹导读
本文主要总结了在ICBU的核心沟通场景下服务端在此次性能优化过程中做的工作,供大家参考讨论。
一、背景与效果
二、措施一:流式分块传输(核心)
2.1. HTTP分块传输介绍
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) {// logger}}private void flushVm(String templateName, Context model, PrintWriter writer) throws Exception {StringWriter tmpWri = new StringWriter();// vm渲染engine.mergeTemplate(templateName, "UTF-8", model, tmpWri);// 数据写出writer.write(tmpWri.toString());writer.flush();}
2.2. 页面流式分块传输优化方案
服务端分批进行数据准备,可以减少首次需要准备的数据量,极大缩短准备时间。
浏览器分批接收数据,当接收到第一部分的数据时,可以立刻进行js渲染,提升其利用率。
注意界面资源加载的依赖关系,前序界面不能依赖后序界面的变量。
将偏静态与核心的资源前置,后端服务器可以快速完成数据准备并返回第一段html供前端加载。
2.3. 注意事项
二方包或自定义的HTTP请求 filter 会改写 response 的 header,导致分块传输失效。如果应用中有这种情况,我们在进行流式推送时,可以获取到最原始的response,防止被其他filter影响:
/*** 防止filter或者其他代理包装了response并开启缓存* 这里获取到真实的response** @param response* @return*/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,如下所示:
VelocityEngine模板引擎的自定义tool。
private void flushVm(String templateName, Context model, PrintWriter writer) throws Exception {StringWriter tmpWri = new StringWriter();// vm渲染engine.mergeTemplate(templateName, "UTF-8", model, tmpWri);// 数据写出writer.write(tmpWri.toString());writer.flush();}
需要注意的是VelocityEngine模板引擎支持自定义tool,在vm文件中是如下的形式,当vm引擎渲染到对应位置时,会调用配置好的方法进行解析:
<title>$tool.do("xx", "$!{arg}")</title>
/*** 初始化 toolbox.xml 中的工具*/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);}}
Nginx 的 location 配置
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
@GetMapping("/chunked")public ResponseEntity<StreamingResponseBody> 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的数据,进而渲染错误:
/*** A controller method return value type for asynchronous request processing* where the application can write directly to the response {@code OutputStream}* without holding up the Servlet container thread.** <p><strong>Note:</strong> when using this option it is highly recommended to* configure explicitly the TaskExecutor used in Spring MVC for executing* asynchronous requests. Both the MVC Java config and the MVC namespaces provide* options to configure asynchronous handling. If not using those, an application* can set the {@code taskExecutor} property of* {@link org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter* RequestMappingHandlerAdapter}.** @author Rossen Stoyanchev* @since 4.2*/@FunctionalInterfacepublic interface StreamingResponseBody {/*** A callback for writing to the response body.* @param outputStream the stream for the response body* @throws IOException an exception while writing*/void writeTo(OutputStream outputStream) throws IOException;}
三、措施二:非流量中间件优化
public static void registerDynamicConfig(final String dataIdKey, final String groupName) {IOException initError = null;try {String e = Diamond.getConfig(dataIdKey, groupName, DEFAULT_TIME_OUT);if(e != null) {getGroup(groupName).put(dataIdKey, e);}logger.info("Diamond config init: dataId=" + dataIdKey + ", groupName=" + groupName + "; initValue=" + e);} catch (IOException e) {logger.error("Diamond config init error: dataId=" + dataIdKey, e);initError = e;}Diamond.addListener(dataIdKey, groupName, new ManagerListener() {@Overridepublic Executor getExecutor() {return null;}@Overridepublic void receiveConfigInfo(String s) {String oldValue = (String)DynamicConfig.getGroup(groupName).get(dataIdKey);DynamicConfig.getGroup(groupName).put(dataIdKey, s);DynamicConfig.logger.warn("Receive config update: dataId=" + dataIdKey + ", newValue=" + s + ", oldValue=" + oldValue);}});if(initError != null) {throw new RuntimeException("Diamond config init error: dataId=" + dataIdKey, initError);}}
四、措施三:数据直出
静态图片直出,页面上有静态的loge图片,原本为cdn地址,在浏览器渲染时,需要建联并会抢占线程,对于这类不会发生发生变化的图片,可以直接替换为base64的形式,js可以直接加载。
加载数据直出,这部分需要根据具体业务来分析,部分业务数据是浏览器运行js脚本在本地二次请求加载的,由于低端机以及本地浏览器的能力限制,如果需要加载的数据很多,就很导致js线程的挤占,拖慢整体的时间,因此,可以考虑在服务器将部分数据预先加载好,随http请求一起给浏览器,减少这部分的卡点。
五、措施四:本地缓存
六、措施五:下线历史债务
未下线的实验或者分流接口调用;
时间线拉长,这部分的代码残骸在所难免,而且积少成多,累计起来往往有几十上百毫秒的资源浪费,再加上业务开发时,大家往往没有额外资源去评估这部分的很多代码是否可以下线,因此可以借助性能优化的契机进行治理。
已经废弃的vm变量与重复变量治理。
对vm变量的盘点过程中发现有很多之前在使用但现在已经废弃的变量。当然,这部分变量的需要前后端同学共同梳理,防止下线线上依旧依赖的变量。