【源码分析专题】-阿里开源Nacos配置中心 最佳长轮询 实现原理

前言:看完本篇,你将了解到web端常用的实时通讯技术种类及其适用场景,你将了解到几种不同的长轮询方式,以及它们的差异,最后将一睹互联网大厂Nacos的长轮询技术,从而在以后遇到消息推送场景/在线聊天/配置中心等需要长轮询技术的场景时,可以写出优雅又性能爆棚的代码,文中内容看起来较长,其实大部分篇幅是代码实现,可以选择跳过或者简单看看,里面的代码都是可以直接跑通的,不妨复制粘贴到IDE里运行看看效果.另外延伸阅读部分建议看完通篇后有余力再阅读,否则会打断思路.


目录

1.Web端即时通讯技术

1.1常见的Web端实时通讯技术的实现方式

1.1.1短轮询

1.1.2长轮询

1.1.3长连接

1.1.4websocket

2.长轮询详解

2.1长轮询的好处

2.2长轮询的原理

2.3长轮询的实现

2.3.1实现一:while死循环

2.3.2实现二:Lock notify  + future.get(timeout)

2.3.3实现三:schedule + AsyncContext方式(Nacos的配置中心实现方式)

3.总结


1.Web端即时通讯技术

Web端即时通讯技术即服务端可以即时将信息变更传输至客户端,这种场景在开发中非常常见,比如消息推送,比如在线聊天等,是一种比较实用的技术,在很多场景对提升用户体验有奇效.

1.1常见的Web端实时通讯技术的实现方式

1.1.1短轮询

客户端每隔一段时间向服务端发送请求,服务端收到请求即响应客户端请求,这种方式实现起来最简单,也比较实用,但缺点显而易见,实时性不高,而且频繁的请求在用户量过大时对服务器会造成很大压力.

1.1.2长轮询

服务端收到客户端发来的请求后不直接响应,而是将请求hold住一段时间,在这段时间内如果数据有变化,服务端才会响应,如果没有变化则在到达一定的时间后才返回请求,客户端Js处理完响应后会继续重发请求...这种方式能够大幅减少请求次数,减少服务端压力,同时能够增加响应的实时性,做的好的话基本上是即时响应的.

1.1.3长连接

长连接SSE是H5推出的新功能,全称Server-Sent Events,它可以允许服务推送数据到客户端,SSE不需要客户端向服务端发请求,服务端数据发生变化时,会主动向客户端发送,可以保证实时性,显著减轻服务端压力.

1.1.4websocket

websocket是H5提供的一个新协议,可以实现客户端和服务端的全双工通信,服务端和客户端可以自由相互传输数据,不存在请求和响应的区别.

以上实现方式各有优劣,不作评判,各有各的适用场景,不必纠结哪种技术更好,只有更适合.

另外本篇只对长轮询做详细介绍,因为最近研究了大厂的长轮询技术,觉得很厉害,佩服的膝盖都献上了,所以分享一下.

2.长轮询详解

2.1长轮询的好处

长轮询具有实现相对简单,高效,服务端压力小,轻量,响应迅速等优点,所以被广泛的用于各种中间件,配置中心,在线聊天(如Web qq)等场景.

2.2长轮询的原理

客户端向服务端发起请求,服务端收到请求后不直接响应,而是把请求hold一段时间,在这段时间如果服务端检测到有数据发生变化,就中断hold,然后立即响应客户端,否则就啥也不做,直到达到预设的超时时间,再返回响应. 在hold住请求这段时间,其实是一个监听器或者观察者模式,但重点是如何Hold住请求?

(延伸阅读:【设计模式】-监听者模式和观察者模式的区别与联系https://blog.csdn.net/lovexiaotaozi/article/details/102579360)

2.3长轮询的实现

服务端实现长轮询的方式也有很多种,本篇介绍三种,先不着急写代码,理一下思路:

①被观察对象如果没有改变,服务端就啥也不做,傻傻等待就完事了.

②一旦被观察对象发生改变,立即终结hold状态,响应请求.

③hold直到预设的超时时间都没数据发生变化,返回响应(可以包含超时信息,也可以不包含,反正客户端还是要重新发请求的...)

2.3.1实现一:while死循环

完整代码我已经贴出来了,可以直接复制到你的IDE里运行:

@RestController
@RequestMapping("/loop")
public class LoopLongPollingController {
    @Autowired
    LoopLongPollingService loopLongPollingService;

    /**
     * 从服务端拉取被变更的数据
     * @return
     */
    @GetMapping("/pull")
    public Result pull() {
        String result = loopLongPollingService.pull();
        return ResultUtil.success(result);
    }

    /**
     * 向服务端推送变更的数据
     * @param data
     * @return
     */
    @GetMapping("/push")
    public Result push(@RequestParam("data") String data) {
        String result = loopLongPollingService.push(data);
        return ResultUtil.success(result);
    }
}
@Data
public class Result<T> {
    private T data;
    private Integer code;
    private Boolean success;
}
public class ResultUtil {
    public static Result success() {
        Result result = new Result();
        result.setCode(200);
        result.setSuccess(true);
        return result;
    }

    public static Result success(Object data) {
        Result result = new Result();
        result.setSuccess(true);
        result.setCode(200);
        result.setData(data);
        return result;
    }
}
@Configuration
public class ThreadPoolConfig {
    @Bean
    public ScheduledExecutorService getScheduledExecutorService() {
        AtomicInteger poolNum = new AtomicInteger(0);
        ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(2, r -> {
            Thread t = new Thread(r);
            t.setName("LoopLongPollingThread-" + poolNum.incrementAndGet());
            return t;
        });
        return scheduler;
    }
}
public interface LoopLongPollingService {
    String pull();

    String push(String data);
}
@Service
public class LoopLongPollingServiceImpl implements LoopLongPollingService {
    @Autowired
    ScheduledExecutorService scheduler;
    private LoopPullTask loopPullTask;

    @Override
    public String pull() {
        loopPullTask = new LoopPullTask();
        Future<String> result = scheduler.schedule(loopPullTask, 0L, TimeUnit.MILLISECONDS);
        try {
            return result.get();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        return "ex";
    }

    @Override
    public String push(String data) {
        Future<String> future = scheduler.schedule(new LoopPushTask(loopPullTask, data), 0L, TimeUnit.MILLISECONDS);
        try {
            return future.get();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        return "ex";
    }
}
@Slf4j
public class LoopPullTask implements Callable<String> {
    @Getter
    @Setter
    public volatile String data;
    private Long TIME_OUT_MILLIS = 10000L;

    @Override
    public String call() throws Exception {
        Long startTime = System.currentTimeMillis();
        while (true) {
            if (!StringUtils.isEmpty(data)) {
                return data;
            }
            if (isTimeOut(startTime)) {
                log.info("获取数据请求超时" + new Date());
                data = "请求超时";
                return data;
            }
            //减轻CPU压力
            Thread.sleep(200);
        }
    }

    private boolean isTimeOut(Long startTime) {
        Long nowTime = System.currentTimeMillis();
        return nowTime - startTime > TIME_OUT_MILLIS;
    }
}
public class LoopPushTask implements Callable<String> {
    private LoopPullTask loopPullTask;
    private String data;

    public LoopPushTask(LoopPullTask loopPullTask, String data) {
        this.loopPullTask = loopPullTask;
        this.data = data;
    }

    @Override
    public String call() throws Exception {
        loopPullTask.setData(data);
        return "changed";
    }
}

然后我依次在浏览器访问:

http://localhost:8080/loop/pull

http://localhost:8080/loop/push?data=aa

效果:

超时:

如果可以动态展示就好了,这样看效果不直观,原本效果是拉取数据变更的页面处在加载过程中,当数据变更页面被访问后,加载中的页面即刻收到了返回,返回的内容就是变更后的数据,有兴趣的可以自行演示.


思考:

这样做确实实现了预期的效果,但存在非常严重的性能问题,在请求获取数据时一直处于while(true)的死循环中,如果在这个过程中并没有任何数据变更,CPU资源就白白浪费了,在并发较高的场景中,所有线程都在竞争CPU资源,然后while(true)循环,不仅宝贵的CPU资源被浪费,还容易使服务器过载崩溃。那能否采用什么手段,使得CPU资源仅在数据发生改变时才被利用,其余时间被让出做别的事情,答案是肯定的。

2.3.2实现二:Lock notify  + future.get(timeout)

思路:通过Object.wait()阻塞拉取任务的线程,等到数据发生变更时,再将其唤醒,这样就不会像前面的while死循环那样浪费CPU资源了,而且通知也足够及时!

为了区分,这里采用Lock代替Loop,其余公用代码与上面保持一致:

@RestController
@RequestMapping("/lock")
public class LockLongPollingController {
    @Autowired
    private LockLongPollingService lockLongPollingService;

    @RequestMapping("/pull")
    public Result pull() {
        String result = lockLongPollingService.pull();
        return ResultUtil.success(result);
    }

    @RequestMapping("/push")
    public Result push(@RequestParam("data") String data) {
        String result = lockLongPollingService.push(data);
        return ResultUtil.success(result);
    }
}
public interface LockLongPollingService {
    String pull();

    String push(String data);
}
@Service
public class LockLongPollingServiceImpl implements LockLongPollingService {
    @Autowired
    ScheduledExecutorService scheduler;
    private LockPullTask lockPullTask;
    private Object lock;

    @PostConstruct
    public void post() {
        lock = new Object();
    }

    @Override
    public String pull() {
        lockPullTask = new LockPullTask(lock);
        Future<String> future = scheduler.submit(lockPullTask);
        try {
            return future.get(10000, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        } catch (TimeoutException e) {
            return dealTimeOut();
        }
        return "ex";
    }

    private String dealTimeOut() {
        synchronized (lock) {
            lock.notifyAll();
            lockPullTask.setData("timeout");
        }
        return "timeout";
    }

    @Override
    public String push(String data) {
        Future<String> future = scheduler.schedule(new LockPushTask(lockPullTask, data, lock), 0L,
            TimeUnit.MILLISECONDS);
        try {
            return future.get();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        return "ex";
    }
}
@Slf4j
public class LockPullTask implements Callable<String> {
    @Getter
    @Setter
    public volatile String data;
    private Object lock;

    public LockPullTask(Object lock) {
        this.lock = lock;
    }

    @Override
    public String call() throws Exception {
        log.info("长轮询任务开启:" + new Date());
        while (StringUtils.isEmpty(data)) {
            synchronized (lock) {
                lock.wait();
            }
        }
        log.info("长轮询任务结束:" + new Date());
        return data;
    }

}
@Slf4j
public class LockPushTask implements Callable<String> {
    private LockPullTask lockPullTask;
    private String data;
    private Object lock;

    public LockPushTask(LockPullTask lockPullTask, String data, Object lock) {
        this.lockPullTask = lockPullTask;
        this.data = data;
        this.lock = lock;
    }

    @Override
    public String call() throws Exception {
        log.info("数据发生变更:" + new Date());
        synchronized (lock) {
            lockPullTask.setData(data);
            lock.notifyAll();
            log.info("数据变更为:" + data);
        }
        return "changed";
    }
}

测试效果:

超时:

思考:

这样做在性能方面有了很大提升,同时也解决了通知的时效性问题,但仔细看还是存在一些问题,比如:《阿里巴巴java开发手册》中提到的异常不要用来做流程控制,然而这边在超时的异常处理中做了流程控制,当然这样写也无可厚非...

那么有没有办法可以让代码更优雅?  

2.3.3实现三:schedule + AsyncContext方式(Nacos的配置中心实现方式)

Nacos在设计上考虑了颇多,除了我前面提到的代码优雅的问题,还需要考虑高并和多用户订阅以及性能等诸多问题,对于各种细节本篇不作讨论,只抽取最为核心的长轮询部分作演示.

基本思路是通过Servlet3.0后提供的异步处理能力,把请求的任务添加至队列中,在有数据发生变更时,从队列中取出相应请求,然后响应请求,负责拉取数据的接口通过延时任务完成超时处理,如果等到设定的超时时间还没有数据变更时,就主动推送超时信息完成响应,下面我们来看代码实现:

@RestController
@GetMapping("/nacos")
public class NacosLongPollingController extends HttpServlet {
    @Autowired
    private NacosLongPollingService nacosLongPollingService;

    @RequestMapping("/pull")
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        String dataId = req.getParameter("dataId");
        if (StringUtils.isEmpty(dataId)) {
            throw new IllegalArgumentException("请求参数异常,dataId能为空");
        }
        nacosLongPollingService.doGet(dataId, req, resp);
    }
    //为了在浏览器中演示,我这里先用Get请求,dataId可以区分不同应用的请求
    @GetMapping("/push")
    public Result push(@RequestParam("dataId") String dataId, @RequestParam("data") String data) {
        if (StringUtils.isEmpty(dataId) || StringUtils.isEmpty(data)) {
            throw new IllegalArgumentException("请求参数异常,dataId和data均不能为空");
        }
        nacosLongPollingService.push(dataId, data);
        return ResultUtil.success();
    }
}
public interface NacosLongPollingService {
    void doGet(String dataId, HttpServletRequest req, HttpServletResponse resp);

    void push(String dataId, String data);
}

Service层注意asyncContext的启动一定要由当前执行doGet方法的线程启动,不能在异步线程中启动,否则响应会立即返回,不能起到hold的效果。 

@Service
public class NacosLongPollingServiceImpl implements NacosLongPollingService {
    final ScheduledExecutorService scheduler;
    final Queue<NacosPullTask> nacosPullTasks;

    public NacosLongPollingServiceImpl() {
        scheduler = new ScheduledThreadPoolExecutor(1, r -> {
            Thread t = new Thread(r);
            t.setName("NacosLongPollingTask");
            t.setDaemon(true);
            return t;
        });
        nacosPullTasks = new ConcurrentLinkedQueue<>();
        scheduler.scheduleAtFixedRate(() -> System.out.println("线程存活状态:" + new Date()), 0L, 60, TimeUnit.SECONDS);
    }

    @Override
    public void doGet(String dataId, HttpServletRequest req, HttpServletResponse resp) {
        // 一定要由当前HTTP线程调用,如果放在task线程容器会立即发送响应
        final AsyncContext asyncContext = req.startAsync();
        scheduler.execute(new NacosPullTask(nacosPullTasks, scheduler, asyncContext, dataId, req, resp));
    }

    @Override
    public void push(String dataId, String data) {
        scheduler.schedule(new NacosPushTask(dataId, data, nacosPullTasks), 0L, TimeUnit.MILLISECONDS);
    }
}

NacosPullTask负责拉取变更内容,注意内部类中的this指向内部类本身,而非引用匿名内部类的对象.

@Slf4j
public class NacosPullTask implements Runnable {
    Queue<NacosPullTask> nacosPullTasks;
    ScheduledExecutorService scheduler;
    AsyncContext asyncContext;
    String dataId;
    HttpServletRequest req;
    HttpServletResponse resp;

    Future<?> asyncTimeoutFuture;

    public NacosPullTask(Queue<NacosPullTask> nacosPullTasks, ScheduledExecutorService scheduler,
        AsyncContext asyncContext, String dataId, HttpServletRequest req, HttpServletResponse resp) {
        this.nacosPullTasks = nacosPullTasks;
        this.scheduler = scheduler;
        this.asyncContext = asyncContext;
        this.dataId = dataId;
        this.req = req;
        this.resp = resp;
    }

    @Override
    public void run() {
        asyncTimeoutFuture = scheduler.schedule(() -> {
            log.info("10秒后开始执行长轮询任务:" + new Date());
            //这里如果remove this会失败,内部类中的this指向的并非当前对象,而是匿名内部类对象
            nacosPullTasks.remove(NacosPullTask.this);
            //sendResponse(null);
        }, 10, TimeUnit.SECONDS);
        nacosPullTasks.add(this);
    }

    /**
     * 发送响应
     *
     * @param result
     */
    public void sendResponse(String result) {
        System.out.println("发送响应:" + new Date());
        //取消等待执行的任务,避免已经响完了,还有资源被占用
        if (asyncTimeoutFuture != null) {
            //设置为true会立即中断执行中的任务,false对执行中的任务无影响,但会取消等待执行的任务
            asyncTimeoutFuture.cancel(false);
        }

        //设置页码编码
        resp.setContentType("application/json; charset=utf-8");
        resp.setCharacterEncoding("utf-8");

        //禁用缓存
        resp.setHeader("Pragma", "no-cache");
        resp.setHeader("Cache-Control", "no-cache,no-store");
        resp.setDateHeader("Expires", 0);
        resp.setStatus(HttpServletResponse.SC_OK);
        //输出Json流
        sendJsonResult(result);
    }

    /**
     * 发送响应流
     *
     * @param result
     */
    private void sendJsonResult(String result) {
        Result<String> pojoResult = new Result<>();
        pojoResult.setCode(200);
        pojoResult.setSuccess(!StringUtils.isEmpty(result));
        pojoResult.setData(result);
        PrintWriter writer = null;
        try {
            writer = asyncContext.getResponse().getWriter();
            writer.write(pojoResult.toString());
            writer.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            asyncContext.complete();
            if (null != writer) {
                writer.close();
            }
        }
    }
}

NacosPushTask执行数据变更: 

public class NacosPushTask implements Runnable {
    private String dataId;
    private String data;
    private Queue<NacosPullTask> nacosPullTasks;

    public NacosPushTask(String dataId, String data,
        Queue<NacosPullTask> nacosPullTasks) {
        this.dataId = dataId;
        this.data = data;
        this.nacosPullTasks = nacosPullTasks;
    }

    @Override
    public void run() {
        Iterator<NacosPullTask> iterator = nacosPullTasks.iterator();
        while (iterator.hasNext()) {
            NacosPullTask nacosPullTask = iterator.next();
            if (dataId.equals(nacosPullTask.dataId)) {
                //可根据内容的MD5判断数据是否发生改变,这里为了演示简单就不写了
                //移除队列中的任务,确保下次请求时响应的task不是上次请求留在队列中的task
                iterator.remove();
                //执行数据变更,发送响应
                nacosPullTask.sendResponse(data);
                break;
            }
        }
    }
}

效果:

超时场景:

3.总结

从实现难易角度来看:实现一 < 实现二 < 实现三

从性能角度来看:实现一 < 实现二 < 实现三

实现三不同于前两种实现方式的根本在于,实现三发送response的方法由自己控制,而前面两种方式是交给springboot控制的.自己控制就避免了受制于人的限制,更加自由灵活.如果进一步设计,可以考虑加上缓存,对dataId和data加密确保信息安全,对数据变更的MD5校验,心跳监控,高可用等...配合一套UI界面,就可以初步媲美阿里提供的中间件了.

文中如有不正之处欢迎留言斧正,有疑问也可以留言,我看到会及时回复.

如果觉得阅读本文有收获,欢迎关注,我将与大家一起持续分享和学习成长.

发布了89 篇原创文章 · 获赞 69 · 访问量 4万+

猜你喜欢

转载自blog.csdn.net/lovexiaotaozi/article/details/102775350