- AsyncContext介绍
1.1 概念
1.2 疑问 - 项目实战
2.1 API方法
2.2 项目实战—实现配置更新 - nacos实现长轮询
1. AsyncContext介绍
有这么一个场景:客户端轮询的去服务器读取更新的配置信息,但读取的频率高就会影响客户端和服务器的性能。那么如何优化这种场景呢?
1.1 概念
SpringBoot集成了servlet一系列的操作,故servlet提供的新特性,在SpringBoot环境中可以正常使用。
而servlet3.0提供了异步处理的特性【AsyncContext】:使Servlet线程不再一直阻塞,直到业务处理完成才能输出响应,最后结束Servlet线程。而是在接受到请求之后,Servlet线程可以将耗时的操作委派给一个异步线程执行,而Servlet线程在不生成响应的情况下返回容器。这种方案可以大大减少服务器资源占用,提高并发处理速度。
1.2 疑问
servlet线程返回容器,异步线程处理完耗时的操作后,还可以设置响应对象进行返回吗?
可以!服务端的servlet线程返回容器,客户端依旧和服务端保持着http连接,通过AysncContext依旧可以返回响应数据
2. 项目实战
2.1 API方法
在Servlet3.0中,ServletRequest提供了startAysnc()
方法。
/**
* @see AsyncContext#dispatch()
* @since Servlet 3.0
*/
public AsyncContext startAsync() throws IllegalStateException;
/**
*
* @since Servlet 3.0
*/
public AsyncContext startAsync(ServletRequest servletRequest,
ServletResponse servletResponse)
throws IllegalStateException;
2.2 项目实战—实现配置更新
http层面实现的推拉结合的配置更新。
在controller层获取到HttpServletRequest和HttpServletResponse对象。
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
@RestController
@RequestMapping
public class LongPollingController {
//定时任务,阻塞的最大超时时间。
private ScheduledExecutorService timeoutChecker = new ScheduledThreadPoolExecutor(1, threadFactory);
private static final ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("longPolling-timeout-checker-%d")
.build();
private static final Multimap dataIdContext = Multimaps.synchronizedSetMultimap(HashMultimap.create());
@GetMapping(path = "getConf")
@ResponseBody
public String getThreadPoolConf(HttpServletRequest request, HttpServletResponse response)
String serviceName = request.getParameter("serviceName");
String timeOut =request.getParameter("timeOut");
if (timeOut == null) {
//如果没设置过期时间则默认 29秒超时
timeOut = "29";
}
// 开启异步
AsyncContext asyncContext = request.startAsync(request, response);
AsyncTask asyncTask = new AsyncTask(asyncContext, true);
// 维护 serviceName 和异步请求上下文的关联
dataIdContext.put(serviceName, asyncTask);
// 启动定时器,30s 后写入 304 响应
timeoutChecker.schedule(() -> {
//触发定时后,判断任务是否被执行,即isTimeout为true(没有被执行)
//则返回客户端304的状态码-即无修改。
if (asyncTask.isTimeout()) {
//清除缓存中的任务
if (dataIdContext.remove(serviceName, asyncTask)) {
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
asyncTask.getAsyncContext().complete();
}
}
}, Integer.parseInt(timeOut), TimeUnit.SECONDS);
}
//当配置被修改,触发事件后,会调用该方法。
public void publishConfig(String serviceName, List result) {
if (StringUtil.isEmpty(serviceName) && CollectionUtils.isEmpty(result)) {
log.error("publishConfig:serviceName:result all is null");
return;
}
if (StringUtil.isNotEmpty(serviceName) && CollectionUtils.isEmpty(result)) {
log.error("publishConfig result is null");
//去持久化的存储源(es/mysql...)读取配置信息
result = threadPoolService.getConfigByServiceName(serviceName);
if (result == null) {
log.error("publishConfig:serviceName:result all is null but not find threadPoolProperties serviceName:{}", serviceName);
return;
}
}
//读取到的配置信息,json化
String configInfo = JSONArray.toJSONString(result);
//移除AsyncTask的缓存
Collection asyncTasks = dataIdContext.removeAll(serviceName);
if (CollectionUtils.isEmpty(asyncTasks)) return;
//为每一个AsyncContext设置200的状态码以及响应数据。
for (AsyncTask asyncTask : asyncTasks) {
//表明未超时,已经进行处理了。
asyncTask.setTimeout(false);
HttpServletResponse response = (HttpServletResponse)asyncTask.getAsyncContext().getResponse();
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().println(configInfo);
asyncTask.getAsyncContext().complete();
}
}
//自定义任务对象
@Data
private static class AsyncTask {
// 长轮询请求的上下文,包含请求和响应体
private AsyncContext asyncContext;
// 超时标记
private boolean timeout;
public AsyncTask(AsyncContext asyncContext, boolean timeout) {
this.asyncContext = asyncContext;
this.timeout = timeout;
}
}
}
上面代码实现的逻辑:客户端拉取服务端的配置,服务端开启长轮询,使用AysncContext进行异步阻塞。当服务端有配置被改变时,(例如:由redis的订阅发布模式)来进行通知,最终执行publishConfig
方法,将服务器的配置推送给客户端,其本质实现的是http推拉结合的配置通知。
- startAsync()会直接利用原有的请求与响应对象来创建AsyncContext
- startAsync(ServletRequest request,ServletResponse response)可以传入自行创建的请求、响应封装对象;
注意事项:
- 可以通过AsyncContext的getRequest()、getResponse()方法取得请求、响应对象,此次对客户端的响应将暂缓至调用AsyncContext的complete()或dispatch()方法为止,前者表示响应完成,后者表示将调派指定的URL进行响应。
- AysncContext.setTimeout()的超时时间不准,所以需要自己控制。
- 客户端的http并未断开连接。
- 第一次客户端请求,可以将全量的数据直接发生(普通连接),后续的请求,可以使用AysncContext来实现长轮询。
3. nacos实现长轮询
项目源码:nacos也是借助AysncContext来实现配置的更新。
@Service
public class LongPollingService extends AbstractEventListener {
private static final int FIXED_POLLING_INTERVAL_MS = 10000;
private static final int SAMPLE_PERIOD = 100;
private static final int SAMPLE_TIMES = 3;
private static final String TRUE_STR = "true";
private Map retainIps = new ConcurrentHashMap();
private static boolean isFixedPolling() {
return SwitchService.getSwitchBoolean(SwitchService.FIXED_POLLING, false);
}
//......
static public boolean isSupportLongPolling(HttpServletRequest req) {
return null != req.getHeader(LONG_POLLING_HEADER);
}
public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp, Map clientMd5Map,
int probeRequestSize) {
String str = req.getHeader(LongPollingService.LONG_POLLING_HEADER);
String noHangUpFlag = req.getHeader(LongPollingService.LONG_POLLING_NO_HANG_UP_HEADER);
String appName = req.getHeader(RequestUtil.CLIENT_APPNAME_HEADER);
String tag = req.getHeader("Vipserver-Tag");
int delayTime = SwitchService.getSwitchInteger(SwitchService.FIXED_DELAY_TIME, 500);
/**
* 提前500ms返回响应,为避免客户端超时 @qiaoyi.dingqy 2013.10.22改动 add delay time for LoadBalance
*/
long timeout = Math.max(10000, Long.parseLong(str) - delayTime);
if (isFixedPolling()) {
timeout = Math.max(10000, getFixedPollingInterval());
// do nothing but set fix polling timeout
} else {
long start = System.currentTimeMillis();
List changedGroups = MD5Util.compareMd5(req, rsp, clientMd5Map);
if (changedGroups.size() > 0) {
generateResponse(req, rsp, changedGroups);
LogUtil.clientLog.info("{}|{}|{}|{}|{}|{}|{}",
System.currentTimeMillis() - start, "instant", RequestUtil.getRemoteIp(req), "polling",
clientMd5Map.size(), probeRequestSize, changedGroups.size());
return;
} else if (noHangUpFlag != null && noHangUpFlag.equalsIgnoreCase(TRUE_STR)) {
LogUtil.clientLog.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "nohangup",
RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize,
changedGroups.size());
return;
}
}
String ip = RequestUtil.getRemoteIp(req);
// 一定要由HTTP线程调用,否则离开后容器会立即发送响应
final AsyncContext asyncContext = req.startAsync();
// AsyncContext.setTimeout()的超时时间不准,所以只能自己控制
asyncContext.setTimeout(0L);
scheduler.execute(
new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));
}
//......
}
分析:LongPollingService的isSupportLongPolling是通过判断request是否有LONG_POLLING_HEADER的header来实现的;addLongPollingClient方法主要是创建ClientLongPolling,然后提交到scheduler定时线程池执行。
4. 文章参考
AsyncContext异步请求的用法
聊聊nacos config的doPollingConfig