【源码分析专题】-阿里开源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)等场景. 反正我在第一次接触到长轮询时感觉还挺神奇的,就是当时第一次用spring-cloud的配置中心config时,对它可以在github或者码云上修改配置文件application.yml后可以立即在客户端即时拉取该更新的配置内容产生了极大兴趣和好奇,你是否也有同样的疑惑,读完本篇就可以解开此谜团。

2.2长轮询的原理

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

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

2.3长轮询的实现

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

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

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

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


TIPS:答应我,一定要看到实现三,因为实现三这种方式是阿里内部某不方便透露名字的中间件产品采用的核心技术,该中间件作为阿里的配置中心,承载了每年双11海量的请求, 当然该中间件并没有这么简单,里面融入了缓存,负载均衡,MD5...各种内容,但长轮询的核心实现原理就是实现三这么朴素。


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界面,就可以初步媲美阿里提供的中间件了.

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

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

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值